Skip to content

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.

publish(topic, data) runs the payload through three stages before touching the network:

  1. 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.
  2. Base64. MessagingService payloads travel as text, so the buffer becomes a Base64 string. This is the string whose length the platform limit actually measures.
  3. 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.

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.

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)
end
end)
local Ok, Err = Messages:publish("LiveOps", { Event = "BossSpawn", BossId = 3 })
if not Ok then
Log.warn("LiveOps publish failed", Err)
end

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

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 base64

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 clients
end)
-- from an admin command, any single server:
Messages:publish("Announce", { Text = "Double XP weekend" })

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

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.

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