Player Settings
Settings is persisted per-player preferences behind an allowlisted schema. The schema is the allowlist: clients may only write keys it declares, every value is kind-checked and normalized before it touches storage, and every client write runs the fail-closed Intent.Settings_Set hook chain. The defining decision: normalization is the security boundary. Out-of-range numbers clamp, oversized strings reject, and string arrays are rebuilt index-by-index so hidden hash keys never reach storage — what the handler stores is what the validator produced, not what the client sent.
The client side is SettingsClient, a thin optimistic cache: set() applies locally and fires the intent; the server clamps or rejects; a Settings_Sync state push carries the authoritative value back.
Mental model
Section titled “Mental model”A client write travels this path:
- Wire. The client fires the
Settings_Setintent (key: String,value: Any), rate-limited at 10 writes/s per player by default. - Gate. The kit installs its validator on
Intent.Settings_Setat priority 50 — game validators registered at lower numbers run first. The gate rejects keys absent from the schema, then runs the kind check:number— must be finite (NaN and infinities reject), then clamps intoMin/Max.boolean— type-checked.string— type-checked, rejects pastMaxLength(default 64).strings— must be a table with at mostMaxItemsentries (default 8), each a string withinMaxLength; the array is rebuilt from indices1..n, so non-array keys smuggled in the same table are dropped.- The gate then overwrites the intent’s second argument with the normalized value — the handler stores what validation produced.
- Store. The handler writes into
session.Profile.Datawhen a DataDriver profile is attached, else intosession.Data(transient, per-visit). - Announce.
Settings.Changedpublishes on the bus, andSettings_Syncpushes the authoritative key/value back to the owning client.
Server-side set() runs the same normalization — but an invalid value there is a caller bug, so it errors instead of silently rejecting.
Persistence works pre-profile too. Writes made before the profile loads land in session.Data. When Kernel.ProfileLoaded fires, those transient values merge into the profile and win over persisted ones — the transient values are the player’s most recent choices.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Settings = require(Root.Settings)
return function(kernel) local Prefs = Settings.attach(kernel, { Schema = { MusicVolume = { Kind = "number", Min = 0, Max = 1, Default = 0.5 }, ShowHints = { Kind = "boolean", Default = true }, Nickname = { Kind = "string", MaxLength = 16, Default = "" }, DashKeys = { Kind = "strings", MaxItems = 2, MaxLength = 20, Default = { "Q" } }, }, })
-- server-side read: the default until the player writes kernel:onSession(function(session) session.Data.HintsEnabled = Prefs:get(session, "ShowHints") end)
-- game rule ahead of the kit gate (50): premium-only nicknames kernel.Hooks:on("Intent.Settings_Set", function(context) local IsNickname = context.Args[1] == "Nickname" if IsNickname and context.Player.MembershipType ~= Enum.MembershipType.Premium then return false end end, 10)
kernel.Bus:subscribe("Settings.Changed", function(_, session, key, value) print(session.Player.Name, "set", key, "=", value) end)end-- src/Client/Bootstrap.luaulocal ChloeKernel = game:GetService("ReplicatedStorage").ChloeKernellocal NetClient = require(ChloeKernel.Net.Client)local SettingsClient = require(ChloeKernel.Net.SettingsClient)
return function(kernel) local Net = NetClient.new() local Prefs = SettingsClient.new(Net)
-- one yielding round trip primes the cache with merged defaults + overrides local All = Prefs:fetch() applyMusicVolume(All.MusicVolume)
-- optimistic write: cache updates now, the server confirms or corrects Prefs:set("MusicVolume", 0.8)
-- fires for local optimistic sets AND every server-confirmed change Prefs:onChanged(function(key, value) if key == "MusicVolume" then applyMusicVolume(value) end end)endPersist remappable keybinds
Section titled “Persist remappable keybinds”Keybinds live client-side in the InputDriver; Settings is where they survive rejoins. The pattern: one strings setting per remappable action, key names on the wire, Enum.KeyCode lookups on arrival. Input:getBindings() snapshots every action’s bindings as name strings, ready to store:
-- src/Client/Bootstrap.luau (continuing from above)local InputDriver = require(ChloeKernel.InputDriver)
local Input = InputDriver.attach(kernel)Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Handler = function(state) if state == Enum.UserInputState.Begin then kernel.Bus:publish("Movement.DashRequested") end end,})
-- restore persisted keys on joinlocal function applyDashKeys(names: { string }) local Keys = {} for _, Name in names do table.insert(Keys, Enum.KeyCode[Name]) end Input:rebind("Dash", "Keyboard", Keys)endapplyDashKeys(Prefs:fetch().DashKeys)
-- settings menu: rebind locally, persist through the schemalocal function rebindDash(newKey: Enum.KeyCode) Input:rebind("Dash", "Keyboard", newKey) local Names = Input:getBindings().Dash.Keyboard -- key names, not enums if type(Names) == "string" then Names = { Names } -- the "strings" rule requires an array end Prefs:set("DashKeys", Names)endThe MaxItems/MaxLength caps on the schema bound what a hostile client can store — two keys of at most 20 characters, nothing else, ever.
Sync protocol
Section titled “Sync protocol”Three channels, defined by attach on the injected or default NetDriver:
| Channel | Kind | Schema | Wire name | Notes |
|---|---|---|---|---|
Settings_Set |
Intent | { "String", "Any" } |
CKI_Settings_Set |
Rate-limited (RateLimit, default 10/s). Guarded by the kit gate at priority 50 |
Settings_Get |
Request | {} → { "Any" } |
CKR_Settings_Get |
Returns the merged defaults+overrides table. A payload-less read: a no-op validator at 50 keeps it off the fail-closed reject path without marking the channel Open |
Settings_Sync |
State | { "String", "Any" } |
CKS_Settings_Sync |
Authoritative key/value push to the owner after every accepted write, client- or server-initiated |
The "Any" wire type means the packet schema does not constrain the value’s shape — the hook-chain gate is the security boundary, which is exactly why it rejects anything the kind check cannot normalize.
Validation rules
Section titled “Validation rules”Each schema entry is a ValueRule:
| Field | Applies to | Default | Meaning |
|---|---|---|---|
Kind |
all | required | "number", "boolean", "string", or "strings" |
Default |
all | required | Served by get/all until a value is stored |
Min / Max |
number |
unbounded | Clamp range — out-of-range values clamp, they do not reject |
MaxLength |
string, strings |
64 |
Per-string byte cap — rejects TooLong |
MaxItems |
strings |
8 |
Array length cap — rejects TooManyItems |
Reject reasons produced by the kind check: NotFiniteNumber, NotBoolean, NotString, TooLong, NotArray, TooManyItems. Client rejections surface as ordinary intent rejections (counted in the driver’s IntentsRejected, visible in the NET panel); the same reasons appear in the error message when a server-side set is handed an invalid value.
API reference
Section titled “API reference”Settings (server)
Section titled “Settings (server)”| Member | Description |
|---|---|
Settings.attach(kernel, config) → Settings |
Defines the three channels, installs the gates, and subscribes to Kernel.ProfileLoaded for the transient merge |
settings:get(session, key) → any |
Stored value or the rule’s Default. Errors on keys outside the schema |
settings:all(session) → table |
The merged table — every schema key, stored value or default. This is what Settings_Get serves |
settings:set(session, key, value) |
Server-side write through the same normalization. Errors on unknown keys or invalid values — an invalid server write is a bug, not input |
settings:destroy() |
Removes the gates and the profile subscription |
Config fields for attach:
| Field | Default | Meaning |
|---|---|---|
Schema |
required | The allowlist — a ValueRule per key |
Field |
"Settings" |
The key inside profile.Data / session.Data where values live |
RateLimit |
10 |
Settings_Set intents per second per player |
Net |
kernel:net() |
Injected driver — specs pass a fake-packet driver here |
SettingsClient API
Section titled “SettingsClient API”Lives at ReplicatedStorage.ChloeKernel.Net.SettingsClient.
| Member | Description |
|---|---|
SettingsClient.new(netClient) → SettingsClient |
Wires the intent, request, and state handles on an existing NetClient |
client:fetch() → table |
Yields for the server’s merged table and primes the cache. Call once after join |
client:get(key) → any |
Cache read — nil before fetch or the first sync for that key |
client:set(key, value) |
Optimistic: updates the cache, fires the intent, fires onChanged locally. The server’s Settings_Sync confirms or corrects |
client:onChanged(fn) → connection |
fn(key, value) for optimistic local writes and every server-confirmed change. Disconnectable |
client:destroy() |
Disconnects the sync subscription and the changed signal |
Hooks & bus topics
Section titled “Hooks & bus topics”| Name | Kind | Args / context | Notes |
|---|---|---|---|
Intent.Settings_Set |
hook, fail-closed | context.Args = { key, value } |
Kit gate at priority 50 rewrites Args[2] to the normalized value. Prepend game rules at lower numbers |
Request.Settings_Get |
hook, fail-closed | no payload | No-op validator at 50 — present so the payload-less read is not rejected for lacking a validator |
Settings.Changed |
bus | session, key, value |
Published on every accepted write, client intent or server set |
Kernel.ProfileLoaded |
bus (consumed) | session, profile |
Triggers the transient-into-profile merge; transient values win |
Gotchas & design notes
Section titled “Gotchas & design notes”onChangedfires twice per successful localset— once optimistically, once when the sync confirms (even if the value is identical). Make handlers idempotent; applying a volume twice must be harmless.- Without a DataDriver, settings are per-visit. Values live in
session.Dataand vanish on leave. Everything else behaves identically, which makes the no-persistence mode useful in specs and prototypes. - After
destroy(), client writes reject. The channels stay defined and the handlers attached, but the gates are gone — and a handler-bearing channel with no validator is rejected by the fail-closed rule (see Architecture & security). Absence of a validator is not permission. - Numbers clamp by design. A slider dragged past its range should land on the edge, not error — and an exploiter writing
math.hugegetsMax, not a crash. Non-finite numbers still reject: NaN would otherwise poison comparisons downstream.