Characters and Variables¶
The character systems in Helix and Parallax are the closest of any topic in this guide. Both use a RegisterVar-centric API with auto-generated getters and setters, both type-check values through the same set of type constants, both persist to a dedicated character table, and both expose the character as a first-class object with a predictable meta.
Most of the port is the namespace rename plus a handful of small signature drifts.
Table of Contents¶
- Overall API Shape
- RegisterVar: Field-by-Field
- Getters and Setters
- The
dataVar - Var Change Callbacks
- Character Lifecycle
- Character Access Patterns
- Full Port: Custom "Reputation" Var
- Meta Methods on the Character Object
- Pitfalls
Overall API Shape¶
| Helix | Parallax | |
|---|---|---|
| Library | ix.char |
ax.character |
| Call style | Dot | Colon |
| Registration | ix.char.RegisterVar(name, data) |
ax.character:RegisterVar(name, data) |
| Loaded store | ix.char.loaded[id] |
ax.character.instances[id] |
| Meta table | ix.meta.character |
ax.character.meta |
| Var registry | ix.char.vars[name] |
ax.character.vars[name] |
| Per-player cache | ix.char.cache[steamID64] |
(no equivalent — chars load on select) |
| Creation | ix.char.Create(data, cb) |
ax.character:Create(data, cb) |
| Restore on join | ix.char.Restore(client, cb, bNoCache, id) |
ax.character:Restore(client, cb) |
The largest hidden difference: Helix preloads all of a player's characters on join (so the character-select menu is populated instantly), then caches them in ix.char.cache across reconnects. Parallax loads characters on demand when the player selects one. If your schema had logic that read other characters' data on login, that logic will need to issue an explicit load.
RegisterVar: Field-by-Field¶
Compare the Helix and Parallax signatures of a registration call:
Helix:
ix.char.RegisterVar("name", {
field = "name",
fieldType = ix.type.string,
default = "John Doe",
index = 1,
alias = "Name",
OnValidate = function(self, value, payload, client)
if ( !value ) then return false, "invalid", "name" end
return tostring(value):Trim()
end,
OnPostSetup = function(self, panel, payload)
-- Called after the character creation UI builds the panel for this var
panel:SetBackgroundColor(Color(255, 255, 255, 25))
end,
OnSet = function(self, client, value)
-- Called on the server after the value is set
end,
OnAdjust = function(self, client, data, value, newData)
-- Transform value before storage
end,
bNoDisplay = false,
bNoNetworking = false,
})
Parallax:
ax.character:RegisterVar("name", {
field = "name",
fieldType = ax.type.string,
default = "John Doe",
alias = { "Name" }, -- table, not string
validate = function(value) -- lowercase, simpler signature
if ( !value ) then return false end
return isstring(value) and #value > 0
end,
changed = function(char, newValue, oldValue, bNoNet, recipients, bNoDB)
-- runs after the setter
end,
Get = function(self, char) return char.vars.name end, -- optional override
Set = function(self, char, value) char.vars.name = value end,
bNoGetter = false,
bNoSetter = false,
})
Field-by-field mapping:
| Helix field | Parallax field | Notes |
|---|---|---|
field |
field |
Same — database column. |
fieldType |
fieldType |
Same — uses ax.type constants. |
default |
default |
Same. |
index |
(no equivalent) | Helix used this to order character-creation panels; Parallax UI is theme-driven. |
alias = "Desc" (string) |
alias = { "Desc" } (table) |
Must be a table even for a single alias. |
OnValidate(self, value, payload, client) |
validate(value) |
Lowercase, just the value. Use hook.Run for payload-aware validation. |
OnPostSetup(self, panel, payload) |
(no equivalent) | Parallax character creation UI is not field-injection based. |
OnSet(self, client, value) |
changed(char, new, old, bNoNet, recipients, bNoDB) |
Different signature, same purpose. |
OnAdjust(self, client, data, value, newData) |
(fold into validate or Set) |
|
bNoDisplay |
(no equivalent) | |
bNoNetworking |
(runtime option on SetVar) |
Pass { bNoNetworking = true } when calling SetVar. |
| (no field) | Get(self, char, ...) |
Optional custom getter to replace the default lookup. |
| (no field) | Set(self, char, ...) |
Optional custom setter. |
| (no field) | bNoGetter |
Suppress auto-generated GetX method. |
| (no field) | bNoSetter |
Suppress auto-generated SetX method. |
| (no field) | canPopulate(payload, client) |
Server check: can this var be set during character creation? |
Getters and Setters¶
Both frameworks auto-generate GetX / SetX methods on the character meta. Calling style is identical:
Capitalization: both capitalize the first letter of the var name. RegisterVar("attributes", ...) generates GetAttributes/SetAttributes. Aliases generate their own matching getters/setters.
In Helix, reading a var directly via character.vars.name is sometimes seen in older code — it works but is considered internal. In Parallax, character.vars.name also works, but the auto-generated getter is strongly preferred because it correctly resolves defaults and custom Get overrides.
The data Var¶
Both frameworks ship with a generic catch-all variable named data for per-character key-value storage that doesn't merit a dedicated var. The usage differs slightly.
Helix:
Parallax: The data var is registered as fieldType = ax.type.data, and the setter/getter signatures accept a nested key:
-- Reading nested data
local t = ax.character:GetVar(char, "data", "lastLogin", 0)
-- Writing nested data
ax.character:SetVar(char, "data", "lastLogin", { dataValue = os.time() })
-- Or, using the auto-generated getter (returns the full table):
local allData = character:GetData() -- returns { lastLogin = ..., ... }
The most ergonomic pattern for frequent access to a specific key is to register a dedicated var for it — don't lean on data for everything.
Var Change Callbacks¶
Helix offered two ways to react to var changes:
OnSeton the var itself (server-side).ix.char.HookVar(varName, hookName, fn)— registered per-var, per-hook.
Parallax has:
changedon the var itself (shared).- Generic
OnCharacterVarChangedhook, filtered on the var name.
Helix pattern:
ix.char.HookVar("money", "NotifyOnChange", function(character, oldValue)
local client = character:GetPlayer()
if ( IsValid(client) ) then
client:Notify("Money changed.")
end
end)
Parallax pattern (inside the var itself):
ax.character:RegisterVar("money", {
fieldType = ax.type.number,
default = 0,
changed = function(char, new, old)
local client = char:GetPlayer()
if ( IsValid(client) ) then
client:Notify("Money changed: $" .. new)
end
end,
})
Parallax pattern (from outside the var definition):
hook.Add("OnCharacterVarChanged", "NotifyMoneyChange", function(char, name, value)
if ( name != "money" ) then return end
local client = char:GetPlayer()
if ( IsValid(client) ) then
client:Notify("Money changed: $" .. value)
end
end)
When porting, place the callback in whichever pattern is closer to the original — HookVar calls tend to translate to hook.Add("OnCharacterVarChanged", ...) because they were usually registered from unrelated code files.
Character Lifecycle¶
| Event | Helix hook | Parallax hook |
|---|---|---|
| Player joins, chars load | CharacterLoaded (per char) |
OnCharacterLoaded |
| Player selects character | PlayerLoadedCharacter(client, new, old) |
PostPlayerLoadedCharacter(client, new, old) |
| Character created | OnCharacterCreated(client, char) |
OnCharacterCreated(client, char) |
| Character deleted | OnCharacterDelete(client, id) |
PreCharacterDeleted(client, char) |
| Character disconnect | OnCharacterDisconnect(client, char) |
OnCharacterDisconnected(character) |
| Var changed | ix.char.HookVar(varName, hookName, fn) |
OnCharacterVarChanged(char, name, value) |
Character Access Patterns¶
Getting the character object for a player is the same:
Getting a character by ID:
Iterating all loaded characters:
-- Helix
for id, char in pairs(ix.char.loaded) do ... end
-- Parallax
for id, char in pairs(ax.character.instances) do ... end
Getting a character's owning player:
Full Port: Custom "Reputation" Var¶
Start from a Helix registration and walk through the port step by step.
Helix (schema/meta/sh_character.lua or a plugin):
ix.char.RegisterVar("reputation", {
field = "reputation",
fieldType = ix.type.number,
default = 0,
alias = "Rep",
OnValidate = function(self, value, payload, client)
value = tonumber(value) or 0
return math.Clamp(value, -100, 100)
end,
OnSet = function(self, client, value)
if ( value < -50 ) then
client:Notify("Your reputation is critically low.")
end
end,
})
ix.char.HookVar("reputation", "OnRepChanged", function(char, old)
local client = char:GetPlayer()
if ( IsValid(client) ) then
client:Notify("Reputation changed: " .. char:GetReputation())
end
end)
Parallax (<your-schema>/gamemode/schema/meta/sh_character.lua):
ax.character:RegisterVar("reputation", {
field = "reputation",
fieldType = ax.type.number,
default = 0,
alias = { "Rep" },
validate = function(value)
value = tonumber(value) or 0
-- Clamp is not naturally returned; validate is a yes/no check.
-- Clamp inside the setter instead.
return isnumber(value) and value >= -100 and value <= 100
end,
changed = function(char, new, old)
local client = char:GetPlayer()
if ( !IsValid(client) ) then return end
if ( new < -50 ) then
client:Notify("Your reputation is critically low.")
end
client:Notify("Reputation changed: " .. new)
end,
})
Points of interest:
alias = "Rep"becomesalias = { "Rep" }— a mandatory table form.OnValidateboth validated and transformed (clamped) in Helix. Parallax'svalidateis strictly a boolean predicate. Do the clamp insidechanged, inside a customSet, or at the call site.OnSetand the externalHookVarconsolidate into a singlechangedcallback.
Both getters (GetReputation, GetRep) and setters (SetReputation, SetRep) are auto-generated.
Meta Methods on the Character Object¶
Methods that exist on both character objects with the same name and purpose:
char:GetID() -- database ID
char:GetPlayer() -- owning Player entity
char:GetName() -- display name
char:GetFaction() -- faction index
char:GetModel() -- model path
char:GetInventory() -- primary inventory object
char:GetData(k, d) -- generic data blob
char:SetData(k, v) -- generic data blob write
char:HasFlag(f) -- access flag check (both frameworks)
char:GiveFlag(f) -- grant flag
char:TakeFlag(f) -- revoke flag
char:GetMoney() -- if money var is registered (both frameworks default to money)
char:SetMoney(n) -- (same)
Parallax adds some methods not present in Helix:
char:GetVar(name, fallback) -- generic var reader with fallback
char:SetVar(name, value) -- generic var writer
char:GetInventoryID() -- just the numeric ID of the inventory
char:Save() -- explicit save to the database
Helix has some methods not directly present in Parallax:
char:GetAttribute(name) -- uses ix.attributes — see 10-classes-and-attributes.md
char:GetClass() -- available; call ax.class:Get(char:GetClass()) for the table
Pitfalls¶
aliasas string. Easiest mistake. Always wrap in{}for Parallax.validatereturning a value. Helix'sOnValidatecould return a transformed value to be stored. Parallax'svalidateis a predicate — transformation belongs inchangedor a customSet.payloadparameter gone. Helix'sOnValidatereceived the whole character-creation payload. Parallax'svalidatedoes not — if you need to cross-validate fields (e.g. "description must mention the faction name"), use theCanPlayerCreateCharacterhook instead.- Stale
ix.char.loadediteration. After porting, some code might still loopix.char.loaded— harmless (it's nil) but silently skips every character. Grep forix.char.loadedandix.char.cacheto catch these. - Character selection races. Helix's pre-loaded-on-join model means code could safely read any character's data during login processing. Parallax loads on select; code that runs on join and reads
ax.character:Get(id)might see nil. - Direct
character.vars.x = yassignment. Works but skips validation, networking, and the database write. Usecharacter:SetX(y)always.
Next: 06-commands.md