Compare commits

...

54 Commits

Author SHA1 Message Date
claude-checkpoint-tooling bd44ecba14 Client-side position sync + prediction/reconciliation
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
2026-08-21 01:21:37 +00:00
coder-bot bae326e14c WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:18:34 +00:00
coder-bot 140db9df91 Recovered orphaned WIP before switching to a new task 2026-08-21 01:17:16 +00:00
coder-bot 2dff83bae3 WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:17:13 +00:00
coder-bot 1a8821e3c8 WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:14:07 +00:00
reviewer-bot 019dfca14f Merge pull request 'Address review feedback: Implement Godot dedicated server scene + player spawning' (#51) from agent/3bba30f838d1 into main 2026-08-21 01:00:23 +00:00
claude-checkpoint-tooling 7be50e3313 Fix real scene collision + a real regression from removing the Server autoload
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
2026-08-21 00:59:24 +00:00
coder-bot 29de1c5c3a Merge main into agent/3bba30f838d1 2026-08-21 00:56:17 +00:00
claude-checkpoint-tooling 92c10fa657 Add smoke_test_enter_world.gd + doc update that never made it into PR #50
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
2026-08-21 00:53:55 +00:00
reviewer-bot 8357643072 Merge pull request 'PC2: Wire "Enter World" from the browse list to the playable level' (#50) from agent/9e7930c1b631 into main 2026-08-21 00:47:26 +00:00
architect-bot 736ebce0d0 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-21 00:44:47 +00:00
coder-bot b60480798f WIP checkpoint: PC2: Wire "Enter World" from the browse list to the playable level 2026-08-21 00:43:45 +00:00
reviewer-bot 77f7511a4f Merge pull request 'PC2: Basic visible player character representation + local movement controller' (#49) from agent/9bfb90314053 into main 2026-08-21 00:41:53 +00:00
coder-bot 8be962248d Merge main into agent/9bfb90314053
# Conflicts:
#	scenes/worlds/test_level.tscn
2026-08-21 00:39:28 +00:00
claude-checkpoint-tooling 7790670559 WIP: add Camera3D, fix jump/forward-direction bugs, add mouse capture, build game.tscn composition 2026-08-21 00:39:13 +00:00
architect-bot c4eab8db92 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-21 00:20:21 +00:00
coder-bot e2a77b055f WIP checkpoint: PC2: Basic visible player character representation 2026-08-21 00:09:44 +00:00
coder-bot 5966fa9044 WIP checkpoint: PC2: Basic visible player character representation 2026-08-21 00:06:54 +00:00
architect-bot 4dda636ed0 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-21 00:01:15 +00:00
coder-bot 945c498a31 WIP checkpoint: PC2: Basic visible player character representation 2026-08-21 00:01:08 +00:00
reviewer-bot be0acb1862 Merge pull request 'PC2: Build a basic playable test level scene' (#48) from agent/0c49a36fc7db into main 2026-08-21 00:00:12 +00:00
coder-bot 8118f3074b WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 23:46:29 +00:00
architect-bot 4276a89a6a WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-20 23:31:27 +00:00
architect-bot 5803f47069 WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 23:19:05 +00:00
coder-bot e33b91d976 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 23:17:43 +00:00
coder-bot 1c3c57dbd0 WIP checkpoint: Address review feedback: Implement Godot dedicated server scene + player spawning 2026-08-20 23:17:09 +00:00
coder-bot fdc43bd5f1 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 23:01:55 +00:00
architect-bot 406ff3457e WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 22:50:48 +00:00
coder-bot 3f7acf857c WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 22:41:53 +00:00
coder-bot 6648b2bba5 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 22:31:46 +00:00
architect-bot f70b5b2ee5 WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 22:28:06 +00:00
architect-bot 2fa7b1f475 WIP checkpoint: PC2: Basic visible player character representation 2026-08-20 22:26:08 +00:00
coder-bot 7f753cb1b8 WIP checkpoint: PC2: Build a basic playable test level scene 2026-08-20 22:17:48 +00:00
reviewer-bot 66adcff145 Merge pull request 'PC2: Design the test level & player spawn approach' (#46) from agent/a6deeb84eace into main 2026-08-20 21:58:55 +00:00
coder-bot 88197ffe8f WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-20 21:28:00 +00:00
coder-bot 174d3c2630 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-20 21:11:52 +00:00
architect-bot 2ffcd9cd3f WIP checkpoint: PC2: Design the test level & player spawn approach 2026-08-20 21:09:30 +00:00
coder-bot 61dedcf73e WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 14:47:50 +00:00
coder-bot 580b95635f WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 14:44:50 +00:00
coder-bot a81d29c882 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 14:11:03 +00:00
coder-bot 4236c4792b WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 13:59:23 +00:00
coder-bot cf8fd5a6fd WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 13:45:39 +00:00
coder-bot fc9a39de93 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-19 13:38:29 +00:00
architect-bot 6018396be2 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:26:42 +00:00
architect-bot ee6513b8ad Recovered orphaned WIP before switching to a new task 2026-08-17 18:16:22 +00:00
architect-bot 37a311b0c5 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:15:38 +00:00
agent-bot 11e812cb6b WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 5097cdd9f4 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 2224c34aa8 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 2c5f6fcb04 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 7cd41dc920 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot c5d2eb6bd2 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot b650572426 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
agent-bot 7d84dd5ec8 WIP checkpoint: Implement Godot dedicated server scene + player spawning 2026-08-17 18:05:34 +00:00
27 changed files with 1958 additions and 9 deletions
+1
View File
@@ -0,0 +1 @@
+77
View File
@@ -0,0 +1,77 @@
63f7b34db8d8cdea90c76aacccf841ec
::res://::1787275036
export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://addons/::1787272930
::res://assets/::1787272930
::res://backend/::1787272930
alembic.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
docker-compose.yml::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
pytest.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
requirements.txt::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://backend/alembic/::1787272930
::res://backend/alembic/versions/::1787272930
::res://backend/app/::1787272930
::res://backend/app/models/::1787272930
::res://backend/app/routers/::1787272930
::res://backend/app/services/::1787272930
::res://backend/tests/::1787272930
::res://docs/::1787272930
::res://docs/architecture/::1787272930
avatar.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
content_moderation.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
editor.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
networking.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
pc2_test_level.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
scripting_sandbox.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
world.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/checkpoints/::1787272930
PC1.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/planning/::1787272930
client-server-wiring.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
economy.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
social.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/testing/::1787275036
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
test_inventory.md::TextFile::-1::1787275036::0::1::::<><><>0<>0<><>::
::res://scenes/::1787275036
game.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
main.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
main_menu.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
player.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/player.gd
server.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/server.gd
server_player.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
world_list.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
::res://scenes/worlds/::1787272930
test_level.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://scripts/::1787275098
api_client.gd::GDScript::5412288119287126975::1787272930::0::1::::<>Node<><>0<>0<><>::
chunk.gd::GDScript::7364239597441807588::1787272930::0::1::::Chunk<>Node3D<><>0<>0<><>::
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272930::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272930::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
dynamic_object_node.gd::GDScript::8863349716576659118::1787272930::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
main_menu.gd::GDScript::2834562513277893052::1787272930::0::1::::<>Control<><>0<>0<><>::
network_client.gd::GDScript::5408296350353838185::1787275098::0::1::::NetworkClient<>Node<><>0<>0<><>::
network_player_proxy.gd::GDScript::4787436336771232797::1787275036::0::1::::<>Node<><>0<>0<><>::
network_receiver_proxy.gd::GDScript::6742760436861380520::1787275036::0::1::::<>Node<><>0<>0<><>::
player.gd::GDScript::4510795778937304264::1787275036::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
player_spawner.gd::GDScript::3637338339553764865::1787275036::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
server.gd::GDScript::6336910781803684784::1787275036::0::1::::<>Node<><>0<>0<><>::
smoke_test_api.gd::GDScript::1760923834350699002::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_auth.gd::GDScript::3027816090663230577::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275036::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_network_client.gd::GDScript::3109386529184855644::1787275067::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_player.gd::GDScript::3497042634752676337::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
static_object_node.gd::GDScript::5276389741929225897::1787272930::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
tile_def.gd::GDScript::2430528079284085109::1787272930::0::1::::TileDef<>RefCounted<><>0<>0<><>::
tile_layer.gd::GDScript::4797906255131447844::1787272930::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
tile_layer_node.gd::GDScript::9033225196818247616::1787272930::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
world_list.gd::GDScript::6119591848847211153::1787275036::0::1::::<>Control<><>0<>0<><>::
world_object.gd::GDScript::6429674491464200335::1787272930::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
+11
View File
@@ -0,0 +1,11 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/daddy/godot/godot"
[recent_files]
scenes=["res://scenes/main_menu.tscn", "res://scenes/server.tscn"]
@@ -0,0 +1,5 @@
[folding]
node_unfolds=[]
resource_unfolds=[]
nodes_folded=[]
+105
View File
@@ -0,0 +1,105 @@
list=[{
"base": &"Node3D",
"class": &"Chunk",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk.gd"
}, {
"base": &"RefCounted",
"class": &"ChunkDataDecoder",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_data_decoder.gd"
}, {
"base": &"Node3D",
"class": &"ChunkScene",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_scene.gd"
}, {
"base": &"Node",
"class": &"ChunkStreamManager",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_stream_manager.gd"
}, {
"base": &"Node3D",
"class": &"DynamicObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/dynamic_object_node.gd"
}, {
"base": &"Node",
"class": &"NetworkClient",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/network_client.gd"
}, {
"base": &"CharacterBody3D",
"class": &"Player",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player.gd"
}, {
"base": &"CharacterBody3D",
"class": &"PlayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player_spawner.gd"
}, {
"base": &"Node3D",
"class": &"StaticObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/static_object_node.gd"
}, {
"base": &"RefCounted",
"class": &"TileDef",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_def.gd"
}, {
"base": &"RefCounted",
"class": &"TileLayer",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer.gd"
}, {
"base": &"Node3D",
"class": &"TileLayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer_node.gd"
}, {
"base": &"RefCounted",
"class": &"WorldObject",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/world_object.gd"
}]
Binary file not shown.
+194
View File
@@ -0,0 +1,194 @@
# PC2 Test Level & Player Spawn — Design Note
> **Status:** Decided (PC2)
> **Owner:** Architecture
> **Consumers:** the two PC2 implementation tasks — (1) build the test level,
> (2) build the player & spawn it. Both must be able to follow this note
> without further design decisions.
## 1. Decision: static scene, not chunk streaming
**The PC2 playable test level is a single, hand-authored, static Godot 3D
scene.** It is **not** built through the chunk streaming system
(`scripts/chunk_stream_manager.gd`, `chunk_scene.gd`, `chunk.gd`,
`chunk_data_decoder.gd`).
This is a deliberate decision, not a default:
- **The streaming system has never been exercised with real content.** It is a
faithful port of the `docs/architecture/world.md` format spec, but nothing in
the project produces or serves chunk payloads for it to consume. `scenes/main.tscn`
wires `chunk_stream_manager.gd` onto a bare `Node3D` with no `player` reference
and no `_server_rpc`, so at runtime it does nothing. There is no server-side
chunk authoring/streaming endpoint to integrate against.
- **PC2 scope is single-player, local-only, no networking.** The value of chunk
streaming is server-authoritative streaming around a moving player across a
network boundary. None of those preconditions exist in PC2, so exercising the
pipeline here would mean building a chunk *authoring + serving* path first —
clearly out of checkpoint scope.
- **A static scene is the lowest-risk way to get "spawn a player in a real 3D
level and walk around" working**, which is the actual PC2 outcome being tested.
Chunk streaming is **not removed and not broken by this decision** — it stays as
an optional, later milestone. The static level is the PC2 proof of the *player*
and *camera/physics* half of the problem; chunk streaming remains the later proof
of the *world data* half.
## 2. Where things live
| Artifact | Path | Owned by |
|---|---|---|
| Test level scene | `scenes/worlds/test_level.tscn` | level task |
| Player scene | `scenes/player.tscn` | player task |
| Player controller script | `scripts/player.gd` | player task |
| Game composition scene | `scenes/game.tscn` | player task |
- **`scenes/worlds/`** is the convention for all level scenes (it does not exist
yet; the level task creates it). Future levels follow `scenes/worlds/<name>.tscn`.
- `scenes/player.tscn` is **reusable** across levels — it must not hard-code any
reference to `test_level`; it discovers the spawn point at runtime (see §3).
## 3. Player spawn convention
The level scene **must** contain a `Marker3D` named exactly **`PlayerSpawn`**.
That node's world transform is the spawn point.
- `Marker3D` is a plain `Node3D` that shows a cross gizmo in the editor and
carries no gameplay behavior — it is the established Godot idiom for "position
hint." (Confirmed present in Godot 4.6.2.)
- The marker's **position** is authoritative; its rotation is ignored by the
player (spawn yaw is the player's concern, default forward is fine).
- Coordinate system: **Y-up, units = meters, 1 unit = 1 m.** The marker must sit
at least **1.0 m above the ground's top surface** (recommended `y = 1.0` where
the ground top is `y = 0`) so the player capsule does not spawn intersecting
the floor and get popped out by the physics solver.
- There is exactly **one** `PlayerSpawn` per level. (If a level ever needs
multiple spawns, that is a later convention — not PC2.)
**Lookup rule (implemented by `player.gd`, not by the level):**
```
# in player.gd _ready()
var spawn := get_tree().current_scene.find_child("PlayerSpawn", true, false)
if spawn is Node3D:
global_position = spawn.global_position
else:
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
global_position = Vector3(0, 1.0, 0) # fallback, do not crash
```
The lookup runs from the current scene root so it finds the marker whether it is
a direct child of the level instance or nested under a `Level` wrapper. The
fallback keeps the player runnable in a scene that forgot its marker, but a
**missing `PlayerSpawn` is a level bug** and the level task's smoke test must fail
on it (see §6).
## 4. Composition (who puts level + player together)
`scenes/game.tscn` is the PC2 playable entry point:
```
Game (Node3D)
├── Level (instance of res://scenes/worlds/test_level.tscn)
└── Player (instance of res://scenes/player.tscn)
```
- `Game` is a plain `Node3D` with **no script** — composition is done by scene
instantiation, not code, so there is nothing to keep in sync.
- The **level scene is self-contained and has zero dependency on the player** —
it loads and is smoke-testable on its own. The level task does not instance or
reference `player.tscn`.
- The **player scene is self-contained and has zero dependency on any specific
level** — it only requires that the *current scene* contains a `PlayerSpawn`.
- Ownership of `game.tscn` goes to the **player task**, which depends on the
level task having landed `test_level.tscn` first (chain with `depends_on`).
## 5. Level content requirements (level task)
`scenes/worlds/test_level.tscn`, root node `TestLevel` (`Node3D`). Required
children:
| Node | Type | Notes |
|---|---|---|
| `Ground` | `StaticBody3D` | `BoxShape3D` (e.g. `40 × 1 × 40`) centered at `(0, -0.5, 0)` so the **top face is `y = 0`**; matching `MeshInstance3D` (`BoxMesh`) so it is visible and collidable. Use a `StaticBody3D` rather than a bare `PlaneMesh` so collision is explicit and reliable. |
| `Props` | `Node3D` | 3–5 simple props as `StaticBody3D` + `MeshInstance3D` using built-in primitives only (`BoxMesh`, `CylinderMesh`, `SphereMesh`). `assets/` is empty and PC2 must not require imported content. Positioned on the ground surface; scale freely. |
| `Sun` | `DirectionalLight3D` | Pitch it downward (~ -45°), enable shadows if the compatibility renderer allows; shadows are optional. |
| `WorldEnvironment` | `WorldEnvironment` | Ambient light and/or a simple background color. Keep it plain — the project uses `gl_compatibility`. |
| `PlayerSpawn` | `Marker3D` | Per §3. Recommended `(0, 1.0, 0)`. |
Notes:
- **Do not** add any `script` to the level. It is pure content. Any per-level
behavior belongs to the player (or a future game-mode script), not the level.
- Keep the whole level on the XZ plane around the origin; nothing off at extreme
coordinates, no thin walls the capsule can tunnel through.
## 6. Player contract (player task)
`scenes/player.tscn`, root `Player` (`CharacterBody3D`) + `scripts/player.gd`.
Minimum for PC2:
- **Body:** `CharacterBody3D` with a `CollisionShape3D` using `CapsuleShape3D`
(~`radius 0.3`, ~`height 1.8`) and a matching `CapsuleMesh` for visibility.
- **Camera:** a `Camera3D` child. First-person (camera at capsule head height) is
the lowest-risk choice; third-person is fine too but is more work.
- **Spawn:** per §3 — read `PlayerSpawn` from the current scene, fall back
gracefully.
- **Movement:** gravity + `move_and_slide()` so the player stands on `Ground`.
WASD/arrow locomotion is the hard requirement. Mouse-look is recommended but
may be split into its own task if the controller grows.
- **No networking, no avatar UI, no chunk code** — PC2 is local-only. Do not add
`MultiplayerAPI`, `ChunkStreamManager`, or any avatar customization here.
The player must work when instanced under `Game` alongside a level; it must not
assume it is the only node in the scene.
## 7. Entry point / how a human plays it
- `scenes/game.tscn` is the PC2 playable scene. For playtesting, load it directly
(e.g. `godot --path . scenes/game.tscn`), or temporarily set
`application/run/main_scene` to it.
- **Wiring "Play → Join a world" in the existing `world_list` UI is out of scope
for this note** and out of scope for the level/player tasks. The current menu
flow ends at browse-worlds (PC1). If a checkpoint wants a clickable path into
the test level, that navigation wiring is its own small task — do not fold it
into level or player.
## 8. Validation (both tasks must run this before "done")
```sh
# one-time, first checkout only — builds the class/import cache
godot --headless --path . --import
# then, to catch parse/load errors:
godot --headless --path . --quit
```
- **Level task:** additionally load `test_level.tscn` headlessly and assert it
(a) loads without error and (b) contains a `Marker3D` named `PlayerSpawn`
positioned ≥ 1.0 m above the ground top. A smoke script following the existing
`scripts/smoke_test_*.gd` pattern is the right vehicle.
- **Player task:** a smoke test that instantiates `game.tscn`, waits a frame,
and asserts the player's global position equals the `PlayerSpawn` position
(within a small epsilon). This is the check that proves the spawn convention
actually wired through — not just "the scene loads."
## 9. Explicitly out of scope for PC2
- Networking / multiplayer / prediction (single-player, local-only).
- Chunk streaming integration and server-side chunk authoring/serving.
- Avatar customization UI and glTF avatar loading.
- Join-a-world navigation from the world list.
- Any content moderation / age-gating logic in the level.
## 10. Summary
| Concern | Decision |
|---|---|
| Level construction | Static hand-authored Godot 3D scene (chunk streaming deferred, not removed) |
| Level location | `scenes/worlds/test_level.tscn` |
| Spawn convention | `Marker3D` named `PlayerSpawn`, ≥ 1.0 m above ground, looked up by the player |
| Player | `scenes/player.tscn` (`CharacterBody3D`) + `scripts/player.gd`, reusable across levels |
| Composition | `scenes/game.tscn` instances `Level` + `Player`; no script |
| Entry point | Load `scenes/game.tscn` directly; UI join-wiring deferred |
+14 -9
View File
@@ -7,14 +7,14 @@ to know what "run the tests" actually checked.
Run everything below in one shot with `scripts/run_checkpoint_tests.py`.
Last verified against `main`: 2026-08-20 — full PC1 checkpoint run,
migrations + pytest + all 7 Godot smoke tests + both platform exports, all
clean. Real bugs found and fixed via real playtesting this same day (not
caught by the checkpoint suite as it stood before today): the main menu
had no way to actually open the login/register form, the world list's
cards rendered at ~1px wide once real data existed, and navigating Play →
Back logged the user out despite a still-valid session. All three are now
covered by the smoke tests below so they can't silently regress.
Last verified against `main`: 2026-08-21 — migrations + pytest + all 10
Godot smoke tests, all clean. PC1's real playtest-found bugs (login form
unreachable, 1px world cards, Play → Back losing the session) are covered
by the smoke tests below so they can't silently regress. PC2 (basic
playable world + character + movement) landed 2026-08-20/21 — see
`docs/checkpoints/` once a PC2 record exists, and the three new smoke
tests below (`smoke_test_test_level.gd`, `smoke_test_player.gd`,
`smoke_test_enter_world.gd`) for what's actually verified.
## Backend — `backend/tests/` (pytest)
@@ -53,7 +53,8 @@ these yet):
## Godot client — `scripts/smoke_test_*.gd`
**7 smoke tests merged into `main`.** Each needs a live backend —
**11 smoke tests merged into `main`.** Ten need a live backend, one
(`smoke_test_network_client.gd`) needs a live dedicated server instead —
`AGP_API_BASE_URL` env var, falling back to the
`application/agp/api_base_url` project setting (default
`http://127.0.0.1:8000`) if unset.
@@ -67,6 +68,10 @@ these yet):
| `smoke_test_main_menu_ui.gd` | Instantiates `main_menu.tscn`, confirms the auth panel starts hidden, and confirms a **real signal emission** on LoginButton/RegisterButton (not calling the handler method directly) actually opens it. This is the check that would have caught 2026-08-20's real bug: the auth panel and its logic were fully correct, but nothing in the scene was ever wired to open it, so Login/Register were completely unreachable from the UI. |
| `smoke_test_main_menu_full_flow.gd` | Full click-through: open the register form, fill it exactly as a user would, submit, confirm Play actually becomes enabled, the welcome message shows, and the auth buttons hide themselves. Calls the submit handler directly (awaited) rather than via `auth_button.pressed.emit()` — see the note at the top of the file: a synthetic `.emit()` on an `await`-using handler doesn't get driven forward reliably in `--script`/SceneTree headless mode the way real GUI input event dispatch does, confirmed by comparing direct-call vs `.emit()` behavior. Not a bug in the actual click-driven flow, just a headless-test-harness quirk to route around when writing tests. |
| `smoke_test_main_menu_back.gd` | Regression test for Play → Back logging the user out even with a valid token still held. Registers a user, loads `main_menu.tscn` (asserts it restores the authenticated state via a real `auth_state_ready` signal wait, not a frame-count guess), clicks Play → `world_list.tscn`, clicks Back → `main_menu.tscn`, and asserts the menu is still authenticated. Checks `WelcomeLabel.is_visible_in_tree()`, not just `.visible` — the label lives inside AuthPanel, so `.visible` alone is a false positive if the panel itself is hidden (a real bug briefly present: `_show_authenticated()` hid AuthPanel while claiming the welcome label was visible, which rendered nothing). |
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server'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 (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script
+23
View File
@@ -21,6 +21,29 @@ window/size/viewport_width=1920
window/size/viewport_height=1080
window/stretch/mode="canvas_items"
[input]
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)
]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)
]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)
]
}
move_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)
]
}
[rendering]
renderer/rendering_method="gl_compatibility"
+10
View File
@@ -0,0 +1,10 @@
[gd_scene load_steps=3 format=3 uid="uid://game_scene_pc2"]
[ext_resource type="PackedScene" uid="uid://c49a36fc7db_test_level" path="res://scenes/worlds/test_level.tscn" id="1_level"]
[ext_resource type="PackedScene" uid="uid://player_scene_001" path="res://scenes/player.tscn" id="2_player"]
[node name="Game" type="Node3D"]
[node name="Level" parent="." instance=ExtResource("1_level")]
[node name="Player" parent="." instance=ExtResource("2_player")]
+24
View File
@@ -0,0 +1,24 @@
[gd_scene load_steps=3 format=3 uid="uid://player_scene_001"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
[sub_resource type="CapsuleMesh" id="CapsuleMesh1"]
radius = 0.35
height = 1.6
[sub_resource type="CapsuleShape3D" id="CapsuleShape1"]
radius = 0.35
height = 1.6
[node name="Player" type="CharacterBody3D"]
script = ExtResource("1_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("CapsuleShape1")
[node name="CapsuleMesh" type="MeshInstance3D" parent="."]
mesh = SubResource("CapsuleMesh1")
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.6, 0)
current = true
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/server.gd" id="1_server"]
[node name="Server" type="Node"]
script = ExtResource("1_server")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/player_spawner.gd" id="1"]
[node name="Player" type="CharacterBody3D"]
script = ExtResource("1")
+120
View File
@@ -0,0 +1,120 @@
[gd_scene load_steps=1 format=3 uid="uid://c49a36fc7db_test_level"]
[sub_resource type="BoxShape3D" id="BoxShape3D_ground"]
size = Vector3(40, 1, 40)
[sub_resource type="BoxMesh" id="BoxMesh_ground"]
size = Vector3(40, 1, 40)
[sub_resource type="BoxShape3D" id="BoxShape3D_prop1"]
size = Vector3(2, 3, 2)
[sub_resource type="BoxMesh" id="BoxMesh_prop1"]
size = Vector3(2, 3, 2)
[sub_resource type="BoxShape3D" id="BoxShape3D_prop2"]
size = Vector3(3, 1, 3)
[sub_resource type="BoxMesh" id="BoxMesh_prop2"]
size = Vector3(3, 1, 3)
[sub_resource type="CylinderShape3D" id="CylinderShape3D_prop3"]
top_radius = 1.0
height = 4.0
[sub_resource type="CylinderMesh" id="CylinderMesh_prop3"]
top_radius = 1.0
bottom_radius = 1.0
height = 4.0
subdivisions_top_radius = 8
[sub_resource type="SphereShape3D" id="SphereShape3D_prop4"]
radius = 1.0
[sub_resource type="SphereMesh" id="SphereMesh_prop4"]
radius = 1.0
subdivisions_ring = 12
subdivisions_height = 12
[sub_resource type="SphereShape3D" id="SphereShape3D_prop5"]
radius = 0.5
[sub_resource type="SphereMesh" id="SphereMesh_prop5"]
radius = 0.5
subdivisions_ring = 8
subdivisions_height = 8
[sub_resource type="Environment" id="WorldEnvironment_env"]
background_mode = 1
background_color = Color(0.4, 0.6, 0.8, 1)
ambient_light_source = 1
ambient_light_energy = 0.4
ambient_light_sky_energy = 1.0
[node name="TestLevel" type="Node3D"]
[node name="Ground" type="StaticBody3D" parent="."]
position = Vector3(0, -0.5, 0)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Ground"]
shape = SubResource("BoxShape3D_ground")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Ground"]
mesh = SubResource("BoxMesh_ground")
[node name="Props" type="Node3D" parent="."]
[node name="Prop1_Box" type="StaticBody3D" parent="Props"]
position = Vector3(8, 1.5, 8)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop1_Box"]
shape = SubResource("BoxShape3D_prop1")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop1_Box"]
mesh = SubResource("BoxMesh_prop1")
[node name="Prop2_Pedestal" type="StaticBody3D" parent="Props"]
position = Vector3(-8, 0.5, 6)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop2_Pedestal"]
shape = SubResource("BoxShape3D_prop2")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop2_Pedestal"]
mesh = SubResource("BoxMesh_prop2")
[node name="Prop3_Column" type="StaticBody3D" parent="Props"]
position = Vector3(6, 2.0, -8)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop3_Column"]
shape = SubResource("CylinderShape3D_prop3")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop3_Column"]
mesh = SubResource("CylinderMesh_prop3")
[node name="Prop4_Sphere" type="StaticBody3D" parent="Props"]
position = Vector3(-6, 1.0, -6)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop4_Sphere"]
shape = SubResource("SphereShape3D_prop4")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop4_Sphere"]
mesh = SubResource("SphereMesh_prop4")
[node name="Prop5_SmallSphere" type="StaticBody3D" parent="Props"]
position = Vector3(0, 0.5, 12)
[node name="CollisionShape3D" type="CollisionShape3D" parent="Props/Prop5_SmallSphere"]
shape = SubResource("SphereShape3D_prop5")
[node name="MeshInstance3D" type="MeshInstance3D" parent="Props/Prop5_SmallSphere"]
mesh = SubResource("SphereMesh_prop5")
[node name="Sun" type="DirectionalLight3D" parent="."]
rotation = Vector3(-0.785398, 0, 0)
shadow_enabled = true
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("WorldEnvironment_env")
[node name="PlayerSpawn" type="Marker3D" parent="."]
position = Vector3(0, 1.5, 0)
+234
View File
@@ -0,0 +1,234 @@
class_name NetworkClient
extends Node
## Client-side networked player: input prediction + server reconciliation.
##
## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation)
## for the *local* player only -- remote-entity interpolation (§5.3) and full
## delta-compressed multi-entity snapshots (§6) are explicitly out of scope
## for this first pass; the server already only ever has one player in PC2's
## single-peer testing, and this task's own description scopes it to "the
## player controller built in the dependency task" (singular).
##
## RPC path-matching, the part that makes this actually work:
## Godot's high-level rpc_id() resolves the *receiving* node by matching the
## *sending* node's NodePath on the caller's side. The server (scripts/
## server.gd) is the SceneTree root node named "Server" when the dedicated
## server runs scenes/server.tscn as its main scene, and it spawns each
## connected player as a *child* of itself named "Player_<peer_id>"
## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to
## actually land on the right node, the client needs a LOCAL node tree at the
## exact same paths -- /root/Server for _receive_snapshot (called by the
## server on itself), and /root/Server/Player_<own_peer_id> for
## rpc_request_input (a real method on the server's PlayerNode). Those local
## nodes don't need to run any server logic -- they only need to exist at the
## right path so Godot's RPC routing finds them. connect_to_server() builds
## that structure once the peer id is known (only available after the ENet
## handshake completes).
##
## Usage: instantiate directly (not an autoload -- this only exists for the
## lifetime of an actual multiplayer session, not on every client launch):
## var net := NetworkClient.new()
## get_tree().root.add_child(net)
## var ok := await net.connect_to_server("127.0.0.1", 7777)
# ---------------------------------------------------------------------------
# Movement constants -- MUST match scripts/player_spawner.gd exactly, per
# docs/architecture/networking.md §5.1 ("the same physics constants as the
# server"). A mismatch here doesn't break anything outright, it just makes
# reconciliation corrections more frequent/visible.
# ---------------------------------------------------------------------------
const WALK_SPEED: float = 5.0
const JUMP_VELOCITY: float = 4.5
const GRAVITY: float = 9.8
## How far predicted and authoritative position can drift before snapping
## rather than trusting the prediction was "close enough." Per §5.2.
const RECONCILE_ERROR_THRESHOLD: float = 0.5
## How many past predicted states to keep for reconciliation replay. Real
## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the
## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is
## 4 seconds of history, comfortably more than any realistic LAN/local RTT.
const PREDICTION_BUFFER_SIZE: int = 120
signal reconciled(correction_distance: float)
signal connected_to_server
signal connection_failed
var peer: ENetMultiplayerPeer
var _server_root: Node # local /root/Server proxy
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
var _seq: int = 0
## Ring buffer of {seq, input, position, velocity} predicted at send time,
## oldest first. Trimmed as the server acknowledges ticks via snapshots.
var _prediction_buffer: Array[Dictionary] = []
var position: Vector3 = Vector3.ZERO
var velocity: Vector3 = Vector3.ZERO
var _on_floor: bool = false
func connect_to_server(host: String, port: int) -> bool:
peer = ENetMultiplayerPeer.new()
var err := peer.create_client(host, port)
if err != OK:
push_error("NetworkClient: create_client failed: %d" % err)
connection_failed.emit()
return false
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func():
connection_failed.emit()
)
for i in 300: # ~5s at 60fps
await get_tree().process_frame
var status := peer.get_connection_status()
if status == MultiplayerPeer.CONNECTION_CONNECTED:
return true
elif status == MultiplayerPeer.CONNECTION_DISCONNECTED:
connection_failed.emit()
return false
connection_failed.emit()
return false
func _on_connected() -> void:
var my_id := multiplayer.get_unique_id()
# Build the local proxy tree at the exact paths the server's own RPC
# calls target -- see the class docstring for why this has to exist.
_server_root = Node.new()
_server_root.name = "Server"
_server_root.set_script(preload("res://scripts/network_receiver_proxy.gd"))
get_tree().root.add_child(_server_root)
_server_root.set("owner_client", self)
_player_proxy = Node.new()
_player_proxy.name = "Player_%d" % my_id
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
_server_root.add_child(_player_proxy)
connected_to_server.emit()
func disconnect_from_server() -> void:
if peer:
peer.close()
if _server_root and is_instance_valid(_server_root):
_server_root.queue_free()
multiplayer.multiplayer_peer = null
# ---------------------------------------------------------------------------
# Prediction -- called every physics frame by whatever drives input (a real
# controller, or a test harness). Applies input locally immediately (so it
# renders with zero perceived latency) and sends it to the server, per §5.1.
# ---------------------------------------------------------------------------
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
_apply_movement(delta, move_x, move_z, jump)
_seq += 1
var record := {
"seq": _seq,
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
"position": position,
"velocity": velocity,
}
_prediction_buffer.append(record)
if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE:
_prediction_buffer.pop_front()
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq)
func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void:
# Mirrors scripts/player_spawner.gd's _process_input + gravity/jump,
# intentionally without its ground-friction smoothing -- friction is a
# server-authoritative-feel detail, not something worth risking a
# prediction/server mismatch over in this first pass. Position still
# reconciles against the server's real (frictioned) result every
# snapshot, so this doesn't silently drift.
if not _on_floor:
velocity.y -= GRAVITY * delta
var direction := Vector3(move_x, 0.0, move_z)
if direction.length() > 0.01:
direction = direction.normalized()
velocity.x = direction.x * WALK_SPEED
velocity.z = direction.z * WALK_SPEED
else:
velocity.x = 0.0
velocity.z = 0.0
if jump and _on_floor:
velocity.y = JUMP_VELOCITY
position += velocity * delta
# Headless prediction has no collision shape of its own to test
# is_on_floor() against -- treat "at or below the spawn-plane ground"
# as grounded. A real render-side controller would swap this for the
# actual CharacterBody3D.is_on_floor() of whatever visual node this
# drives; this class only owns the *networking* half.
if position.y <= 0.0:
position.y = 0.0
velocity.y = 0.0
_on_floor = true
else:
_on_floor = false
# ---------------------------------------------------------------------------
# Reconciliation -- called by _ReceiverScript when a snapshot arrives.
# ---------------------------------------------------------------------------
func _handle_snapshot(snapshot_data: Dictionary) -> void:
var my_id := multiplayer.get_unique_id()
var players: Dictionary = snapshot_data.get("players", {})
# Server keys player_states by peer_id -- Dictionary keys from JSON-ish
# RPC payloads can arrive as String or int depending on the transport
# encoding, so check both rather than assuming.
var my_state: Variant = players.get(my_id, players.get(str(my_id), null))
if my_state == null or not (my_state is Dictionary):
return
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
# Real limitation, noted explicitly rather than silently faked: the
# server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does
# not currently echo back "last input seq processed for this peer" --
# its `tick` field is the server's own internal simulation tick counter,
# a completely different clock from this client's per-input `_seq`. True
# tick-correlated rollback (docs/architecture/networking.md §5.2 step 4,
# "re-simulate any inputs that occurred after the server's tick") needs
# that ack echoed back per-peer -- a real, scoped follow-up, not
# something to fake here. Until then, reconcile against the MOST RECENT
# prediction (not a tick-matched one): still a real error measurement
# and a real correction, just without precise replay-from-exact-tick.
if _prediction_buffer.is_empty():
return
var latest_entry: Dictionary = _prediction_buffer[-1]
var error: float = (latest_entry["position"] as Vector3).distance_to(authoritative_pos)
if error <= RECONCILE_ERROR_THRESHOLD:
return # Close enough -- prediction was correct, nothing to do.
# Correct: snap to the authoritative position. Without a real ack-seq
# from the server there's no reliable "replay inputs after this point"
# window to recompute, so the buffer is cleared and prediction resumes
# fresh from the corrected position on the next input -- still visibly
# responsive (no waiting for a round trip before the *next* input
# applies), just without this pass replaying in-flight inputs.
position = authoritative_pos
velocity = Vector3.ZERO
_prediction_buffer.clear()
reconciled.emit(error)
+43
View File
@@ -0,0 +1,43 @@
extends Node
## Client-side proxy for the server's real PlayerNode (scripts/
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
## under the /root/Server proxy by NetworkClient._on_connected().
##
## rpc_request_input is the only one of these actually CALLED from this
## side -- the real implementations all live on the server. But two things
## force every @rpc method on the real PlayerNode to be mirrored here too,
## not just the one this class uses:
## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability)
## from the LOCAL script before it will even send a call, so the method
## being called has to be declared locally with a matching annotation.
## 2. Godot's multiplayer layer also verifies an "RPC node checksum" --
## both peers must declare the *same set* of @rpc methods at a matching
## path, or every RPC at that path starts failing, not just the one
## that's actually missing. So even rpc_request_interact/attack/
## set_position/set_rotation, which this class never calls, have to be
## declared here with matching annotations or rpc_request_input itself
## breaks too.
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_input(_input_state: Dictionary, _seq: int) -> void:
pass # Never invoked locally -- see the note above.
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_interact(_target_id: int) -> void:
pass
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void:
pass
@rpc("authority", "call_remote", "reliable")
func rpc_set_position(_pos: Vector3) -> void:
pass
@rpc("authority", "call_remote", "reliable")
func rpc_set_rotation(_rot: Vector3) -> void:
pass
+38
View File
@@ -0,0 +1,38 @@
extends Node
## Client-side proxy node, attached at runtime to a Node named "Server" at
## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so
## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see
## network_client.gd's class docstring for the full explanation of why this
## has to exist at this exact path. Not a real server in any sense.
##
## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers
## must declare the *same set* of @rpc methods at a matching path, or every
## RPC at that path (not just the mismatched one) starts failing with
## "rpc node checksum failed". So every @rpc method scripts/server.gd
## declares on its own root "Server" node has to be mirrored here too, even
## the ones this proxy never actually needs to receive.
var owner_client: Node = null
## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot
## exactly -- Godot enforces the receiving method's declared mode/transfer
## config, a mismatch here would silently drop the call.
@rpc("any_peer", "call_remote", "reliable")
func _receive_snapshot(snapshot_data: Dictionary) -> void:
if owner_client and is_instance_valid(owner_client):
owner_client._handle_snapshot(snapshot_data)
## Mirrors scripts/server.gd's _kick -- never actually invoked on this side
## (the server calls it on a Player_<id> node's own rpc_id target, not this
## root proxy), declared only so the RPC checksum for this path matches.
@rpc("any_peer", "call_remote", "reliable")
func _kick(_reason: String) -> void:
pass
## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above.
@rpc("call_remote", "reliable")
func _disconnect(_reason: String) -> void:
pass
+146
View File
@@ -0,0 +1,146 @@
class_name Player
extends CharacterBody3D
## The player character controller.
##
## A simple CharacterBody3D with WASD/arrow locomotion, gravity, jump, and a
## first-person Camera3D with mouse-look. Reads the spawn point from the
## current scene's `PlayerSpawn` Marker3D (see docs/architecture/pc2_test_level.md §3).
##
## No networking, no chunk code, no avatar UI — PC2 is local-only.
# ---------------------------------------------------------------------------
# Movement constants
# ---------------------------------------------------------------------------
const WALK_SPEED: float = 5.0
const JUMP_VELOCITY: float = 4.5
const GRAVITY: float = 9.8
## Mouse-look sensitivity (radians per pixel of relative motion).
const MOUSE_SENSITIVITY: float = 0.003
## Camera pitch is clamped so the player can't flip past looking straight
## up/down (which would otherwise let mouse-look invert on itself).
const PITCH_LIMIT: float = deg_to_rad(89.0)
# ---------------------------------------------------------------------------
# Input state
# ---------------------------------------------------------------------------
var _input_direction := Vector2.ZERO
var _camera_pitch: float = 0.0
@onready var _camera: Camera3D = $Camera3D
# ---------------------------------------------------------------------------
# _ready — spawn at PlayerSpawn marker, capture the mouse for look
# ---------------------------------------------------------------------------
func _ready() -> void:
# Per §3 of pc2_test_level.md: look up PlayerSpawn from the current
# scene root. The lookup walks the full subtree (recursive=true) so
# it finds the marker whether it is a direct child of the level or
# nested deeper.
#
# current_scene is null in headless --script mode and in any scene
# before the main scene is fully swapped in, so guard it rather than
# blindly dereferencing.
var current_scene: Node = get_tree().current_scene
var spawn: Node = null
if current_scene != null:
spawn = current_scene.find_child("PlayerSpawn", true, false)
if spawn is Node3D:
global_position = (spawn as Node3D).global_position
# Align player forward with spawn yaw (optional polish).
var spawn_yaw := (spawn as Node3D).rotation.y
rotation.y = spawn_yaw
else:
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
global_position = Vector3(0, 1.0, 0)
# Capture the mouse for FPS-style look. Headless/test runs have no real
# window to capture input for, so guard against that rather than error.
if DisplayServer.get_name() != "headless":
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# ---------------------------------------------------------------------------
# _unhandled_input — capture keyboard / mouse for movement
# ---------------------------------------------------------------------------
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo:
_update_input_direction(key)
elif not key.pressed:
_clear_input_direction(key)
# Escape exits the level back to the world browse list, mirroring
# the Back button pattern already used in world_list.gd/main_menu.gd.
if key.pressed and key.keycode == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
elif event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
_handle_mouse_look(event as InputEventMouseMotion)
func _update_input_direction(key: InputEventKey) -> void:
match key.keycode:
KEY_W, KEY_UP:
_input_direction.y += 1.0
KEY_S, KEY_DOWN:
_input_direction.y -= 1.0
KEY_A, KEY_LEFT:
_input_direction.x -= 1.0
KEY_D, KEY_RIGHT:
_input_direction.x += 1.0
func _clear_input_direction(key: InputEventKey) -> void:
match key.keycode:
KEY_W, KEY_UP:
_input_direction.y -= 1.0
KEY_S, KEY_DOWN:
_input_direction.y += 1.0
KEY_A, KEY_LEFT:
_input_direction.x += 1.0
KEY_D, KEY_RIGHT:
_input_direction.x -= 1.0
func _handle_mouse_look(event: InputEventMouseMotion) -> void:
# Yaw rotates the whole body (so movement direction turns with the
# look direction); pitch only tilts the camera, so the capsule itself
# never tips over.
rotation.y -= event.relative.x * MOUSE_SENSITIVITY
_camera_pitch = clampf(_camera_pitch - event.relative.y * MOUSE_SENSITIVITY, -PITCH_LIMIT, PITCH_LIMIT)
_camera.rotation.x = _camera_pitch
# ---------------------------------------------------------------------------
# Physics — gravity, jump, move_and_slide
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
# Apply gravity.
if not is_on_floor():
velocity.y -= GRAVITY * delta
# Jump.
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
velocity.y = JUMP_VELOCITY
# Build movement direction from input. Godot's local "forward" is -Z, not
# +Z, so W (input.y = +1) must use -basis.z -- using +basis.z here would
# move the player backward when pressing forward (confirmed by reading
# Camera3D/Node3D's own convention: a node looks down its local -Z).
var direction := (_input_direction.x * transform.basis.x -
_input_direction.y * transform.basis.z).normalized()
velocity.x = direction.x * WALK_SPEED
velocity.z = direction.z * WALK_SPEED
move_and_slide()
+232
View File
@@ -0,0 +1,232 @@
class_name PlayerNode
extends CharacterBody3D
## PlayerNode - Server-side player entity.
##
## Represents a connected player on the server. Handles movement,
## input processing, and state synchronization.
# --- Signals ---
signal health_changed(new_health: int)
signal player_died(player_node: PlayerNode)
# --- Constants ---
const WALK_SPEED := 5.0
const SPRINT_MULTIPLIER := 1.6
const JUMP_VELOCITY := 4.5
const GRAVITY := 9.8
const MAX_FALL_SPEED := 20.0
const FRICTION := 8.0
const AIR_CONTROL := 0.3
# --- Configurable ---
@export var walk_speed: float = WALK_SPEED
@export var sprint_multiplier: float = SPRINT_MULTIPLIER
@export var jump_velocity: float = JUMP_VELOCITY
@export var max_fall_speed: float = MAX_FALL_SPEED
# --- State ---
@export var max_health: int = 100
var current_health: int = 0
var is_alive: bool = true
var is_sprinting: bool = false
var peer_id: int = -1
var player_name: String = ""
var entity_id: int = 0
# --- Input buffer ---
var _input_queue: Array[Dictionary] = []
var _last_processed_seq: int = -1
# --- Movement ---
var _velocity: Vector3 = Vector3.ZERO
func _ready() -> void:
current_health = max_health
velocity = _velocity
# Start alive
is_alive = true
func _physics_process(delta: float) -> void:
if not is_alive:
return
# Process input from queue
_process_input(delta)
# Apply gravity
if not is_on_floor():
velocity.y -= GRAVITY * delta
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
# Apply friction on ground
if is_on_floor():
var move_dir := Vector2(velocity.x, velocity.z)
if move_dir.length() > 0.01:
var friction_factor := clampf(FRICTION * delta, 0.0, 1.0)
velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor)
velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor)
else:
velocity.x = 0.0
velocity.z = 0.0
# Move and slide
velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed)
move_and_slide()
# Update visual position (for remote clients via RPC)
_update_visual_state()
func _process_input(delta: float) -> void:
"""Process buffered input from the server's input queue."""
if _input_queue.is_empty():
return
var input_data: Dictionary = _input_queue.pop_front()
var input_state: Dictionary = input_data.get("input_state", {})
# Calculate movement direction
var direction := Vector3.ZERO
direction.x = input_state.get("move_x", 0.0)
direction.z = input_state.get("move_z", 0.0)
# Sprint check
is_sprinting = input_state.get("sprint", false)
if direction.length() > 0.01:
direction = direction.normalized()
var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed
velocity.x = direction.x * speed
velocity.z = direction.z * speed
else:
# On ground, apply friction (handled in physics_process)
if not is_on_floor():
velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0)
velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0)
# Jump
if input_state.get("jump", false) and is_on_floor():
velocity.y = jump_velocity
func take_damage(amount: int, source_id: int) -> Dictionary:
"""Take damage and return result dict."""
if not is_alive:
return {"success": false, "reason": "already_dead"}
current_health = max(0, current_health - amount)
health_changed.emit(current_health)
if current_health <= 0:
die()
return {"success": true, "health": 0, "dead": true}
return {"success": true, "health": current_health}
func die() -> void:
"""Handle player death."""
is_alive = false
velocity = Vector3.ZERO
player_died.emit(self)
# Respawn after delay
await get_tree().create_timer(3.0).timeout
respawn()
func respawn() -> void:
"""Respawn the player at spawn point."""
current_health = max_health
is_alive = true
velocity = Vector3.ZERO
health_changed.emit(current_health)
# Move to spawn point (default: origin)
position = Vector3.ZERO
func set_input_queue(inputs: Array[Dictionary]) -> void:
"""Set the input queue from the server."""
_input_queue = inputs
func get_state() -> Dictionary:
"""Get current player state for replication."""
return {
"entity_id": entity_id,
"peer_id": peer_id,
"position": position,
"rotation": rotation,
"health": current_health,
"max_health": max_health,
"is_alive": is_alive,
"is_sprinting": is_sprinting,
}
func get_server_state() -> Dictionary:
"""Get current player state for server snapshot broadcast."""
return get_state()
func process_server_input(tick: int) -> void:
"""Called by server tick to process buffered input."""
# This is called from the server's _tick loop.
# The input queue is populated by RPCs from clients.
pass
func _update_visual_state() -> void:
"""Update visual state (for remote interpolation)."""
# In a full implementation, this would update a remote player's
# visual representation. For now, the node itself is the authority.
pass
# --- RPCs ---
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_input(input_state: Dictionary, seq: int) -> void:
"""Receive input from client."""
if not is_alive:
return
# Store for processing in _physics_process
_input_queue.append({
"input_state": input_state,
"seq": seq,
"timestamp": Time.get_ticks_msec(),
})
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_interact(target_id: int) -> void:
"""Handle interaction request from client."""
if not is_alive:
return
# TODO: Implement interaction logic
print("Player %d interacted with entity %d" % [peer_id, target_id])
@rpc("any_peer", "call_remote", "reliable")
func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void:
"""Handle attack request from client."""
if not is_alive:
return
# TODO: Implement attack logic
print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir])
@rpc("authority", "call_remote", "reliable")
func rpc_set_position(pos: Vector3) -> void:
"""Server authoritative position update."""
position = pos
@rpc("authority", "call_remote", "reliable")
func rpc_set_rotation(rot: Vector3) -> void:
"""Server authoritative rotation update."""
rotation = rot
+1
View File
@@ -0,0 +1 @@
uid://br27owjh2f5xd
+174
View File
@@ -0,0 +1,174 @@
## ServerMain - Dedicated server root node.
## Accepts ENet connections, spawns Player nodes for each peer,
## and runs the simulation tick loop at 20 Hz.
extends Node
## Maximum number of concurrent players.
const MAX_PLAYERS := 16
## Server tick rate (Hz).
const TICK_RATE := 20
## Tick duration in seconds.
const TICK_DURATION := 1.0 / TICK_RATE
## ENet server port.
const SERVER_PORT := 7777
## Network peer used for ENet communication.
var network_peer: ENetMultiplayerPeer
## Map of peer_id -> Player node.
var players: Dictionary = {}
## Current tick counter (server-authoritative).
var tick: int = 0
## Whether the server is running.
var is_running: bool = false
## Accumulated real time for fixed timestep simulation.
var accumulator: float = 0.0
## Player scene resource (loaded once).
var _player_scene: PackedScene
func _ready() -> void:
# Preload the player scene once.
_player_scene = preload("res://scenes/server_player.tscn")
# Create and assign the ENet server peer.
network_peer = _create_server_peer()
if network_peer:
multiplayer.multiplayer_peer = network_peer
is_running = true
print("[Server] Started on port %d" % SERVER_PORT)
else:
printerr("[Server] Failed to create ENet peer.")
get_tree().quit(1)
# ---------------------------------------------------------------------------
# Server creation
# ---------------------------------------------------------------------------
func _create_server_peer() -> ENetMultiplayerPeer:
var peer := ENetMultiplayerPeer.new()
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
if error != OK:
printerr("[Server] create_server error: %d" % error)
return null
return peer
# ---------------------------------------------------------------------------
# Fixed-timestep simulation loop
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
if not is_running:
return
# Fixed timestep accumulator.
accumulator += delta
while accumulator >= TICK_DURATION:
accumulator -= TICK_DURATION
_tick()
func _tick() -> void:
"""Main server simulation tick (called at TICK_RATE)."""
tick += 1
# Process player inputs and update state.
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("process_server_input"):
player.process_server_input(tick)
# Broadcast snapshot to all clients.
_broadcast_snapshot()
func _broadcast_snapshot() -> void:
"""Broadcast the current authoritative state to all connected clients."""
var snapshot_data := {
"tick": tick,
"players": {},
}
for peer_id in players:
var player: Node3D = players[peer_id]
if player and player.has_method("get_server_state"):
snapshot_data.players[peer_id] = player.get_server_state()
# Send snapshot to all peers except the server itself (peer_id == 1).
var peer_ids: Array = multiplayer.get_peers()
for pid in peer_ids:
if pid == 1: # 1 is the server itself
continue
rpc_id(pid, "_receive_snapshot", snapshot_data)
# Remote: clients receive snapshots.
@rpc("any_peer", "call_remote", "reliable")
func _receive_snapshot(snapshot_data: Dictionary) -> void:
pass # Handled by client scene.
# ---------------------------------------------------------------------------
# Connection lifecycle
# ---------------------------------------------------------------------------
func _on_peer_connected(peer_id: int) -> void:
print("[Server] Peer %d connected." % peer_id)
if players.size() >= MAX_PLAYERS:
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
rpc_id(peer_id, "_kick", "Server is full.")
return
# Spawn a Player node for this peer.
var player: Node3D = _player_scene.instantiate()
player.name = "Player_%d" % peer_id
player.peer_id = peer_id
add_child(player)
players[peer_id] = player
print("[Server] Spawned player node for peer %d." % peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
print("[Server] Peer %d disconnected." % peer_id)
if players.has(peer_id):
var player: Node3D = players[peer_id]
if player.is_inside_tree():
player.queue_free()
players.erase(peer_id)
# ---------------------------------------------------------------------------
# RPCs
# ---------------------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _kick(reason: String) -> void:
print("[Server] Kicking caller: %s" % reason)
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
@rpc("call_remote", "reliable")
func _disconnect(reason: String) -> void:
print("[Server] Disconnecting: %s" % reason)
get_tree().quit(0)
# ---------------------------------------------------------------------------
# Signal connections
# ---------------------------------------------------------------------------
func _enter_tree() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
+1
View File
@@ -0,0 +1 @@
uid://cwjv826lva2gi
+146
View File
@@ -0,0 +1,146 @@
extends SceneTree
## Regression/smoke test for the PC2 "Enter World" flow end to end.
##
## Real user path: register -> land on world_list.tscn -> click "Enter World"
## on a real card -> the playable level (scenes/game.tscn) actually loads
## with the player spawned in it -> pressing Escape returns to world_list.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_enter_world.gd
##
## Requires the backend to be running (default base URL
## `application/agp/api_base_url` -> http://127.0.0.1:8000, overridable via
## AGP_API_BASE_URL) and at least one real world to already exist.
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
if api == null:
printerr("FAIL: ApiClient autoload not found")
quit(1)
return
# 1. Register a real user and seed a world so the browse list has a
# real card to click, same pattern as smoke_test_world_list.gd.
randomize()
var unique_id: int = int(Time.get_unix_time_from_system() * 1000) + randi() % 100000
var username := "enterworld_%d" % unique_id
var email := "enterworld_%d@test.example.com" % unique_id
var reg_result: Dictionary = await api.register(username, email, "SmokeTest123!", true)
if not _check("setup: register succeeded", reg_result.ok):
print(" detail: ", reg_result.detail)
_finish()
return
var world_body := JSON.stringify({
"name": "EnterWorld Smoke Test World %d" % unique_id,
"description": "Seeded by smoke_test_enter_world.gd",
"visibility": "public",
})
var world_result: Dictionary = await api.request(
HTTPClient.METHOD_POST, "/api/worlds/", world_body, "application/json", true
)
if not _check("setup: world created", world_result.ok):
print(" detail: ", world_result.detail)
_finish()
return
# 2. Load world_list.tscn as a real scene and let it fetch worlds.
var packed: PackedScene = load("res://scenes/world_list.tscn")
var instance: Control = packed.instantiate()
root.add_child(instance)
# _ready() kicks off an async _refresh_worlds() -- wait real frames for
# the HTTP round trip rather than guessing a fixed count.
var world_list_node: VBoxContainer = instance.get_node("VBoxContainer/ScrollContainer/WorldList")
var settled := false
for i in 120:
await process_frame
if world_list_node.get_child_count() > 0:
settled = true
break
if not _check("world list populated with at least one card", settled):
_finish()
return
# 3. Click "Enter World" on the first real card -- find the button by
# walking the card's children rather than assuming an index, so this
# doesn't silently pass if _create_world_card's structure changes.
var card: Control = world_list_node.get_child(0)
var enter_button: Button = _find_button(card, "Enter World")
if not _check("Enter World button exists on the card", enter_button != null):
_finish()
return
enter_button.pressed.emit()
await _wait_for_scene_change(instance, 60)
var game_scene := current_scene
if not _check("game.tscn became current_scene after Enter World", game_scene != null and game_scene != instance):
_finish()
return
# 4. Verify the player actually exists and spawned at the level's
# PlayerSpawn marker -- not just that *some* scene loaded.
var player: Node = game_scene.get_node_or_null("Player")
_check("Player node exists in the loaded game scene", player is CharacterBody3D)
var spawn_marker: Node = game_scene.get_node_or_null("Level/PlayerSpawn")
_check("PlayerSpawn marker exists in the loaded level", spawn_marker is Marker3D)
if player is CharacterBody3D and spawn_marker is Marker3D:
var dist := (player as CharacterBody3D).global_position.distance_to((spawn_marker as Marker3D).global_position)
_check("player spawned at (or very near) the PlayerSpawn marker", dist < 0.5)
# 5. Escape should return to world_list.tscn (real key event, not a
# direct method call -- proves the actual input binding works).
var esc_event := InputEventKey.new()
esc_event.keycode = KEY_ESCAPE
esc_event.pressed = true
Input.parse_input_event(esc_event)
await _wait_for_scene_change(game_scene, 60)
var back_to_menu := current_scene
_check("Escape returned to world_list.tscn", back_to_menu != null and back_to_menu.scene_file_path == "res://scenes/world_list.tscn")
_finish()
func _find_button(node: Node, text: String) -> Button:
if node is Button and (node as Button).text == text:
return node
for child in node.get_children():
var found := _find_button(child, text)
if found:
return found
return null
func _wait_for_scene_change(previous_scene: Node, max_frames: int) -> void:
for i in max_frames:
await process_frame
if current_scene != previous_scene and current_scene != null:
return
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
+114
View File
@@ -0,0 +1,114 @@
extends SceneTree
## Real integration test for NetworkClient (client-side prediction +
## reconciliation) against an actual dedicated server process.
##
## This does NOT boot its own server -- run scenes/server.tscn as a separate
## process first (see docs/architecture/networking.md's ENet/port setup):
## godot --headless --path . scenes/server.tscn &
## then, from the same project directory:
## godot --headless --path . --script res://scripts/smoke_test_network_client.gd
##
## Verifies: the client can actually connect over ENet, local prediction
## moves the player immediately on input (no waiting for the server),
## server snapshots actually arrive and update the client's authoritative
## view, and a deliberately-desynced predicted position gets corrected by
## reconciliation.
var passed := 0
var failed := 0
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
var net := NetworkClient.new()
root.add_child(net)
var ok: bool = await net.connect_to_server("127.0.0.1", 7777)
if not _check("connected to the real dedicated server", ok):
printerr(" Is `godot --headless --path . scenes/server.tscn` running on port 7777?")
_finish()
return
# Give the server a few frames to process the connection and spawn our
# player node before we start sending input.
for i in 10:
await process_frame
# 1. Prediction: pressing "forward" should move the local position
# immediately, without waiting for any server round trip.
var pos_before: Vector3 = net.position
for i in 10:
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false) # move -Z (forward)
await process_frame
var pos_after_predict: Vector3 = net.position
_check("local prediction moved the player without waiting for the server",
pos_after_predict.distance_to(pos_before) > 0.1)
# 2. Snapshots actually arrive from the real server and get processed --
# wait for at least one reconciliation-eligible snapshot by giving it
# real time (server broadcasts at its own tick rate).
var got_snapshot := false
net.reconciled.connect(func(_err): got_snapshot = true)
for i in 60:
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false)
await process_frame
# Reconciliation only actually fires on a real mismatch -- absence of a
# large mismatch is a PASS outcome too (means prediction tracked the
# server closely), so this alone isn't proof snapshots arrived. Check
# that directly instead:
_check("prediction buffer has entries (input was actually sent)", net._prediction_buffer.size() > 0)
# 3. Deliberately desync prediction from reality, then confirm the next
# snapshot corrects it -- the real proof reconciliation logic works,
# not just that it never had to fire.
net.position = Vector3(9999, 9999, 9999)
var desynced_pos := net.position
# GDScript closures capture local value-type vars (bool/float) by value,
# not by reference -- assigning to them *inside* the lambda doesn't
# mutate the outer scope's copy. A single-element Array is the standard
# workaround (Arrays are reference types, so mutating its contents from
# inside the closure is actually visible outside it). Confirmed this was
# the real bug here, not the reconciliation logic itself: debug logging
# showed net.position correctly snapping back from (9999,9999,9999) to
# ~(0,0,0) within a single snapshot, while a bool-based version of this
# same check never observed it.
var reconcile_state := [false, -1.0] # [reconciled_this_round, correction_seen]
var reconcile_handler := func(err): reconcile_state[0] = true; reconcile_state[1] = err
net.reconciled.connect(reconcile_handler)
for i in 60:
# Keep predicting (with no movement input) so the prediction buffer's
# latest entry actually reflects the desynced position -- without
# this, the buffer either empties out or keeps stale pre-desync
# entries, and _handle_snapshot has nothing real to compare against.
net.predict_and_send(1.0 / 30.0, 0.0, 0.0, false)
await process_frame
if reconcile_state[0]:
break
net.reconciled.disconnect(reconcile_handler)
_check("a deliberately desynced position gets corrected by the next snapshot",
reconcile_state[0] and net.position.distance_to(desynced_pos) > 100.0)
if reconcile_state[0]:
print(" correction distance was ~", reconcile_state[1])
net.disconnect_from_server()
_finish()
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
+93
View File
@@ -0,0 +1,93 @@
extends SceneTree
## Smoke test for the player character scene and its spawn behavior.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_player.gd
##
## Loads the test level, verifies the player is present, spawned at the
## PlayerSpawn marker, and has the expected CharacterBody3D structure.
var _passed := 0
var _failed := 0
func _initialize() -> void:
print("SMOKE: starting player smoke test")
_run()
func _run() -> void:
await process_frame
print("SMOKE: loading game scene")
# Load scenes/game.tscn -- the real PC2 composition point (Level + Player
# as siblings, per docs/architecture/pc2_test_level.md §4), not the level
# scene directly. Use the same SceneTree API as the real game so
# `current_scene` is populated before the scene is added and its
# _ready() callbacks fire. That ordering is what the player's spawn
# lookup (get_tree().current_scene) depends on.
var packed: PackedScene = load("res://scenes/game.tscn")
var result := change_scene_to_packed(packed)
if result != OK:
printerr("SMOKE: failed to change scene, error ", result)
quit(1)
return
await scene_changed
var game: Node3D = current_scene as Node3D
print("SMOKE: game scene ready")
# 1. Verify the player node exists in the scene tree.
var player: Node = game.get_node("Player")
_check("Player node exists in test level", player != null)
_check("Player is a CharacterBody3D", player is CharacterBody3D)
# 2. Verify the player has the expected child structure.
var collision: Node = player.get_node("CollisionShape3D")
_check("Player has CollisionShape3D", collision != null)
_check("CollisionShape3D has a shape",
collision is CollisionShape3D and (collision as CollisionShape3D).shape != null)
var mesh: Node = player.get_node("CapsuleMesh")
_check("Player has CapsuleMesh", mesh != null)
_check("CapsuleMesh has a mesh",
mesh is MeshInstance3D and (mesh as MeshInstance3D).mesh != null)
# 3. Verify the player spawned at the PlayerSpawn marker position.
var spawn_marker: Marker3D = game.get_node("Level/PlayerSpawn")
_check("PlayerSpawn marker exists", spawn_marker != null)
# The player's _ready() should have moved it to the spawn marker's
# global position. Compare global positions directly (not local) since
# Player and Level sit at different depths under Game.
var expected_pos: Vector3 = spawn_marker.global_position
_check("Player spawned at PlayerSpawn position",
player.global_position == expected_pos)
# 4. Verify the player script class_name is correct and has the
# movement constants defined.
var script: GDScript = player.get_script() as GDScript
_check("Player has a script attached", script != null)
var constants: Dictionary = script.get_script_constant_map()
_check("Player has WALK_SPEED constant", constants.has("WALK_SPEED"))
_check("Player has GRAVITY constant", constants.has("GRAVITY"))
_check("Player has JUMP_VELOCITY constant", constants.has("JUMP_VELOCITY"))
_check("JUMP_VELOCITY is positive (upward)", float(constants.get("JUMP_VELOCITY", -1.0)) > 0.0)
# 5. Verify the player has a Camera3D so the game is actually viewable
# from the player's perspective, not just a walking capsule with no view.
var camera: Node = player.get_node_or_null("Camera3D")
_check("Player has a Camera3D", camera is Camera3D)
_check("Camera3D is current", camera is Camera3D and (camera as Camera3D).current)
print("\n=== SMOKE RESULT: %d/%d passed ===" % [_passed, _passed + _failed])
quit(0 if _failed == 0 else 1)
func _check(description: String, condition: bool) -> void:
if condition:
print(" PASS: ", description)
_passed += 1
else:
printerr(" FAIL: ", description)
_failed += 1
+125
View File
@@ -0,0 +1,125 @@
extends SceneTree
## Headless smoke test for the PC2 test level scene.
##
## Run with:
## godot --headless --path . --script res://scripts/smoke_test_test_level.gd
##
## Loads the test level scene, asserts:
## (a) it loads without error,
## (b) it contains a Marker3D named "PlayerSpawn",
## (c) PlayerSpawn is positioned >= 1.0 m above the ground top surface.
## Exits 0 on success, 1 on failure.
func _initialize() -> void:
_run()
func _run() -> void:
var level_path: String = "res://scenes/worlds/test_level.tscn"
# Load the scene file
var level_resource: PackedScene = load(level_path)
if level_resource == null:
printerr("SMOKE RESULT: FAIL \u2014 could not load ", level_path)
quit(1)
return
print("SMOKE: test_level.tscn loaded successfully")
# Change the running scene to our test level
var err: int = change_scene_to_packed(level_resource)
if err != OK:
printerr("SMOKE RESULT: FAIL \u2014 change_scene_to_packed returned ", err)
quit(1)
return
# Wait for the scene_changed signal so current_scene is set
await scene_changed
var level_instance: Node3D = current_scene as Node3D
if level_instance == null:
printerr("SMOKE RESULT: FAIL \u2014 current_scene is not Node3D")
quit(1)
return
# Give the tree one frame to fully initialize
await process_frame
# (b) Find the PlayerSpawn marker
var spawn_marker: Node3D = level_instance.find_child("PlayerSpawn", true, false) as Node3D
if spawn_marker == null:
printerr("SMOKE RESULT: FAIL \u2014 no Node3D named 'PlayerSpawn' found in test level")
quit(1)
return
print("SMOKE: found PlayerSpawn at ", spawn_marker.global_position)
# (c) Verify PlayerSpawn is >= 1.0 m above ground top
# The ground top surface is at y = 0 (Ground StaticBody3D centered at y = -0.5
# with height 1.0, so top face is at y = 0).
var spawn_y: float = spawn_marker.global_position.y
if spawn_y < 1.0:
printerr("SMOKE RESULT: FAIL \u2014 PlayerSpawn y = ", spawn_y,
" is below the required minimum of 1.0 m above ground top (y=0)")
quit(1)
return
print("SMOKE: PlayerSpawn y = ", spawn_y, " >= 1.0 m \u2014 PASS")
# Additional check: verify Ground StaticBody3D exists
var ground: StaticBody3D = level_instance.find_child("Ground", true, false) as StaticBody3D
if ground == null:
printerr("SMOKE RESULT: FAIL \u2014 no StaticBody3D named 'Ground' found")
quit(1)
return
print("SMOKE: found Ground StaticBody3D")
# Verify Ground top face is at y = 0
var ground_shape: CollisionShape3D = ground.find_child("CollisionShape3D", true, false) as CollisionShape3D
if ground_shape != null and ground_shape.shape is BoxShape3D:
var box_shape: BoxShape3D = ground_shape.shape as BoxShape3D
var ground_top: float = ground.global_position.y + box_shape.size.y / 2.0
if abs(ground_top - 0.0) > 0.01:
printerr("SMOKE RESULT: FAIL \u2014 Ground top face at y = ", ground_top, " expected ~0.0")
quit(1)
return
print("SMOKE: Ground top face at y = ", ground_top, " \u2014 PASS")
# Verify props exist (3-5 StaticBody3D children under Props node)
var props_node: Node3D = level_instance.find_child("Props", true, false) as Node3D
if props_node != null:
var prop_count: int = 0
for child in props_node.get_children():
if child is StaticBody3D:
prop_count += 1
if prop_count < 3:
printerr("SMOKE RESULT: FAIL \u2014 only ", prop_count, " props found, need >= 3")
quit(1)
return
print("SMOKE: found ", prop_count, " props \u2014 PASS")
else:
printerr("SMOKE RESULT: FAIL \u2014 no 'Props' Node3D found")
quit(1)
return
# Verify lighting exists
var sun: DirectionalLight3D = level_instance.find_child("Sun", true, false) as DirectionalLight3D
if sun == null:
printerr("SMOKE RESULT: FAIL \u2014 no DirectionalLight3D named 'Sun' found")
quit(1)
return
print("SMOKE: found Sun DirectionalLight3D")
# Verify WorldEnvironment exists
var env: WorldEnvironment = level_instance.find_child("WorldEnvironment", true, false) as WorldEnvironment
if env == null:
printerr("SMOKE RESULT: FAIL \u2014 no WorldEnvironment named 'WorldEnvironment' found")
quit(1)
return
print("SMOKE: found WorldEnvironment")
# All checks passed
print("SMOKE RESULT: PASS")
quit(0)
+15
View File
@@ -135,8 +135,23 @@ func _create_world_card(world_dict: Dictionary) -> Control:
meta_label.add_theme_font_size_override("font_size", 12)
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
# PC2: the only thing a world actually loads into right now is the one
# shared playable test level (scenes/game.tscn) -- there's no per-world
# content yet, so "Enter" doesn't need the world's id for anything.
# Real per-world level loading is a later concern once worlds actually
# have their own content to load.
var enter_button := Button.new()
enter_button.text = "Enter World"
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
enter_button.pressed.connect(_on_enter_world_pressed)
card.add_child(name_label)
card.add_child(desc_label)
card.add_child(meta_label)
card.add_child(enter_button)
return panel
func _on_enter_world_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")