-- Matchmaker hook: turns a set of matched users into a balanced, seated roster -- and hands it to a matchsession. local nk = require("nakama") local config = require("config") local util = require("util") local profile = require("profile") local M = {} -- Snake draft: sort by MMR descending, then deal in the order 0,1,1,0,0,1,1,0... -- -- Plain alternation (0,1,0,1,...) systematically stacks team 0 - it always gets -- the stronger player of every pair. Snaking gives team 1 back-to-back picks -- after team 0's, which keeps the aggregate MMR within one "gap" of even. -- -- For 1v1 (team_size 1) the pattern degenerates to one player each, which is -- correct: there is nothing to balance. local function snake_draft(users, team_size) table.sort(users, function(a, b) -- Tie-break on user_id so the seating is deterministic for a given set. if a.mmr == b.mmr then return a.user_id < b.user_id end return a.mmr > b.mmr end) local teams = { [0] = {}, [1] = {} } for i, user in ipairs(users) do -- 0-based pick index -> 0,1,1,0 repeating. local phase = (i - 1) % 4 local team = (phase == 0 or phase == 3) and 0 or 1 -- If the drafted team is already full, the other one takes the pick. With -- an exact roster this only fires on the final picks of odd patterns, but it -- guarantees we can never overfill a team regardless of player count. if #teams[team] >= team_size then team = 1 - team end user.team = team user.slot = #teams[team] teams[team][#teams[team] + 1] = user end return teams end -- Nakama's Lua matchmaker entries carry ONE merged `properties` table - the -- separate string_properties/numeric_properties split only exists in the Go and -- TypeScript runtimes. Reading the wrong field yields nil for every property and -- the hook silently declines every match. local function props_of(matched_user) return matched_user.properties or matched_user.string_properties or matched_user.numeric_properties or {} end -- Build the seated roster for a matched set. function M.build_roster(matched_users, mode) local users = {} for _, mu in ipairs(matched_users) do local presence = mu.presence users[#users + 1] = { user_id = presence.user_id, username = presence.username, session_id = presence.session_id, mmr = tonumber(props_of(mu).mmr) or profile.mmr(presence.user_id), } end local teams = snake_draft(users, mode.team_size) local roster = {} for team = 0, 1 do for _, user in ipairs(teams[team]) do roster[#roster + 1] = { user_id = user.user_id, -- A seat belongs to a SESSION, not an account. The same account can hold -- several sessions at once (two dev clients on one machine, or a player -- with the game open twice), and keying seats by user_id silently merges -- them into one - the lobby then never reaches its player count. session_id = user.session_id, username = user.username, team = user.team, slot = user.slot, mmr = user.mmr, } end end return roster end -- Registered as the matchmaker_matched hook. -- -- Returning a match id makes Nakama tell every matched client to join that -- match. Returning nil would make Nakama spin up a *relayed* match instead, -- which is exactly what we do not want - all gameplay traffic belongs to Mirror. function M.matchmaker_matched(context, matched_users) if matched_users == nil or #matched_users == 0 then return nil end -- Every ticket in a matched set carries the same mode, because the matchmaker -- query pins it (+properties.mode:1v1). Read it off the first user. local mode = config.mode(props_of(matched_users[1]).mode) if mode == nil then nk.logger_error(("matchmaker_matched with unknown mode %q - declining") :format(tostring(props.mode))) return nil end if #matched_users ~= mode.players then -- Nakama can match between min and max count. We always queue with -- min == max == mode.players, so a mismatch means a client sent bad counts. nk.logger_error(("matchmaker_matched for %s got %d users, expected %d - declining") :format(mode.key, #matched_users, mode.players)) return nil end local roster = M.build_roster(matched_users, mode) -- Choose the environment now, while context.env is in reach, and carry it in -- the match params. Match handlers do not reliably see runtime env, and the -- whole roster must land in the SAME scene, so it cannot be re-rolled later. local scene = config.resolve_scene(context, mode) if scene == nil then nk.logger_error(("mode %s has no scenes configured - declining"):format(mode.key)) return nil end local match_id = nk.match_create("matchsession", { mode = mode.key, scene = scene, roster = roster, }) nk.logger_info(("matched %d players for %s in %s -> matchsession %s") :format(#roster, mode.key, scene, match_id)) return match_id end return M