MessageDriver
Cross-server pub/sub over MessagingService: Serde-packed and optionally Zstd-compressed payloads, a size guard before any network call, the platform's send and subscription limits, and crash-isolated subscribers.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .MessageDriver - Since
- v0.3.0
- Source
- MessageDriver.luau
MessageDriver sends events between servers of the same experience: global boss spawns, live-ops switches, shard
coordination. MemoryDriver owns cross-server state and DataDriver owns
storage; this module owns events. It wraps MessagingService with the failure handling the raw API leaves to you:
payloads are Serde-packed and size-checked before the first network call, the platform’s per-server
limits are enforced locally, only transient failures retry, and one malformed message or crashing subscriber cannot
break the others.
Events are not state. A message reaches the servers that are alive and subscribed when it is sent, and nobody 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) checks and packs before it touches the network:
- Topic. It must be a string of 1 to 80 characters, or
publishreturns(false, "MessagingService topics must be 1..80 characters, got N"). - Encode. The data packs into a buffer through the driver’s schema codec if it has one, or Serde’s adaptive encoder, which handles any encodable shape.
- Compress, when
Compress = true. See Compression. - Base64. MessagingService payloads travel as text, so the buffer becomes a Base64 string. This is the string the platform’s size limit measures.
- Size guard. A packed payload over 950 characters fails with an error that gives the packed byte size and the base64
length, and suggests splitting it, setting
Compress, or sending a key into MemoryDriver. The guard checks the packed length because the roughly 1 KB MessagingService ceiling applies to the transmitted string; a raw-byte cap would let the service reject the message after every retry had run.
A payload that fails any of these returns (false, error) without a network call and without retrying. It can never
succeed, so retrying would only spend quota.
Platform limits
Section titled “Platform limits”MessagingService limits each server, and the driver enforces them before calling it:
| Limit | Value | What the driver does |
|---|---|---|
| Topic length | 1 to 80 characters | publish returns false; subscribe errors |
| Messages sent per minute | 600 + 240 × players on this server | A token bucket with the full limit as its burst, refilled at limit / 60 per second. Every attempt, retries included, spends one token. An empty bucket fails the publish with MessagingService send budget spent (N per minute with P players); publish later |
| Live subscriptions | 20 + 8 × players on this server | Counted across every driver on the same service. Subscribing past the limit warns and suggests sharing one topic and routing by payload; it does not refuse |
The ledger is keyed by the MessagingService instance, so several drivers on one server share one send budget and one
subscription count. driver:usage() reports both.
Retries
Section titled “Retries”A failed PublishAsync retries only when the error looks transient. Errors whose text mentions a quota, a limit,
throttling, “too many”, “exceed”, “too large”, “too long”, an argument, “invalid”, “must be” or “unable to cast” fail at
once: they repeat on an immediate retry, and retrying a quota error only spends more of the same minute’s budget.
Transient failures wait between attempts. BackoffSeconds defaults to { 1, 2, 4 }: up to four attempts. Each wait is
jittered by up to 25 percent either way (seconds × (0.75 + random × 0.5)), so servers do not retry in lockstep during
an incident. When the last attempt fails, publish returns (false, lastError). A 0 in the table retries with no
wait, which the specs use.
Compression
Section titled “Compression”MessageDriver.new({ Compress = true }) compresses payloads with EncodingService:CompressBuffer using Zstd at level 3.
The packed format is one marker byte and then the body: marker 0 for the Serde bytes as they are, marker 1 for Zstd. A
payload is compressed only when that makes it smaller, so small messages cost one extra byte. Where EncodingService is
unavailable, every payload goes out with marker 0.
On receive, the driver reads the Zstd frame’s declared size before inflating and refuses anything over 65,536 bytes.
Messages come from any server of the experience, so the size is checked before any memory is spent. A marker-1 message
on a server without EncodingService fails to decode and is dropped with a warning.
Publishers and subscribers of a topic must agree on Compress: the marker byte exists only in compressed mode.
Crash-isolated subscribers
Section titled “Crash-isolated subscribers”The SubscribeAsync handler wraps every message in two separate guards:
- Decode failures (a garbage payload, non-string data, a codec or compression mismatch) are warned through the
MessageDriverlogger scope as"<topic>": payload failed to decode: ...and dropped. The callback never sees them. - Callback errors are caught and warned as
"<topic>" subscriber errored: .... The subscription stays alive for the next message.
--!strict-- LiveOpsService: relays live-ops events between servers.
local ServerScriptService = game:GetService("ServerScriptService")
local MessageDriver = require(ServerScriptService.ChloeKernelServer.MessageDriver)
local LiveOpsService = { Name = "LiveOpsService", Dependencies = {},}
function LiveOpsService.init(self: any, kernel: any) self.Kernel = kernel self.Messages = MessageDriver.new()end
function LiveOpsService.start(self: any) self.Subscription = self.Messages:subscribe("LiveOps", function(data, sentAt: number?) if data.Event == "BossSpawn" then print(`boss {data.BossId} spawned at {sentAt}`) end end)end
function LiveOpsService.spawnBoss(self: any, bossId: number) local Ok, Err = self.Messages:publish("LiveOps", { Event = "BossSpawn", BossId = bossId }) if not Ok then warn(`LiveOps publish failed: {Err}`) endend
function LiveOpsService.stop(self: any) if self.Subscription then self.Subscription:Disconnect() endend
return LiveOpsServiceEvery server subscribed to the topic receives the message, including the one that sent 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 adaptive encoding describes every field. For a frequent topic with a fixed shape, build the driver with a schema codec: the same data in a fraction of the bytes, with more room 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 bytes before base64Checking the budget
Section titled “Checking the budget”local Usage = Messages:usage()print(`{Usage.SendTokens}/{Usage.SendLimit} sends left, {Usage.Subscriptions}/{Usage.SubscriptionLimit} subscriptions`)SendLimit and SubscriptionLimit follow the current player count, so they rise and fall as players join and leave.
Cross-server patterns
Section titled “Cross-server patterns”Announcement to every client of every server
Section titled “Announcement to every client of every server”Chain the driver into BusBridge: one publish on one server reaches every server, and each server’s bridge forwards it to its clients.
local Messages = MessageDriver.new()
Messages:subscribe("Announce", function(data) Kernel.Bus:publishRemote("Server.Announcement", data.Text) -- to this server's clientsend)
-- From an admin command on any one server:Messages:publish("Announce", { Text = "Double XP weekend" })Live-ops switches
Section titled “Live-ops switches”Pair a message with state that later servers can read: the message is the “now” signal, and MemoryDriver or DataDriver holds the value for servers that boot afterwards. Publishing only the event loses every server that was not alive to hear it. LiveConfig follows this pattern.
Matchmaking and shard coordination
Section titled “Matchmaking and shard coordination”Matchmaking already uses MessagingService internally for its match broadcast. Use your own topics for coordination it does not cover: shard election pings, cross-server world events, drain signals for ops tooling. Every topic is a subscription against the per-server limit, so prefer a few topics routed by payload over one topic per event.
Payloads that do not fit
Section titled “Payloads that do not fit”The guard’s error lists the options: split the payload, set Compress, or publish a key and put the body in
MemoryDriver. A message like { Kind = "WorldEvent", Key = "Event_771" } costs about 30 bytes;
receiving servers fetch the body from the shared store.
API reference
Section titled “API reference”| Member | Description |
|---|---|
MessageDriver.new(options?) -> driver |
Builds a driver. Drivers on the same service share one limits ledger. Build one per codec |
driver:publish(topic, data) -> (boolean, string?) |
Checks, packs, spends the send budget and publishes with retries. Yields. Never throws for payload, budget or service failures |
driver:subscribe(topic, fn(data, sentAt?)) -> connection |
Subscribes with decode and callback isolation per message. Errors on a bad topic or when SubscribeAsync fails (for example, API access disabled). Warns past the subscription limit. Returns the platform connection; :Disconnect() to stop |
driver:usage() -> Usage |
{ SendLimit, SendTokens, Subscriptions, SubscriptionLimit } for this server right now |
new options:
| Field | Type | Description |
|---|---|---|
Codec |
Serde schema codec? | :encode and :decode for a fixed payload shape. Default: adaptive encoding |
Compress |
boolean? |
Zstd through EncodingService for payloads that shrink. Publishers and subscribers of a topic must agree |
BackoffSeconds |
{ number }?, default { 1, 2, 4 } |
Waits between attempts, each jittered by up to 25 percent. Attempts = entries + 1 |
Service |
table? | Injectable MessagingService, a test seam |
Encoding |
table? | Injectable EncodingService, a test seam |
Players |
table? | Injectable Players for the per-player limits, a test seam |
Clock |
(() -> number)? |
Send budget clock. Default os.clock |
Constants
Section titled “Constants”| Constant | Value | Why |
|---|---|---|
MaxPackedChars |
950 |
Guard on the base64 string, under the roughly 1 KB service ceiling with room for the envelope |
MaxTopicLength |
80 |
The MessagingService topic limit |
| Send limit | 600 + 240 × players per minute |
The MessagingService per-server send limit |
| Subscription limit | 20 + 8 × players |
The MessagingService per-server subscription limit |
DefaultBackoffSeconds |
{ 1, 2, 4 } |
Four attempts, about 7 s of waiting before jitter |
| Zstd level | 3 |
Compression level passed to CompressBuffer |
| Max decompressed size | 65536 bytes |
Refused before inflating |
Failure modes
Section titled “Failure modes”| Failure | Behavior |
|---|---|
| Topic not a string, or outside 1 to 80 characters | publish: (false, message) with no network call. subscribe: errors |
| Payload fails to encode | (false, "payload failed to encode: ..."), no network call, no retry |
| Packed payload over 950 characters | (false, message) with the packed and base64 sizes, no network call, no retry |
| Send budget spent | (false, "MessagingService send budget spent ..."), before calling the service. After a failed attempt, the earlier error is included |
PublishAsync fails with a quota, limit, throttle or argument error |
(false, error) at once, no retry |
PublishAsync fails with any other error |
Retries per BackoffSeconds, then (false, lastError) |
| Received payload fails to decode or inflate | Warned and dropped; the callback is not called |
| Subscriber callback throws | Warned; the subscription stays live |
SubscribeAsync throws |
subscribe errors at the call site. Surface it at boot |
| More subscriptions than the limit | Warned; the subscription is still made |