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 on disc is namespaced under localhoster so several
hoster services can run side by side on one box: this service's own code and games sit under
/opt/localhoster/gamehoster/, beside any sibling service. The runtime logs live under
/srv/localhoster/: the access logs and machine-health samples every service shares, and this
service's own game logs beside them. The bare-IP certificate lives once under /etc/localhoster/. How it all serves in the large — the target
split into a static server, a websocket server and worker processes — is the
Infrastructure page.
/opt/localhoster/ every localhoster service on this box, side by side
gamehoster/ this service (a sibling like sitehoster/ sits beside it)
gamehoster-server-<version>/ all the gamehoster server code (one dir per installed version)
gamehoster-server.js the entry systemd runs: HTTP + WebSocket + the APIs
VERSION this package's version (matches the dir name)
localhoster-server/ shared server toolkit, identical across every hoster
logs-stats.js the read-only /logs + /stats routes
stats-recorder.js the loop that writes the stat day-files
stats.js one machine-health snapshot
gamehoster-engine/ gamehoster's own game runtime
engine.js the World: instances, the tick, per-viewer deltas
loader.js load a game dir into a runnable game
schema.js pure schema helpers
node_modules/
ws/ the vendored WebSocket library (pure JS)
deploy/
install.sh the installer this box ran
uninstall.sh
start.sh
stop.sh
Caddyfile the Caddy config source
gamehoster.service the systemd unit source
gamehoster-server-current → gamehoster-server-<version>/ (the unit follows this; an upgrade flips it)
gamehoster-games/ this service's data — one folder per domain
game.example.com/
pong/
gamehoster-game.json
gamehoster.server.js generated by the local sync
gamehoster.client.js generated front-end library
gamehoster-token this service's bearer token (mode 600)
/srv/localhoster/ runtime logs under localhoster: some shared, some this service's
web-logs/ Caddy access logs (one Caddy fronts every domain)
access.log the active log, one JSON line per request
access-<timestamp>.log rotated, immutable (keyed by mtime, not name)
server-logs/ machine-health samples (the box, not any one service)
cpu-<YYYY>-<MM>-<DD>.log
memory-<YYYY>-<MM>-<DD>.log
disk-<YYYY>-<MM>-<DD>.log
diskio-<YYYY>-<MM>-<DD>.log
netio-<YYYY>-<MM>-<DD>.log
gamehoster-logs/ this service's game telemetry, the Game Logs tool source
game.example.com/ a DOMAIN, mirroring the games tree
pong/ a GAME, its own telemetry subtree
gamehoster-processes/ one directory per running process
3f9c1a2b7d4e/ a PROCESS: 6 random bytes in hex; one today, a pool later
stats-<YYYY>-<MM>-<DD>-<HH>.jsonl per-instance counters, one file per hour
snapshot-<YYYY>-<MM>-<DD>-<HH>.jsonl periodic instance state captures, one file per hour
/etc/localhoster/ box-wide localhoster config, shared
localhoster-api-certificate.crt the self-signed cert for the bare-IP endpoint
localhoster-api-certificate.key its private key (mode 640 root:caddy)
/etc/caddy/
Caddyfile the Caddy config (serves every service's domains)
/etc/systemd/system/
gamehoster.service one unit per service, side by side
caddy.service the shared public listener (from the Caddy package)
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/localhoster/gamehoster/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/localhoster/gamehoster/gamehoster-server-<version>/ | directory | The self-contained server package: the entry, the shared localhoster-server/ toolkit, gamehoster's own gamehoster-engine/, the vendored ws, and the deploy scripts. |
/opt/localhoster/gamehoster/gamehoster-token | file | The one secret: the bearer token the whole API is gated on. Absent turns the API off. |
/srv/localhoster/web-logs/ | directory | Caddy's combined JSON access log, active plus size-rotated files. |
/srv/localhoster/server-logs/ | directory | The server-health samples, one dated file per statistic type. |
/srv/localhoster/gamehoster-logs/ | directory | This service's game telemetry: one subtree per game, then per process, holding rolling hourly stat and snapshot files for every instance that ran on that process. |
/etc/localhoster/ | 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/localhoster/gamehoster/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/localhoster/gamehoster/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/localhoster/gamehoster/gamehoster-server-<version>/
Each version installs into its own directory; the service
unit points at gamehoster-server-current/gamehoster-server.js, a symlink to the installed
gamehoster-server-<version>/, so an upgrade is atomic (the installer flips the symlink) and the
previous version stays on disc for rollback. The tree below is the whole
self-contained package: the entry, the shared localhoster-server/ toolkit (the read-only
/logs + /stats API and the health recorder), gamehoster's own
gamehoster-engine/ (the World, the loader, and the schema helpers), the vendored
ws, and the deploy scripts. Every module is plain Node; ws is vendored into
node_modules, so the box needs no npm install and nothing is copied alongside the
package. The compile step that produces the generated files is not here — it runs on the
local tool at sync, and the server only ever
requires what it receives.
gamehoster-server-<version>/
gamehoster-server.js the entry systemd runs (HTTP + WebSocket + the APIs)
VERSION this package's version (names the install dir)
localhoster-server/ shared server toolkit, identical across every hoster
logs-stats.js the read-only /logs + /stats routes
stats-recorder.js the loop that writes the stat day-files
stats.js one machine-health snapshot
gamehoster-engine/ gamehoster's own game runtime
engine.js the World: instances, the tick, the per-viewer deltas
loader.js load a game dir into a runnable game
schema.js pure schema helpers (its own copy; the sync carries a duplicate)
node_modules/
ws/ the vendored WebSocket library (pure JS)
deploy/
install.sh the installer this box ran
uninstall.sh
start.sh
stop.sh
Caddyfile the Caddy config source
gamehoster.service the systemd unit source
| Name | Type | Description |
|---|---|---|
gamehoster-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(). It also runs each game's two timers — the tick that advances every instance, and the send loop that builds each connected viewer their appear/changed/gone delta and then clears the instance's dirty set, so every send window starts clean. |
localhoster-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. |
localhoster-server/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. |
localhoster-server/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. |
gamehoster-engine/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, then per-actor updates, then the instance body). A command applies on the next tick, and each send tick every viewer gets an appear / changed / gone delta of just the public state they can see. It runs on the server only; the browser runs no game logic. The Instance page is this in full. |
gamehoster-engine/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. |
gamehoster-engine/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. The compiler carries its own copy, so the generated code and the running server can never disagree. |
node_modules/ws/ | dir | The vendored WebSocket library (pure JS, dependency-free), our own copy so the box needs only Node and Caddy — no npm install, no manifest. |
deploy/ | dir | The install and service scripts: install.sh, uninstall.sh, start.sh, stop.sh, the Caddyfile, and the gamehoster.service unit. |
/opt/localhoster/gamehoster/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.
/srv/localhoster/web-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/localhoster/web-logs/
access.log the active log, one JSON line per request
access-.log rotated and immutable; the number is its rotation time in ms
access-.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/localhoster/server-logs/
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, one of the paths the service may write, beside the games mirror and the game-logs tree.
/srv/localhoster/server-logs/
cpu---.log per-core cumulative CPU times
memory---.log total / used / available / free bytes
disk---.log usage of the filesystem holding /
diskio---.log cumulative bytes read / written
netio---.log cumulative bytes in / out
cpu---.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.
/srv/localhoster/gamehoster-logs/
The runtime telemetry behind the Game Logs tool. It sits with the other logs
under /srv/localhoster/, but unlike the box-wide web and server logs it is this service's own. It
is separate from the games mirror too: that tree holds a
game's definition, this one records the games actually running. The top level is one subtree
per game, keyed the same <domain>/<game> way the games mirror is, so each game's
telemetry stays apart.
Inside a game is a gamehoster-processes/ directory, holding one directory per
process. A process directory is named by a fresh random 6-byte id in hex, minted when the
process starts. It is not the operating-system pid. The server runs as a single process today, so there is one
process directory, with the same id under every game. When the server later runs a pool of processes, each
writes its own directory and the games it hosts appear beneath it. A process directory holds two rolling
families of files, one file per hour, kept for 30 days and then pruned:
/srv/localhoster/gamehoster-logs/
game.example.com/ a DOMAIN, one per host
pong/ a GAME
gamehoster-processes/ one directory per running process
3f9c1a2b7d4e/ a PROCESS: 6 random bytes in hex, minted at startup
stats-<YYYY>-<MM>-<DD>-<HH>.jsonl counters for every instance this hour, kept 30 days
stats-<YYYY>-<MM>-<DD>-<HH>.jsonl
snapshot-<YYYY>-<MM>-<DD>-<HH>.jsonl one state capture of each instance, on the hour
snapshot-<YYYY>-<MM>-<DD>-<HH>.jsonl
Both files are JSON Lines: one JSON object per line, appended as it happens. Every line names the process and the instance it belongs to, so a single instance can be followed across the hour files and told apart from every other. There is no directory per instance: the instance id is a field on each line, and one process file covers them all. Every instance carries its own random 6-byte hex id, never reused.
stats-<YYYY>-<MM>-<DD>-<HH>.jsonl
One line per instance per sample interval, holding that interval's counters and gauges. The Game Logs tool sums them for a game's charts and for each instance's own totals.
{"t":1784999200,"process":"3f9c1a2b7d4e","instance":"8899d818977d",
"players":41,"entities":96,"arrivals":6,"departures":4,
"playtime":{"sum":91234,"count":18},
"commands":{"aim":320,"thrust":140,"fire":90},
"cmdBytes":{"sum":7040,"count":550},
"updBytes":{"sum":286000,"count":615},
"delta":{"appeared":42,"changed":540,"gone":38}}
| Field | Type | Description |
|---|---|---|
t | integer | Unix seconds at the end of the sample interval. |
process | string | The process id (6-byte hex) that wrote the line. |
instance | string | The instance id (6-byte hex) the counters are for. |
players | integer | Players in the instance at the sample (a gauge, not a total). |
entities | integer | Entities in the instance at the sample (a gauge). |
arrivals · departures | integer | Players who joined and who left during the interval. |
playtime | object | {sum, count} of completed-session seconds, so the average is sum / count. A player still connected is not counted until they leave. |
commands | object | A count of each command type received during the interval, keyed by command name. |
cmdBytes | object | {sum, count} of command-packet bytes in, so the average packet size is sum / count. |
updBytes | object | {sum, count} of update-packet bytes out, for the average update size. |
delta | object | {appeared, changed, gone}: how many players and entities appeared in, changed within, or left the updates sent to players. |
snapshot-<YYYY>-<MM>-<DD>-<HH>.jsonl
One line per live instance, written on the hour. The state is whatever the game's own
serialise body returns; the tool reconstructs it with the game's deserialise
body and draws it with the game's debug-render body, so a snapshot can be shown as an image and
browsed as raw JSON. The capture is hourly so it never costs the tick, and so every snapshot an instance ever
took fits on that instance's page.
{"t":1784998800,"process":"3f9c1a2b7d4e","instance":"8899d818977d",
"state":{"phase":"open","wave":3,
"players":[{"id":"p1","name":"nova","x":412.5,"y":88.2,"angle":1.83,"score":40}],
"entities":[{"id":"e1","type":"rock","x":210,"y":344,"radius":28}]}}
| Field | Type | Description |
|---|---|---|
t | integer | Unix seconds when the snapshot was taken (on the hour). |
process | string | The process id (6-byte hex) that captured it. |
instance | string | The instance id (6-byte hex) captured. |
state | object | The game-specific serialization: exactly what the game's serialise body returns. Its shape is the game's own, so only that game's deserialise and debug-render bodies interpret it. |
The server serves this tree read-only through the tools API, and the
local tool mirrors the latest from each process down to build the Game Logs
pages. It is owned by the gamehoster user, one of the paths the service may write.
/etc/localhoster/
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 local tool reach the API by the server's IP — it skips verification for an IP endpoint, and
the token is what actually authenticates it.
/etc/localhoster/
localhoster-api-certificate.crt self-signed cert for the bare-IP endpoint
localhoster-api-certificate.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/localhoster/gamehoster/gamehoster-games, /srv/localhoster/server-logs and
/srv/localhoster/gamehoster-logs. It has no
EnvironmentFile — the process reads its one secret from /opt/localhoster/gamehoster/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/localhoster/gamehoster/gamehoster-server-<version>/gamehoster-server.js
caddy.service the public listener, from the Caddy package