Skip to content

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.

  1. SettingsClient calls the Settings_Write request with (key, value). It is rate-limited per player at RateLimit (default 10 per second, burst max(10, RateLimit)).
  2. The kit’s validator on Request.Settings_Write fires the Intent.Settings_Set chain with the same arguments and Acknowledged = true, so game validators on that hook apply to every write path.
  3. The kit gate sits on Intent.Settings_Set at 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.
  4. A rejection by the gate or a game validator answers { Accepted = false, Value }, where Value is the stored value.
  5. An accepted write stores the value in session.Profile.Data[Field], or in session.Data[Field] before the profile exists, publishes Settings.Changed(session, key, value) on the Bus, pushes Settings_Sync to the owner, and answers { Accepted = true, Value } with the stored value.
  6. A write that cannot be answered yet answers false, which the client treats as transient and retries: the session is ending, WaitForProfile is on and the profile has not loaded, or a filtered write was superseded.
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.

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, a Settings_Sync rollback for the legacy intent, false from a server-side set. 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.
  • attach asserts that Filter is "Broadcast" and appears only on string kinds.

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.

src/Server/Game/Config/Settings.luau
--!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" } },
}
src/Server/Game/Services/SettingsService.luau
--!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()
end
end
return SettingsService

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.

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.

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 = false restores the stored value in the cache (firing onChanged) and fires onWriteFailed(key, "Rejected") once. It is not retried.
  • A reply with a Value confirms 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 to MaxAttempts attempts (default 3) for that value. Then the cache restores the last stored value and onWriteFailed(key, "Unavailable") fires. A new set for the key starts a fresh count.
  • A Settings_Sync for 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.

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.

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.

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.
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.
  • Numbers clamp by design. A slider dragged past its range lands on the edge, and an exploiter writing math.huge is rejected as not finite instead of stored. NaN would poison every later comparison.
  • onChanged fires 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.Data and 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_Set intent should push its own Settings_Sync rollback, because the kit gate pushes one only for its own rejections. Acknowledged writes need nothing: the reply restores the client.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.