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.

Server architecture Caddy terminates TLS, serves each game's library straight from disk, and proxies /api and wss to the backend; the backend runs an API web server and a websocket server, and the websocket server hands each game socket to a worker process running one game and a shard of its instances. Caddy · TLS — serves game libraries from disk, proxies /api and wss to the backend static /api wss STATIC WEB SERVER game library /<game>/gamehoster.js served from disk by Caddy no backend hop BACKEND PROCESS API web server /api · cert gate game sync + logs token-authed · bare 404 WebSocket server wss://…/<game> resolve · origin · place hands off the socket ↓ WORKER PROCESS asteroids · shard A a subset of its instances tick clock + send clock talks to its players direct WORKER PROCESS asteroids · shard B a subset of its instances tick clock + send clock talks to its players direct WORKER PROCESS pong · shard A a subset of its instances tick clock + send clock talks to its players direct
Caddy terminates TLS, serves each game's library straight from disk, and proxies /api and wss to the backend; the backend's API web server takes syncs and logs, its websocket server places each player, and worker processes run the games — spawned on demand, retired when empty.

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:

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:

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:

  1. The browser dials wss://<domain>/<game>; Caddy terminates TLS and proxies the upgrade to the backend on localhost.
  2. The server resolves (Host, first path segment) to a hosted game and checks the Origin against the game's allowed origins; an unknown game or a disallowed origin is dropped here.
  3. 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.
  4. 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.

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

TypeCarries
helloThe join request: the join fields the game collects, and optionally a chosen instance and its password when the game lets players pick one.
commandOne 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.
ackThe 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:

TypeCarries
welcomeYour assigned player id, the game and its version, and the instance you were placed in. Sent once, first.
snapshotThe 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.
commandA 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.
deltaThe sent fields that changed since the tick you last acked, keyed by object id. The ordinary per-send-tick update for values.
keyframeThe 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 / leaveAn 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.
signalA 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.

syncOn the wireHow the client gets it
sharednever as a valueReproduced by re-running the relayed commands, and re-anchored by the odd keyframe. This is what the client's prediction runs on.
sentas a valueTaken as truth: the full value in a snapshot or enter, then the changes in each delta.
serverneverNot 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:

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.