Skip to main content

Muster

CI Wally Docs Demo License: MIT

Cross-server matchmaking for Roblox

For regional matching, use built-in regions: each region gets its own lane, with automatic cross-region fallback after a configurable wait. Keep partitions for pools that must never mix.

Muster forms matches across Roblox servers using your game's ratings, attributes, server reservation and teleportation systems. It is a library you run in your own servers. Standard queues can scale through lanes; attribute-rule queues use one lane per partition unless built-in regions assign a lane to each region. Candidate discovery is bounded. Capacity depends on match size, rules, party composition and backend latency; the benchmarks below are measurements of the local implementation, not a guaranteed player limit.

Version 0.5.0 adds built-in regional lanes with automatic fallback, preserving queue age and whole parties.

Installation​

[dependencies]
Muster = "shmeatley/muster@0.5.0"

Then run:

wally install

Quick start​

local Muster = require(ReplicatedStorage.Packages.Muster)
local Players = game:GetService("Players")

local duels = Muster.new({ name = "duels", size = 2 })

duels.matched:connect(function(match)
for _, member in match.members do
print(member.userId, "->", match.matchId)
end
end)

Players.PlayerAdded:Connect(function(player)
duels:enqueue(player.UserId)
end)

That is the whole setup. The worker starts itself, subscribes for cross-server delivery, cleans up when players leave, and matches on its first scan.

See it running​

There is a demo place, published so you can watch a match form without building anything: Muster Demo. Click the blue pad to queue, the grey one to add a bot, and the board tells you what the queue is doing. That is a real queue on real MemoryStore, matching across every server the game is running.

MusterDemo.rbxl is committed here too, so you can open it in Studio and read the source alongside it, or rebuild it with:

rojo build demo.project.json -o MusterDemo.rbxl

The wiring is all in demo/server/Sandbox: one Muster.new call, a handful of signal handlers, and the pads that drive them. See demo/README.md.

Reserved servers, teams, and the finalize hook​

Muster never teleports, but it makes teleporting easy. finalize runs once per match, on whichever server formed it, and whatever it returns is attached to the envelope as match.data and delivered to everyone. This is where you can reserve a server, pick a map, and assign teams. For example:

To be clear, finalize is not a hook that runs on every server, it runs only on the server that formed the match. The data it returns is then sent to all servers that have players in the match.

matched fires on every server that has at least one player in the match, so you can teleport them all to the reserved server.

local ranked = Muster.new({
name = "ranked",
size = { min = 4, target = 8, max = 8 },
rating = { window = 150, relax = 25, default = 1000 },

finalize = function(match)
return {
accessCode = TeleportService:ReserveServerAsync(ARENA_PLACE),
map = pickMap(match.size),
killer = match.members[math.random(match.size)].userId,
}
end,

onAbort = function(context)
Analytics:log("match_abandoned", { reason = context.reason })
end,
})

ranked.matched:connect(function(match)
local present = {}
for _, member in match.members do
local player = Players:GetPlayerByUserId(member.userId)
if player then
table.insert(present, player)
end
end
if #present == 0 then
return
end

TeleportService:TeleportToPrivateServer(ARENA_PLACE, match.data.accessCode, present, nil, {
matchId = match.matchId,
map = match.data.map,
killer = match.data.killer,
})
end)

match.data is typed as the finalizer's return type, so match.data.accessCode autocompletes.

The hook's contract​

Read this before writing one. All four points come from the same fact: this is a distributed system where servers cannot see each other.

  • It runs exactly once per matchId, never concurrently for the same one.
  • It may run more than once for the same set of players, under different matchIds, if a worker stalls and another repairs its work. Deduplicate on matchId, never on the player set.
  • Its side effects may survive a match that never forms, because the lease can be lost while it yields. Write it to be idempotent or cheap to abandon, and use onAbort to release anything expensive. (A leaked reserved-server access code costs nothing and expires on its own, which is why this is usually fine.)
  • Because of that, be careful when doing things like writing to a database.
  • If it throws, exceeds finalizeTimeout, or returns something unstorable, the match aborts cleanly and every player goes back in the queue.

Quick Play (optional)​

Everything above forms new matches. Quick Play adds the other half: dropping a player straight into a match that is already running with room to spare. It is off unless you ask for it, and it ignores rating entirely, so it belongs on casual queues rather than ranked ones.

local casual = Muster.new({ name = "casual", size = 8, quickPlay = true })

On the arena server, advertise yourself once you are ready for players:

casual.quickplay:host({
data = { accessCode = code, placeId = ARENA_PLACE },
})

That is the whole match-server side. The listing refreshes its player count every 30 seconds off the back of the worker that is already running, and a server that crashes is delisted 90 seconds later without anyone cleaning up.

host hands back a handle for the two things a round actually needs:

local listing = Muster.Result.unwrap(casual.quickplay:host({ data = ... }))

listing:setOpen(false) -- round underway; stop taking players, stay listed
listing:setOpen(true) -- intermission; take them again
listing:close() -- delist now rather than waiting to expire

Calling queue:stop() closes everything this server hosts, so binding it to game:BindToClose is enough to stop a shutting-down arena from being sent players it will not be around to seat.

In the lobby:

casual.quickPlayMatched:connect(function(assignment)
TeleportService:TeleportToPrivateServer(
assignment.data.placeId, assignment.data.accessCode, { player })
end)

casual:quickPlay(player.UserId)

Queueing a group​

Quick Play can backfill a group together using the same userId list accepted by enqueueGroup:

casual:quickPlayGroup({ leaderId, friendId })

The group is one indivisible ticket. It reserves enough seats for every member in one listed match, or reserves nothing and stays in the normal queue. The equivalent lower-level form is casual:enqueueGroup({ leaderId, friendId }, { quickPlay = true }).

If everything is full, nothing special happens​

quickPlay writes an ordinary ticket and then looks for a seat. If every listed match is full, the player simply stays in the normal queue and forms a fresh match the usual way, retrying the backfill search every few seconds while they wait. So a slot opening up mid-wait pulls them in, and a slot never opening still gets them a game.

The two outcomes race, which is why quickPlay returns the ticket rather than the match: a backfill arrives on quickPlayMatched, a fresh match on matched, and exactly one of them fires.

Two lobbies, one last seat​

This is the part that has to be right. When a 7/8 match is visible to fifty lobby servers, exactly one of them may take the last seat.

Every claim is a single compare-and-set on the listing record. The store re-runs the loser's transform against the winner's write, the seat is gone the second time around, and the loser moves on to its next candidate. There is no lock and no coordination between lobbies.

A claimed seat is held as a reservation until the player arrives, because a player count that refreshes every 30 seconds cannot be trusted on its own. The reservation retires when the host's next heartbeat reports that player present, and expires after 45 seconds if they never show.

The index is sorted by seats free, ascending, so backfill completes a 7/8 game before it grows a 2/8 one, and full matches are dropped from it entirely rather than read and discarded.

Options​

FieldDefaultEffect
capacitysize.maxPlayers a hosted match holds.
heartbeatInterval30Seconds between player-count refreshes.
listingExpirationheartbeatInterval * 3Seconds a listing survives without one.
reservationTimeout45Seconds a claimed seat is held.
searchInterval3Seconds between retries by a queued player.
lanes"auto"Listing lane count. Pin it with an integer.
maxListingDataBytes1024Budget for the listing's data.

Listings shard into lanes exactly as tickets do, because a MemoryStore range read returns at most 200 items and a busy game can have more live matches than that. The count starts at one and doubles only once a lane is consistently too full to read in one go, so a game with six live matches pays for none of it.

Queueing a group​

If you already have parties, or your parties form in one lobby and live in a table on that server, just hand Muster the userIds:

ranked:enqueueGroup({ leaderId, friendId }, { ratings = ratings })

That is one indivisible ticket: never split across matches, matched at the group's aggregate rating. This is the path most games want, and it involves none of the machinery below.

Parties (optional)​

Muster also ships a cross-server party system, built on the same store. It is constructed on first access to queue.parties and writes nothing until you use it, so ignoring it costs you nothing.

You need it only if parties have to survive players being on different servers. That happens more than you might expect:

  • Join-friend drops you in another server when your friend's is full
  • You want to invite someone who isn't in your lobby yet
  • Players scatter across lobby servers on the trip back from a match

If none of those apply to your game, use enqueueGroup and skip this.

local parties = ranked.parties

local party = Muster.Result.unwrap(parties:create(leaderUserId))
parties:invite(leaderUserId, friendUserId)

-- On the friend's server, wherever that is:
local invites = Muster.Result.unwrap(parties:listInvites(friendUserId))
parties:acceptInvite(friendUserId, invites[1].id)

-- Only the leader may queue, and membership freezes until it resolves.
ranked:enqueueParty(party.id, leaderUserId, { ratings = ratings })

Membership is enforced by compare-and-set, so a player is never in two parties at once even when two servers try at the same moment. That is the whole difficulty, since those servers cannot see each other.

Ratings​

Muster does skill-based matching, not skill rating. Bring your own number from wherever you keep it.

rating = {
window = 150, -- start within +/-150
relax = 25, -- widen by 25 per second waited
unbounded = 60, -- after a minute, take anyone
default = 1000, -- for players who have no rating yet
}

Omit the rating table entirely for an unrated queue. Without attribute rules, matching is oldest-first; rules adds its own constraints and preferences. Rated and unrated is a per-queue property: a rated queue requires a rating for every player, and an unrated one refuses them. Mixing the two in one lane has no defensible behaviour, so it is rejected loudly at your call site instead of silently producing bad matches.

A party queues at its mean rating pulled halfway toward its strongest member, so a good player cannot hide behind low-rated friends. This will help combat the "boosting" problem in your game, but it is not a rating system, but it does not compute or store ratings, and it does not prevent a player from queuing with someone who is much stronger than them. That is up to your own rating system.

Error handling​

Invalid queue configuration throws when you construct the queue. Some call-site errors, such as a missing required rating, also throw. Enqueue validation uses Result errors such as InvalidAttributes; check those before using the ticket. Network operations return a Result too, so the game can handle failures:

local result = queue:enqueue(player.UserId, { rating = 1200 })
if not result.ok then
if result.error.code == "AlreadyQueued" then
return "You're already in a queue."
end
return if result.error.retryable then "Try again in a moment." else "Couldn't queue."
end

Delivery​

matched fires on every server holding one of the matched players, not just the one that formed the match. If none of them are here, it does not fire here, so your handler always has someone to act on.

A match is written to MemoryStore before it is announced, so delivery never depends on MessagingService:

SituationTime to delivery
Notification after commitbackend-dependent
A message is droppedup to 30 s
MessagingService is down entirely~5 s, automatically

The last row is the point: Muster notices when the doorbell stops working and tightens its polling until it recovers. A match is never lost, since the ledger holds it for five minutes regardless.

Polling backs off per ticket (2s, 4s, 8s, 16s, then capped), not per player, so a full minute of waiting costs a handful of reads. A server with nothing queued costs nothing at all.

Lanes scale themselves​

This section describes queues without rules or built-in regions. Ordinary attribute-rule queues resolve lanes = "auto" to one fixed lane per partition and reject explicit counts above one, including when rating is also configured. Built-in regions instead manage one lane per region with cross-region discovery; see dynamic regions.

A standard queue is split into lanes. Workers search their own lane, relevant draining generations and, when warranted, adjacent rating bands. Lanes buy parallelism by splitting the pool, so how many you want depends on how deep the queue is, and where you cut depends on what the queue knows about its players. You don't have to guess either.

By default (lanes = "auto") a queue starts on one lane, so two players match on the first scan. It divides only once a single lane is consistently too full for one worker to see all of, and folds back down when the queue thins out again. No sizing table, no tuning.

A rated queue divides by rating. Every lane is a contiguous rating range, and the boundaries come from the ratings actually queueing: a lane that grows too deep splits at its own median, and one that goes quiet merges back into its neighbour. Cutting along the axis matchmaking already cares about means a split reduces unnecessary comparisons, but can separate compatible players near a boundary or after their windows relax. Nearby high-rated players tend to stay together rather than being scattered by ticket hash.

A band refuses to split unless its observed ratings span at least two initial rating windows. This protects tight clusters, but does not guarantee that every compatible pair shares a lane. A worker also reads the immediate adjacent bands when its own depth is below eight tickets or its oldest ticket has reached rating.unbounded. It does not search every band covered by a relaxed window.

An unrated queue divides by hash, hash(ticketId) % count, doubling and halving. With no ratings there is nothing to order players by and every ticket may match every other, so an even spread is the best split available.

Pin it if you'd rather: lanes = 4 (any integer, 1 to 64) disables scaling entirely, writes no lane record, and spreads by hash.

How resharding avoids losing tickets

Moving lanes around naively is a good way to strand players: a ticket written under 2 lanes lives in hash % 2, and a server that has moved to 4 lanes looks in hash % 4 and never finds it. It doesn't error, the player just waits until their ticket expires.

Muster stamps the layout into every key:

mu1:ranked:idx:EMEA:n04g7:02
^^^^^ ^^
layout lane

n04 is the lane count and g7 the revision it belongs to, tracked separately because a rating boundary can move without the count changing. Servers that disagree read and write different key spaces rather than corrupting one, and every ticket stays exactly where its writer put it. While the old layout drains, workers on the new one also read the old lanes theirs draws from: one extra where a lane was split, two where two were merged, and the same one or two when a hash count doubles or halves. Changes are spaced a full ticketExpiration apart, which guarantees an abandoned layout has drained before another lands.

Partitions​

partitionKey splits a queue into pools that never mix, such as separate mode variants. Use built-in regions for regional lanes with automatic fallback. Partition by region only when cross-region matching must remain impossible, including for Quick Play backfill.

partitionKey = function(request)
return request.metadata.modeVariant
end

A partition never merges with another; splitting a thin pool can leave it with nobody to match. Use dynamic regions for regional routing and automatic fallback. Muster does not measure latency for you. Quick Play backfill bypasses both regional and attribute matching rules.

Skill brackets are the one split worth not doing here. Give the queue a rating instead and its lanes become rating ranges on their own, with the difference that a lane rejoins its neighbour when it empties out and a partition never does.

Dynamic regions​

Configure the regions your game supports and pass a routing region on every enqueue, enqueueGroup or enqueueParty call:

local queue = Muster.new({
name = "squads",
size = 8,
regions = {
names = { "NA", "EU", "ASIA" },
fallbackAfter = 30,
},
-- Optional ratings and other attribute rules still apply.
})

queue:enqueue(player.UserId, { region = "NA" })
queue:enqueueGroup({ leaderId, friendId }, { region = "EU" })
queue:enqueueParty(partyId, leaderId, { region = "NA" })

Each region has an independently leased lane inside each permanent partition. Muster searches the local lane first. Once an observed local ticket has waited regions.fallbackAfter, the worker alternates local scans with scans that also read the other configured regions. Busy regions can form local matches while sparse regions find each other automatically. The lane count stays fixed at the number of configured regions; this does not add population-based lane merging or further sharding within a region.

Cross-region acceptance is mutual: both tickets must have waited the configured time. An old ticket cannot pull a fresh ticket out of its region. Same-region combinations receive a lower regional preference cost. Other attribute constraints and rating eligibility still apply; widening discovery does not disable them. The fallback time defaults to 30 seconds; zero permits cross-region matching immediately. It is separate from the top-level fallbackAfter for smaller matches. This is an eligibility time, not a guaranteed match deadline.

Tickets stay in their original lane with the same ID, queuedAt, reservations and party lock. There is no cancellation/requeue or queue-age reset. The existing claim protocol arbitrates workers reading overlapping regions and rechecks regional acceptance against the claimed authoritative tickets before finalizing. Groups and distributed parties always stay whole. A group or party supplies one routing region for the entire ticket; the game chooses it, for example based on its leader or the region its members agree to use. Muster does not infer member locations, measure ping, or choose the destination server's geographic region. Your finalizer and teleport integration still control the destination.

names accepts one to eight unique names, each 1-32 letters, digits, underscores or hyphens. Names are sorted internally, so configuration order does not change lane assignment. All workers must use the same names, fallback time and rules. Changing them creates a separate new-match pool; keep old workers until their tickets drain. Omit lanes or leave it on "auto"; regions manage the count. Custom matcher and layout overrides are unsupported with regions. Missing or unknown enqueue regions return InvalidRegion before reserving users. Supplying region without a regions config also returns InvalidRegion.

Regional acceptance uses a generated rule named __musterRegion. This name is reserved: do not supply it as a rule or enqueue attribute. It appears in returned member attribute snapshots and counts toward the normal attribute byte budget and 16-rule limit, leaving room for 15 user rules. User attributes are copied; you do not need an attributes table when no user rules are configured.

Discovery remains bounded. Local scans request at most 100 ticket entries from one regional map. Fallback scans share 100 entries across all configured regions: up to half for the local region, reduced when recent scans found fewer local tickets. The remaining budget alternates between all other regions and one rotating peer, so a busy peer can fill most of a sparse region's match. Each map has its own overlapping forward-page cursor, reset on worker takeover. A scan performs at most two range reads per region (16 with eight regions). Failed reads preserve that map's cursor for retry. Full local scans continue between fallback scans. Scattered compatible tickets may still never share a window; neither this search nor the matcher's eight-alternative search is exhaustive. Existing benchmark numbers below measure ordinary attribute queues, not regional fallback.

Quick Play backfill keeps its existing behavior: it bypasses regional, attribute and rating constraints and searches listings in the ticket's permanent partition. Built-in regional lanes apply to new-match formation only. Do not use Quick Play when a regional restriction must also apply to backfill; use permanent partitions for pools that must never mix.

Multiple matching attributes​

Use rules to compare independent numeric, string and boolean attributes when forming new matches. Use regions for regional routing and fallback; keep permanently separate pools in partition. The example combines built-in regions with independent attribute rules.

local queue = Muster.new({
name = "social",
size = 8,
regions = { names = { "NA", "EU" }, fallbackAfter = 30 },
rules = {
Age = {
type = "number", compare = "distance", prefer = "closer",
limit = {
{ after = 0, value = 2 },
{ after = 30, value = 5 },
{ after = 90, value = 10 },
},
},
Input = { type = "string", compare = "equal", relaxAfter = 45 },
IsVerified = { type = "boolean", compare = "equal" },
IsFlaggedCheater = { type = "boolean", compare = "required", value = false },
},
})

queue:enqueue(player.UserId, {
region = "NA",
attributes = {
Age = 21, Input = "GAMEPAD",
IsVerified = true, IsFlaggedCheater = false,
},
})

Each rule declares type and compare:

ComparisonBehavior
distanceNumeric absolute difference, or a string-category cost matrix. limit is a fixed nonnegative number or a schedule starting at { after = 0, value = ... }. The default is zero.
equalValues must match. Optional relaxAfter allows unequal values once both tickets have waited that long. Omit it for permanent equality.
requiredEvery member must have value. It is checked at enqueue for ordinary tickets and cannot relax. For example, value = false excludes flagged players; equality instead separates flagged and unflagged players.

Distance schedules have increasing times and nondecreasing finite limits, with at most 32 steps. The final limit stays enforced indefinitely. Each attribute schedule is evaluated independently from the original queue timestamp, without requeueing. Acceptance is always mutual: a distance must fit the smaller of the two current limits. An older ticket cannot override a new ticket's limit.

Checks cover every member pair, including within a party; age is not averaged. A currently incompatible party waits until its own restrictions relax. A party with conflicting permanent equality values cannot form a new match; validate that party composition in your game before queueing. Parties are never split. Both enqueueGroup and enqueueParty take attributes keyed by userId:

queue:enqueueGroup({ aliceId, bobId }, {
region = "NA",
attributes = {
[aliceId] = { Age = 21, Input = "GAMEPAD", IsVerified = true, IsFlaggedCheater = false },
[bobId] = { Age = 23, Input = "GAMEPAD", IsVerified = true, IsFlaggedCheater = false },
},
})

For string distance, supply a symmetric matrix. Identical categories cost zero; missing edges remain forbidden. Every category must have a row, including empty rows for categories that can only match themselves. These are game-defined costs:

Input = {
type = "string", compare = "distance", prefer = "closer",
distances = {
KEYBOARD = { GAMEPAD = 1, TOUCH = 2 },
GAMEPAD = { KEYBOARD = 1, TOUCH = 2 },
TOUCH = { KEYBOARD = 2, GAMEPAD = 2 },
},
limit = { { after = 0, value = 0 }, { after = 30, value = 1 }, { after = 60, value = 2 } },
}

Use numeric distance for values whose gaps have meaning, such as age or a skill measure. Arbitrary category IDs do not imply proximity.

Constraints reject candidates; preferences rank eligible groups. Add prefer = "closer" to distance rules or prefer = "equal" to equality rules to rank groups while retaining the constraint. Set mode = "preference" on distance or equality rules to rank without rejecting. Optional nonnegative weight (default 1) scales the penalty. Each preference contributes its worst member-pair distance (or 0/1 equality penalty); their weighted sum is minimized, then the sum of ticket queue timestamps. Weights must account for differing units. Missing matrix edges have infinite preference cost. Required rules are always constraints.

If rating is also configured, its existing anchor-based window remains an additional eligibility check; rule preferences supply the group score. Without rules, the existing rating matching and scoring behavior is unchanged.

Current bounds: without built-in regions, rule queues resolve lanes = "auto" to one fixed lane and reject explicit lane counts above one. Built-in regions manage one lane per configured region. Both paths reject custom matcher/layout overrides. A scan requests at most 100 candidate entries. Most scans start with the oldest tickets; forward pages also give newer tickets a chance when a head is stalled or stays busy. Selection visits visible anchors oldest first and keeps eight alternatives per reachable group size. This is still bounded search: compatible tickets spread across separate windows may never be compared. It is not exhaustive matching or a distributed attribute index.

Configure 1-16 named attributes (names up to 32 bytes). Every member must supply all configured fields, with no unknown fields, finite numbers and strings up to 128 bytes. Invalid input returns InvalidAttributes. Values are copied at enqueue and included in match.members[i].attributes. The estimated serialized attribute budget per ticket is the smaller of 4096 bytes and floor(8192 * ticketSize / size.max), reserving attribute space across a full match. Keep unrelated data in metadata. Rules configuration is copied, frozen and limited to an estimated 16 KiB.

Rule fingerprints separate new-match lane indexes, leases and layouts from legacy queues and other rule configurations. All workers for a pool must use the same configuration. Changing rules starts a separate new-match pool; old tickets need old workers to finish or must expire/cancel. User reservations and Quick Play listings remain shared for the same namespace and queue name.

Quick Play backfill deliberately bypasses matching rules, as it bypasses ratings. A Quick Play ticket still needs valid attributes for the new-match path, but its live-match destination is selected by the existing partition and seat rules. Quick Play tickets may also bypass required-value eligibility at enqueue; that requirement still prevents them from forming a new match. Hard attribute constraints are guarantees about new-match formation only.

Attribute matching performance​

The matcher now reuses compatibility and score calculations and skips search work that cannot improve a match. It keeps the same eight beam alternatives and matching rules.

Here are the original results alongside two runs after optimization, measured on October 16, 2026 with Lune 0.10.5 on a Ryzen 5 7600X running Windows:

FixtureOriginal medianNew medians, two runs
25 solo tickets, 8-player match9.32 ms1.33-2.54 ms
50 solo tickets, 8-player match41.23 ms3.98-6.24 ms
100 solo tickets, 8-player match171.97 ms12.44-13.72 ms
100 solo tickets, hard constraints only132.20 ms9.89-11.28 ms
100 solo tickets, 32-player match884.29 ms27.19-35.64 ms
100 solo tickets, 16 rules798.27 ms61.53-62.00 ms
100 four-player parties, 8-player match80.66 ms5.40-5.82 ms

The usual fixture has four rules: age proximity, region, verification and a required unflagged value. Each run takes 3-9 timed samples after warmup. These are local timings, not Roblox server limits. Results vary: the mixed verified/unverified fixture measured 12.58 and 28.27 ms at 100 tickets.

The standard 100-ticket scan is about 13 times faster, so the earlier warning about CPU cost at 50 tickets no longer applies to that fixture. Larger matches and more rules still cost more. Profile your game on Roblox before choosing a capacity limit or adding lanes.

An earlier discovery test found a real problem: 100 incompatible old tickets hid eight compatible players behind them. The worker now pages beyond that head. These ordinary rule queues use one lane per partition, and a party counts as one ticket.

After a full scan makes no matches, the next scan reads an oldest prefix and a forward page. A busy head also gets a forward scan after four normal scans, so some ongoing matching cannot keep newer players hidden forever. Each forward scan is followed by a normal oldest-first scan. Pages overlap by up to size.target - 1 tickets and wrap when they reach the tail. Cursors belong to the worker lease; they reset if another worker takes over. Queue timestamps and rule relaxation schedules do not change.

Each scan requests at most 100 entries across at most two range reads. Small queues still use a single read. Large targets can leave less room for the oldest prefix and require more overlap. Custom store adapters must support the optional fifth sortedRange argument, an exclusive { sortKey, key } cursor; the bundled adapters implement it. Quick Play discovery is unchanged.

The hidden group now matches after 3.25 simulated seconds in that test, instead of staying stuck for the full 100-second run. This does not guarantee every compatible combination will share a window, particularly with scattered groups, ongoing arrivals or frequent worker changes. Ordinary rule queues do not search across lanes; built-in regions add the bounded cross-region search described above.

A compatible 5,000-player burst drained in 15.5 simulated seconds. That clock excludes CPU time and network latency, and the in-memory store does not enforce real quotas or throttling. It is a useful comparison fixture, not a promise about how many players a live queue can handle.

Run the benchmarks from the repository root:

lune run bench/attributes
lune run bench/optimization/verify

The first command overwrites the latest results. The second checks optimized matching against the saved original implementation. See the benchmark notes for methods, validation, the two-run comparison and the earlier runs. Those two optimized-matcher reruns kept the same outcomes. The later discovery change matches the hidden group after 3.25 simulated seconds. The latest run keeps all 44 pure matcher counts and the normal burst/arrival outcomes unchanged; bench/discovery-before.json preserves the pre-fix results.

Testing your own game against it​

Muster.stores.InMemory and Muster.messaging.InMemory are real implementations with a synthetic clock and fault injection, so your tests can run the whole system with no MemoryStore, no Studio, and no waiting:

local now = 0
local clock = function() return now end
local store = Muster.stores.InMemory.new(clock)

local queue = Muster.new({
name = "test", size = 2,
store = store, clock = clock, monotonic = clock,
autoStart = false, broadcast = false,
spawn = function(fn) fn() end,
sleep = function() end,
})

queue:enqueue(1)
queue:enqueue(2)
queue:step() -- matched, deterministically

store:failNext("update", 2) -- and now with the store falling over
store:conflictNext("update") -- and now losing a compare-and-set race

The latest recorded full suite passed 654 checks with zero failures or crashes (October 16, 2026); run it again for your checkout. It covers three servers contending for one lane, a worker crashing mid-commit, and a finalizer that loses its lease while yielding. Regional tests also cover mutual fallback, queue-age preservation, whole distributed parties, uneven regional populations, paging, failed reads and overlapping regional workers.

How it works​

Every server is both a producer and an opportunistic worker; there is no matchmaker server to elect or provision.

  1. Enqueue reserves each player's id with compare-and-set, writes the ticket, and indexes it in a sorted map keyed by queue time.
  2. Acquire takes a lease on the lane, carrying a monotonically increasing fencing epoch.
  3. Scan reads the oldest candidates and runs the grouping algorithm.
  4. Commit is two-phase: claim every ticket in sorted order, re-check the lease, run finalize, re-check the lease again because it yielded, then flip the record to Ready with a compare-and-set guarded on the epoch. A server that stalled and lost its lease cannot commit, because the store itself rejects the write.
  5. Deliver publishes to the servers holding the players.

Quick Play runs alongside that rather than inside it: a flagged ticket also tries to claim a seat in a live match on every pass, and the two paths arbitrate on the same compare-and-set that moves a ticket out of Queued, so a player resolves to exactly one of them.

Nothing needs cleaning up when a server dies. Leases expire and the epoch fences out the dead holder; claims expire and the next worker either adopts a match that did commit or aborts one that did not; tickets stop heartbeating and evict themselves.

Full API reference: shmeatley.github.io/muster

Development​

rokit install
lune run test/run # headless suite
selene src
stylua --check src test demo
lune run bench/optimization/verify # compare optimized selection with preserved source
lune run bench/attributes # overwrite latest benchmark JSON

License​

MIT