Skip to content

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.

A client write travels this path:

  1. Wire. The client fires the Settings_Set intent (key: String, value: Any), rate-limited at 10 writes/s per player by default.
  2. Gate. The kit installs its validator on Intent.Settings_Set at 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 into Min/Max.
    • boolean — type-checked.
    • string — type-checked, rejects past MaxLength (default 64).
    • strings — must be a table with at most MaxItems entries (default 8), each a string within MaxLength; the array is rebuilt from indices 1..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.
  3. Store. The handler writes into session.Profile.Data when a DataDriver profile is attached, else into session.Data (transient, per-visit).
  4. Announce. Settings.Changed publishes on the bus, and Settings_Sync pushes 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.luau
local Root = game:GetService("ServerScriptService").ChloeKernelServer
local 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

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 join
local function applyDashKeys(names: { string })
local Keys = {}
for _, Name in names do
table.insert(Keys, Enum.KeyCode[Name])
end
Input:rebind("Dash", "Keyboard", Keys)
end
applyDashKeys(Prefs:fetch().DashKeys)
-- settings menu: rebind locally, persist through the schema
local 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)
end

The MaxItems/MaxLength caps on the schema bound what a hostile client can store — two keys of at most 20 characters, nothing else, ever.

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.

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.

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

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
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
  • onChanged fires twice per successful local set — 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.Data and 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.huge gets Max, not a crash. Non-finite numbers still reject: NaN would otherwise poison comparisons downstream.