LiveConfig
Feature flags and tunables changed without a redeploy, from a MemoryStore document with push invalidation or from Roblox Experience Configs, with defaults in code.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .LiveConfig - Since
- v0.1.0
- Source
- LiveConfig.luau
LiveConfig serves feature flags and tunables that change without a redeploy. Defaults ship in code; overrides live
outside it. A server that cannot reach the override source runs on its compiled-in defaults, so get always answers.
There are two sources:
- MemoryStore (the default). One shared document holds the overrides.
setandupdatewrite it from any server, push an invalidation over MessagingService so every server re-reads at once, and a slow poll reconciles anything missed. - ConfigService (
Source = "ConfigService"). Roblox Experience Configs, edited in the Creator Hub or through Open Cloud. LiveConfig reads a snapshot at boot and follows its update event, with no polling, MemoryStore or MessagingService, andforPlayerserves per-player experiment values.
Both publish Config.Changed(key, value) on the Bus whenever an effective value changes.
Mental model
Section titled “Mental model”Each instance holds three tables:
Defaults: a copy of what you passed tonew. Never changed.Values: the effective table thatgetreads. Starts as a copy ofDefaults.Overrides: which keys currently differ from their default because the source says so.
Change detection is by value. Scalars compare with ==; tables compare by their Serde encoding, so a
re-read of an identical table does not fire Config.Changed again. Reverting a key to its default fires
Config.Changed with the default.
The MemoryStore document
Section titled “The MemoryStore document”The overrides live in one document under the key config in a MemoryDriver hash map named
CKLiveConfig, with a 7-day TTL. Every server reads the same document.
A server reconciles its values with the document on every read:
- Each key in the document becomes an override, and
Config.Changedfires when its effective value changed. - Each key that is overridden but missing from the document reverts to its default. Otherwise a deleted override would stay forever.
- A document that does not exist (expired, or never written) reverts every override. That keeps a long-running server in agreement with a fresh one, which boots straight into defaults.
- A read that fails (MemoryStore unreachable or timing out) changes nothing. The server keeps what it last knew, so a brief outage does not flap the config.
Reads happen at boot (on their own thread, so new never yields), on every invalidation, and on the poll. Only one read
runs at a time: an invalidation that arrives during a read queues one more read after it. A read that started before a
local write on the same server is discarded when it lands, so it cannot roll that write back.
Writes and invalidation
Section titled “Writes and invalidation”set(key, value) runs a read-modify-write (UpdateAsync underneath) on the document, stamps it, publishes an
invalidation on the MessagingService topic CKLiveConfig, and applies the change on this server at once. Every
subscribed server re-reads when the invalidation arrives, so the fleet sees the change as fast as MessagingService
delivers it.
set(key, nil) deletes the override and every server reverts to its default. When the write fails, set returns
false, err and nothing changes on this server.
update(transform) is the same for several keys at once. transform receives the document without its stamp (an empty
table when there is none) and returns the new document plus any result you want back. The whole returned document is
applied locally, including reverts for keys it no longer has.
The invalidation is one publish attempt with no retry. The poll is the backstop: it runs every 30 s by default, and every
120 s once the server’s invalidation subscription is live. An explicit PollSeconds is kept as given. Where
MessagingService is unavailable, as in Studio, the subscription fails quietly and the poll stays at 30 s.
Keeping the document alive
Section titled “Keeping the document alive”MemoryStore TTLs refresh only on write, and a document nobody writes for a week would expire and revert every override.
Each write stamps the document with the reserved field __CKWrittenAt. When a poll finds the stamp older than this
server’s refresh age, it re-stamps the document through an update that checks the stamp again inside the transform, so
only one server refreshes. Each server picks its refresh age once, between half and three quarters of the TTL
(3.5 to 5.25 days), so the fleet does not refresh at the same moment. The document expires only after a full TTL with
no server running.
Experience Configs source
Section titled “Experience Configs source”Source = "ConfigService" reads Roblox Experience Configs (ConfigService, generally available since November 2025):
- On its own thread,
newcallsConfigService:GetConfigAsync(). If that fails, the server serves defaults, warns once, and retries on the Scheduler atBackgroundpriority after 5, 15, 45 and 135 seconds, then every 300 seconds. - Once it has a snapshot, it connects
ConfigSnapshot.UpdateAvailable. Each update callsRefresh()and re-applies. A snapshot that is alreadyOutdatedwhen LiveConfig connects refreshes at once, because an update that landed before the connection fires no event. - Applying reads only the keys declared in
Defaults. A missing value reverts the key to its default. A value whose type differs from its default is ignored with one warning per key, and the key keeps its default. Keys outsideDefaultsare never read. - A snapshot whose
Erroris notNonereturnsnilfor every key. That is not a reset, so LiveConfig keeps the current values and warns once until a healthy snapshot arrives.
set and update return false with "ConfigService values are edited in the Creator Hub or through Open Cloud".
apply(key, value) still overrides one server.
Per-player values
Section titled “Per-player values”Experiments in Experience Configs apply per player. forPlayer(session) returns a view whose get(key) answers for
that player:
- The first call for a session yields for
ConfigService:GetConfigForPlayerAsync(player). Concurrent calls for the same session wait for that one request. The view follows its snapshot’sUpdateAvailableand is released when the session ends. view:get(key)returns the player’s value when the key is declared inDefaults, the snapshot has no error, and the value’s type matches the default. Otherwise it returns the server-wide value fromget.- When the player’s snapshot cannot be fetched, the call warns, the view serves server-wide values, and the next call for that session tries again.
- In MemoryStore mode
forPlayerreturns at once with a view of the server-wide values.
--!strict-- TuningService: live tunables for the boss event and the shop.
local ServerScriptService = game:GetService("ServerScriptService")
local LiveConfig = require(ServerScriptService.ChloeKernelServer.LiveConfig)
local TuningService = { Name = "TuningService",}
function TuningService.init(self: any, kernel: any) self.Kernel = kernel self.Config = LiveConfig.new(kernel, { Defaults = { BossHealth = 100000, DoubleXp = false, NewShopEnabled = true, }, })end
function TuningService.start(self: any) self.Connection = self.Kernel.Bus:subscribe("Config.Changed", function(_, key, value) if key == "DoubleXp" then self.Kernel.Bus:publish("Event.DoubleXp", value) -- this game's own topic end end)end
-- Always answers: the override when one exists, else the default. Never yields.function TuningService.bossHealth(self: any): number return self.Config:get("BossHealth")end
function TuningService.stop(self: any) if self.Connection then self.Connection:disconnect() end if self.Config then self.Config:destroy() endend
return TuningServiceFor Experience Configs, change the constructor:
self.Config = LiveConfig.new(kernel, { Source = "ConfigService", -- edited in the Creator Hub or through Open Cloud Defaults = { BossHealth = 100000, DoubleXp = false, ShopLayout = "Grid" },})
-- Per-player experiment value. Yields the first time per session.local Layout = self.Config:forPlayer(session):get("ShopLayout")Change a value across the fleet
Section titled “Change a value across the fleet”In MemoryStore mode, set writes the shared document, so it works from any server, including an instance built in the
developer console of a live server:
-- Developer console, server side, on any live serverlocal ServerScriptService = game:GetService("ServerScriptService")local Root = ServerScriptService.ChloeKernelServerlocal LiveConfig = require(Root.LiveConfig)local MessageDriver = require(Root.MessageDriver)local ServerKernel = require(Root)
local Ops = LiveConfig.new(ServerKernel.current(), { Defaults = {}, SkipLoop = true, -- no poll and no subscription: this instance only writes Messages = MessageDriver.new({ BackoffSeconds = {} }), -- SkipLoop builds none, and set needs it to push})
print(Ops:set("DoubleXp", true)) -- every server re-reads on the invalidationprint(Ops:set("NewShopEnabled", false)) -- an incident kill switchprint(Ops:set("DoubleXp", nil)) -- deletes the override: every server reverts to its defaultWithout the Messages line, a SkipLoop instance publishes no invalidation and the fleet picks the change up on its next
poll, up to 120 seconds later. The operating loop this supports: flip a flag during an incident, watch it land, then
ship the new value as a code default and delete the override.
API reference
Section titled “API reference”| Member | Description |
|---|---|
LiveConfig.new(kernel, options) -> config |
Copies Defaults into the effective table. MemoryStore mode (unless SkipLoop): schedules the poll, spawns the boot read and the invalidation subscription. ConfigService mode: spawns the snapshot connection. Never yields. |
config:get(key) -> any |
The effective value: the override if there is one, else the default. A table read; never yields. |
config:set(key, value) -> (boolean, string?) |
Yields. Writes the key into the shared document, publishes an invalidation, applies locally. nil deletes the override. false, err when the write failed, in ConfigService mode, or after destroy(). Errors for the reserved key __CKWrittenAt. |
config:update(transform) -> (boolean, any, string?) |
Yields. transform(document) -> (document, result) edits the whole document; it must return a table. Returns true, result or false, nil, err. Refused the same way as set. |
config:apply(key, value) |
Overrides this server only, with no write. nil reverts the key. Discards a read already in flight. |
config:forPlayer(session) -> view |
A per-player view. Yields the first time per session in ConfigService mode. |
view:get(key) -> any |
The player’s value, or the server-wide value. |
config:destroy() |
Stops the poll, disconnects the invalidation subscription and the snapshot, cancels a pending ConfigService retry, and releases every player view. |
Options
Section titled “Options”| Option | Default | Meaning |
|---|---|---|
Defaults |
required | The value per key when no override exists or the source is unreachable. |
Source |
"MemoryStore" |
"MemoryStore" or "ConfigService". |
PollSeconds |
30, or 120 once the invalidation subscription is live |
MemoryStore poll cadence. An explicit value is never changed. |
Driver |
MemoryDriver on hash map CKLiveConfig, 7-day TTL |
The document store. Inject a double in specs, or a differently named store for a separate config namespace. |
Messages |
a MessageDriver with one attempt per publish | The invalidation transport. Not created when SkipLoop is set. |
Clock |
os.time |
The document stamp’s clock. |
SkipLoop |
false |
No poll, no boot read and no subscription: write-only instances and specs. |
ConfigService |
the engine service | Test injection point. |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Config.Changed |
key, value |
An effective value changed: a local set, update or apply, a read of the document, a ConfigService update, or a revert to the default (then value is the default). Never fires for a change that leaves the value equal. |
Gotchas and design notes
Section titled “Gotchas and design notes”- A failed
setis not always a no-op. MemoryDriver reports an update whose outcome is unknown (a timeout that may have committed) as a failure instead of running the transform twice. The next read reconciles every server, this one included, to whatever the document holds. - An unreachable store holds the last known values, and an expired or missing document reverts every override. The first protects against outages; the second keeps old and new servers in agreement.
- In MemoryStore mode, keys outside
Defaultswork with one asymmetry:setstores them andgetserves them, but their default isnil, so deleting the override reverts them tonil. In ConfigService mode they are never read. Declare every key you use. - The whole override table is one MemoryStore value, which caps at 32 KB. LiveConfig is for flags and tunables, not content tables.
- Each server reads the document once per poll (every 120 s once subscribed) plus once per invalidation, and
holds one MessagingService subscription.
setandupdatecost one publish each. update’s transform runs insideUpdateAsyncand may run more than once. Keep it free of side effects.