- backend/app/services/game_servers.py: an in-process supervisor. One
headless Godot dedicated-server process per world, keyed by world_id,
spawned on demand from the join endpoint, port-allocated from a
configured range, with a background reaper that kills a world's server
once its status file reports 0 players for the idle timeout. Off by
default (GAME_SERVERS_ENABLED); the checkpoint/playtest launcher enables it.
- POST /api/worlds/{id}/join -> {world_id, host, port}; 404 for
unknown/hidden worlds, 503 when orchestration is disabled. Auth required.
- main.py lifespan starts/stops the supervisor.
- config.py: GODOT_BIN, GAME_PROJECT_DIR, GAME_SERVER_HOST, port range,
idle timeout.
- tests/test_world_join.py (auth / 404 / visibility / 503) -- suite 72 passed.
- checkpoint runner also runs the 2p multiplayer shell test.
Verified end to end: enabled orchestration, POST /join spawned the Godot
server, a real client connected to the returned address and moved; backend
shutdown reaped the process.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Made the multiplayer core actually work (it had never run a real sim):
- project.godot: physics pinned to 30 Hz -- the shared sim/snapshot/prediction rate.
- server.gd: loads the world's level (real ground + collision), spawns a
server Player per peer at the level's PlayerSpawn, broadcasts a full
snapshot every physics frame with each peer's state AND last_seq (the last
input seq applied for that peer). Accepts `--port/--world/--status-file`
args (status file = player count for a future idle reaper); defaults keep
`godot res://scenes/server.tscn` working standalone.
- player_spawner.gd (server Player): rewrote -- drains its whole input queue
each physics frame in seq order, integrates gravity/walk/jump with the
shared constants, move_and_slide(), tracks last_applied_seq. No more no-op
process_server_input().
- network_client.gd: rewrote reconciliation -- drops inputs the server has
acked (last_seq), re-simulates the still-in-flight ones from the
authoritative state, snaps only on real drift. Exposes get_remote_states()
(peer_id -> latest snapshot state) for the game to render other players.
predict_and_send() now also sends body yaw.
- proxies trimmed to the RPC set the rewrite actually uses.
- scripts/mp_test_client.gd + run_multiplayer_2p_test.sh: two real client
processes vs one real server -- client A sees client B and its own
movement round-trips. PASS.
- run_checkpoint_tests.py: also run the 2p shell test.
smoke_test_network_client 4/4, player 15/15, avatar_in_world 10/10, all green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
smoke_test_network_client.gd needs a separate `godot scenes/server.tscn`
process on ENet 7777; the runner never started one, so that test has been
failing in every checkpoint run (the only failure in an otherwise-green
suite). Start it just for that test, tear it down after.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
- api_client.gd: get_avatar_loadout() / save_avatar_loadout()
- main_menu.gd: on successful auth (fresh or session-restore), load the
catalog + fetch the saved loadout into AvatarSystem so the in-world
player renders as the player's avatar. Best-effort: a slow/failed prime
never gates getting in (UI goes authenticated first, prime runs after).
New "Customize Avatar" button (enabled only when signed in).
- avatar_system.gd: local-player convenience API -- ensure_catalog(),
set_loadout_from_dict() (from the backend's GET shape), has_loadout(),
build_current_avatar().
- player.gd: swaps the placeholder capsule for the assembled avatar
(feet-aligned to the capsule bottom) when a loadout is primed; silent
fallback to the capsule otherwise. Camera stays first-person for now
(third-person is a noted follow-up).
- avatar_visualizer.gd: rewrote it -- the old version referenced
AvatarLoader.SlotResult / load_slot() which never existed, so it didn't
compile and took avatar_customization_ui.gd down with it. Now a thin
wrapper over AvatarLoader for the preview.
- avatar_customization_ui.gd: seeds from the live loadout, saves through to
AvatarSystem + PUT /api/avatar/loadout (local file kept as fallback),
fixed a Camera3D.look_at() before-in-tree crash.
- scenes/avatar_customization.tscn rebuilt (old one used the Godot-3
`ViewportContainer` node type) + avatar_customization_screen.gd wrapper
that returns to the menu on close.
- smoke_test_avatar_in_world.gd: primed loadout -> game.tscn -> player
shows the assembled avatar, capsule hidden, rig + bone mounts present.
Verified: cold-cache smokes green (player 15/15, avatar_system 14/14,
avatar_in_world 10/10, main_menu_ui 13/13); against a live backend
main_menu_full_flow 12/12, main_menu_back 20/20, world_list 4/4,
enter_world 9/9, e2e 4/4; plus a manual register->default-loadout->
customize->save->re-fetch-persisted round trip.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
assets/avatars/catalog.json referenced 14 GLB files that were never in the
repo, so the avatar system could not load a single avatar. And the loader's
assembly path had never run against a real skeleton -- it called
Skeleton3D.has_bone() (no such method in Godot 4) and hand-copied bones in a
way that dropped the skin binding.
- scripts/tools/gen_placeholder_avatars.gd: generates the pack headlessly via
GLTFDocument -- one rigged/skinned base body (the full 24-bone canonical
rig from a T-pose) + 13 primitive attachments. Crude on purpose; real art
swaps in as a pure content change (catalog paths unchanged).
- assets/avatars/*.glb: the generated pack (checked in).
- avatar_loader.gd: rewrote _parse_glb_as_avatar to lift the skin-bound
Skeleton3D that generate_scene() already produces, instead of rebuilding it.
Attachments now mount via BoneAttachment3D to the catalog's mount_bone and
are actually added to the avatar tree (they were parsed then dropped).
Fixed the has_bone() crash, the null ARRAY_BONES cast in _retarget_skin,
and the Array[String] type mismatch on the accessory list.
- smoke_test_avatar_system.gd: replaced the "asserts it fails gracefully
because the files don't exist" section with a real assembly check --
Skeleton3D present, full 24-bone rig, canonical bones resolve by name,
visible body mesh, equipped items mounted to bones. 14/14 from a cold cache.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Root cause: a stale .godot/global_script_class_cache.cfg was force-committed
to the repo (last regenerated before the avatar system merged), and Godot
does not rebuild the global class registry on a plain headless scene run --
only an editor scan / `--import` does. So every cross-file `class_name`
reference in the avatar module failed to resolve from a fresh clone, and the
AvatarSystem autoload failed to instantiate entirely.
- Untrack .godot/ and sandbox_probe/.godot/ (already in .gitignore; were
force-added during old WIP checkpoints)
- Reference sibling avatar scripts via preload() constants instead of their
global class_name, so the module parses independent of scan order. Keeps
the class_name declarations for editor/tooling use.
- avatar_loadout.gd's static from_json() factory self-references via a
preload const too (a script can't resolve its own class_name mid-compile
on a cold cache).
- smoke_test_avatar_system.gd: same preload treatment so it runs from a bare
`--script` invocation.
Verified from a wiped .godot/: main_menu.tscn and game.tscn boot with zero
script errors; smoke_test_avatar_system 10/10, smoke_test_player 15/15,
smoke_test_main_menu_ui 13/13, smoke_test_test_level PASS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
Found while re-verifying the full PC2 test suite from a fresh clone:
the "prediction buffer has entries" check failed ~1 in 3 runs. Root
cause is a genuine race in the test, not in prediction/reconciliation
itself -- a server snapshot RPC can land on the 60-frame loop's final
`await process_frame`, triggering a legitimate reconciliation that
clears the buffer (network_client.gd's documented behavior) right
before the check reads it. The desync-correction check right after it
already proves reconciliation logic works correctly on its own, so
this was never actually catching a real bug -- just occasionally
racing a real correction event.
Fix: one more predict_and_send() right before the check guarantees a
fresh buffer entry regardless of whether a correction just fired.
Verified 5/5 clean runs against a real dedicated server after the fix
(previously observed a failure within 3 runs).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Merges the pre-existing avatar system implementation (catalog, slot
registry, loadout, glTF-based loader, customization UI, visualizer --
~2300 lines across scripts/avatar/) onto current main, which had
diverged significantly since this branch was last touched (PC1 UI
fixes, PC2's playable level/player/multiplayer work).
Fixes found while integrating:
- project.godot had wholesale-replaced the [autoload] section again
(same recurring pattern as the multiplayer server/client branches),
dropping the ApiClient autoload entirely. Restored it additively
alongside AvatarSystem.
- All other real conflicts (main_menu.tscn/.gd, main.tscn, .godot
cache) were pure "this branch re-created a stale duplicate of
something main already has" -- resolved by taking main's version,
confirmed via `git diff` that this task's own changes never touched
those files' actual logic.
New: scripts/smoke_test_avatar_system.gd -- confirms AvatarSystem is a
real autoload, the committed catalog.json parses, loadout equip/
validate/JSON-round-trip works against real catalog entries, and --
since assets/avatars/*.glb model files don't exist yet -- that
AvatarLoader.load_base_body() fails gracefully (null + load_error
signal) rather than crashing when given a real catalog entry with no
backing file. 10/10 passing.
Verified no regressions: all 10 pre-existing Godot smoke suites still
pass against a live backend, and all 61 backend pytest tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Real client-side networking for the player controller, verified with a
genuine two-process integration test (an actual dedicated server process
+ a real ENet-connected client), per docs/architecture/networking.md §5.
scripts/network_client.gd: NetworkClient -- predicts movement locally on
input (same movement constants as scripts/player_spawner.gd), sends input
to the server via RPC, and reconciles against the server's authoritative
snapshots when they diverge beyond a threshold.
scripts/network_receiver_proxy.gd + network_player_proxy.gd: the RPC
plumbing that makes this actually route correctly, which took real
debugging to get right:
- Godot's rpc_id() resolves the receiving node by matching the sending
node's NodePath between peers. scripts/server.gd is the SceneTree root
node "Server" when the dedicated server runs, and spawns each player as
"Player_<peer_id>" under itself -- so the client needs its own node
tree at those same paths purely for RPC routing.
- Godot also enforces an RPC "node checksum": both peers must declare the
*same set* of @rpc methods at a matching path, or every RPC at that
path fails, not just a genuinely missing one. Every @rpc method on
scripts/server.gd's root and scripts/player_spawner.gd had to be
mirrored here (mostly as no-op stubs), not just the ones this class
actually calls.
Known, explicitly-scoped simplification (stated in the code, not hidden):
scripts/server.gd's snapshot doesn't currently echo back a per-peer "last
input seq processed", so reconciliation corrects against the most recent
prediction rather than true tick-exact rollback-and-replay
(networking.md §5.2 step 4). A real follow-up once the server echoes
that ack, not faked here.
Verified: real two-process test (godot --headless --path . scenes/
server.tscn as an actual persistent process, a second process running
scripts/smoke_test_network_client.gd against it over real ENet) --
4/4 passing: connects, predicts locally before any server round trip,
sends real input, and a deliberately-desynced position (moved 9999 units
away) gets corrected by the very next real snapshot. Full existing
client suite (10 suites) + 61 backend pytest still passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Two genuine bugs, found by reproducing rather than trusting the branch's
own state:
1. scenes/player.tscn collided with PC2's client-side player scene at the
exact same path (server-authoritative PlayerNode vs. the local
single-player character) -- whichever merged last would silently
overwrite the other. The branch's own last commit had literally
committed unresolved <<<<<<< conflict markers into the file instead of
resolving them. Renamed the server's version to
scenes/server_player.tscn, restored scenes/player.tscn to match main's
real client scene exactly, repointed scripts/server.gd's preload.
2. scenes/server.tscn's root Node had no script attached at all -- it only
ever worked before because project.godot had `Server` registered as a
project-wide autoload (the exact wholesale-overwrite this task was
already asked to fix). Once that autoload was correctly removed,
nothing attached server.gd to the scene tree anymore and the dedicated
server silently did nothing. Attached scripts/server.gd directly to
scenes/server.tscn's root node instead.
Verified: godot --headless --import/--quit both clean, full 10-suite
client smoke test all passing, and the dedicated server actually listens
on UDP port 7777 when launched explicitly (`godot --path . scenes/server.tscn`)
-- confirmed with `ss -ulnp`, not just trusting the startup print (first
checked with `ss -tlnp`, which only shows TCP and falsely suggested it
wasn't listening at all; ENet is UDP).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Both were verified (9/9 passing) before PR #50 merged but got lost in the
handoff between my push and the concurrent live agent session reusing the
same workspace -- PR #50's actual merged diff only had the world_list.gd/
player.gd code changes, missing the new test file and the doc update.
Caught while auditing everything feeding PC2 for real completeness.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
_show_authenticated() (the session-restore path, used when returning to
main_menu.tscn -- e.g. after clicking Back from world_list) only hid
EmailEdit/Over18Check, unlike _on_auth_complete() (the fresh-login path)
which hides the full form: UsernameEdit, PasswordEdit, EmailEdit,
Over18Check, AuthButton, AuthModeLabel. Play was correctly enabled and
the welcome message was correctly visible, so a check that stopped at
those two passed clean -- but the actual login fields were still on
screen, which is what a real user sees as "it's making me log in again."
Confirmed via live PC1 playtest today.
Reported by: real playtest, 2026-08-20 (yesterday's automated regression
test for this exact Play -> Back flow only asserted Play/welcome/auth
buttons row -- strengthened smoke_test_main_menu_back.gd to also check
the individual form fields, and confirmed it fails exactly this way with
the bug reintroduced before restoring the fix).
Verified against a live backend: 61/61 pytest (unaffected), 8/8 Godot
smoke tests (including 20/20 on the strengthened back-nav test).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Two things, found together while investigating a stuck blocker task:
1. URGENT: main_menu.gd currently fails to parse at all on main
("Identifier '_dob_edit' not declared in the current scope"). PR #44
(hide-all-fields-on-auth-success) and PR #45 (DOBEdit -> Over18Check
checkbox) each merged cleanly on their own -- Gitea's merge reported
mergeable:true for both -- but together they left a reference to the
old _dob_edit var on a line neither PR's diff touched, so no conflict
was ever flagged. Same failure mode in smoke_test_main_menu_full_flow.gd
(dob_edit, would have failed to even parse in the checkpoint suite).
Fixed both to reference _over_18_check / over_18_check.
2. Lands the Play -> Back session-persistence fix (PR #43, task
28357bca192d) that was stuck behind review the whole time main didn't
have it yet: signal auth_state_ready, _show_authenticated() /
_reset_to_logged_out() helpers, and _ready() restoring the
authenticated UI (not resetting it) when ApiClient already holds a
valid token. Plus the real fix a review round caught on top of that
(task 9186405a6373): _show_authenticated() was hiding AuthPanel while
claiming the welcome label inside it was visible -- a no-op UI-wise,
since WelcomeLabel.visible=true does nothing once its parent is
hidden. AuthPanel now stays visible; the regression test
(smoke_test_main_menu_back.gd, new) asserts is_visible_in_tree(), not
just .visible, so this class of bug can't pass silently again.
Verified against a live backend: 61/61 pytest, 7/7 Godot smoke tests
(including the new smoke_test_main_menu_back.gd, 12/12).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
The register form collected and validated an actual date of birth just to
throw it away (backend never stored it beyond a nullable column). Per the
roadmap note: don't collect/store DOB right now, just get a client-side
"I am 18 or older" confirmation. RegisterRequest.date_of_birth (str) is now
over_18 (bool); the endpoint rejects over_18=false with 400 same as before.
calculate_age() is kept (unused by /register now) since real DOB collection
is coming back in a future pass and deleting it broke test_auth_age_gate.py's
import for no benefit.
Frontend: DOBEdit LineEdit replaced with an Over18Check CheckBox; api_client.gd's
register() now takes a bool instead of a date string. All Godot callers
(smoke_test_auth/e2e/world_list/main_menu_ui/main_menu_full_flow) and backend
test files updated to match. Full suite verified against a live backend:
61/61 pytest, 6/6 Godot smoke tests (33 individual assertions), all passing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
Reported directly by the user after seeding a test world through the
browse-worlds screen: "screen is blank now". Two compounding layout
bugs, both real:
1. world_list.tscn's root VBoxContainer had no anchors at all, only
size_flags -- but its parent (the scene's root node) is a plain
Control, not a Container, so size_flags on a Control's direct child
are meaningless there. Without anchors it never actually filled the
screen, so it (and everything under it) collapsed toward zero size.
2. size_flags_horizontal/vertical used the raw integer 4 everywhere
expand-fill was clearly intended (ScrollContainer, its WorldList
child, HeaderHBox, TitleLabel) -- but 4 is Control.SIZE_SHRINK_CENTER
in Godot 4, not SIZE_EXPAND_FILL (which is 3). ScrollContainer with
SHRINK_CENTER takes only its own minimum size instead of expanding,
cascading a ~1px width down through every card and every label.
Confirmed the actual enum values by asking Godot directly rather
than assuming.
Both are exactly the kind of bug a headless smoke test checking only
"did cards get added to the tree" can't catch -- child_count was
correctly non-zero, nothing crashed, the content was just rendered
invisibly thin. Verified the fix is real by deliberately reintroducing
bug #2 and confirming the test now correctly fails (card width 25px,
below the 200px sanity threshold) before restoring it.
Also replaced the card background: it was a ColorRect manually moved to
child index 0 of a VBoxContainer, which doesn't work as an overlay --
a VBoxContainer lays out ALL its children in sequence, so the "moved to
front" ColorRect rendered as an empty dark bar ABOVE the text, not
behind it. Now a proper PanelContainer with a StyleBoxFlat background,
the idiomatic way to get a colored panel behind auto-laid-out content
in Godot.
smoke_test_world_list.gd strengthened to actually catch this class of
bug going forward: seeds a real world via POST /api/worlds/ first (the
checkpoint runner's fresh DB meant the populated-card path never ran in
CI at all before this, only empty-list did), and asserts each card's
real computed width, not just that cards exist.
Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests, both platform exports.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported directly by the user playtesting the exported exe: Play was
permanently disabled and there was no way to log in or register. Root
cause: main_menu.tscn wired Play and Quit, but nothing was ever
connected to _on_login_button_pressed()/_on_register_button_pressed() --
the AuthPanel, its form, and all its backend-calling logic were fully
correct, just completely unreachable. The script's own top comment even
said "(the existing Play/Quit buttons)" as if Play/Quit somehow doubled
as login triggers, which they never did -- a sign nobody ever actually
clicked through the running scene, only checked things at the API level.
Fix: added a real AuthButtonsRow (LoginButton, RegisterButton) wired to
the existing handlers, which now hide themselves once auth succeeds.
Also fixed grab_focus() targeting the AuthPanel (which can't accept
focus, just a background warning) to focus the username field instead.
Verified via two new smoke tests (smoke_test_main_menu_ui.gd,
smoke_test_main_menu_full_flow.gd) using a *real signal emission* on
the buttons, not a direct method call -- that distinction is exactly
what would have caught the original bug. Full click-through confirmed
against a real backend: open register form, fill it, submit, Play
actually becomes enabled, welcome message shows, auth buttons hide.
Also documents a real headless-testing gotcha found while verifying
this: a synthetic Button.pressed.emit() on an await-using handler
doesn't reliably get driven forward in --script/SceneTree mode the way
real GUI input dispatch does -- confirmed by comparing direct-call vs
emit() behavior side by side. Not a bug in the actual click-driven flow,
a test-harness quirk to route around (call the handler directly,
awaited, instead of emitting the signal).
Full checkpoint suite re-verified clean on this branch: migrations,
68/68 pytest, all 6 Godot smoke tests (including the 2 new ones, picked
up automatically by run_checkpoint_tests.py's glob).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Tester had already written a complete, correct test (4/4 steps pass
against a real backend) but stalled investigating DB/migration setup
after its own verification run silently exited with no result -- the
task spec's suggested invocation included --quit, which kills a --script
SceneTree test before its awaited HTTP calls finish (same Godot
--script-mode gotcha documented elsewhere this session).
Renamed smoke_test.gd -> smoke_test_e2e.gd: the task spec named it
without the checkpoint runner's expected smoke_test_*.gd suffix, so
scripts/run_checkpoint_tests.py's glob would have silently never picked
it up. Also fixed the --quit flag in the file's own usage comments.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Resolves conflict in main_menu.gd's play button: kept this branch's real
world_list.tscn transition over main's stale TODO placeholder (main's
version predates this fix; nothing else has touched the play button
since). Brings in PR #37's /auth/refresh endpoint and its tests cleanly,
no other conflicts.