Skip to content

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. set and update write 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, and forPlayer serves per-player experiment values.

Both publish Config.Changed(key, value) on the Bus whenever an effective value changes.

Each instance holds three tables:

  • Defaults: a copy of what you passed to new. Never changed.
  • Values: the effective table that get reads. Starts as a copy of Defaults.
  • 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 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:

  1. Each key in the document becomes an override, and Config.Changed fires when its effective value changed.
  2. Each key that is overridden but missing from the document reverts to its default. Otherwise a deleted override would stay forever.
  3. 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.
  4. 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.

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.

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.

Source = "ConfigService" reads Roblox Experience Configs (ConfigService, generally available since November 2025):

  1. On its own thread, new calls ConfigService:GetConfigAsync(). If that fails, the server serves defaults, warns once, and retries on the Scheduler at Background priority after 5, 15, 45 and 135 seconds, then every 300 seconds.
  2. Once it has a snapshot, it connects ConfigSnapshot.UpdateAvailable. Each update calls Refresh() and re-applies. A snapshot that is already Outdated when LiveConfig connects refreshes at once, because an update that landed before the connection fires no event.
  3. 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 outside Defaults are never read.
  4. A snapshot whose Error is not None returns nil for 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.

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’s UpdateAvailable and is released when the session ends.
  • view:get(key) returns the player’s value when the key is declared in Defaults, the snapshot has no error, and the value’s type matches the default. Otherwise it returns the server-wide value from get.
  • 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 forPlayer returns at once with a view of the server-wide values.
src/Server/Game/Services/TuningService.luau
--!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()
end
end
return TuningService

For 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")

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 server
local ServerScriptService = game:GetService("ServerScriptService")
local Root = ServerScriptService.ChloeKernelServer
local 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 invalidation
print(Ops:set("NewShopEnabled", false)) -- an incident kill switch
print(Ops:set("DoubleXp", nil)) -- deletes the override: every server reverts to its default

Without 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.

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.
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.
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.
  • A failed set is 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 Defaults work with one asymmetry: set stores them and get serves them, but their default is nil, so deleting the override reverts them to nil. 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. set and update cost one publish each.
  • update’s transform runs inside UpdateAsync and may run more than once. Keep it free of side effects.

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