Matchmaking
Matchmaking queues players into matches across your whole game, not one server. The queue lives in MemoryStore, the match broadcast rides MessagingService, and every server runs the same coordinate loop — there is no elected leader, no lobby server, no single point of failure. The design decision that makes that safe is the queue’s read-invisibility window: entries one coordinator reads are invisible to every other coordinator for 30 seconds, so two servers ticking at the same moment cannot form the same match twice.
Mental model
Section titled “Mental model”One match, end to end:
enqueue(player)adds the player’s UserId to a MemoryStore queue namedCKMM_<queueName>, with a 300-second TTL. A localQueuedflag marks the player as matchable on this server.- Every server’s coordinate tick (every
CoordinateIntervalSeconds, a Background-priority Scheduler job) callsReadAsync(TeamSize, allOrNothing, 0)— full teams only. A short read returns nothing; nobody reserves a server for a partial match. - Read invisibility kicks in. The read entries vanish from every other server’s view for
ReadInvisibilitySeconds(30). If this coordinator finishes the job, it callsRemoveAsync(readId)and the entries are consumed. If it crashes mid-match — server shutdown,ReserveServerfailure — the entries reappear after the window and another coordinator retries. Nothing is lost, nothing is doubled. - Ghost filtering. Each read entry is checked against a tombstone map (
CKMM_<queueName>_Cancelled) and deduplicated. If any ghost is found, the read is consumed, the live members are re-queued, and the tick ends — a tombstoned player never shrinks a match belowTeamSize. ReserveServer(PlaceId)allocates a private server. On failure the tick just returns; the invisible entries reappear and get retried.PublishAsyncbroadcasts{ AccessCode, UserIds }on topicCKMM_<queueName>. Only after a successful publish is the queue read consumed withRemoveAsync.- Every server receives the broadcast, filters the UserIds down to players present on this server and still locally queued, publishes
Matchmaking.MatchFound, and teleports its local members to the reserved server. The match assembles from players scattered across any number of servers.
One queue owns three named resources:
| Resource | Name | Notes |
|---|---|---|
| MemoryStore queue | CKMM_<queueName> |
Constructed with the 30s read-invisibility timeout |
| MemoryStore sorted map | CKMM_<queueName>_Cancelled |
The tombstone map |
| MessagingService topic | CKMM_<queueName> |
Carries { AccessCode, UserIds } broadcasts |
Why tombstones
Section titled “Why tombstones”MemoryStore queues cannot remove an entry by value — there is no “delete UserId 123 from the queue”. So dequeue does two things instead: it clears the local Queued flag (which stops the teleport on this server) and writes a tombstone into the cancel map (which stops other servers from matching around the stale queue entry). enqueue clears any tombstone first, or a re-queued player would stay filtered out.
What each failure does
Section titled “What each failure does”The coordinate tick is built so that every failure leaves the queue in a retryable state — nothing is lost, nothing double-fires:
| Failure point | Consequence |
|---|---|
ReadAsync errors, or fewer than TeamSize entries |
Tick ends. Nothing read, nothing consumed |
| A tombstoned or duplicate entry in the read | Read consumed, live members re-added to the queue, tick ends. No server reserved for an undersized match |
ReserveServer fails |
Nothing consumed. The invisible entries reappear after 30s and any coordinator retries |
PublishAsync fails |
Read not consumed — entries reappear and the match re-forms. The reserved server is abandoned (reserved servers cost nothing until joined) |
| Coordinator server dies mid-tick | Same as above: invisibility expires, another server picks the entries up |
TeleportAsync fails three times |
Members still present are re-enqueued; Matchmaking.TeleportFailed publishes |
local Root = game:GetService("ServerScriptService").ChloeKernelServerlocal Matchmaking = require(Root.Matchmaking)
return function(kernel) local Duels = Matchmaking.new(kernel, "Duels", { TeamSize = 2, PlaceId = ARENA_PLACE_ID, -- a place in the SAME universe })
kernel.Bus:subscribe("Matchmaking.MatchFound", function(_, queueName, members) -- members are THIS server's players in the match; the teleport is automatic for _, Member in members do print(Member.Name, "matched in", queueName) end end)
kernel.Bus:subscribe("Matchmaking.TeleportFailed", function(_, queueName, members) -- all three teleport attempts failed; members still present were re-queued end)
-- from a lobby pad or UI intent: -- Duels:enqueue(player) -- Duels:dequeue(player) -- changed their mindendThe arena place receives arrivals like any reserved-server teleport — boot it with its own kernel and read TeleportService:GetLocalPlayerTeleportData / player join as usual.
Configuration
Section titled “Configuration”Matchmaking.new(kernel, queueName, options?) — options:
| Option | Default | Description |
|---|---|---|
TeamSize |
2 |
Players per match. Reads are all-or-nothing at exactly this count |
PlaceId |
game.PlaceId |
Destination place. Must be in the same universe — a ReserveServer requirement |
CoordinateIntervalSeconds |
5 |
Coordinate loop cadence (Background priority) |
Queue |
MemoryStore queue CKMM_<name> |
Test seam — inject a mock queue |
CancelMap |
MemoryStore sorted map CKMM_<name>_Cancelled |
Test seam — inject a mock tombstone map |
Messaging |
MessagingService |
Test seam |
Teleport |
TeleportService |
Test seam |
GetPlayer |
Players:GetPlayerByUserId |
Test seam — resolve UserIds to players |
SkipLoop |
false |
Don’t schedule the coordinate loop (specs drive _coordinate by hand) |
Two constants are fixed in DefaultConfig and not exposed as options: QueueTtlSeconds = 300 (queue entries and tombstones expire after five minutes — an abandoned queue drains itself) and ReadInvisibilitySeconds = 30 (the double-match guard; also the retry latency when a coordinator dies mid-match).
Teleport behavior
Section titled “Teleport behavior”When a match broadcast lands, local members are teleported with TeleportAsync and a ReservedServerAccessCode. The call is retried three times with growing backoff (1s, 2s, 3s waits). If all three fail:
- Members still in this server (
Parent ~= nil) are re-enqueued — they go back to the front of the matchmaking flow rather than silently falling out. Matchmaking.TeleportFailedis published with the affected members.
Players who leave the server are cleaned up automatically: a Kernel.SessionEnd subscription clears their local Queued flag (their queue entry expires by TTL, and any coordinator that reads it treats them as absent on every server).
API reference
Section titled “API reference”| Member | Description |
|---|---|
Matchmaking.new(kernel, queueName, options?) → mm |
Build a queue. Subscribes to the match topic and starts the coordinate loop immediately |
mm:enqueue(player) → (ok, err?) |
Queue a player. false, "already queued" on double-enqueue; false, err when the MemoryStore write fails. Clears any tombstone first. Publishes Matchmaking.Queued |
mm:dequeue(player) |
Clear the local flag, write a tombstone, publish Matchmaking.Dequeued. Never errors — MemoryStore failures are swallowed (the TTL is the backstop) |
mm:destroy() |
Cancel the coordinate loop, disconnect the message and bus subscriptions, clear local state |
Internal but spec-driven: mm:_coordinate() runs one coordinate tick (specs call it directly with SkipLoop = true).
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Matchmaking.Queued |
player, queueName |
A player entered the queue on this server |
Matchmaking.Dequeued |
player, queueName |
A player left the queue on this server |
Matchmaking.MatchFound |
queueName, members |
A match formed — members are this server’s players in it; teleport follows automatically |
Matchmaking.TeleportFailed |
queueName, members |
All three teleport attempts failed; present members were re-enqueued |
Consumed: Kernel.SessionEnd — departing players are dropped from the local queue flags. See Signals & Bus for subscription mechanics and Sessions for the session lifecycle.