Skip to content

Character and Persistence

What a character was, and how it survived a server restart.

Sources: metatables/sv_player.lua (965 lines), metatables/sh_player.lua, libraries/sv_player.lua, init.lua:99.


1. There is no character system

This is the first and most important thing to record: Applejack has no characters. It has players, and a player is permanently identified by their Steam account.

  • One record per Steam user, keyed on UniqueID.
  • No character creation, no character selection, no multiple characters, no deletion.
  • No name of your own choosing — your character's name is your Steam name (_Name = ply:Name()), and it changes when you change it on Steam.
  • No description, no biography, no attributes, no skills.
  • The only appearance choice is gender (_Gender), which selects from a job's male or female model list.

Everything the master plan's Phase 5 lists — identity, description, attributes, skills, appearance, statistics, lifecycle, deletion — is new work with no legacy behaviour to preserve. There is nothing to be faithful to here, which is liberating: design it properly from first principles.

Design note: the "your character is your Steam profile" model is a significant part of why the original felt persistent and accountable — you could not escape your reputation by rerolling. If proper characters are introduced, that accountability needs a deliberate replacement (a persistent account layer above characters), or the social dynamics change considerably.


2. The player record

State is split by lifetime, using a naming convention rather than a type.

2.1 ply.cider — persistent

Created in meta:LoadData(). This table is the save file; every key becomes a database column.

Key Type Default Meaning
_Name string Steam name Display name
_SteamID string Steam ID
_UniqueID number Primary key
_Clan string "" Free-text clan/gang tag
_Money int 0 Cash on hand, floored at 0
_Access string "" Permission flag string — see §3
_Donator int 0 Donator tier
_Arrested bool false Currently serving a sentence
_Inventory map {} {itemID = count} — see 03
_Blacklist table {} {kind = {thing = expiryTime}}
_Misc table {} Catch-all. Notably holds EquippedWeapons = {class = true}

New players receive GM.Config["Default Inventory"], and are saved immediately so the row exists.

_Misc is a warning. An untyped bag on the save record accumulates everything nobody wanted to design a column for. The new save format has no such field, deliberately — a module that needs to persist something declares a schema for it (Architecture/06_PERSISTENCE.md).

2.2 ply._Xxx — transient

Never saved; rebuilt on spawn. Recorded here because it defines the runtime character state the new design must model explicitly:

Field Meaning
_Initialized Data has finished loading — nothing may touch the player before this
_Salary, _JobTimeLimit, _JobTimeExpire Employment state
_Gender, _NextSpawnGender, _GenderWord Appearance and pronoun for emotes
_Hunger, _Stamina Needs (owned by their plugins)
_GunCounts[TYPE_SMALL/LARGE] Holster occupancy
_FreshWeapons, _Equipping, _NextDeploy Weapon equip flow state
_Sleeping, _Stunned, _beTied Physical state
_GPS, _ScaleDamage, _HideHealthEffects Misc modifiers
ragdoll = {entity, health} The player's ragdoll while knocked out — carries its own health
_StuckInWorld, _IdleKick Anti-stuck and idle kick (default 15 minutes)
_TMPMoneyUpdate Money change batching (D-12)

Note GetPos() is overridden on the player metatable (metatables/sh_player.lua) to return the ragdoll's position when one exists. A knocked-out player is physically located where their body is. Small detail, large consequence for every distance check in the game — and a good example of behaviour worth preserving expressed in a way that must not be (overriding a fundamental accessor globally).


3. Permissions — the flag string

Access is a string of characters. _Access = "bsm" means the player holds flags b, s and m. HasAccess(flag) is a substring test.

Three flags are computed rather than stored, via GM.FlagFunctions (libraries/sv_player.lua), which exists so plugins can add their own:

Flag Resolves to
s ply:IsSuperAdmin()
a ply:IsAdmin()
m ply:IsModerator()

Team-level flags are separate, declared per job (see 05_TEAMS_JOBS_FACTIONS.md): b boss, d demote, g give/take entities, D deposable, M mandatory transit class.

The default access is configured as "b" but the assignment is commented out in LoadData() with the note "No one needs the default access any more, as hasAccess catches it" — so players start with "".

Assessment. A single-character namespace with a global flat namespace shared between admin ranks, job powers and command gates. It is compact, greppable and completely unextensible — there are 52 possible permissions in the universe, and their meanings are documented only in the command table. The new design uses named permissions with a hierarchy; see Architecture/03_SERVICE_REGISTRY.md.


4. Blacklists

_Blacklist = { kind = { thing = expiryTimestamp } }, with Blacklist / UnBlacklist / Blacklisted / BlacklistAlert methods.

A generic, timed, per-category ban list — used to bar a player from a job or an item type for a period. Genuinely good design: one mechanism, many uses, expiry built in. Preserve the concept.


5. Persistence

Three unrelated backends, no shared versioning, no migration story (D-15).

5.1 Players → MySQL via tmysql

tmysql.initialize(Host, Username, Password, Database, 3306, 5, 5) at init.lua:99.

Schema: one row per player, one column per ply.cider key. The table shape is therefore implicitly defined by whatever keys happen to exist at runtime — there is no schema file, no migration, and adding a field means altering the table by hand.

Load (meta:LoadData)

SELECT * FROM <MySQL Table> WHERE _UniqueID = <uid>

Asynchronous. On a hit, each column is decoded per player.loadKnownKeys:

Key Codec
_Inventory "function" — custom, see below
_Blacklist, _Misc "GLON"
_Donator, _Money "number"
_Clan, _Access "string"

player.loadIgnoreKeys (_Key, _SteamID, _UniqueID, _Name) are never loaded back — they are re-derived from the connecting player.

Columns not in the known-keys table are type-sniffed at runtime: is it a number? does byte 2 look like a GLON prefix? is it the string "true"/"false"? otherwise string. A guess, on every unknown column, on every login.

If loading has not completed after 30 seconds, LoadData calls itself again — an unbounded retry loop with no backoff and no failure state.

The inventory codec

_Inventory is not GLON. It is a bespoke text format:

"cake: 5; ak47: 1; small_pocket: 2"

Written by player.saveFunctions._Inventory (string concatenation), read by player.loadFunctions._Inventory (a string.gsub with the pattern "([^;%s]+): ([0-9]+)").

Unknown item IDs are logged and dropped on load — so removing an item definition silently destroys every copy players were holding. Worth noting as a hazard for the new design: item definitions must be deprecated, never deleted, or the migration must be explicit.

_Arrested is restored by re-arresting

player.loadFunctions._Arrested calls ply:Arrest() on load. You cannot log out of jail — one of the original's best consequence mechanics, and worth preserving deliberately rather than by accident.

Save (meta:SaveData)

getKVs() walks ply.cider and encodes each value:

  1. In saveIgnoreKeys → skip (the table is empty in practice).
  2. In saveFunctions → custom codec (_Inventory).
  3. Is a table → glon.encode, wrapped in pcall.
  4. In saveEscapeKeys (_Name, _Clan only) → tmysql.escape.
  5. Otherwise → tostring.

Then an INSERT or UPDATE is built by string concatenation. Only two columns are escaped. This is D-01 and it is the most serious defect in the codebase.

getKVs also has a side effect: it rebuilds _Misc.EquippedWeapons from the player's currently held weapons, filtered by PlayerCanHolster. Equipped weapons are therefore persisted separately from the inventory and re-added on load — meaning the save path mutates game state, which makes saving unsafe to do at an arbitrary moment.

Save cadence

  • player.SaveAll() — batches 5 players per frame on a timer.
  • GM:ShutDown — saves everyone.
  • Individual saves on significant events.

There is no journal, no dirty-tracking and no write-ahead. A crash loses everything since the last cycle.

5.2 Per-map plugin data → flat files with GLON

Written to <LuaFolder>/<plugin>/<map>.txt via the plugin hooks PLUGIN:LoadData() / PLUGIN:SaveData():

Data File
Door ownership and metadata doors/<map>.txt (plugins/doors/sv_init.lua:29,138,142)
Team spawn points spawnpoints/<map>.txt (:21,44,56)
Prison points prisonpoints/<map>.txt (:18,31,47)

Per-map keying is correct and is preserved — world state is meaningless across maps.

5.3 Prop protection → local SQLite

libraries/sv_propprotection.lua — a bundled third-party addon (SPropProtection) using sql.Query against its own tables (spropprotectiona, spropprotectionafriends). Entirely independent of the player database. Not carried forward; S&box has no prop-spawning sandbox to protect by default.


6. What must be saved in the new design

The legacy save set, plus what the original could not represent. This list is the input to Architecture/06_PERSISTENCE.md.

Was saved: money, access flags, donator tier, arrest state, inventory contents, clan, blacklists, equipped weapons, door ownership and access lists (per map), spawn points, prison points, frags/deaths (plugins/savefragsdeaths).

Was not saved and needs to be: item instance state (durability, ammunition, container contents), hunger and stamina (reset on every join), needs generally, container contents placed in the world, vehicle ownership and state, injuries, crafting progress, skills, statistics, warrants (they expire on restart), housing/rent state.

Must never be saved: anything derived. _Name is re-read from Steam, _UniqueID is identity, and the equipped-weapons round-trip should not exist at all once inventories hold real item instances.