Server
Two processes run on the box: Caddy, the only public listener, and a small
Node game server bound to 127.0.0.1:8788 that Caddy reverse-proxies to. The game
server loads each game's generated module and runs its tick, serves the game's static library, answers the game
sockets, and carries the token-authed sync and
tools API (it also samples the machine's health on a timer). There is no
configuration to speak of — every path is fixed in the code or a systemd environment line, and the only
per-install state is one secret. Everything Gamehoster puts on disk lives under three roots. How it all serves in
the large — the target split into a static server, a websocket server and worker processes — is the
Networking page.
/opt/gamehoster/
server/ the server code (+ the ws dependency)
common/ the shared tools toolkit
shared/ the health snapshot + stats recorder
games/ the games mirror — one folder per domain
token the bearer token (the one secret)
VERSION the installed bundle's build stamp
/srv/
gamehoster-logs/ Caddy's access logs
gamehoster-stats/ server-health samples
/etc/
gamehoster/ the self-signed bare-IP cert
caddy/
Caddyfile the Caddy config
systemd/
system/ the service units
Contents
Every path is fixed in the code or the service unit; the installer creates all of them and nothing lives outside them.
| Name | Type | Description |
|---|---|---|
/opt/gamehoster/games/ | directory | The games mirror — one folder per domain, each holding game folders — what the sync writes and the server loads and serves. |
/opt/gamehoster/server/ | directory | The program itself: every source file the server runs, plus its one dependency, ws. |
/opt/gamehoster/common/ · shared/ | directory | The shared tools toolkit and the stats recorder the server requires, copied verbatim across every hoster. |
/opt/gamehoster/token | file | The one secret: the bearer token the whole API is gated on. Absent turns the API off. |
/srv/gamehoster-logs/ | directory | Caddy's combined JSON access log, active plus size-rotated files. |
/srv/gamehoster-stats/ | directory | The server-health samples, one dated file per statistic type. |
/etc/gamehoster/ | directory | The self-signed certificate for the bare-IP endpoint. |
/etc/caddy/Caddyfile | file | The Caddy config the installer lays down, plus a backup of any earlier one. |
/etc/systemd/system/ | directory | The service units — the gamehoster server, and Caddy's from its package. |
/opt/gamehoster/games/
The games mirror — the tree the sync pushes up and the server loads and
serves. Its children are the domains: one directory per domain, named exactly for the host,
each holding one directory per game. A game folder carries its gamehoster-game.json
config and definition files, plus the two generated files the sync produces — the static server
module the server requires, and the library the browser loads. Names beginning _ or .
are reserved: the sync never pushes or prunes them, so the box can hold a _shared/ library the
client does not own. It is owned by the gamehoster service user, the only tree the server writes.
/opt/gamehoster/games/
game.example.com/ a DOMAIN — a dotted directory, named for the host
asteroids/ a GAME
gamehoster-game.json the game config (tick/send rates, capacity, origins)
gamehoster.server.js GENERATED — the static module the server requires
gamehoster.client.js GENERATED — the library handed to the browser
gamehoster-player/ player schema + join / leave / view / update bodies
gamehoster-instance/ instance schema + update bodies
gamehoster-entities/ one folder per entity type: schema + update bodies
gamehoster-commands/ one folder per command: schema + writes + handler
_shared/ reserved (_ prefix) — never synced, never pruned
The server resolves each socket through the same <domain>/<game> path — the request's
Host picks the domain, the first path segment picks the game — and serves that game's
gamehoster.js library from its folder. A file arriving on the API is written to its path here, and
a short debounce folds a burst of writes into a single reload; a domain that hosts no game with a valid
gamehoster-game.json is simply skipped. The structure
rules that decide what a domain and a game are live on the Sync page.
/opt/gamehoster/server/
The program installs under a fixed directory; the service
unit points straight at server/server.js. The tree below is every source file the running
server loads — the entry point and the modules it imports — plus the sibling common/ and
shared/ trees it requires, copied verbatim across the hoster family. Every module is plain Node;
the one dependency is ws, the pure-JS WebSocket library, bundled into node_modules so
the box needs no npm install. The generate.js that produces the generated files is
not here — generation runs on the client at
sync, and the server only ever requires what it receives.
/opt/gamehoster/
server/
server.js the entry systemd runs (HTTP + WebSocket + API)
engine.js the World: instances, the tick, rollback, deltas
loader.js load a game dir into a runnable game
schema.js pure schema helpers (shared with the browser)
package.json declares the one dependency, ws
node_modules/
ws/ the pure-JS WebSocket library, bundled
common/
server/
logs-stats.js the read-only /logs + /stats routes
shared/
stats.js one machine-health snapshot
stats-recorder.js the loop that writes the stat day-files
| Name | Type | Description |
|---|---|---|
server/server.js | file | The server and the process systemd starts. One localhost HTTP listener carrying the game sockets (wss://<domain>/<game>), the static library route, the sync surface, the read-only logs/stats routes, the version probe, and Caddy's on-demand-TLS gate. Loads every game on start, reads the token once, and starts the stats recorder on listen(). |
server/engine.js | file | The authoritative engine: one World per game holds its live instances and players and advances them one tick at a time — commands, per-actor updates, the instance body — with snapshot/rewind for late commands and the per-player delta protocol. The same engine is bundled into the browser for prediction. The Instance page is this in full. |
server/loader.js | file | Loads a game directory into a runnable game. It prefers the generated gamehoster.server.js (a plain require, no new Function); a dev-only fallback interprets the raw definition when no generated module is present. |
server/schema.js | file | Pure schema helpers with no Node dependencies — read a state schema to field descriptors, compute defaults, decide which fields a front-end body may write — so the server and the bundled browser engine compute identically. |
server/package.json · node_modules/ws/ | file · dir | The one dependency, ws, and its lockfile, bundled so the target box needs only Node and Caddy. |
common/server/logs-stats.js | file | The read-only /logs and /stats routes (plus the /stats/now debug snapshot), identical across every hoster and mounted under the same token. |
shared/stats.js | file | Takes one snapshot of the machine's health — CPU, memory, disk, and cumulative disk- and network-IO counters. Common to every hoster. |
shared/stats-recorder.js | file | Started by the server, it snapshots on the interval, appends a JSON line per type to the dated day-file, and prunes files past the retention window. |
Also copied under the version dir but not run by the server: the rest of common/
(net.js, browse/, client/) — the shared client and tools toolkit, carried
so one bundle serves every role.
/opt/gamehoster/token
The whole per-install configuration is one secret. The installer generates it once (32 random bytes, printed
once) and the server reads it at startup; the sync and tools client is handed the same value out of band. Every
API call is gated on it, and with no token file the API is off entirely — the sockets still serve, but nothing
can be pushed or read. To rotate it, write a new value and restart the server. It is mode 600,
owned by the gamehoster user. Alongside it, a VERSION file records the installed
bundle's build stamp.
/srv/gamehoster-logs/
Caddy writes one combined JSON access log covering every game domain and the IP endpoint, each line tagged with
its host. It rotates by size only (there is no daily rotation), so there is one active file and a trail of
size-rotated ones. The server exposes this directory read-only (GET /api/v1/logs) and the client
mirrors it down for the Web Logs tool; the directory is mode
0770 group caddy, which writes it, and the gamehoster user is added to the
caddy group so the server can read it.
/srv/gamehoster-logs/
access.log the active log, one JSON line per request
access-1784514159284.log rotated and immutable; the number is its rotation time in ms
access-1784370489663.log
Rotation is set in the Caddyfile (roll_size 64MiB, roll_keep 50,
roll_keep_for 2160h). Rotated files are keyed to the client by mtime, never by name, so filenames
never cross the wire.
/srv/gamehoster-stats/
The server samples the machine's own health every 15 seconds and appends one JSON line per statistic type to a
dated, per-type day-file. A day-file is frozen the moment the UTC date rolls over — no rename, no lock — and
files older than 60 days are pruned. The server serves these read-only (GET /api/v1/stats); the
client mirrors each past day once and keeps it, which is the data behind the
Server Logs tool. The directory is mode 750, owned by the
gamehoster user, and is the only path besides games/ the service may write.
/srv/gamehoster-stats/
cpu-2026-07-20.log per-core cumulative CPU times
memory-2026-07-20.log total / used / available / free bytes
disk-2026-07-20.log usage of the filesystem holding /
diskio-2026-07-20.log cumulative bytes read / written
netio-2026-07-20.log cumulative bytes in / out
cpu-2026-07-19.log yesterday's files, immutable until the retention cutoff
Rates are never stored: the counter fields are cumulative, so the reader diffs two samples and divides by the true elapsed time — a gap after downtime averages out instead of spiking, and a reboot's counter reset is dropped. Metrics a host can't report (e.g. disk/net IO off Linux) are simply absent.
/etc/gamehoster/
A self-signed certificate for the bare-IP endpoint, generated once by the installer (CN=gamehoster,
ten-year life). Caddy obtains real certificates on demand for the game domains; this one only completes the
handshake for a no-SNI / bare-IP connection, selected via the Caddyfile's default_sni. It is what
lets the sync client reach the API by the server's IP — the client skips verification for an IP endpoint, and
the token is what actually authenticates it.
/etc/gamehoster/
tls.crt self-signed cert for the bare-IP endpoint
tls.key its private key (mode 640 root:caddy)
/etc/caddy/Caddyfile
The Caddyfile is fully static — no environment placeholders. It terminates TLS, obtains real certs on demand
(gated by a localhost call to /internal/allow, so issuance can't be forced for domains you don't
host), redirects HTTP → HTTPS, serves the bare-IP block from the self-signed cert, and
reverse-proxies everything — IP-addressed requests and each game domain alike, including the game
WebSocket upgrades — to the game server on 127.0.0.1:8788. The installer copies it
from the source deploy/Caddyfile, backing up any file already there once.
/etc/caddy/
Caddyfile our config, installed from deploy/Caddyfile
Caddyfile.pre-gamehoster.<ts> a one-time backup of any earlier Caddyfile
/etc/systemd/system/
Two units run at boot. Gamehoster ships its own; Caddy's comes from its package (the installer requires Caddy
to be installed from a package so this unit exists) and the installer just enables and restarts it. The game
server unit runs as the unprivileged gamehoster user with NoNewPrivileges,
ProtectSystem=strict and ProtectHome, and may write only
/opt/gamehoster/games and /srv/gamehoster-stats. It has no
EnvironmentFile — the process reads its one secret from /opt/gamehoster/token and
takes everything else from fixed Environment= lines (port, bind address, the games root, and the
log/stats dirs). It is added to the caddy supplementary group so it can read the access logs.
/etc/systemd/system/
gamehoster.service the game server — runs /opt/gamehoster/server/server.js
caddy.service the public listener, from the Caddy package