MessageDriver
MessageDriver sends events between servers of the same game — global boss spawns, live-ops switches, shard coordination. It completes the infrastructure trio: MemoryDriver owns cross-server state, DataDriver owns storage, this owns events. The defining decision is that it wraps MessagingService with the failure handling the raw API makes you write yourself: payloads are Serde-packed and size-guarded before the first network call, publishes retry with backoff, and one malformed message or crashing subscriber can never take down the others.
Events, not state: a message reaches servers that are alive and subscribed when it fires, and no one else. A server that boots a second later never sees it. Anything a late server must be able to read belongs in MemoryDriver.
Mental model
Section titled “Mental model”The pack pipeline
Section titled “The pack pipeline”publish(topic, data) runs the payload through three stages before touching the network:
- Encode. The data packs into a buffer — through your schema codec if the driver was built with one, else through Serde’s adaptive schemaless encoder, which handles any encodable shape.
- Base64. MessagingService payloads travel as text, so the buffer becomes a Base64 string. This is the string whose length the platform limit actually measures.
- The size guard. Payloads over 950 base64 characters fail immediately with an error naming the raw byte size and the base64 size. The guard checks the packed length, not the raw bytes, because the ~1 KB MessagingService ceiling applies to the transmitted string — a raw-byte cap would let the service reject the message after every retry had already run.
Only a payload that clears all three stages reaches PublishAsync. Encode failures and oversize payloads return (false, error) without a single network call and without retrying — they can never succeed, so retrying them would only burn quota.
Retries
Section titled “Retries”A failed PublishAsync (throttle, quota exhaustion, transient service error) retries with backoff. BackoffSeconds defaults to { 1, 2, 4 }: up to four attempts across roughly seven seconds. When the last attempt fails, publish returns (false, lastError) — quota exhaustion is an error result, never an unhandled throw. A 0 in the backoff table retries immediately without waiting, which is what the specs use.
Crash-isolated subscribers
Section titled “Crash-isolated subscribers”The SubscribeAsync handler wraps every message in two independent guards:
- Decode failures (garbage payload, non-string data, codec version mismatch) are warned and dropped —
[MessageDriver] "topic": payload failed to decode …. Your callback never sees them. - Callback crashes are caught and warned —
[MessageDriver] "topic" subscriber errored …. Other subscribers on the same topic still run, and the subscription stays alive.
The driver’s guarantee, spec-verified: malformed payloads never throw out of the message handler, and one bad publisher cannot break a subscriber.
local ServerScriptService = game:GetService("ServerScriptService")local MessageDriver = require(ServerScriptService.ChloeKernelServer.MessageDriver)
local Messages = MessageDriver.new()
Messages:subscribe("LiveOps", function(data, sentAt) if data.Event == "BossSpawn" then spawnGlobalBoss(data.BossId) endend)
local Ok, Err = Messages:publish("LiveOps", { Event = "BossSpawn", BossId = 3 })if not Ok then Log.warn("LiveOps publish failed", Err)endEvery server subscribed to the topic receives the message — including the one that published it. sentAt is the platform’s send timestamp (message.Sent), useful for ignoring stale events after a delivery delay.
Schema codecs shrink the wire
Section titled “Schema codecs shrink the wire”The default adaptive encoding self-describes every field. For a hot topic with a fixed shape, build the driver with a schema codec — same shape, fraction of the bytes, and more headroom under the 950-character guard:
local ReplicatedStorage = game:GetService("ReplicatedStorage")local Serde = require(ReplicatedStorage.ChloeKernel.Serde)
local BossCodec = Serde.schema({ Health = "NumberU32", Phase = "NumberU8" })local BossFeed = MessageDriver.new({ Codec = BossCodec })
BossFeed:publish("Boss", { Health = 123456, Phase = 3 }) -- 5 raw bytes before base64Cross-server patterns
Section titled “Cross-server patterns”Global announcement, every client of every server
Section titled “Global announcement, every client of every server”Chain the driver into BusBridge: one publish on one server fans out to every server, and each server’s bridge fans out to its clients.
local Messages = MessageDriver.new()
Messages:subscribe("Announce", function(data) Kernel.Bus:publishRemote("Server.Announcement", data.Text) -- bridge to this server's clientsend)
-- from an admin command, any single server:Messages:publish("Announce", { Text = "Double XP weekend" })Live-ops switches
Section titled “Live-ops switches”Pair a message with LiveConfig-style state: the message is the now signal, MemoryDriver or DataDriver holds the value for servers that boot later. Publishing only the event loses every server that wasn’t alive to hear it.
Matchmaking and shard coordination
Section titled “Matchmaking and shard coordination”Matchmaking already rides MessagingService internally for its match broadcast — any server can assemble a match and every server with queued members hears about it. You don’t wire MessageDriver into it; treat it as the pattern’s reference implementation. Use your own topics for the coordination Matchmaking doesn’t cover: shard election pings, cross-server world events, “drain this shard” signals for ops tooling.
Payloads that don’t fit
Section titled “Payloads that don’t fit”The guard’s error says it directly: split the payload, or publish a key and put the body in MemoryDriver. A message that says { Kind = "WorldEvent", Key = "Event_771" } costs 30 bytes; the receiving servers fetch the full body from the shared store.
API reference
Section titled “API reference”| Member | Description |
|---|---|
MessageDriver.new(options?) → driver |
No global state — build one per topic family if they need different codecs |
driver:publish(topic, data) → (ok, err?) |
Packs, guards, publishes with retries. Yields. Never throws for payload or service failures |
driver:subscribe(topic, fn(data, sentAt?)) → connection |
Subscribes with decode + crash isolation per message. Throws if SubscribeAsync itself fails (e.g. API access disabled). Returns the platform connection — :Disconnect() to stop |
new options:
| Field | Type | Description |
|---|---|---|
Codec |
Serde schema codec? | :encode/:decode pair for a fixed payload shape. Default: adaptive schemaless encoding |
Service |
table? | Injectable MessagingService — the test seam the specs fake |
BackoffSeconds |
{ number }? = { 1, 2, 4 } |
Wait between attempts; attempts = entries + 1 |
Constants
Section titled “Constants”| Constant | Value | Why |
|---|---|---|
MaxPackedChars |
950 |
Guard threshold on the base64 string, under the ~1 KB service ceiling with margin for the envelope |
DefaultBackoffSeconds |
{ 1, 2, 4 } |
Four attempts, ~7 s worst case |
Failure modes
Section titled “Failure modes”| Failure | Behavior |
|---|---|
| Payload fails to encode | (false, "payload failed to encode: …"), no network call, no retry |
| Packed payload over 950 chars | (false, …) with raw and base64 sizes, no network call, no retry |
PublishAsync throws (throttle/quota) |
Retry per BackoffSeconds; then (false, lastError) |
| Received payload fails to decode | Warned and dropped; callback not invoked |
| Subscriber callback throws | Warned; other subscribers unaffected; subscription stays live |
SubscribeAsync throws |
subscribe errors at the call site — surface it at boot, not silently |