-- Registry of headless Mirror game servers. -- -- Each server self-registers on boot and heartbeats every ~15s. Nakama never -- launches servers; it only tracks which ones are alive and free. That keeps -- this module identical on a LAN Mac and in production - the only thing that -- changes is what starts the containers (Compose here, Agones/Edgegap/k8s later). -- -- Storage layout: collection "gameservers", one system-owned object per server, -- key = server_id: -- { server_id, public_host, port, region, build_version, capacity, -- in_use, match_id, mode, last_seen } local nk = require("nakama") local config = require("config") local util = require("util") local M = {} local COLLECTION = config.COLLECTION_GAMESERVERS local function is_alive(entry, now) return (now - (entry.last_seen or 0)) <= config.GS_HEARTBEAT_TIMEOUT_SEC end -- List every registered server. Small by definition (tens, not thousands), so a -- single page is enough; we page anyway rather than silently truncating. local function list_all() local out = {} local cursor = nil repeat local objects, next_cursor = nk.storage_list(util.SYSTEM_USER, COLLECTION, 100, cursor) for _, obj in ipairs(objects or {}) do out[#out + 1] = { value = obj.value, version = obj.version, key = obj.key } end cursor = next_cursor until cursor == nil or cursor == "" return out end -- --------------------------------------------------------------------------- -- Called by the game server -- --------------------------------------------------------------------------- -- Register (or re-register after a restart). Idempotent: a server that crashes -- and comes back with the same id overwrites its own entry and is free again. function M.register(context, payload) local input = util.decode(payload) util.assert_gameserver(context, input) if not input.server_id or input.server_id == "" then util.fail("server_id required") end if not input.public_host or input.public_host == "" then -- Refusing this is deliberate: a server that advertises nothing (or a -- container-internal IP it inferred itself) produces matches nobody can -- join, and the failure looks like a matchmaking bug instead of a config one. util.fail("public_host required - set GS_PUBLIC_HOST to the LAN-reachable address") end if not input.port or tonumber(input.port) == nil then util.fail("port required") end local entry = { server_id = input.server_id, public_host = input.public_host, port = tonumber(input.port), region = input.region or "lan", build_version = input.build_version or "unknown", capacity = tonumber(input.capacity) or 10, in_use = false, match_id = nil, mode = nil, last_seen = util.now(), } -- No version check: registration is intentionally last-writer-wins so a -- restarted server always reclaims its slot. The result IS checked, though - -- reporting success on a failed write leaves a server that believes it is in -- the pool while the matchmaker cannot see it. if not util.storage_write(COLLECTION, entry.server_id, nil, entry, nil) then util.fail("could not persist server registration") end nk.logger_info(("gameserver registered: %s at %s:%d (%s)") :format(entry.server_id, entry.public_host, entry.port, entry.build_version)) return util.ok({ server_id = entry.server_id, heartbeat_sec = 15 }) end function M.heartbeat(context, payload) local input = util.decode(payload) util.assert_gameserver(context, input) local entry, version = util.storage_read(COLLECTION, input.server_id, nil) if entry == nil then -- Nakama restarted and lost the registry, or the server was reaped. Tell it -- to register again rather than silently dropping it out of rotation. return util.ok({ reregister = true }) end entry.last_seen = util.now() if input.player_count ~= nil then entry.player_count = tonumber(input.player_count) end -- A server reporting itself idle releases its match binding. This is the -- recovery path for a match that ended without a clean rpc_gs_match_ended. if input.in_use == false then entry.in_use = false entry.match_id = nil entry.mode = nil end util.storage_write(COLLECTION, input.server_id, nil, entry, version) return util.ok({ reregister = false }) end -- --------------------------------------------------------------------------- -- Called by matchsession -- --------------------------------------------------------------------------- -- Claim a free, alive server for a match. -- -- The version check is what makes this safe: two matchsessions allocating at the -- same instant both read version N, both try to write with version N, and the -- loser's write is rejected so it moves on to the next candidate. Without it -- both matches would be sent to the same server. function M.allocate(match_id, mode_key) local now = util.now() local stale = 0 for _, row in ipairs(list_all()) do local entry = row.value if not is_alive(entry, now) then stale = stale + 1 -- Evict here rather than relying on a periodic sweep. Matches usually -- resolve in well under a second, so a timer-driven reap almost never -- fires and dead entries would accumulate in storage indefinitely. -- Allocation already walks every server, so this costs nothing. nk.logger_info(("reaping dead gameserver %s (last seen %ds ago)") :format(tostring(entry.server_id), now - (entry.last_seen or 0))) util.storage_delete(COLLECTION, row.key, nil) elseif entry.in_use ~= true then entry.in_use = true entry.match_id = match_id entry.mode = mode_key entry.last_seen = now if util.storage_write(COLLECTION, entry.server_id, nil, entry, row.version) then nk.logger_info(("allocated gameserver %s to match %s (%s)") :format(entry.server_id, match_id, mode_key)) return entry end -- Lost the race; another match took this one. Fall through to the next. nk.logger_debug(("allocate lost race on %s, trying next"):format(entry.server_id)) end end nk.logger_warn(("no free gameserver for match %s (%s); %d stale entries seen") :format(match_id, mode_key, stale)) return nil end function M.release(server_id, reason) if server_id == nil then return end local entry, version = util.storage_read(COLLECTION, server_id, nil) if entry == nil then return end entry.in_use = false entry.match_id = nil entry.mode = nil util.storage_write(COLLECTION, server_id, nil, entry, version) nk.logger_info(("released gameserver %s (%s)"):format(server_id, reason or "match over")) end -- Evict servers whose heartbeat lapsed. Called opportunistically from the -- matchsession tick, so it costs nothing when nobody is matchmaking. function M.reap() local now = util.now() for _, row in ipairs(list_all()) do if not is_alive(row.value, now) then nk.logger_info(("reaping dead gameserver %s (last seen %ds ago)") :format(row.value.server_id, now - (row.value.last_seen or 0))) util.storage_delete(COLLECTION, row.key, nil) end end end -- Debug/ops helper, exposed over http_key so the admin can see the pool. function M.rpc_list(context, payload) local input = util.decode(payload) util.assert_gameserver(context, input) local now = util.now() local servers = {} for _, row in ipairs(list_all()) do local e = row.value servers[#servers + 1] = { server_id = e.server_id, address = ("%s:%d"):format(e.public_host, e.port), region = e.region, in_use = e.in_use == true, match_id = e.match_id, alive = is_alive(e, now), age_sec = now - (e.last_seen or 0), } end return util.ok({ servers = servers, count = #servers }) end return M