Skip to content

Matchmaking

Cross-server queues on MemoryStore that form matches on any server, reserve a server and teleport the players, plus server attributes for Roblox's public matchmaking.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Matchmaking
Since
v0.1.0
Source
Matchmaking.luau

Matchmaking queues players into matches across the whole experience. The queue lives in MemoryStore, the match broadcast rides MessagingService, and every server with local queued players runs the same coordinate pass. There is no elected leader and no lobby server. What makes that safe is the queue’s read invisibility: entries one coordinator reads are hidden from every other coordinator for 30 seconds, so two servers polling at the same moment cannot form the same match. Match teleports go through TeleportRetry, which SoftShutdown shares, so a player never has two teleports in flight. Matchmaking.serverAttributes is a separate tool: it steers Roblox’s own public-server matchmaking and has nothing to do with the queues.

One match, end to end:

  1. enqueue(player) checks that the player has no teleport in flight, is not already queued, and that the match subscription has landed. It reserves the player’s local ticket before any MemoryStore call yields, removes any old tombstone for the player, and adds the UserId to the queue with a 300 s TTL. If the player dequeued, matched or left while that call was yielding, it writes a tombstone and returns false, "cancelled". On success it wakes the coordinate loop and publishes Matchmaking.Queued.
  2. A server polls the queue only while it has local queued players. The poll is a Scheduler every(CoordinateIntervalSeconds) job at Background priority that spawns one coordinate pass per tick. Passes on one server never overlap: a tick that finds a pass still running returns at once. The job is cancelled when the last local ticket goes. MemoryStore has no change events, which is why an armed server polls at all.
  3. The pass calls ReadAsync(TeamSize, allOrNothing, 0). Servers read full teams only, so a short read returns nothing and no server is reserved for a partial match. The home server of a queued group reads whatever is there instead, because a group can fill a team with fewer items than TeamSize.
  4. The items read are hidden from every other server for 30 seconds. If this pass consumes them, they are gone. If the pass fails or the server dies, they reappear after the window and another coordinator retries.
  5. Every UserId is checked against the tombstone map and against the other UserIds in the read. A tombstoned id, a duplicate or an unreadable item makes it a ghost read: the pass removes the read, adds the live members back as their own items at priority 1 (ahead of newer arrivals), and ends. A tombstoned player never shrinks a match below TeamSize.
  6. Items are packed whole, greedily in queue order, starting at the oldest item that can begin a full team. When nothing fills a team, the read is handed back the same way.
  7. The broadcast must fit MessagingService’s 1 kB message limit. The pass measures the JSON of { AccessCode, UserIds } with a 100-character placeholder for the access code and refuses anything over 950 characters before it reserves a server. It warns once per queue and never reserves a server for a match it could not announce.
  8. TeleportService:ReserveServerAsync(PlaceId) reserves the destination. On failure the pass ends and the items reappear after the invisibility window.
  9. PublishAsync sends { AccessCode, UserIds } on the queue’s topic. Only after a successful publish is the read removed and the unpicked items added back at priority 1.
  10. Every subscribed server takes the UserIds that belong to its own queued players, clears their tickets, tombstones them, publishes Matchmaking.MatchFound and teleports them to the reserved server in batches of 50. The match assembles from players on any number of servers.

One queue owns three named resources:

Resource Name Notes
MemoryStore queue CKMM_<queueName> Read invisibility 30 s. Items are one UserId, or { UserIds } for a group.
MemoryStore sorted map CKMM_<queueName>_Cancelled Tombstones, keyed by UserId string, 300 s TTL.
MessagingService topic CKMM_<queueName> Carries { AccessCode, UserIds } match broadcasts.

MemoryStore queues cannot remove an entry by value. When a queued player dequeues or leaves the server, their entry stays in the shared queue until its TTL runs out, so Matchmaking writes a tombstone for them instead. Coordinators on every server filter reads against the tombstone map. Three things write one:

  • dequeue(player), which also clears the local ticket so this server will not teleport them.
  • The player’s session ending while queued (Kernel.SessionEnd), so other servers never match around a player who left.
  • Receiving a match, for every local member. If the coordinator’s final RemoveAsync failed, the matched entries reappear after 30 seconds, and the tombstones keep them from forming a second match.

enqueue removes the player’s tombstone before it adds the new entry; otherwise a re-queued player would stay filtered out.

Every failure leaves the queue in a state some later pass can retry. Nothing is lost and nothing is matched twice.

Failure point Consequence
ReadAsync errors, reads nothing, or reads fewer than TeamSize on a server without a local group The pass ends. Nothing was read.
A tombstoned, duplicate or unreadable item in the read The read is removed and its live members go back at priority 1. The pass ends without reserving.
The items read cannot pack into a full team (groups that do not add up, or a group home server’s partial read) The read is handed back at priority 1 instead of staying hidden for 30 s.
The match message would exceed 950 characters One warning per queue. Nothing is reserved; the items reappear after 30 s. Lower TeamSize.
ReserveServerAsync fails Nothing is consumed. The items reappear after 30 s and any coordinator retries.
PublishAsync fails The read is not consumed, so the match forms again later. The reserved server is abandoned; reserved servers cost nothing until someone joins.
The hand-back RemoveAsync fails Nothing is re-added (that would duplicate items). Everything reappears after 30 s; matched members are tombstoned by then.
The coordinating server dies mid-pass The invisibility window expires and another server picks the items up.
A member’s teleport fails See Teleport behavior.

SubscribeAsync yields and can fail (a server holds at most 20 + 8 x players subscriptions), so new runs it on its own thread and retries with backoff: 2 s, 4 s, 8 s and so on, capped at 60 s, scheduled at Background priority. Until it lands, enqueue and enqueueGroup return false, "unavailable", because a match found for this server’s players would be missed.

enqueueGroup(players) queues a party as one item, so the whole party lands in one match and is never split across matches. The list is deduplicated. An empty list returns false, "empty group" and a group larger than TeamSize returns false, "group larger than TeamSize". Every member must pass the same checks as enqueue, and a member who leaves or dequeues while the add is yielding cancels the whole group.

PartyKit’s members(player) is a ready roster: a partyless player is a party of one.

local Ok, Reason = self.Squads:enqueueGroup(self.Parties:members(leader))

Packing takes whole items only. With TeamSize = 4, a party of 3 and two solo players, the party and the first solo player form the team and the second solo player goes back ahead of newer arrivals. If a group member leaves after the group is queued, that member is tombstoned and the next read of the group is a ghost read; the remaining members go back into the queue together as one item.

src/Server/Game/Services/LobbyService.luau
--!strict
-- LobbyService: duel and squad queues, fed from lobby intents.
local ServerScriptService = game:GetService("ServerScriptService")
local Matchmaking = require(ServerScriptService.ChloeKernelServer.Matchmaking)
local Places = require(script.Parent.Parent.Config.Places)
local LobbyService = {
Name = "LobbyService",
Dependencies = { "PartyService" },
}
function LobbyService.init(self: any, kernel: any)
self.Kernel = kernel
self.Duels = Matchmaking.new(kernel, "Duels", { TeamSize = 2, PlaceId = Places.Arena })
self.Squads = Matchmaking.new(kernel, "Squads", { TeamSize = 4, PlaceId = Places.Arena })
end
function LobbyService.start(self: any)
self.Net.JoinQueue.on(function(session: any, queueName: string)
local Parties = self.Kernel:getService("PartyService").Parties
task.spawn(function() -- enqueue yields on MemoryStore
local Ok, Reason
if queueName == "Squads" then
Ok, Reason = self.Squads:enqueueGroup(Parties:members(session.Player))
else
Ok, Reason = self.Duels:enqueue(session.Player)
end
if not Ok then
self.Kernel.Bus:publish("Lobby.QueueRefused", session, queueName, Reason)
end
end)
end)
self.Net.LeaveQueue.on(function(session: any)
self.Duels:dequeue(session.Player)
self.Squads:dequeue(session.Player)
end)
self.Connections = {
self.Kernel.Bus:subscribe("Matchmaking.MatchFound", function(_, queueName, members)
-- members are this server's players in the match; the teleport is already under way
self.Kernel.Bus:publish("Lobby.Matched", queueName, members)
end),
self.Kernel.Bus:subscribe("Matchmaking.TeleportFailed", function(_, queueName, members, reason, result, message)
-- one member per event; re-queued unless the destination refused them
self.Kernel.Bus:publish("Lobby.TeleportFailed", queueName, members[1], reason, result, message)
end),
}
end
function LobbyService.stop(self: any)
for _, Connection in self.Connections or {} do
Connection:disconnect()
end
if self.Duels then
self.Duels:destroy()
self.Squads:destroy()
end
end
return LobbyService

JoinQueue and LeaveQueue are intents declared in Game.Channels with a rate limit and a validator that allows only known queue names. PartyService is the game service that attached PartyKit. Places.Arena is a place in the same universe, booted with its own kernel to receive the arrivals. The arena reads the players’ join data as it would for any reserved-server teleport.

Matchmaking.new(kernel, queueName, options?) options:

Option Default Description
TeamSize 2 Players per match. A whole number from 1 to 100 (the ReadAsync limit), asserted.
PlaceId game.PlaceId The destination place. Must be in the same universe, a ReserveServerAsync requirement.
CoordinateIntervalSeconds 5 Coordinate pass cadence while this server has local queued players.
Queue MemoryStore queue CKMM_<name> Test seam: a queue double.
CancelMap MemoryStore sorted map CKMM_<name>_Cancelled Test seam: a tombstone map double.
Messaging MessagingService Test seam.
Teleport TeleportService Test seam. The teleport tracker is built on it.
GetPlayer Players:GetPlayerByUserId Test seam: resolves broadcast UserIds to local players.
SkipLoop false Never schedule the coordinate loop; specs call _coordinate() by hand.

The queue name must be 1 to 75 characters, so the CKMM_ topic fits MessagingService’s 80-character limit. new asserts both limits.

Fixed constants:

Constant Value Why
Queue and tombstone TTL 300 s An abandoned queue drains itself.
Read invisibility 30 s The double-match guard, and the retry delay when a pass fails.
Teleport batch 50 players The TeleportAsync group limit. Every batch carries the same access code, so they land together.
Match message budget 950 characters of JSON MessagingService caps a message at 1 kB; the margin matches MessageDriver’s.
Access code allowance 100 characters The size check runs before the code exists.
Re-queue priority 1 Items handed back or re-queued go ahead of new arrivals (priority 0).
Subscription backoff cap 60 s

A match broadcast sets ReservedServerAccessCode on one TeleportOptions and sends each batch of up to 50 local members through the queue’s TeleportRetry tracker with its default 30-second deadline. The tracker retries each member separately: at most 3 calls, 1 s then 2 s apart (5 s then 10 s after Flooded). A reserved destination is pinned, so GameFull and GameEnded end the attempt at once.

When a member’s attempt ends in failure, the failure callback runs after the tracker has released them:

  • The member is re-queued alone at priority 1, unless the reason was Unauthorized or GameNotFound: retrying the same match cannot fix those.
  • Matchmaking.TeleportFailed(queueName, { member }, reason, result, message) publishes, one event per member.

An attempt whose outcome is still unknown at the deadline is neither reported nor re-queued. The member stays owned by the tracker until the engine reports a failure or they leave, and enqueue refuses them with "already teleporting" in the meantime, even after a dequeue. Re-queueing a player whose first teleport might still complete would risk two teleports.

Matchmaking.serverAttributes(kernel, map, options?) mirrors Bus state into MatchmakingService server attributes, which steer Roblox’s own matchmaking into public servers (the queues above cover reserved matches). Each entry of map names an attribute:

Field Description
Topic Bus topic to follow. Each publish sets the attribute.
Value(...) Maps the topic’s arguments to the value. Default: the first argument. A nil result is skipped.
Initial Set at attach. In Studio, the Initial values also become the test schema through InitializeServerAttributesForStudio.
self.Attributes = Matchmaking.serverAttributes(kernel, {
Phase = { Topic = "Round.PhaseChanged", Initial = "Lobby" }, -- RoundKit publishes (phaseName, duration)
Mode = { Initial = "Casual" },
})
self.Attributes.set("Mode", "Ranked")

Names must be 1 to 50 characters. Values must be a string, number or boolean whose text form is at most 50 characters. A value equal to the last one set is skipped. SetServerAttribute reports errors as (false, message) instead of throwing; set logs them and returns false, message.

Member Description
Matchmaking.new(kernel, queueName, options?) -> mm Asserts the name and TeamSize limits, builds the queue, tombstone map and teleport tracker, subscribes to Kernel.SessionEnd and starts the background subscription. The coordinate loop starts with the first local enqueue.
mm:enqueue(player) -> (ok, err?) Yields. Queues one player. Refusals: "already teleporting", "already queued", "unavailable" (subscription not landed), "cancelled" (dequeued, matched or left during the call), or the MemoryStore error text. Publishes Matchmaking.Queued.
mm:enqueueGroup(players) -> (ok, err?) Yields. Queues the deduplicated list as one item. Adds the refusals "empty group" and "group larger than TeamSize". Publishes Matchmaking.Queued per member.
mm:dequeue(player) Clears the local ticket, cancels this queue’s scheduled teleport retries for the player, stops the loop when idle, writes a tombstone and publishes Matchmaking.Dequeued. Never errors; it tombstones and publishes even for a player who was not queued.
mm:destroy() Destroys the teleport tracker, cancels the loop and any subscription retry, disconnects the message subscription and the session listener, and clears local tickets.
Matchmaking.serverAttributes(kernel, map, options?) -> attributes Sets each Initial, follows each Topic. options are test injection points (Service, Studio).
attributes.set(name, value) -> (ok, err?) Validates the limits, skips unchanged values, calls SetServerAttribute.
attributes.destroy() Disconnects the Bus subscriptions. Attributes already set keep their values.
mm:_coordinate() One coordinate pass. Returns at once if a pass is running. Specs call it with SkipLoop = true.
Topic Payload When
Matchmaking.Queued player, queueName A player entered the queue on this server, including a re-queue after a failed teleport.
Matchmaking.Dequeued player, queueName dequeue ran for the player.
Matchmaking.MatchFound queueName, members A match formed. members are this server’s players in it; the teleport starts right after.
Matchmaking.TeleportFailed queueName, { member }, reason, result?, message? One member’s teleport ended in failure. reason is a TeleportResult name, TeleportFailed or TeleportTimedOut.

Consumed: Kernel.SessionEnd. A departing player’s ticket is cleared, their scheduled teleport retries are cancelled, and a queued player is tombstoned. See Bus and Sessions.

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