Player Settings
Persisted per-player preferences behind an allowlisted schema, with normalized fail-closed client writes, filtered text rules and an optimistic client cache that the server corrects.
- Realm
- Server + Client
- Require
ServerScriptService.ChloeKernelServer .Settings - Since
- v0.3.0
- Source
- Settings.luauNet/SettingsClient.luau
Settings stores per-player preferences behind an allowlisted schema. The schema is the allowlist: a client can write only
the keys it declares, every value is kind-checked and normalized before it reaches storage, and every client write runs
the fail-closed Intent.Settings_Set hook chain. Normalization is the security boundary. Numbers
clamp, oversized or malformed strings reject, and string arrays are rebuilt index by index so hidden hash keys never
reach storage: what gets stored is what validation produced, never what the client sent. Values persist in the
DataDriver profile. Text other players will see can pass through
TextFilter on the way in.
The client half, SettingsClient, is an optimistic cache. set updates the cache at once and sends an acknowledged
write; the server’s reply confirms, corrects or rejects it.
Mental model
Section titled “Mental model”A client write
Section titled “A client write”- SettingsClient calls the
Settings_Writerequest with(key, value). It is rate-limited per player atRateLimit(default 10 per second, burstmax(10, RateLimit)). - The kit’s validator on
Request.Settings_Writefires theIntent.Settings_Setchain with the same arguments andAcknowledged = true, so game validators on that hook apply to every write path. - The kit gate sits on
Intent.Settings_Setat priority 50, so game validators registered at lower numbers run first. It rejects keys missing from the schema, then runs the kind check and replaces the second argument with the normalized value. - A rejection by the gate or a game validator answers
{ Accepted = false, Value }, whereValueis the stored value. - An accepted write stores the value in
session.Profile.Data[Field], or insession.Data[Field]before the profile exists, publishesSettings.Changed(session, key, value)on the Bus, pushesSettings_Syncto the owner, and answers{ Accepted = true, Value }with the stored value. - A write that cannot be answered yet answers
false, which the client treats as transient and retries: the session is ending,WaitForProfileis on and the profile has not loaded, or a filtered write was superseded.
Kind checks
Section titled “Kind checks”| Kind | Rule | Reject reasons |
|---|---|---|
number |
Must be finite (NaN and infinities reject), then clamps into Min and Max. |
NotFiniteNumber |
boolean |
Type-checked. | NotBoolean |
string |
Type-checked, at most MaxLength bytes (default 64), valid UTF-8. |
NotString, TooLong, InvalidUtf8 |
strings |
A table of at most MaxItems entries (default 8), each a string within MaxLength and valid UTF-8. Rebuilt from indices 1 to n, so other keys in the table are dropped. |
NotArray, TooManyItems, NotString, TooLong, InvalidUtf8 |
Invalid UTF-8 rejects because DataDriver refuses to save a profile that holds a string it cannot serialize; one bad byte
sequence stored in settings would get every later save of that profile refused. A server-side set runs the same checks, but there an invalid value is a caller bug, so it errors with the
reason instead of returning.
Filtered text
Section titled “Filtered text”A string or strings rule with Filter = "Broadcast" is for text other players see: a nickname, a pet name, a guild
tag. After the validation chain passes, each string goes through TextFilter:broadcast, and the filtered result is
what gets stored, synced and published, so no path can show the raw text. Filtering yields.
- A refused or failed filter call rejects the write:
{ Accepted = false, Value }for acknowledged writes, aSettings_Syncrollback for the legacy intent,falsefrom a server-sideset. TextFilter fails closed. - A newer write to the same key, the session ending, or
destroy()while the filter yields supersedes the older write: it stores nothing. attachasserts thatFilteris"Broadcast"and appears only on string kinds.
Persistence
Section titled “Persistence”Writes made before the profile loads land in session.Data[Field]. When Kernel.ProfileLoaded fires, those values are
merged into the profile and win over the persisted ones, because they are the player’s most recent choices. Then
Settings_Sync pushes every schema key to the client, so a client that fetched before the profile loaded receives the
real values.
Stored values ride the profile’s normal saves. With PersistDelay above 0, each write also schedules a profile:save()
that many seconds after the session’s last write, one pending timer per session however fast a slider moves.
--!strict-- Settings: the per-player settings schema, which is also the client allowlist.
return { MusicVolume = { Kind = "number", Min = 0, Max = 1, Default = 0.5 }, ShowHints = { Kind = "boolean", Default = true }, Nickname = { Kind = "string", MaxLength = 20, Default = "", Filter = "Broadcast" }, DashKeys = { Kind = "strings", MaxItems = 2, MaxLength = 20, Default = { "Q" } },}--!strict-- SettingsService: stored player preferences and the rules around them.
local ServerScriptService = game:GetService("ServerScriptService")
local Schema = require(script.Parent.Parent.Config.Settings)local Settings = require(ServerScriptService.ChloeKernelServer.Settings)
local SettingsService = { Name = "SettingsService", Dependencies = { "DataService" },}
function SettingsService.init(self: any, kernel: any) self.Kernel = kernel self.Prefs = Settings.attach(kernel, { Schema = Schema, PersistDelay = 5 })end
function SettingsService.start(self: any) -- Runs before the kit gate (50): nicknames are a Premium perk self.Unhook = self.Kernel.Hooks:on("Intent.Settings_Set", function(context: any): boolean? local IsNickname = context.Args[1] == "Nickname" if IsNickname and context.Player.MembershipType ~= Enum.MembershipType.Premium then return false end return nil end, 10) self.Connection = self.Kernel.Bus:subscribe("Settings.Changed", function(_, session, key, value) if key == "Nickname" then session.Player:SetAttribute("Nickname", value) -- already filtered end end)end
-- Server-side read: the stored value, or the schema default.function SettingsService.hintsEnabled(self: any, session: any): boolean return self.Prefs:get(session, "ShowHints")end
function SettingsService.stop(self: any) if self.Unhook then self.Unhook() end if self.Connection then self.Connection:disconnect() end if self.Prefs then self.Prefs:destroy() endend
return SettingsService--!strict-- SettingsController: player preferences and the remappable dash key, applied as they change.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local SoundService = game:GetService("SoundService")
local InputDriver = require(ReplicatedStorage.ChloeKernel.InputDriver)local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local NetClient = require(ReplicatedStorage.ChloeKernel.Net.Client)local SettingsClient = require(ReplicatedStorage.ChloeKernel.Net.SettingsClient)
local Log = Logger.scope("SettingsController")
local SettingsController = { Name = "SettingsController",}
function SettingsController.init(self: any, kernel: any) self.Prefs = SettingsClient.new(NetClient.new()) self.Input = InputDriver.attach(kernel) self.Input:bindAction("Dash", { Keyboard = Enum.KeyCode.Q, Handler = function(state: Enum.UserInputState) if state == Enum.UserInputState.Begin then kernel.Bus:publish("Movement.DashRequested") end end, })end
-- Every value, from a fetch, a sync, a local set or a correction, lands herefunction SettingsController.apply(self: any, key: string, value: any) if key == "MusicVolume" then local Music = SoundService:FindFirstChild("Music") if Music and Music:IsA("SoundGroup") then Music.Volume = value end elseif key == "DashKeys" then self.Input:rebind("Dash", "Keyboard", InputDriver.parseKeys(value)) endend
function SettingsController.start(self: any) self.Changed = self.Prefs:onChanged(function(key: string, value: any) self:apply(key, value) end) self.Failed = self.Prefs:onWriteFailed(function(key: string, reason: string) -- "Rejected": the server refused the value. "Unavailable": retries ran out. The cache is already restored. Log.warn(`setting {key} was not saved ({reason})`) end) task.spawn(function() -- fetch fills the cache without firing onChanged, so apply what it returns for Key, Value in self.Prefs:fetch() do self:apply(Key, Value) end end)end
function SettingsController.setMusicVolume(self: any, volume: number) self.Prefs:set("MusicVolume", volume) -- the cache and onChanged update now; the server confirms or correctsend
-- The settings menu calls this with the key the player pressedfunction SettingsController.rebindDash(self: any, key: Enum.KeyCode) self.Prefs:set("DashKeys", { key.Name }) -- rebinds at once through onChanged; a rejection rebinds backend
function SettingsController.stop(self: any) if self.Prefs then self.Prefs:destroy() end if self.Input then self.Input:destroy() endend
return SettingsControllerPersist remappable keybinds
Section titled “Persist remappable keybinds”Keybinds live on the client in InputDriver; Settings is where they survive a rejoin. The
controller above stores key names in the DashKeys setting and turns them back into KeyCodes with
InputDriver.parseKeys, which drops unknown, deprecated and tampered names instead of erroring. Because every value
arrives through apply, the stored binding is restored on join (from the fetch or the profile-load sync), a local rebind
takes effect at once, and a rejected write rebinds back to the stored keys.
MaxItems = 2 and MaxLength = 20 bound what a hostile client can store under this key: two names of at most 20
bytes.
Wire channels
Section titled “Wire channels”attach defines four channels on the injected or default NetDriver:
| Channel | Kind | Schema | Wire name | Notes |
|---|---|---|---|---|
Settings_Write |
Request | { "String", "Any" } to { "Any" } |
CKR_Settings_Write |
SettingsClient’s write path. RateLimit per second, burst max(10, RateLimit), RejectValue = false. Answers { Accepted, Value }, or false for a transient failure. |
Settings_Get |
Request | {} to { "Any" } |
CKR_Settings_Get |
Returns the merged defaults and stored values. With WaitForProfile, waits for the profile up to ProfileWaitSeconds; the wait ends early when the session ends. A no-op validator at 50 keeps this payload-less read off the fail-closed reject path without marking it Open. |
Settings_Sync |
State | { "String", "Any" } |
CKS_Settings_Sync |
Authoritative key and value, pushed after every accepted write, for every key when the profile loads, and after the kit gate or the filter rejects a legacy intent. |
Settings_Set |
Intent | { "String", "Any" } |
CKI_Settings_Set |
The legacy fire-and-forget write, kept for older callers. Rate-limited at RateLimit. A kit gate or filter rejection pushes Settings_Sync with the stored value. |
The Any wire type does not constrain the value’s shape, which is why the hook-chain gate rejects anything the kind
check cannot normalize.
Client retries
Section titled “Client retries”SettingsClient keeps one pending target per key, the newest value set, and one worker that sends them:
- Each round gives every pending key one attempt, and waits 0.04 s between keys. A key whose request keeps timing out cannot hold the others behind all of its retries.
- A reply with
Accepted = falserestores the stored value in the cache (firingonChanged) and firesonWriteFailed(key, "Rejected")once. It is not retried. - A reply with a
Valueconfirms the write. The cache takes the stored value, so a clamped number corrects itself. - No usable reply (
false, or a timeout) retries after 0.15 s, up toMaxAttemptsattempts (default 3) for that value. Then the cache restores the last stored value andonWriteFailed(key, "Unavailable")fires. A newsetfor the key starts a fresh count. - A
Settings_Syncfor a key with a newer local write pending leaves the optimistic value alone.
fetch() never throws. When the server is still waiting for a slow profile, or the request times out, it returns the
current cache, and the Settings_Sync push at profile load delivers every key through onChanged. A fetch reply never
overwrites a key with a pending write or a key that synced while the fetch was in flight.
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 and 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 |
Byte cap per string. |
MaxItems |
strings |
8 |
Array length cap. |
Filter |
string, strings |
nil |
"Broadcast" stores TextFilter’s broadcast result. |
Client rejections also count as rejected requests or intents in the NetDriver, visible in the debug panels.
API reference
Section titled “API reference”Settings (server)
Section titled “Settings (server)”| Member | Description |
|---|---|
Settings.attach(kernel, config) -> settings |
Defines the four channels, installs the gates and subscribes to Kernel.ProfileLoaded. Asserts the Filter rules. |
settings:get(session, key) -> any |
The stored value or the rule’s Default. Errors for keys outside the schema. |
settings:all(session) -> { [string]: any } |
Every schema key, stored value or default. What Settings_Get serves. |
settings:set(session, key, value) -> boolean |
A server-side write through the same normalization. Errors on an unknown key or an invalid value. Yields for a Filter rule and returns false when the filter refused the text or a newer write superseded it. Errors when a trade holds the profile (profile is in a transaction). |
settings:destroy() |
Ends pending profile waits, cancels queued saves, removes the three validators and the profile subscription. The channels stay defined, so later client writes are rejected by the fail-closed rule. |
attach config:
| Field | Default | Meaning |
|---|---|---|
Schema |
required | The allowlist: a ValueRule per key. |
Field |
"Settings" |
The key inside profile.Data and session.Data that holds the values. |
RateLimit |
10 |
Writes per second per player, for Settings_Write and Settings_Set. |
PersistDelay |
0 |
Seconds after a session’s last write before an extra profile:save(). 0 relies on the profile’s normal saves. |
WaitForProfile |
false |
Settings_Get waits for the profile, and Settings_Write answers false (retry) until it loads. |
ProfileWaitSeconds |
30 |
Cap on that wait. At least 1. |
Net |
kernel:net() |
The NetDriver. Specs inject one. |
TextFilter |
TextFilter.new() when a rule filters |
The filter. Specs inject a double. |
SettingsClient
Section titled “SettingsClient”Lives at ReplicatedStorage.ChloeKernel.Net.SettingsClient.
| Member | Description |
|---|---|
SettingsClient.new(netClient, options?) -> client |
Wires the Settings_Get and Settings_Write requests and the Settings_Sync state on a NetClient. options.MaxAttempts (default 3, an integer from 1 to 10); Spawn and Wait are test injection points. |
client:fetch() -> { [string]: any } |
Yields for the server’s merged table, fills the cache and returns it. Returns the current cache when the reply is empty or late. Does not fire onChanged. |
client:get(key) -> any |
A cache read. nil before a fetch or the first sync for that key. |
client:set(key, value) |
Optimistic: updates the cache, fires onChanged when the value changed, and queues the write. Asserts value ~= nil. |
client:onChanged(fn) -> connection |
fn(key, value) whenever the cached value changes: optimistic sets, corrections, restores and syncs. |
client:onWriteFailed(fn) -> connection |
fn(key, reason) with "Rejected" or "Unavailable", after the cache is restored. |
client:destroy() |
Disconnects the sync, drops pending writes and destroys both signals. |
Hooks and Bus topics
Section titled “Hooks and Bus topics”| Name | Kind | Payload or context | Notes |
|---|---|---|---|
Intent.Settings_Set |
hook, fail-closed | context.Args = { key, value }, context.Acknowledged on acknowledged writes |
Kit gate at 50 rewrites Args[2] to the normalized value. Game rules go at lower numbers and apply to both write paths. |
Request.Settings_Write |
hook, fail-closed | context.Args = { key, value } |
Kit validator at 50 that runs the Intent.Settings_Set chain. |
Request.Settings_Get |
hook, fail-closed | no payload | No-op validator at 50. |
Settings.Changed |
Bus | session, key, value |
Every accepted write, from a client or a server-side set. Carries the stored (normalized, filtered) value. |
Kernel.ProfileLoaded |
Bus, consumed | session, profile |
Merges transient values into the profile, then syncs every key. |
Gotchas and design notes
Section titled “Gotchas and design notes”- Numbers clamp by design. A slider dragged past its range lands on the edge, and an exploiter writing
math.hugeis rejected as not finite instead of stored. NaN would poison every later comparison. onChangedfires only when the cached value changes. A confirmed write with the same value does not fire it again; a clamp or a rejection does. Handlers should still be safe to run twice.- Without a DataDriver profile, settings last one visit. Values live in
session.Dataand go with the session, and everything else behaves the same, which makes that mode useful in specs and prototypes. - A game validator that rejects a legacy
Settings_Setintent should push its ownSettings_Syncrollback, because the kit gate pushes one only for its own rejections. Acknowledged writes need nothing: the reply restores the client.