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)
Everything on disc: this service under /opt/localhoster/gamehoster/, and the box-wide logs, stats, cert and Caddy shared under localhoster.

Contents

Every path is fixed in the code or the service unit; the installer creates all of them and nothing lives outside them.

NameTypeDescription
/opt/localhoster/gamehoster/gamehoster-games/directoryThe 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>/directoryThe 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-tokenfileThe one secret: the bearer token the whole API is gated on. Absent turns the API off.
/srv/localhoster/web-logs/directoryCaddy's combined JSON access log, active plus size-rotated files.
/srv/localhoster/server-logs/directoryThe server-health samples, one dated file per statistic type.
/srv/localhoster/gamehoster-logs/directoryThis 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/directoryThe self-signed certificate for the bare-IP endpoint.
/etc/caddy/CaddyfilefileThe Caddy config the installer lays down, plus a backup of any earlier one.
/etc/systemd/system/directoryThe 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 games mirror: one folder per domain, each holding game folders.

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
The self-contained server package, unrolled file by file.
NameTypeDescription
gamehoster-server.jsfileThe 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.jsfileThe 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.jsfileStarted 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.jsfileTakes one snapshot of the machine's health — CPU, memory, disk, and cumulative disk- and network-IO counters. Common to every hoster.
gamehoster-engine/engine.jsfileThe 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.jsfileLoads 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.jsfilePure 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/dirThe vendored WebSocket library (pure JS, dependency-free), our own copy so the box needs only Node and Caddy — no npm install, no manifest.
deploy/dirThe 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
Caddy's active and rotated access logs.

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
One dated file per statistic, per day.

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
Per game, per process: a rolling hour of statistics and a rolling hour of state snapshots.

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}}
One stats line: the counters for one instance over one sample interval.
FieldTypeDescription
tintegerUnix seconds at the end of the sample interval.
processstringThe process id (6-byte hex) that wrote the line.
instancestringThe instance id (6-byte hex) the counters are for.
playersintegerPlayers in the instance at the sample (a gauge, not a total).
entitiesintegerEntities in the instance at the sample (a gauge).
arrivals · departuresintegerPlayers who joined and who left during the interval.
playtimeobject{sum, count} of completed-session seconds, so the average is sum / count. A player still connected is not counted until they leave.
commandsobjectA count of each command type received during the interval, keyed by command name.
cmdBytesobject{sum, count} of command-packet bytes in, so the average packet size is sum / count.
updBytesobject{sum, count} of update-packet bytes out, for the average update size.
deltaobject{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}]}}
One snapshot line: the process, the instance, the time, and the game-specific state object.
FieldTypeDescription
tintegerUnix seconds when the snapshot was taken (on the hour).
processstringThe process id (6-byte hex) that captured it.
instancestringThe instance id (6-byte hex) captured.
stateobjectThe 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)
The bare-IP certificate.

/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
The Caddy config, and a backup of any earlier one.

/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
The two service units.