Networking
Gamehoster's authoritative half is one box hosting many domains, each domain many games, each game many live instances. This page is how it serves them over the wire — the networking in the large. Work splits four ways — Caddy is a static web server for each game's library, an API web server takes game updates and serves logs, a websocket server takes players in, and a pool of worker processes runs the games across the machine's CPUs. A game's own front end lives on its own website; the only things this box hands a browser are that one library file and a socket. The files all of this reads and writes are the Server page. The binary protocol it speaks over that socket is the second half of this page.
Static web server
The only file this server hands a browser is the game's custom library, and it is a plain static file. When a
game is synced, the build produces a gamehoster.js for it — the generic client library with that
game's own config, smooth fields and shared front-end code baked in — and writes it into the game's folder.
Caddy serves it straight from disk: no backend process is touched, and it caches like any other asset. The
game's front end is not served here; it lives on the game's own website and simply loads this one
script and opens the socket. There is no public index of games — the server never lists what it hosts.
Interface — one static route per game, served by Caddy from the game's folder:
GET https://<domain>/<game>/gamehoster.js— the game's custom library, written at sync, served static
API web server
Hosts the client↔server API a Gamehoster box exposes: it takes each game's synced files and reads the box's
logs. It is token-authed and returns a uniform bare 404 to anything unauthorised or unknown, so a
probe with no token learns nothing about what exists; with no token configured, the API is off entirely. Caddy
proxies /api to it, and it also answers Caddy's on-demand-TLS gate — per hostname, whether to issue
a certificate — so the box only mints certs for domains it actually hosts.
Interface — proxied to the backend:
/api/…— the game-definition sync surface and the read-only logs and stats, both token-authedGET /internal/allow— Caddy's cert gate:200only for a domain the box hosts
The full request-by-request protocol lives on two pages: game synchronisation is the Sync page, and the access and server logs behind the Web Logs and Server Logs tools are the Tools page.
WebSocket server
Takes each player's game socket, authorises it, seats the player into an instance, and hands the socket to the
worker that runs it — then drops out of the conversation. A game socket is one live player attached to one live
instance for as long as they play, at wss://<domain>/<game>. A new socket is placed in
four steps:
- The browser dials
wss://<domain>/<game>; Caddy terminates TLS and proxies the upgrade to the backend on localhost. - The server resolves
(Host, first path segment)to a hosted game and checks theOriginagainst the game's allowed origins; an unknown game or a disallowed origin is dropped here. - It seats the player into an instance — an open one with room, or a fresh one — and that choice names the worker: an existing instance means its worker, a new instance means the emptiest shard for that game, or a newly spawned worker if every shard is full.
- It hands the accepted socket to that worker rather than proxying it. The worker sends the welcome and from then on talks to the browser directly.
Passing the socket instead of relaying it keeps the backend out of the hot path: every command in and snapshot out then flows straight between browser and worker, so per-message cost is exactly a single process's. The split buys CPU parallelism with no relay tax.
Worker process
Runs one game and a shard of its instances — a subset, never all of them — advancing each instance it holds and streaming state to that instance's players. It is the unit of CPU parallelism: one OS process getting one core's worth of work. Interface — in, sockets handed over by the websocket server; out, each player's stream at the game's send rate.
The shard is the deliberate middle of two bad extremes. Not one process per instance — a game can hold
thousands of tiny instances, and that is thousands of processes. Not one process per game either — then every
instance of a busy game shares one core with nowhere to grow. So a game's instances are dealt across a small
pool of workers, each a shard with a capacity cap. Two shards of one game run truly parallel on two
cores, and the core running asteroids shard A is free of pong entirely.
This works because the instance is the unit of isolation: a complete little world with its own state, players, entities, tick number and short history, and nothing ever crosses between instances. A shard is therefore just a bag of independent worlds — nothing has to be coordinated across the split, and any instance can run in any worker.
Inside a worker each instance runs on two clocks. The tick clock advances it at the game's tick rate — the three-step cycle from Updates: handle the tick's commands, update every actor, run the instance body. The send clock, usually slower, builds each player their stream, the relayed commands and authoritative state of what they can see, and pushes it. A late command rewinds only its own instance and replays it forward, all inside the one worker; the Instance page is that mechanism in full.
Managing the workers
The backend keeps one small table — for each <domain>/<game>, its workers and how
loaded each is — and everything else follows from a single per-game knob: how many instances (or players) a
shard may hold.
- Spawned on demand. A game with no players runs no processes; the first player to arrive spawns its first worker. A game costs a core only while it is being played.
- Grown by capacity. When every worker for a game is at the cap, the next new instance spawns another. Load is placed, not guessed: joins go to the shard already holding their instance, new instances to the emptiest shard.
- Watched. Workers heartbeat and the backend watches for exit. If a worker dies its instances die with it — their history is in memory, not persisted — so the backend forgets them and reconnecting players are seated fresh.
- Drained and retired. When a worker's last instance empties and stays empty past a short grace period, the worker exits and gives its core back. A busy game holds exactly as many workers as its load needs; a quiet one shrinks toward zero. Idle costs nothing.
- Reloaded by rolling. A definition sync writes new files; a short debounce folds a burst of writes into a single reload, and because a game's code lives in the workers, the backend brings up fresh workers on the new definition and drains the old, so a reload never interrupts an instance mid-tick or leaks a running world.
Each part has one job — Caddy serves each game's library, the API web server takes its syncs and logs, the websocket server places connections, a worker owns a shard of live instances, an instance owns its own world. The simplest deployment is a single backend process that is both websocket server and one worker at once; the split into a backend and a pool of workers is what lets one box carry more games without any one of them taking the whole machine.
Layout and configuration
The hierarchy is a directory tree. Games live under one games root, and its children are the
domains — one directory per domain, named by the domain — each holding one directory per
game, which carries that game's gamehoster-game.json, its synced files, and the
generated gamehoster.js library. Names beginning _ or . are reserved for
loose files and shared libraries (a _shared/, say). That tree, and every other file the box holds,
is the Server page. That one tree does double duty: Caddy serves
the library files straight out of it, and the backend resolves each socket through the same
<domain>/<game> path — a request's Host picks the domain directory and its
first path segment picks the game. In dev, where the Host is localhost or a bare IP,
the backend falls back to the first game of that name on any domain, so one box serves every game by name
without configuring DNS.
A few environment knobs place the backend: the port and bind address (localhost in production, behind Caddy;
0.0.0.0 in dev), the games root, and the file the sync token is read from (absent turns the API
off). Caddy is pointed at the same games root to serve the libraries. Each game then sets its own behaviour in
gamehoster-game.json: the tick rate and send rate its two clocks
run at, the timeline depth that bounds how far a late command can rewind, and the
origins allowed to open its socket — a single origin or a list, where * or
omitting the key allows any, *.example.com matches any subdomain, and a non-browser client sending
no Origin is allowed through.
The wire protocol
The server and its generated client talk over that one WebSocket using a compact binary protocol. It is generated from the same game definition on both sides, so the two never disagree about how a message is laid out. You should not normally need this: the generated client and the front-end integration speak it for you. It is here for the curious, and for anyone writing a client in another language.
Three rules shape the wire. Packets go out at the send rate
(gamehoster-game-sendRate), not the faster tick rate. Every
packet is per player: the server builds a distinct stream for each viewer, holding only the
objects relevant to them and only the fields they may see. And each
packet carries as little as possible: relayed commands so a client can reproduce its
shared state itself, and direct values only for the
sent fields it cannot. A server field never appears on the wire.
Framing
Every WebSocket message is one binary frame: a one-byte type tag, a short header, and a payload encoded field by field from the relevant schema. There is no text and no per-message field names; position and type come from the shared definition.
┌──────┬─────────────┬───────────────────────────┐
│ type │ header │ payload │
│ 1 B │ (varies) │ schema-encoded fields │
└──────┴─────────────┴───────────────────────────┘
Numbers use fixed-width little-endian encodings, ids are variable-length integers, and every live message carries the tick it belongs to, so both sides can order and reconcile them. The field encodings themselves are covered under Types.
Message types
Messages fall into two directions. The client sends its player's intent; the server sends each player a tailored stream of what they may know.
Client to server
| Type | Carries |
|---|---|
hello | The join request: the join fields the game collects, and optionally a chosen instance and its password when the game lets players pick one. |
command | One of the player's commands: its name, its parameters (per the command schema), and the tick it was issued on. This is the only input that goes up; continuous movement and one-off actions are both commands. |
ack | The latest server tick the client has applied. The server sends only what changed since, and falls back to a fresh snapshot if the client has dropped too far behind to patch. |
Server to client
A per-player stream, emitted once per send tick and holding only what this viewer may receive:
| Type | Carries |
|---|---|
welcome | Your assigned player id, the game and its version, and the instance you were placed in. Sent once, first. |
snapshot | The full state of every object in your view. Sent on join, and again as a reset if you fall too far behind for deltas to catch up. This is the base your ring starts from. |
command | A relayed command of a player relevant to you (the same name, parameters and tick they sent up), so your client can re-run it and reproduce that player's shared fields. |
delta | The sent fields that changed since the tick you last acked, keyed by object id. The ordinary per-send-tick update for values. |
keyframe | The authoritative value of shared fields, to re-anchor your reconstruction. Cycled object by object in a round robin against drift, and sent at once for any shared field a backend body wrote this tick. |
enter / leave | An object becoming, or ceasing to be, relevant to you. enter carries its full state, ideally just before you need it; leave drops it. Relevance-driven, in place of a global spawn and despawn. |
signal | A one-off event the logic emitted (a hit, a score, a pickup): the moments a front end hangs an animation off. It carries no lasting state. |
How each field reaches the client
What puts a field on the wire is fixed by its sync class
alone, and it is the same for every viewer.
sync | On the wire | How the client gets it |
|---|---|---|
shared | never as a value | Reproduced by re-running the relayed commands, and re-anchored by the odd keyframe. This is what the client's prediction runs on. |
sent | as a value | Taken as truth: the full value in a snapshot or enter, then the changes in each delta. |
server | never | Not on the client at all. The result of any server-only logic reaches a client only by being written into a sent field or arriving as a spawn. |
The case where an in-view value depends on out-of-view state is resolved at design time by declaring that field
sent, so no client is ever asked to reproduce something it cannot see. There is no message that
reclassifies a field on the fly.
Reconstruction, deltas and keyframes
Between the sparse send-rate packets the client keeps ticking, reconstructing its shared fields
from the commands it holds at the full tick rate. The three server messages that keep it true each do one job:
- Relayed
commands drive the reconstruction. Filed into the tick they were made on, they let the client re-run the same tick function the server runs and land on the samesharedstate. deltas keep thesentfields current. A delta describes only thesentfields that changed since the tick the client acked, so a value that holds steady costs nothing.keyframes correctshareddrift. Because reconstruction can drift (floating point, a missed command), the server periodically re-sends the authoritative value of asharedfield. These are cycled object by object in a round robin, so no single tick carries a big block, and one is sent at once for anysharedfield a backend-only body wrote this tick, since the client could not have reconstructed that. The client eases toward a keyframe rather than snapping (see reconciling).
Relevance: enter and leave
A packet is per player, and a player may not see the whole instance. Which players and entities a viewer can
see is their relevance, decided on the server for each viewer. As things
cross that boundary the stream carries an enter with the object's full state, ideally just before
it is needed, and a leave when it drops out. So a client is only ever missing objects it was
deliberately not sent, and everything it does hold, it holds completely. With no view given, every object is
relevant to everyone and nothing ever enters or leaves after the first snapshot.
Acks and resend
The client tells the server the latest tick it has applied with an ack. The server keeps, per
viewer, what it has confirmed to them, and sends each delta relative to that ack, so a lost packet
is simply covered by the next one rather than needing a retransmit. If a client falls too far behind for a
delta to bridge the gap, or joins mid-match, the server sends a fresh snapshot and the client
starts its ring again from there.
Ids and ticks
Players and entities are referred to by small integer ids that are stable for the life of the instance, so a message never has to spell out a type or a path. Ticks are a monotonic counter for the instance; commands going up, and deltas, keyframes and relayed commands coming down, are all stamped with one. That shared tick is how the two sides line up a predicted action with the authoritative result for the same moment, and it is what lets the client tag a command with the tick it acted on so the server, by rewinding, applies it on that same tick.
Errors and boundaries
The protocol carries no error frames, because most errors never reach it. A body that violates the fixed
schema (naming a field that does not exist, the wrong type, an illegal
sync or audience) is rejected at deploy and
never runs, so it never syncs. A violation only a dynamically-built name could expose at runtime is a
contained throw: the engine drops that body's effects for the tick and surfaces the error, and
neither the server nor any client crashes or drops its connection.
View-boundary conditions are not errors and produce no message. Reading an object not
relevant to you simply finds it absent; it was never in your lists.
Writing a field outside a body's class has no effect and the authoritative value stands. So a
shared body running on a client that lacks some out-of-view input does not fault; it reads an
absent object and moves on, while the field it would have touched is kept correct by being sent.