# @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.


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** — `createSimRoom` running 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

```js
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

```sh
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.


---
Source: shared/engines/game-server/README.md
Canonical: https://docs.leumas.tech/p/engines/game-server
