Deployment
Services and ports
docker-compose.yml runs the whole stack:
| service | port | what it is |
|---|---|---|
portal | 8082 | player accounts, characters, JWT play tokens |
admin | 8081 | server controls, bans, audit log |
docs | 8083 | this site (Docusaurus → nginx) |
game-server | 7777/udp | Godot headless, ENet |
uptime-kuma | 3001 | uptime monitoring |
postgres | 5432 | database |
Start everything:
cp .env.example .env # then edit secrets
docker compose up -d --build
Bring up only the game server and its database:
docker compose --profile game build game-server
docker compose --profile game up -d game-server
Game server hosting
The game server is built as a container image by docker/Dockerfile.game-server,
which compiles the C# project and runs a Godot export in one step. The image does
not need Godot or .NET installed on the host — only Docker.
Moving it to another host
A second game server is normally deployed to measure movement and physics rather than to serve players. Only the game server needs to move; the portal, admin and docs are not involved.
Prerequisites
- Docker
- Repo access —
godot_client/,Moondog.GameServer/,docker-compose.yml,rpc_contract.json - UDP 7777 reachable from the client host
Verify the map data matches. This is the check that matters most when comparing two hosts:
diff -rq godot_client/map_data Moondog.GameServer/map_data
No output means identical. Both sides must load the same heightmap, or the comparison is meaningless.
Build and run
git pull
docker compose --profile game build game-server
docker compose --profile game up -d game-server
docker logs --tail 30 moondog-game-server-1
Look for two lines:
Moondog game server listening on UDP 7777
Terrain: v... collision_mode=3 regions=N ...
collision_mode=3 is FULL_GAME and is mandatory. The default generates
collision around a Camera3D, which a headless server does not have, so players
would fall straight through the world.
Godot buffers stdout inside a container, so
listeningandPeer connectedmay not appear indocker logsfor several seconds. Diagnostics that matter are written to stderr withGD.PrintErrand appear immediately. If the container looks hung, wait and re-check rather than assuming failure.
Environment variables
MOONDOG_DBorConnectionStrings__DefaultJWT_SECRET— must match the portal's, or tokens are rejected. A mismatch is reported asIDX10517 ... the token's kid is missing, which reads like a key-rotation fault but is not one; see diagnosing a rejected token. Each server prints aJWT_SECRET fingerprintat startup so the two can be compared without revealing either secret.JWT_ISSUER(defaultMoondog)JWT_AUDIENCE(defaultMoondogClient)GAMESERVER_PORT(default 7777)GAMESERVER_MAX_CLIENTS(default 100)GAMESERVER_NAME(defaultmain)
Running a movement measurement
The client ships a headless probe that drives scripted input through the normal code path and reports divergence between the client's prediction and the server's authoritative position.
python3 scripts/mint_token.py --creds /tmp/moondog-creds.txt --out-dir /tmp/moondog-tokens
MOONDOG_HOST=<game-server-host> MOONDOG_PORT=7777 \
godot --headless --path godot_client -- \
--token-file=/tmp/moondog-tokens/nyx.token \
--probe=straight --probe-seconds=20
Host and port come from the MOONDOG_HOST and MOONDOG_PORT environment
variables — there are no CLI flags for them. The client accepts --token= /
--token-file= and --probe= / --probe-seconds= as arguments.
| scenario | what it does |
|---|---|
idle | stands still — baseline for agreement at rest |
straight | walks one direction |
turn | walks while sweeping facing |
figure8 | wanders continuously |
sprint_jump | sprints, jumping periodically |
stop | sprints 2s, then releases and stands still |
stop exists because the lurch on releasing a key is not covered by the others —
every other scenario holds its input for the whole run.
Tokens expire in 60 minutes. An expired one is logged as
Lifetime validation failed by the server while the client reports
connection_failed, which reads like two unrelated bugs.
Reading the output
DIVERGENCE scenario=stop count=1211 mean=7.7317 p50=0.0977 p95=0.2310 max=1154.0
These are divergence statistics. Ignore max on short runs — the first couple
of packets compare against the default origin position before the prediction
buffer fills.
For per-packet detail, run with correction enabled and read:
~/.local/share/godot/app_userdata/"Moondog Client (example)"/correction_<character>_<pid>.log
The filename carries the character's first 8 hex digits and the OS process id,
so two clients running at once write separate files rather than appending to one
-- which is what makes a two-client comparison possible at all. Each file's
first line also records the full character id, pid and start time, so it stays
self-describing after several runs have accumulated. Match them with
correction_*.log and take the newest.
| column | meaning |
|---|---|
h / ht / hf | horizontal gap, threshold, whether it fired |
v / vt / vf | vertical gap, threshold, whether it fired |
spd | our horizontal speed, m/s |
st | measured staleness of the snapshot, ms |
fps | client frame rate |
yaw_c / yaw_s / dyaw | our yaw, the server's, and the difference |
steer | how differently the server's yaw would steer us, m/s |
terr | terrain's own contribution to the vertical gap |
res | vertical gap after terrain is removed — what vf decides on |
fl / sfl | our floor state / the server's |
true | like-for-like error — server position against our prediction for the seq it echoed |
dt | ms since the previous packet |
true is the column that isolates simulation disagreement. Everything else is
measuring the link. h in particular compares the server's position against
our current position — two different instants — so it includes transport lag by
construction. See Architecture → Movement reconciliation.
Simulating latency on a single host
To reproduce a specific round trip without a second machine, netem delays
packets in-kernel. It must be applied to the Docker bridge the game server
container is attached to, not lo — loopback delay will not touch the game's
traffic at all.
# which bridge?
docker inspect moondog-game-server-1 \
--format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{$v.IPAddress}}{{end}}'
ip -br link | grep br-
tc qdisc add dev <bridge> root netem delay 63ms # ~126ms round trip
ping -c 3 <container-ip> # expect ~63ms
tc qdisc del dev <bridge> root # always clean up
ping -c 3 <container-ip> # expect <1ms
Check tc qdisc show dev <bridge> before adding. A leftover qdisc from an
earlier session stacks with a new one and the delays compound, which
invalidates measurements without producing any visible error.
Telemetry
The portal accepts movement captures from the example client when, and only when, a sink is configured:
Telemetry__Sink: "http://<collector>:8080"
Telemetry__RetentionDays: "14"
Empty means no sink, which means the portal tells clients there is nowhere to send anything and the endpoint answers 404. A production deployment should leave this unset. It is a diagnostic for people testing movement, not player telemetry, and the two should not be confused or enabled together — a player build has no upload path, and turning the sink on for real players would be a decision about consent and retention that this is not the place to make quietly.
The sink URL is returned in the /api/gameauth/token response, which is what
makes the client side zero-configuration: a tester pointed at a portal that has
one gets it without setting anything, and a portal without one hands the client
nothing so the code path never runs.
Uploads are authenticated by the play token the client already holds. That works when the client and this portal are the same deployment, and cannot work across two: a tester whose portal has its own JWT secret gets a token this portal rejects, and every capture is refused. Making both secrets identical would couple the environments so that leaking either leaks both.
So a second credential exists, scoped to telemetry and independent of JWT:
Telemetry__Sink: "http://<collector>:8080"
Telemetry__SinkKey: "<shared ingest credential>"
Either one authenticates. The key travels in an X-Moondog-Telemetry-Key header,
never in a URL, so it does not land in proxy access logs, and it is compared in
constant time. An upload authenticated by the key is stored with no user or
character attached — the key carries no identity claims, and guessing at one
would be worse than recording that nobody is known.
Set it in .env, not in a shell export. A per-invocation export does not
survive a restart, and the failure mode when it is missing is the quiet one: the
portal comes up with no key, every key-authenticated capture is refused, and
nothing on the tester's side reports an error. The key is also returned to
clients in the /api/gameauth/token response, so anyone who can log into a
portal can read it — fine for a test diagnostic, but it means the key is only
as safe as the portal login and must not be reused anywhere else.
To rotate: change it in both .env files and restart both portals. Between the
two restarts, uploads fail 401, which is the safe direction to fail.
Because this endpoint is internet-facing rather than a localhost convenience:
Telemetry__RatePerMinute(default 30) is a sliding one-minute window per credential, so one client in a runaway loop cannot spend everyone's budget. Rate-limit keys are SHA-256 fingerprints, never the raw token — otherwise the map would hold every play token the collector ever saw.Telemetry__MaxTotalMB(default 2048) caps everything stored. Per-row age retention is not enough: a client posting 8 MB every few seconds fills a disk long before the age sweep helps. The counter is maintained incrementally rather than summed per request, becauseSELECT sum(length(payload))over a table whose purpose is to grow is a scan on every upload.Telemetry__RetentionDays(default 14) expires rows on write rather than from a timer — the table's only failure mode is growth, and growth only happens when uploads arrive.
Retrieval is direct rather than through an endpoint, because there is exactly one reader and a read API would be surface without a user:
SELECT "CreatedAt", "Tester", "Platform", "OsVersion", "RefreshRate",
"Scenario", "Note", length("Payload")
FROM telemetry_uploads
-- connectivity-check rows are the client's own "is this working" probe and are
-- not movement data; filtering them keeps the list meaningful.
WHERE "Scenario" <> 'connectivity-check'
ORDER BY "CreatedAt" DESC LIMIT 20;
RefreshRate is the column worth having. Client frame rate governs input cadence
and prediction against a server that always ticks at 60 Hz, so a capture from a
144 Hz machine and one from a 60 Hz machine are different experiments wearing the
same label. It is -1 when unknown — chiefly in headless runs — and -1 rather
than 0, so that "not reported" stays distinguishable from a real reading.
Payloads are gzipped.
Production checklist
- Use strong passwords and JWT secrets
- Run the web apps behind HTTPS (Caddy/Traefik/nginx)
- Back up PostgreSQL —
docker composeincludes apostgres-backupservice - Restrict admin panel access (network allowlist or VPN)
- Monitor
server_statusheartbeats - Rotate credentials periodically
- Enable Docker restart policies
- Leave
Telemetry__Sinkunset. A production deployment must not accept movement captures; the feature exists for people testing movement and is inert without it.
This host is not yet a hardened deployment. It has been a development target: the admin panel is still bound publicly, SSH is open to internet scans, and the database password and
JWT_SECRETwere reachable before3be6b01unpublished Postgres.docs/ALPHA.mdin the repository tracks the hardening phase that closes these. Expand the bullets above into a concrete checklist when it opens.
portal.example.com {
reverse_proxy 127.0.0.1:8082
}
admin.example.com {
reverse_proxy 127.0.0.1:8081
}