Skip to content

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.

One match, end to end:

  1. enqueue(player) adds the player’s UserId to a MemoryStore queue named CKMM_<queueName>, with a 300-second TTL. A local Queued flag marks the player as matchable on this server.
  2. Every server’s coordinate tick (every CoordinateIntervalSeconds, a Background-priority Scheduler job) calls ReadAsync(TeamSize, allOrNothing, 0)full teams only. A short read returns nothing; nobody reserves a server for a partial match.
  3. Read invisibility kicks in. The read entries vanish from every other server’s view for ReadInvisibilitySeconds (30). If this coordinator finishes the job, it calls RemoveAsync(readId) and the entries are consumed. If it crashes mid-match — server shutdown, ReserveServer failure — the entries reappear after the window and another coordinator retries. Nothing is lost, nothing is doubled.
  4. 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 below TeamSize.
  5. ReserveServer(PlaceId) allocates a private server. On failure the tick just returns; the invisible entries reappear and get retried.
  6. PublishAsync broadcasts { AccessCode, UserIds } on topic CKMM_<queueName>. Only after a successful publish is the queue read consumed with RemoveAsync.
  7. 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

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.

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").ChloeKernelServer
local 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 mind
end

The arena place receives arrivals like any reserved-server teleport — boot it with its own kernel and read TeleportService:GetLocalPlayerTeleportData / player join as usual.

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

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.TeleportFailed is 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).

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

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.