Skip to content

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.

publish(topic, data) checks and packs before it touches the network:

  1. Topic. It must be a string of 1 to 80 characters, or publish returns (false, "MessagingService topics must be 1..80 characters, got N").
  2. 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.
  3. Compress, when Compress = true. See Compression.
  4. Base64. MessagingService payloads travel as text, so the buffer becomes a Base64 string. This is the string the platform’s size limit measures.
  5. 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.

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.

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.

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.

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 MessageDriver logger 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.
src/Server/Game/Services/LiveOpsService.luau
--!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}`)
end
end
function LiveOpsService.stop(self: any)
if self.Subscription then
self.Subscription:Disconnect()
end
end
return LiveOpsService

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

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

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

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

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.

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

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