Crux Kata #3: A Server Browser in an Afternoon (Registry, Heartbeats, Liveness)

Hands-on kata: give your dedicated-server game a real server browser with Crux: register servers with metadata, keep them alive with heartbeats, filter the list in the client, and never show players a dead server.

Kata #3 in the Crux Kata series: one real backend feature per exercise, working code, under an hour. Previously: cloud saves and leaderboards. Finale: matchmaking.

If your game ships dedicated servers, you have a discovery problem on day one: players need a list of servers, the list needs to be current, and "current" is the hard part. A server browser is three deceptively simple requirements: servers announce themselves, servers prove they are still alive, and dead servers disappear before a player clicks them. Get the third one wrong and every crash becomes a player-facing "connection failed", which is how server browsers earn bad reputations. This kata builds all three.

The kata: register a dedicated server with metadata, keep it listed with heartbeats, render a filtered browser in the client, and verify dead servers vanish. Time: ~45 minutes. You need: a free Crux project (create one) and a server token from the dashboard (Tokens page), because registration is a trusted-side operation.

Step 0: why a server token, not an API key

Kata #2 ended on the trust boundary: clients identify players, servers identify your infrastructure. The registry only accepts writes from holders of a server token, which means a player cannot register a fake "server" that harvests connection attempts, and cannot deregister yours. Mint one in the dashboard; it lives in your server's environment, never in a client build.

Step 1: the server announces itself

On boot, your dedicated server registers with everything a player needs to choose it:

import { GSBClient } from "@supercraft/gsb";

const crux = GSBClient.forServer(CRUX_API_URL, "proj_...", "env_...", "gsb_servertoken_...");

await crux.registerServer({
  server_id:    "eu-dm-01",            // stable ID, survives restarts
  name:         "Deathmatch EU #1",
  region:       "eu-west",
  map_name:     "forest",
  game_mode:    "deathmatch",
  player_count: 0,
  max_players:  16,
  address:      "198.51.100.1",
  port:         7777
});

Design note that saves later pain: make server_id stable across restarts (hostname-derived, not random). A restarting server should reclaim its identity, not leave a ghost entry and create a twin. Everything else (map, mode, player count) is mutable state you will refresh continuously, which is step 2.

Step 2: heartbeats, the actual product

Registration is an event; liveness is a contract. The registry treats a server as alive only while heartbeats keep arriving, so a crashed process, a hung VM, or a yanked cable all converge to the same correct outcome: the entry expires off the list. Your server's game loop sends:

// Liveness via the SDK:
setInterval(() => crux.heartbeat("eu-dm-01"), 15000);

// The heartbeat endpoint also accepts live state, so the browser shows
// current players and the rotated map; send it via the REST form:
const base = CRUX_API_URL + "/projects/proj_.../environments/env_...";
const serverHeaders = { "Authorization": "Bearer gsb_servertoken_...",
                        "Content-Type": "application/json" };
setInterval(async () => {
  await fetch(base + "/servers/heartbeat", {
    method: "POST", headers: serverHeaders,
    body: JSON.stringify({
      server_id:    "eu-dm-01",
      player_count: currentPlayers.length,
      map_name:     currentMap
    })
  });
}, 15000);

The heartbeat carries the volatile state, so the browser shows live player counts and the current map without a separate update path. Two operational rules: heartbeat well inside the liveness window (if the registry expires at 60 seconds, beat every 15, not every 55), and on clean shutdown, call deregisterServer so the entry vanishes instantly instead of lingering until expiry:

process.on("SIGTERM", async () => {
  await crux.deregisterServer("eu-dm-01");
  process.exit(0);
});

That is the whole liveness machine: register on boot, beat on a timer, deregister on exit, and the failure mode (no beat) is also the correct behavior (no listing). There is no fourth case to handle, which is precisely what makes registries pleasant to operate.

Step 3: the browser in the client

The client reads the list with its normal player credentials, no server token needed for reads:

const cruxClient = GSBClient.forPlayer(CRUX_API_URL, "proj_...", "env_...", "gsb_apikey_...");

// Server-side filters keep the payload small...
const servers = await cruxClient.listServers({ region: playerRegion });

// ...then shape the UI client-side
const visible = servers
  .filter(s => s.player_count < s.max_players)
  .sort((a, b) => b.player_count - a.player_count);   // fuller servers first

Three UI decisions that separate good browsers from list dumps. Sort by population, descending: players want servers with people on them, and this single sort line does more for perceived game health than any marketing. Filter full servers out (or grey them), a click that bounces is worse than no click. And default to the player's region with a manual override, because a 200ms deathmatch server is a refund waiting to happen. The metadata you registered in step 1 is exactly the filter surface: mode tabs, map names, region pickers all come free from the registration payload.

Step 4: prove it worked

The acceptance test is brutal and quick: boot the server script, see it in the client list with live player count, then kill -9 it (no clean shutdown, no deregister) and watch the entry disappear after the liveness window. That last observation is the kata's whole point: you never wrote cleanup code for the crash case, and the browser is still correct. Then restart the server and watch it reclaim its stable ID rather than duplicating.

Why this matters beyond the browser

The registry quietly becomes your fleet's source of truth. The same list that powers the player-facing browser powers your ops view (the dashboard shows the fleet with the same data), capacity decisions (regions trending full), and Kata #4's matchmaking (which needs to place matches onto live servers, and now has a live-servers list to draw from). If you run your dedicated servers on managed game hosting, the pattern is identical: the heartbeat loop lives in your server build, wherever that server happens to run.

Production notes before you ship it

Name regions for players, route for machines. Register with machine identifiers (eu-west) and translate to human labels in the UI ("Europe"). The matcher and the browser key on the identifier; marketing can rename the label without a fleet redeploy.

The registry is a capacity dashboard in disguise. Sum player_count over max_players by region every few minutes and you have the only autoscaling signal a small fleet needs: when a region trends above seventy percent on weekday evenings, that is next month's server order, observed rather than guessed.

Keep environments honest. Register staging servers in a staging environment, never the production one; the day a half-broken test build appears in the public browser is the day you will wish the separation had been a habit. Environments exist exactly for this.

Expect the lying server. A server that heartbeats but cannot accept joins (port blocked, full disk) is the registry's blind spot. The cheap mitigation: have the client report failed joins, and after two failures against the same entry, hide it locally for ten minutes. Players forgive one bad click; they do not forgive three against the same row.

Kata variations to try

  • The community-host variation: issue server tokens to trusted community admins and their servers join the same browser, the classic dedicated-server-game move that multiplies your fleet for free.
  • The playlist variation: register game_mode as a comma-joined playlist and let the browser filter by any mode within it.
  • The ops-bot variation: a cron script diffing the registry every minute into a Discord channel: servers appearing, vanishing, or stuck at zero players, your first monitoring system, twenty lines.

Kata FAQ

Does this work with managed game-server hosting? Yes, and it is the common case: the heartbeat loop lives inside your server build, so it runs identically on a provider's hardware, your colo box, or a dev laptop. The registry neither knows nor cares who runs the metal.

How fast do dead servers disappear? A cleanly-shut-down server vanishes instantly via deregisterServer; a crashed one persists until its liveness window expires, which is why you heartbeat at a quarter of the window, the worst-case ghost is shorter than a player's patience.

Should the client poll the list while the browser is open? Refresh on open and on a manual pull-to-refresh; a 15 to 30 second auto-refresh while the screen is visible is plenty. The list changes at human speed, and player counts a few seconds stale never hurt anyone.

Can players host their own servers and appear in the browser? Yes, that is the community-host variation above: issue a server token per trusted host. Tokens are revocable individually, so retiring a misbehaving community server is one click, not a fleet rotation.

What you just avoided building

The self-built registry is one of those traps that looks like a weekend (a table and two endpoints) and becomes a quarter: TTL expiry that actually fires, the thundering-herd refresh when a popular server restarts, token auth so randoms cannot register honeypots, the admin view, and the monitoring for the monitoring. Server discovery is also the feature cloud-platform pricing punishes hardest at scale, a point we broke down in the cost planning guide; a registry is small, hot, and constant, which is exactly the wrong shape for per-call pricing. The kata version: two trusted calls, one timer, one read.

Run this kata: create a free Crux project, mint a server token, and run the snippets as two Node 18+ scripts (one "server", one "client"). The Unity, Godot, and Roblox SDKs ship the same calls for in-engine use, and the cost calculator prices a whole fleet's heartbeats honestly. Finale: Kata #4, a matchmaking queue in an afternoon.