@leumas/game-server
The authoritative host for Leumas games: the 'game' room kind, a worker-thread simulation pool, per-player interest fan-out and match persistence. Node only.
@leumas/game-server
The authoritative host for Leumas games. Node only.
Two halves, deliberately unaware of each other:
- Transport — the
'game'kind on@leumas/social-service's room hub. Lobby, input, rate
limiting, disconnect. It owns no simulation.
- Simulation —
createSimRoomrunning in worker threads, importing nothing but
@leumas/game-kit and a game definition. It owns no sockets.
The four things worth knowing
1. The simulation is never on the Express event loop. A room ticking at 60 Hz with a physics step in it is a CPU-bound loop, and Node has one thread for JavaScript. Run it inline and every REST request waits behind it — the same failure check:async exists to catch, arriving on a timer instead of from an unawaited promise.
2. spawn is the scale-out plan. A channel is anything with post, onMessage and close. The default is a worker thread; pass a WebSocket to a remote game-node and the same room kind runs against another machine with no change to the kind, the protocol or the game.
3. A full host refuses rather than degrades. Past maxRooms, open() rejects with capacity. Accepting the room and letting every existing match stutter punishes the players who were already there for the arrival of one more.
4. Disconnect is not departure. onDisconnect fires the moment a socket closes and says the body is unattended while the seat is still held. onLeave fires when the reconnect grace expires and says the seat is gone. A shooter needs the first within a couple of hundred milliseconds; without it a player who alt-F4s stands in the world for a full minute as a free kill.
Three traps
Register before buildHandlers(). The hub snapshots its kinds. A kind registered afterwards has no frames at all and every message it expects is silently dropped — nothing throws to say so.
hostId is not authority. The hub migrates it when the host drops. In a server-authoritative game the host chooses lobby settings and when to start, and nothing else. A test asserts simRoom.js never mentions it.
Kick once, not per message. A flood is thousands of messages a second; kicking on each one after the threshold sends thousands of kick frames back down the same connection, which is more traffic in the flood's direction and does its work for it.
Wiring
const gameKind = { current: null };
const host = createSimHost({
onOutbound: (roomId, userId, frame, payload) => gameKind.current?.outbound(roomId, userId, frame, payload),
});
createSocialRealtime({
server,
authenticate,
registerKinds: [(hub) => { gameKind.current = registerGameKind(hub, { host, resolveGame }); }],
});
The mutable holder exists because each half needs the other: the host needs somewhere to send, the kind needs a host to forward to. One has to be built first, and a holder is cheaper than a circular import.
Verify
node --test shared/engines/game-server/test/*.test.js # 22 tests
pnpm smoke:game # a whole match, end to end
pnpm smoke:game --players 8 --seconds 60
smoke:game runs the REAL room hub and the REAL game kind; only the worker thread is replaced by an in-process channel, so a failure there is a failure in code that ships.