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.
_Miscis 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:
- In
saveIgnoreKeys→ skip (the table is empty in practice). - In
saveFunctions→ custom codec (_Inventory). - Is a table →
glon.encode, wrapped inpcall. - In
saveEscapeKeys(_Name,_Clanonly) →tmysql.escape. - 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.