Example Godot Client
The repository includes godot_client/, a minimal first-person example.
Features
- WASD movement, mouse look, jump and sprint
- Gamepad support with rebindable controls
- Settings screen — mouse and stick look sensitivity, controller bindings, reset to defaults
- ENet connection to the server
- JWT authentication from environment variables or CLI
- Remote player capsule rendering with interpolated movement
- In-game chat input and message log
- Client-side prediction and reconciliation against the server
- Headless divergence probe for measuring movement
Run the example
The client reads its configuration from the process environment, falling back
to a .env file. Copy the example and edit it:
cp godot_client/.env.example godot_client/.env
MOONDOG_PORTAL=http://192.168.0.149:8082
MOONDOG_HOST=192.168.0.149
MOONDOG_PORT=7777
MOONDOG_TOKEN=<jwt from portal> # omit to use the login screen
Then open godot_client/project.godot in the Godot editor and run the scene.
Why a file and not just environment variables
Environment variables alone work when the game is launched from a shell. They
silently do not when it is launched from the Godot editor, which is the
documented way to run this. F5 spawns the game as a child of the editor, and
the editor inherits from whatever launched it — usually explorer.exe, whose
environment was fixed at logon. A variable exported in PowerShell after that
is invisible three processes down.
Nothing errors when this happens. The client uses the shipped public portal and the shipped public game server, authenticates successfully against the public deployment, and then sends a local-server login somewhere it cannot be verified. The only symptom is a signature error from a machine nobody is looking at. A file on disk is read from the same path regardless of who launched the process, so it does not have that fragility.
Precedence is deliberate: the real environment wins, and .env only fills
gaps. A stale file cannot shadow a deliberate override.
Check what was resolved
The client announces its configuration on startup, before connecting:
[client] portal: http://192.168.0.149:8082
[client] game server: 192.168.0.149:7777 (pinned)
[client] config read from res://.env
(pinned) means an explicit setting was found; (default) means the client
is on the shipped public deployment. If a local stack reports (default), the
setting never arrived. The portal line matters just as much — a local portal
that reports 127.0.0.1 for the game server will send clients to their own
loopback rather than to the server, which is a connection failure that points
nowhere near its cause.
Per-run overrides
The token can be overridden on the command line, which is how several clients run side by side:
godot --path godot_client -- --token-file=/tmp/nyx.token
--token=<jwt>, --token-file=<path>, --probe=<scenario>,
--probe-seconds=<n>, --probe-yaw=<deg> and --probe-note=<text> are the
only flags for the token. Host and port are
not flags — they come from the environment or .env only.
Reproducing a position
The probe applies scripted input from wherever the character already stands. That is fine on flat ground and useless on slopes and edges, which is where the interesting failures are and which therefore have to be aimed rather than walked to by hand:
python3 scripts/probe_at.py --character Yarrbeard --spawn slope41 \
--scenario edge --yaw 135 --seconds 10
probe_at.py writes the character's stored position and then runs the probe.
It updates the row before connecting, because the server reads a position
once at auth and owns it in memory afterwards — write while a session is live
and the autosave puts the old value straight back.
--spawn takes a documented name (flat, slope38, slope41) or --pos x,z for anywhere else. --yaw sets the starting heading in degrees, which
matters because every walking scenario shares one forward vector: without it a
position can only ever be walked one way.
Run flat as a control. It separates "this slope is bad" from "this server is
bad", which are otherwise easy to confuse — on current measurements the two
slopes diverge less than flat ground does.
MOONDOG_HOST is optional when the portal is configured correctly, because the
portal tells the client where to connect and its answer wins. Set it only to
override that. Whatever the portal reports is what every other client will
also get, so a portal left on its 127.0.0.1 default will misdirect all of
them on a multi-machine setup.
Controls
Movement is WASD, mouse look, space to jump, shift to sprint. All of these are
remappable from the settings screen, which works for keyboard/mouse and gamepad
alike. Bindings persist to user://.
Defaults:
| action | keyboard/mouse | gamepad |
|---|---|---|
| move | W A S D | left stick |
| jump | Space | A |
| sprint | Shift | LB |
| chat | Enter | Y |
| look | mouse | right stick |
Look sensitivity defaults to 0.002 for the mouse, with separate horizontal
and vertical rates for the right stick.
Rebinding replaces the entire event list for that action rather than appending, so an action cannot accumulate duplicates across rebinds.
Correction toggle
Where the ground is
godot --headless --path godot_client --script res://scripts/terrain_grid.gd
Prints a walkability map of the world, in metres. # solid, ~ partial, .
none.
It asks Terrain3D rather than the filesystem, because two cheaper approaches were tried first and both were wrong:
- Region file size. A populated region is ~235 KB and a blank one ~5.7 KB, so coverage looks obvious — but size is a proxy for content, not for whether there is ground at a coordinate. It cannot tell a compact valid region from a mostly-empty one.
- Parsing region filenames. The separator is not cosmetic.
Terrain3DUtil::string_to_locationtakes the first 3 and last 3 characters and strips underscores, soterrain3d_00_01is region (0, +1) andterrain3d_00-01is (0, −1). Normalising the separator made 71 distinct regions look like duplicates and produced a confident list of a blocker that did not exist. All three spellings are canonical —location_to_filenameemits_NNfor non-negative and-NNfor negative, so a hyphen is a minus sign.
The script uses terrain.data.get_height(), which is what the server calls
through TerrainProbe.has_ground() to decide whether a body stands or falls.
NAN means no ground, and that NAN is the condition under which a player falls
through the world — so the map cannot disagree with the game's behaviour, because
it is the behaviour.
Two things it checks rather than assumes:
- A self-check.
(150, -375)must read as solid ground, because probes there ran 100% on-floor. If the region data were not loaded, every cell would read void and the output would be a plausible-looking map of nothing. - The region → world mapping, resolved by sampling each region centre under both plausible conventions and keeping whichever puts ground where the engine says a region exists. Guessing would have produced a confident grid of the wrong part of the world.
One writer to the debug label.
_render_label()is the only function that writes_debug_label.text, and_processcalls it every frame. There were two writers with different formats —_refresh_labelwrote the input readout,_update_debug_labelwrote a terser line — and_processcalled the second whenever a flash expired, so pressingF3replaced the input readout and it only came back if you touched the controls within six seconds. Both parsed, both existed, and neither was wrong alone.check_rpc.pyasserts a single owner.
Press F1 to toggle movement correction. With it off, prediction runs but no
reconciliation is applied — which is how jitter caused by correction is told
apart from jitter caused by physics. An on-screen label shows the current state.
In a debug build, per-packet correction telemetry is written to:
~/.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.
See Deployment → Reading the output for what each column means.
Sending a capture
If the portal is configured with a telemetry sink, the example client can send a capture on request — so a tester can hand over what they saw without finding files or knowing that anyone is looking.
Press F3. It asks one question — what did you notice? — and sends the
capture with the answer attached. That note is the highest-value part: a few
hundred KB of per-packet telemetry is close to uninterpretable without knowing
whether it caught a slope hitch, a stop-lurch, or nothing at all, and the person
who felt something is the only one who knows.
F3 sits beside F1 deliberately. Both mean something about movement looked
wrong and I want a record of it, and having them adjacent means someone told to
press F1 can find F3. A capture is also sent automatically when an
interactive probe run finishes.
Reading a capture
A capture is only useful if someone reads it. scripts/analyze_capture.py
turns one into a verdict, either from a local correction_*.log or straight
out of the collector:
scripts/analyze_capture.py --db --note rubber # newest capture whose note matches
scripts/analyze_capture.py path/to/correction_x.log --top 8
It skips the start-up warmup, splits moving from still, and — because the log carries position — groups divergence by 25 m map cell, worst first. That last table is the point. A capture without it can tell you that movement is wrong but not where, which is the same as being unable to fix it:
MOVING n=591 mean= 0.603 p50= 0.458 on-floor=100%
worst map cells (25 m grid, moving only)
cell n mean max on-floor
( 150, -375) 92 1.412 4.071 100%
( 150, -350) 95 0.717 2.058 100%
--fail <metres> makes it a gate rather than only a report, exiting non-zero
when the moving-state mean exceeds a limit.
Seeing where you are, and how wrong it is
F1 shows live input, correction counters and the build. It also shows where
the body is and how far the two simulations currently disagree, and the colour
follows the disagreement:
recon: ON [F1] [F3] pos 150.3, 0.90, -374.8 err 1.44 m ← red
move=(0.10,-1.00) jump=false sprint=true [real] corr fired 105/1588 f9d38b1
Amber over 0.5 m, red over 1.0 m.
This is so a tester can say where something felt wrong. The first capture that arrived from a real tester was one sentence — "Lots of rubber banding" — over 63 seconds, with no position on it and none recorded in the capture. The cell the divergence was concentrated in could only be found afterwards, by replaying the same route headlessly. The number spiking as you cross bad ground makes the place and the sensation one observation instead of two.
F3 also names the cell you are standing in, using the same 25 m grid as
analyze_capture.py, so every capture is geotagged without anyone reading a
coordinate off a screen and remembering it:
telemetry: say what you noticed, then Enter (Esc skips)
near cell (150, -375)
The grids are deliberately identical — same constant, same rounding. A cell quoted on screen and a cell printed by the analyzer have to be the same cell, or the cross-check between what a tester saw and what the table says is worthless.
F3 works from anywhere, and always answers. Press it on the login screen,
in chat, mid-gameplay — it is not gated on being in a world, because a tester who
felt something wrong should not have to be in gameplay mode to report it.
Every outcome is stated on screen, bottom-left, for a few seconds:
telemetry: say what you noticed, then Enter (Esc skips)
telemetry: capture uploaded
telemetry: refused 401 -- check TELEMETRY_SINK_KEY matches on both portals
telemetry OFF: this portal sent no sink
The same message also goes to the log, but the screen is the point: the answer appears where you are already looking, not in an Output panel.
Three things this cost to get right, each of which made it produce nothing while looking configured and working:
- The handler resolved its target with a path that does not exist. There is
no node called
Network—Main.tscnputsNetwork.gd's script on the root node namedMain— soget_node_or_null("/root/Main/Network")returned null and the null guard discarded every keypress.F3was unreachable on every build since it was added, in every mode, with nothing logged. It now usesget_parent(), which is structurally correct rather than correct-by-agreement. F3sat below the mode guards, so on the login screen or while typing in chat it was unreachable for a second, independent reason.- The feedback only went to
push_warning, which lands in the editor's Output panel or in stderr. Pressing a key and seeing nothing happen is indistinguishable from pressing a key that does nothing.
The first is the one worth remembering. Two rounds of fixing the other two
changed nothing, because the key never reached that code at all — and every one
of those rounds came with a confident explanation. check_rpc.py now asserts the
handler reaches its target by get_parent(), and scripts/f3_check.gd asks the
real scene the question directly, so the next person cannot "fix" it back to a
path.
The note itself used to vanish
The fourth failure was in the code the fixed key finally reached. A tester
pressed F3, typed a real observation, pressed Enter — and the words were gone.
No message, no log line, nothing stored, and no way to tell whether the client
had sent it or the server had dropped it.
The cause was an asymmetry that had been there the whole time. The network half
of the telemetry module reported every outcome in a sentence: refused 401,
unreachable, capture uploaded. The local half — five gates in upload() and two
in upload_telemetry() — return false in silence. Those are the gates a capture
meets first, and one of them is simply no capture recorded yet, which is
exactly what you have if you press F3 straight after logging in. Press at the
wrong moment and the note is discarded before a byte is sent, by code that looks
deliberately quiet.
Everything now announces itself. Every early return goes through _refuse() or
_flash(), which set a status, emit the reported signal and write a log line:
telemetry: not sent -- no capture recorded yet, move for a second and press F3 again
telemetry: not sent -- your client has no sink. Your note was: lurch when stopping
The last line is the part that matters. When a capture is refused, the note is named back to the tester. An unsaved observation is the most expensive data in the system and the only part nobody can regenerate, so it is never silently discarded.
Three further changes follow from not having the evidence:
-
The note is logged on the tester's own machine before the request, and echoed on screen, so there are records on both sides. The portal log carries it too. Three places, because a note that exists in exactly one is a note that can be lost exactly once.
-
Enter submits even if the field has lost focus.
LineEdit.text_submittedonly fires on Enter while focused; if anything ever takes that focus, the text sat on screen, Enter went to the game, and the note was dropped with nothing visibly different._unhandled_inputis now a second route, safe to overlap because_submit()is guarded. -
--probe-note=puts a note on a scripted run, so the whole journey — prompt, payload, collector, database — can be verified without a person at a keyboard:godot --headless --path godot_client -- \--token-file=/tmp/nyx.token --probe=turn --probe-seconds=8 \--telemetry-sink=https://portal.example --telemetry-key=... \--probe-note="lurch when stopping after sprinting on the slope"
Two guards keep this from regressing. scripts/note_check.gd proves the prompt
delivers what was typed, that Enter survives losing focus, and that each refusal
speaks — and it counts its own checks, because its first run printed PASS
while a runtime error had aborted one of its two functions partway through, which
is the same green-for-nothing shape as everything else here. check_rpc.py walks
each of the two functions and requires every return false to be preceded by an
announcement; comparing totals was not enough, since one function announces three
times for two exits and would stay balanced with an exit gone.
Do not truncate
telemetry_uploads. It is shared with the testers. An agent clearing its own verification rows once deleted a real capture from it, and the one that would have shown where the note was going went with it. To clear test data, delete only rows you created — scope theDELETE; do not truncate.
If telemetry is off, the client says so at login, unprompted. A diagnostic that only speaks when asked still has to be asked, and nobody asks one they believe is working:
telemetry OFF: this portal sent no sink (press F3 for detail)
To check whether it works at all, without reproducing anything:
godot --headless --path . -- --token-file=/tmp/x.token \
--telemetry-sink=http://<collector>:8082 --telemetry-check
It prints the resolved configuration — whether telemetry is enabled, which
credential will be used, where captures are being read from, and what the last
result was — and then sends a one-line connectivity capture. That one is tagged
connectivity-check so it is never mistaken for real movement data.
This does nothing unless the portal has a sink configured. A production portal sends the client no sink, so there is no prompt and no upload path at all — the feature is absent rather than disabled. It is a diagnostic for people testing movement, not player telemetry, and the two are deliberately not the same thing.
Headless runs are excluded from the automatic upload. A measurement suite is several scenarios run repeatedly, and uploading all of them buries one interesting capture under hundreds of machine-generated ones. A specific run can still ask for one:
godot --headless --path . -- --token-file=/tmp/x.token \
--probe=turn --probe-seconds=20 --telemetry-sink=http://portal:8082
Captures still write to disk, and rotate automatically keeping the most recent five. Rotation runs at startup as well as after a send, because the machine that accumulates the most is one running scripted probes with telemetry off — which never uploads and so never cleans up after itself.
What a capture identifies
Each upload carries the machine's Platform, OsVersion and display
RefreshRate, plus a Tester label taken from MOONDOG_TESTER in your .env,
falling back to the machine name.
Set MOONDOG_TESTER rather than leaving it unset: it is how you choose to be
identified instead of having a hostname published for you, and the label survives
the machine being renamed. Windows desktops get renamed constantly.
RefreshRate is the one worth explaining. Client frame rate governs how often
input is sent and how prediction advances, 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 it cannot be
determined, which is what headless gives.
Movement probe
A headless probe drives scripted input through the same code path a human uses and reports divergence:
godot --headless --path godot_client -- \
--token-file=/tmp/nyx.token \
--probe=straight --probe-seconds=20
Scenarios are idle, straight, turn, figure8, sprint_jump and stop.
The probe is the regression harness for movement — changes to prediction or
deadzones are verified by re-running it against a live server, and a
--probe scenario is often added alongside a fix so the failure stays covered.
See Deployment.
Script layout
| file | responsibility |
|---|---|
scripts/Network.gd | connection, auth, reconciliation, remote interpolation |
scripts/Player.gd | movement simulation and physics body |
scripts/ClientInput.gd | input routing, mouse look, correction toggle |
scripts/InputBindings.gd | binding persistence |
scripts/Settings.gd | settings UI |
scripts/MovementProbe.gd | headless divergence probe |
scripts/Chat.gd, scripts/Login.gd | chat and login UI |
Note
The example client is a starting point. It implements movement, prediction, chat and settings, but not animations, inventory or combat.