Muster: cross-server matchmaking for Roblox

Muster: cross-server matchmaking for Roblox

I kept rewriting matchmaking for every game I made, so I finally just made it a package.

Muster puts players into a queue, matches them across servers, and hands the finished match back to every server that has one of those players on it. It’s free and MIT.


The smallest version


local Muster = require(ReplicatedStorage.Packages.Muster)

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

duels.matched:connect(function(match)

    for _, member in match.members do

        print(member.userId, "matched in", match.matchId)

    end

end)

duels:enqueue(player.UserId)

That’s the whole setup. Two players on two different servers will match on the first pass, and both servers get told about it.


What it doesn’t do

I want to be upfront about this because it’s the main design decision, and it’ll either suit you or it won’t.

Muster doesn’t teleport players, doesn’t reserve servers, and doesn’t compute or store ratings. It gives you a finished match and gets out of the way.

The reason is that everyone does those three things differently, and every library I’ve used that tried to own them fought me eventually. So instead there’s a hook that runs once per match, on whichever server formed it, and whatever you return from it gets attached to the match and delivered to everyone:


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

        }

    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)

end)

ReserveServerAsync runs once, on one server, and every other server holding a matched player receives the same access code. match.data is typed as whatever your hook returns, so match.data.accessCode autocompletes.


The rest of it

Skill matching. Give it a number and it starts strict, then loosens the longer someone waits. The number can come from anywhere: Elo, Glicko, OpenSkill, a stat you invented. Leave the rating table out entirely and the queue is just oldest-first.

Groups and parties. enqueueGroup({ userIds }) queues people together as one ticket that never gets split. If your friends can end up on different servers, there’s a cross-server party system too, but you don’t have to touch it.

Sharding is automatic. Queues start on one lane and split themselves as they get busy, then merge back when they quiet down. This is the thing I got wrong the most times by hand: shard too early and two players never land in the same bucket, so nobody ever matches.

Delivery doesn’t depend on MessagingService. Matches are written to MemoryStore first and announced second. If a message gets dropped, a poll picks it up. If MessagingService is down entirely, it notices and tightens the polling until it recovers.

It survives servers dying. Leases with fencing tokens, compare-and-set claims, a two-phase commit. If a server dies mid-match another one picks up where it left off, and if it dies after committing, the match still stands.


Testing

The InMemory store and message bus ship with the package, so you can test your own matchmaking 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, instantly, deterministically

store:failNext("update", 2) -- now do it with the store falling over

That’s how Muster tests itself: 323 assertions, including three servers fighting over one lane, a worker crashing halfway through a commit, and a finalize hook that loses its lease while it’s yielding.


Things to know before you use it

Honest limitations
  • No backfill yet. If someone leaves a match, nothing refills it.

  • Partitions don’t merge. If you split your queue by region, a lone player in a quiet region waits in that region. There’s no automatic “give up and go global after 20s”. You can do it yourself by re-queueing them, but it isn’t built in.


If you try it and something is confusing or broken, tell me. Issues on GitHub or a reply here both work. Feedback on the docs is useful too. I already rewrote them once because they were doing too much explaining and not enough describing.

Here is the demo place:

8 Likes

This is good but you should add indentation to the code examples to make it more visible.

Is there a testing EDITable place to check out.

Thanks for pointing that out, I didnt even notice

1 Like

As of now no, but maybe whenever I get time to do so I can throw one together.

Just created this

1 Like