Skip to main content

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.

note

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:

actionkeyboard/mousegamepad
moveW A S Dleft stick
jumpSpaceA
sprintShiftLB
chatEnterY
lookmouseright 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_location takes the first 3 and last 3 characters and strips underscores, so terrain3d_00_01 is region (0, +1) and terrain3d_00-01 is (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_filename emits _NN for non-negative and -NN for 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 _process calls it every frame. There were two writers with different formats — _refresh_label wrote the input readout, _update_debug_label wrote a terser line — and _process called the second whenever a flash expired, so pressing F3 replaced 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.py asserts 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.tscn puts Network.gd's script on the root node named Main — so get_node_or_null("/root/Main/Network") returned null and the null guard discarded every keypress. F3 was unreachable on every build since it was added, in every mode, with nothing logged. It now uses get_parent(), which is structurally correct rather than correct-by-agreement.
  • F3 sat 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_submitted only 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_input is 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 the DELETE; 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​

fileresponsibility
scripts/Network.gdconnection, auth, reconciliation, remote interpolation
scripts/Player.gdmovement simulation and physics body
scripts/ClientInput.gdinput routing, mouse look, correction toggle
scripts/InputBindings.gdbinding persistence
scripts/Settings.gdsettings UI
scripts/MovementProbe.gdheadless divergence probe
scripts/Chat.gd, scripts/Login.gdchat and login UI

Note​

The example client is a starting point. It implements movement, prediction, chat and settings, but not animations, inventory or combat.