-- Thin signalling match. NOT gameplay. -- -- Its whole job is the handoff between "the matchmaker found 10 people" and -- "those 10 people are connected to one Mirror server": -- -- 1. Wait for every matched player to actually join (they might rage-quit the -- queue screen, or their socket might have dropped). -- 2. Allocate a game server from the registry. -- 3. Mint a per-player ticket and send each player only their own. -- 4. Terminate. -- -- If anything fails - someone leaves, no server is free, the join window expires -- - it broadcasts MATCH_CANCELLED, revokes the tickets, releases the server, and -- dies. It must never leave players staring at a spinner forever. local nk = require("nakama") local config = require("config") local util = require("util") local gameservers = require("gameservers") local tickets = require("tickets") local M = {} -- Seats are addressed by SESSION, not by account. One account can legitimately -- hold two sessions (two dev clients on one machine, or the game open twice), -- and keying by user_id collapses those into a single player - the lobby then -- waits forever for a player that is already here. local function seat_for(state, presence) for _, seat in ipairs(state.roster) do if seat.session_id == presence.session_id then return seat end end return nil end local function roster_payload(state) local players = {} for _, seat in ipairs(state.roster) do players[#players + 1] = { userId = seat.user_id, username = seat.username, team = seat.team, slot = seat.slot, joined = state.joined[seat.session_id] ~= nil, } end return util.encode({ players = players, mode = state.mode_key }) end local function cancel(state, dispatcher, reason) if state.finished then return end state.finished = true nk.logger_warn(("matchsession cancelled (%s): %s"):format(state.mode_key, reason)) dispatcher.broadcast_message(config.OP_MATCH_CANCELLED, util.encode({ reason = reason })) tickets.revoke_all(state.minted_tickets) gameservers.release(state.server_id, "match cancelled") state.server_id = nil end -- --------------------------------------------------------------------------- function M.match_init(_context, params) local state = { mode_key = params.mode, mode = config.mode(params.mode), -- Chosen by matchmaker_matched, not looked up here: every player in this -- match must get the same environment, so it is decided exactly once. scene = params.scene, roster = params.roster, joined = {}, -- user_id -> presence minted_tickets = {}, server_id = nil, ticks = 0, started = false, finished = false, } local tick_rate = config.SESSION_TICK_RATE local label = util.encode({ mode = params.mode, kind = "signalling" }) return state, tick_rate, label end function M.match_join_attempt(_context, _dispatcher, _tick, state, presence, _metadata) -- Only the players the matchmaker seated may join. This match id is delivered -- to those clients only, but it is a plain string - refuse gatecrashers. if seat_for(state, presence) == nil then return state, false, "not part of this match" end if state.finished then return state, false, "match already ended" end return state, true end function M.match_join(context, dispatcher, _tick, state, presences) -- match_init does not know the match id yet; Nakama supplies it on context. state.match_id = state.match_id or context.match_id for _, presence in ipairs(presences) do state.joined[presence.session_id] = presence end -- Keep the queue screen honest while the rest of the lobby files in. dispatcher.broadcast_message(config.OP_ROSTER, roster_payload(state)) local joined_count = 0 for _ in pairs(state.joined) do joined_count = joined_count + 1 end if joined_count < #state.roster or state.started then return state end -- Everyone is here. Allocate and hand out tickets. state.started = true local server = gameservers.allocate(state.match_id, state.mode_key) if server == nil then cancel(state, dispatcher, "no game server available") return state end state.server_id = server.server_id for _, seat in ipairs(state.roster) do local presence = state.joined[seat.session_id] if presence ~= nil then local ticket = tickets.mint( seat.user_id, seat.username, state.match_id, server.server_id, state.mode_key, seat.team, seat.slot, state.scene) state.minted_tickets[#state.minted_tickets + 1] = ticket -- Sent to one presence only: a ticket is that player's identity, and -- broadcasting all ten would let any of them impersonate the others. dispatcher.broadcast_message(config.OP_MATCH_READY, util.encode({ host = server.public_host, port = server.port, ticket = ticket, team = seat.team, slot = seat.slot, mode = state.mode_key, scene = state.scene, matchId = server.match_id, }), { presence }) end end nk.logger_info(("matchsession dispatched %d players to %s:%d") :format(#state.roster, server.public_host, server.port)) return state end function M.match_leave(_context, dispatcher, _tick, state, presences) for _, presence in ipairs(presences) do state.joined[presence.session_id] = nil end if state.started then -- Past the handoff. Leaving here just means the client moved on to Mirror, -- which is the happy path - the Mirror server owns them now. return state end -- Someone bailed before the match was dispatched. Cancel rather than making -- the rest wait out the timeout for a player who is provably gone. cancel(state, dispatcher, "a player left before the match started") return nil end function M.match_loop(_context, dispatcher, _tick, state, _messages) state.ticks = state.ticks + 1 if state.finished then return nil -- terminate end if state.started then -- Handoff done. Give clients a moment to receive MATCH_READY, then stop. if state.ticks % config.SESSION_TICK_RATE == 0 then return nil end return state end local elapsed = state.ticks / config.SESSION_TICK_RATE if elapsed >= config.SESSION_JOIN_TIMEOUT_SEC then cancel(state, dispatcher, "timed out waiting for players to join") return nil end -- Cheap opportunistic housekeeping: evict servers whose heartbeat lapsed, so -- the pool is clean by the time we allocate. Once every 10s of waiting. if state.ticks % (config.SESSION_TICK_RATE * 10) == 0 then gameservers.reap() end return state end function M.match_terminate(_context, dispatcher, _tick, state, _grace_seconds) if not state.started and not state.finished then cancel(state, dispatcher, "server shutting down") end return nil end function M.match_signal(_context, _dispatcher, _tick, state, data) return state, data end return M