MemoryDriver
MemoryDriver is cross-server state: round status, global boss health, live leaderboards, matchmaking queues — anything every server needs to see now and nobody needs to keep forever. It wraps MemoryStoreService with the defining decision that values are buffer-serialized: MemoryStore quotas charge by size and cap values at 32KB, so every byte of a value is a byte of quota, and packed buffers are dramatically smaller than the JSON tables most code writes there.
It completes the infrastructure trio: DataDriver owns storage, MessageDriver owns events, MemoryDriver owns state.
Mental model
Section titled “Mental model”One driver wraps one map. Kind picks which MemoryStore primitive backs it:
"HashMap"(default) — unordered key-value, the general case."SortedMap"— ordered keys, unlocksgetRange()for leaderboard-style reads.
Every value goes through a three-stage pipeline on the way in, and the reverse on the way out:
- Encode. With a
Codec, the value packs through that Serde schema — smallest output, typed fields. Without one,Serde.encodepacks it adaptively — still smaller than JSON, no schema required. - Feature-detect buffer support. MemoryStore’s acceptance of raw
buffervalues is probed at runtime per store, never assumed. The first write attempts a raw buffer; only an error mentioningtypeproves buffers are unsupported — a throttle or timeout during the probe must not lock the driver into the fallback, because base64 costs ~33% size overhead forever after. - Pack. Raw buffer if supported; otherwise a base64 string prefixed
CK1:— a versioned marker so a plain string someone else wrote can never be misread as packed data.
On read, the same detection runs in reverse: buffers and CK1:-prefixed strings decode through the codec; anything else is a foreign value — written by something that isn’t a MemoryDriver — and is handed back untouched. You can point a driver at a map that other code already uses.
Every operation runs through a retry ladder (BackoffSeconds, default { 0.25, 0.5, 1 }, attempts = #BackoffSeconds + 1); a zero entry retries without yielding a frame. MemoryStore throttles by request and by size — the packing halves one axis, the ladder absorbs transient failures on the other.
Shared world state, mutated safely from any server:
local GlobalBoss = MemoryDriver.new({ Name = "GlobalBoss", Codec = Serde.schema({ Health = "NumberU32", Phase = "NumberU8" }), DefaultTtlSeconds = 300,})
GlobalBoss:update("current", function(state) state = state or { Health = 1000000, Phase = 1 } state.Health -= Damage return state -- return nil to abort the writeend)update is the safe mutation primitive: the transform receives the decoded current value (or nil for a missing key), and its return is re-encoded and written through UpdateAsync. Returning nil aborts — nothing is written, which is how you express “only take this slot if nobody owns it”:
Coordinator:update("owner", function(current) if current ~= nil then return nil -- someone else already claimed it; abort, write nothing end return { JobId = game.JobId, At = os.time() }end, 30)A live cross-server leaderboard rides a SortedMap:
local Board = MemoryDriver.new({ Name = "WeeklyKills", Kind = "SortedMap", Codec = Serde.schema({ Kills = "NumberU32", Name = "String" }), DefaultTtlSeconds = 7 * 24 * 3600,})
Board:set(tostring(Player.UserId), { Kills = Kills, Name = Player.Name })
local Ok, Top = Board:getRange(Enum.SortDirection.Descending, 10)if Ok then for Rank, Entry in Top :: any do print(`{Rank}. {Entry.value.Name} — {Entry.value.Kills}`) endendFor persistent, paged, cross-week leaderboards see Leaderboards — MemoryDriver is the live tier.
Configuration
Section titled “Configuration”| Field | Default | Description |
|---|---|---|
Name |
— (required) | The MemoryStore map name. |
Kind |
"HashMap" |
"HashMap" or "SortedMap". Only SortedMap supports getRange. |
Codec |
nil |
Serde schema codec. Without one, values pack adaptively via Serde.encode. |
DefaultTtlSeconds |
300 |
Expiry applied when a call passes no ttl. Every write refreshes the TTL. |
BackoffSeconds |
{ 0.25, 0.5, 1 } |
Retry ladder; attempts = length + 1. |
Store |
nil |
Injected mock store for tests — the spec suite runs entirely against fakes. |
ForceBase64 |
false |
Skip buffer detection and always pack as CK1: base64 strings. |
API reference
Section titled “API reference”| Member | Description |
|---|---|
MemoryDriver.new(config) → MemoryDriver |
Wraps GetHashMap(Name) or GetSortedMap(Name) unless Store is injected. |
driver:set(key, value, ttl?) → (ok, err?) |
Encode, pack, SetAsync. Runs the buffer-support probe on first use. |
driver:get(key) → (ok, value, err?) |
GetAsync + decode. A missing key is true, nil. A stored value that fails to decode is an error, not nil. |
driver:update(key, transform, ttl?) → (ok, newValue, err?) |
Atomic UpdateAsync over the decoded value. transform returning nil aborts with nothing written. |
driver:remove(key) → (ok, err?) |
RemoveAsync, retried. |
driver:getRange(direction?, count?) → (ok, { { key, value } }?, err?) |
SortedMap only. Defaults: Enum.SortDirection.Ascending, 100. Every entry is decoded; one corrupt entry fails the whole read with the offending key named. |
Gotchas
Section titled “Gotchas”- 32KB per value, quota by size. The packing exists because of this. A schema codec is the strongest lever: the spec suite shows
{ Score = 1234, Streak = 7 }packing to 5 bytes against its ~25-byte JSON form, and schema output is always smaller than adaptive. - TTL is not optional. MemoryStore data expires; that is the point. Pick
DefaultTtlSecondsto match the data’s real lifetime — state that must survive belongs in DataDriver. updatetransforms may run more than once.UpdateAsynccan retry the transform under contention. Keep transforms pure — compute from the passed value only, no side effects.- Decode failures are loud.
getandgetRangereturn(false, nil, err)for a value that fails to decode rather than silently handing younil— a schema change without a plan for in-flight values shows up here. Foreign (non-driver) values are the exception: they pass through as-is. - The fallback is sticky per driver instance, not per store. Once a
typeerror proves buffers unsupported, that instance stays on base64.ForceBase64pins the behavior up front if you need determinism in tests.