Implements the client-side half of docs/architecture/world_persistence_impl.md
(the backend API in backend/app/routers/{world_content,internal}.py and
game_servers.py's --api-url/--server-token/_await_ready were already merged):
- scripts/http_chunk_source.gd: new HttpChunkSource adapter satisfying
ChunkStreamManager.set_server_rpc()'s seam. Fetches chunks over HTTP from
the public world_content API (JWT via ApiClient), with ETag/If-None-Match
(304) support backed by a user://chunk_cache/<checksum> cache, and a
bounds-query list_chunks() helper (§8.1's chunk index). A 404 (unwritten
coordinate) renders empty instead of erroring, per §8.1.
- scripts/server.gd: startup now parses --api-url/--server-token, and
before binding the ENet port fetches the world's manifest + the
spawn-neighbourhood chunks from /api/internal (§7.2/§7.3/§8.3), decodes
them, and builds real StaticBody3D collision from solid tiles + non-
walkable objects. Falls back to test_level.tscn if the backend is
unreachable, the world has no rooms, or the room has zero chunks -- never
hangs or crashes. Writes "ready"/"content" (plus diagnostic
chunks_loaded/collision_shapes counts) into the status file that
game_servers.py's _await_ready() already reads.
- scripts/api_client.gd: exposes a `base_url` getter so other client code
building its own requests (HttpChunkSource; server.gd doesn't use this
autoload at all, since its auth model is the per-spawn server token, not
a player JWT) can target the same backend.
Also fixes three real, previously-dormant bugs this work's first real
exercise of these code paths uncovered (nothing before this called
encode_binary() or fed real data through set_server_rpc()):
- chunk_data_decoder.gd: `_build_metadata_section` never wrapped its
output with the section id/length header every other section builder
uses, so `encode_binary()` produced corrupt payloads the decoder read as
a single garbage-length section ("truncated section body").
- chunk_stream_manager.gd: `_parse_coord_from_key` assigned a
PackedStringArray (String.split()'s real return type) to an
Array[String]-typed variable -- a hard crash.
- chunk_stream_manager.gd: `_instantiate_chunk_scene_with_data` read a
nonexistent `data.format_version` field; ChunkPayload's field is
`version`.
Adds scripts/smoke_test_http_chunk_source.gd, a real end-to-end smoke test
(register, create world, PUT a real chunk, stream it back through
ChunkStreamManager + HttpChunkSource, plus the unwritten-coordinate and
304-refetch paths).
Verified against a real backend + Postgres:
- Seeded real chunk content, joined through the real
GameServerSupervisor spawn path: status file reports
content=loaded, chunks_loaded=1, collision_shapes=33.
- A room with zero chunks and an unreachable backend (closed port) both
fall back to test_level.tscn cleanly (content=fallback, ready=true, no
hang/crash).
- Full existing Godot smoke suite (16 suites, including
smoke_test_enter_world.gd and smoke_test_network_client.gd) passes with
no regressions.
- Backend pytest (132/132) untouched and still green -- no backend/
changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
- Clients now hard-snap to the server's spawn position on the first
snapshot. player.gd starts everyone on the fixed PlayerSpawn marker but
the server spreads spawns out -- so one client saw players ~1m apart
(correct) while the other saw them stacked (its local player still on the
marker, never corrected because the ~1m drift was under the old 1.5m
threshold). Threshold also tightened to 0.6m.
- game.gd._exit_tree() closes the ENet connection cleanly when you leave a
world, so the server fires peer_disconnected and removes your player.
Before, leaving just changed scene and the server never knew -- everyone
else kept seeing a frozen "ghost".
- server.gd: once-a-second sweep of players whose peer is no longer
connected, as a backstop for a missed disconnect.
- Build stamp: checkpoint_ops bakes "<PC> <commit>" into
application/config/version at export; the menu and in-world HUD show
"build PC3 <hash>" so playtest reports aren't ambiguous about which
build is running.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
All players spawned at the exact same PlayerSpawn point and were on the
same collision layer, so two overlapping CharacterBody3Ds triggered a
physics depenetration shove that could launch one through the 1-unit-thick
floor.
- server.gd: golden-angle spawn spread -- each join lands ~1m+ from the
others, growing outward, never exactly repeating.
- server_player.tscn + player.tscn: players on collision layer 2, mask 1
(world only) -- players no longer collide with each other at all
(fine for a social game; also removes the shove).
- player_spawner.gd + player.gd: hard safety net -- if y < -10 (any
tunnel-through), snap back to the spawn instead of falling forever.
Verified: 3 server players spawn 1.15m+ apart, none fall, all rest on the
floor. 2p multiplayer + network_client smokes green.
Client-side -- rebuild the exe.
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
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