LiveConfig
LiveConfig is feature flags and tunables changed without redeploying. Values live in a single polled MemoryStore document; set() from any server reaches the whole fleet within one poll interval, and set(key, nil) deletes the override so every server reverts to its default. The defining design decision: defaults ship in code, overrides live in memory. A server that cannot reach MemoryStore runs on its compiled-in defaults — LiveConfig degrades to a static config table, never to missing values.
Mental model
Section titled “Mental model”Each instance holds three tables:
Defaults— a clone of what you passed at construction. Never mutated.Values— the effective tableget()reads. Starts as a clone ofDefaults.Overrides— a set marking which keys currently differ because the document says so.
The remote side is one document under the key "config" in a MemoryDriver — by default a MemoryStore hash map named CKLiveConfig with a 7-day TTL (7 * 24 * 3600 seconds). The whole fleet polls the same document.
The poll loop (every PollSeconds, default 30, at Background priority) fetches the document and reconciles:
- Every key in the document becomes an override. If the effective value actually changed,
Config.Changedpublishes on the bus. - Every key currently overridden but absent from the document reverts to its default — otherwise a deleted override would stay stale forever. The revert publishes
Config.Changedtoo.
A boot-time poll runs immediately at construction (in a spawned task, so new() never yields) — without it, overrides would apply one full interval late on fresh servers.
Change detection is by value, not by write. Scalars compare with ==; tables compare by their Serde encoding, so a poll that re-reads an identical table does not re-fire Config.Changed. Subscribers only hear about effective changes.
set() is write-through. It runs a retried read-modify-write (UpdateAsync underneath) on the document, then applies the change locally at once — the server issuing the change sees it immediately, the rest of the fleet within one poll. If the MemoryStore write fails after retries, set returns (false, err) and nothing changes anywhere.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServerlocal LiveConfig = require(Root.LiveConfig)
return function(kernel) local Config = LiveConfig.new(kernel, { Defaults = { BossHealth = 100000, DoubleXp = false, NewShopEnabled = true, }, })
-- read anywhere, any time — always answers, override or default local Health = Config:get("BossHealth")
-- react to changes (local set or remote poll) kernel.Bus:subscribe("Config.Changed", function(_, key, value) if key == "DoubleXp" then announceEvent(value) end end)endChange a value across the fleet
Section titled “Change a value across the fleet”set() writes the shared document, so it works from any server — including a fresh instance constructed in the live dev console. Every instance pointing at the same store name converges on the same document:
-- in the developer console of any live server:local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal LiveConfig = require(Root.LiveConfig)local ServerKernel = require(Root)
local Config = LiveConfig.new(ServerKernel.current(), { Defaults = {}, -- ops instance: no local reads needed SkipLoop = true, -- no poll loop; this instance only writes})
Config:set("DoubleXp", true) -- whole fleet within one poll (≤30s)Config:set("NewShopEnabled", false) -- mid-incident kill switchConfig:set("DoubleXp", nil) -- delete the override: fleet reverts to defaultsThe ops loop this enables: flip a flag during an incident, watch it land within a poll, and once the dust settles ship the new value as a code default and delete the override.
API reference
Section titled “API reference”| Member | Description |
|---|---|
LiveConfig.new(kernel, options) → LiveConfig |
Clones Defaults into the effective table, builds the MemoryStore driver, and (unless SkipLoop) starts the poll loop plus an immediate boot-time poll |
config:get(key) → any |
The effective value: the override if the document has one, else the default. Pure table read — never yields |
config:set(key, value) → (boolean, string?) |
Read-modify-writes the shared document, then applies locally. set(key, nil) deletes the override; every server reverts to its default on its next poll. (false, err) when the store write failed |
Options
Section titled “Options”| Option | Default | Meaning |
|---|---|---|
Defaults |
required | The compiled-in value per key — what every server runs on when no override exists or MemoryStore is unreachable |
PollSeconds |
30 |
Poll cadence; also the fleet-wide propagation bound |
Driver |
MemoryDriver on hash map CKLiveConfig, 7-day TTL |
Injected store — mocks in specs, or a differently-named store to run several independent config namespaces |
SkipLoop |
false |
No poll loop and no boot-time poll — write-only ops instances and specs |
Bus topics
Section titled “Bus topics”| Topic | Args | Published when |
|---|---|---|
Config.Changed |
key, value |
The effective value of a key changed — local set, a remote override arriving via poll, or a revert to default (then value is the default). Never fires for writes that leave the value equal |
Gotchas & design notes
Section titled “Gotchas & design notes”- Unreachable store = defaults, not errors. A failed poll changes nothing:
get()keeps serving whatever the server last knew (defaults on a fresh server). Onlyset()surfaces store failures, through its return values. - Convergence is bounded, not instant. Servers poll on independent clocks, so a fleet-wide change lands over a window of up to
PollSeconds. During that windowConfig.Changedfires at different times on different servers — do not use it to coordinate anything that needs simultaneity. - Keys outside
Defaultswork, with one asymmetry. Aseton an undeclared key propagates andgetserves it — but its “default” isnil, so deleting the override reverts it tonil. Declare every key you intend to use. - One document, 32KB. The entire config table serializes into a single MemoryStore value, which caps at 32KB. LiveConfig is for flags and tunables, not content tables — dozens of scalars, not item catalogs.
- Polls cost quota. Every server reads one MemoryStore value every
PollSeconds. At the default 30s that is well inside quotas at any realistic server count; loweringPollSecondsfor faster propagation trades directly against them.