PC3 Phase 2.3: networked play path in the game

- Session autoload: carries {world_id, host, port} from the world browser
  to the game scene.
- world_list.gd "Enter World": POST /api/worlds/{id}/join -> Session.begin
  -> load game.tscn. On 503 (backend has no orchestration) falls back to
  local single-player; other errors surface on the card.
- api_client.gd: join_world().
- game.gd (new, on game.tscn root): local single-player by default; when
  Session.active, connects a NetworkClient to the world's dedicated server
  and runs networked -- forwards the local Player's input each physics
  frame, snaps to the server position past a generous drift threshold
  (the Player keeps predicting locally with real collision), renders other
  players from snapshots (labelled capsules, lerped -- avatar appearance
  sync is a follow-up, the server doesn't replicate loadouts yet), and
  returns to the world list on disconnect.
- player.gd: exposes net_input (the input frame it applied) and
  apply_server_correction().
- network_client.gd: send_input() + get_own_server_state() for callers that
  do their own collision-aware prediction.
- scripts/mp_game_flow_client.gd + run_game_multiplayer_test.sh: two real
  clients each register -> join via the API -> game.tscn -> networked;
  A sees B and moves. PASS.

Verified end to end against an orchestration-enabled backend. Non-networked
smokes green (enter_world 9/9, world_list 5/5, player 15/15,
avatar_in_world 10/10, main_menu_full_flow 12/12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
2026-09-05 22:07:06 +00:00
parent 260b7a38da
commit dba865ae4b
10 changed files with 369 additions and 9 deletions
+7
View File
@@ -229,6 +229,13 @@ func get_avatar_loadout() -> Dictionary:
return await request(HTTPClient.METHOD_GET, "/api/avatar/loadout", "", "application/json", true)
## Ask the backend for a dedicated server to host [param world_id] and return
## its address. On success result.data is {"world_id", "host", "port"}.
## 503 if this backend has no game-server orchestration.
func join_world(world_id: int) -> Dictionary:
return await request(HTTPClient.METHOD_POST, "/api/worlds/%d/join" % world_id, "", "application/json", true)
## Save (replace) the current user's avatar loadout (requires auth).
## @param loadout A dict in the AvatarLoadout.to_json() shape
## ({"base_body": String, "slots": {...}, "skin_tone"?: {...}}).
+141
View File
@@ -0,0 +1,141 @@
extends Node3D
## Root controller for scenes/game.tscn.
##
## Local single-player by default (walk around the test level). When
## Session.active (set by world_list.gd after POST /api/worlds/{id}/join),
## connects a NetworkClient to the world's dedicated server and runs
## networked: the local Player keeps predicting with its own collision-aware
## movement, its input is forwarded to the server each physics frame, the
## server's authoritative position is a correction backstop, and other
## players are rendered from snapshots.
##
## Appearance sync is a follow-up -- remote players show as labelled capsules,
## not their own avatars (the server doesn't replicate loadouts yet).
const REMOTE_LERP := 12.0
## Snap the local player to the server position past this drift (metres).
## Generous -- only catches real desync / teleport / a wall the client walked
## through, not normal prediction noise.
const CORRECTION_THRESHOLD := 1.5
@onready var _player: Player = $Player
var _net: NetworkClient = null
var _networked := false
var _remotes: Dictionary = {} # peer_id -> Node3D
var _status_label: Label = null
func _ready() -> void:
if Session.active:
_setup_networked()
func _setup_networked() -> void:
_show_status("Connecting to %s…" % Session.host)
_net = NetworkClient.new()
_net.name = "NetworkClient"
add_child(_net)
_net.server_disconnected.connect(_on_server_lost)
_net.connection_failed.connect(_on_server_lost)
var ok: bool = await _net.connect_to_server(Session.host, Session.port)
if not ok:
_on_server_lost()
return
_networked = true
_hide_status()
# Physics-process AFTER the player so net_input reflects this frame.
process_physics_priority = 10
func _physics_process(_delta: float) -> void:
if not _networked or _net == null or not _net.is_connected_to_server():
return
# 1. Forward the local player's input to the server.
var inp: Dictionary = _player.net_input
_net.send_input(inp["move_x"], inp["move_z"], inp["jump"], inp["look_yaw"])
# 2. Server-authoritative correction backstop.
var own: Dictionary = _net.get_own_server_state()
if own.has("position"):
var server_pos: Vector3 = own["position"]
if _player.global_position.distance_to(server_pos) > CORRECTION_THRESHOLD:
_player.apply_server_correction(server_pos)
# 3. Remote players.
_sync_remotes(_net.get_remote_states(), _delta)
func _sync_remotes(states: Dictionary, delta: float) -> void:
for pid in states:
var st: Dictionary = states[pid]
var node: Node3D = _remotes.get(pid)
if node == null:
node = _make_remote(pid)
_remotes[pid] = node
add_child(node)
node.global_position = st.get("position", Vector3.ZERO)
var target: Vector3 = st.get("position", node.global_position)
node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0))
node.rotation.y = st.get("rotation_y", node.rotation.y)
for pid in _remotes.keys():
if not states.has(pid):
_remotes[pid].queue_free()
_remotes.erase(pid)
func _make_remote(pid: int) -> Node3D:
var root := Node3D.new()
root.name = "Remote_%d" % pid
var body := MeshInstance3D.new()
var capsule := CapsuleMesh.new()
capsule.radius = 0.35
capsule.height = 1.7
body.mesh = capsule
body.position = Vector3(0, 0.85, 0)
var mat := StandardMaterial3D.new()
mat.albedo_color = Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.6, 0.9)
body.material_override = mat
root.add_child(body)
var tag := Label3D.new()
tag.text = "Player %d" % pid
tag.position = Vector3(0, 2.0, 0)
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
tag.no_depth_test = true
tag.pixel_size = 0.004
root.add_child(tag)
return root
func _on_server_lost() -> void:
_networked = false
_show_status("Disconnected from the server. Returning to worlds…")
await get_tree().create_timer(1.5).timeout
Session.clear()
get_tree().change_scene_to_file("res://scenes/world_list.tscn")
# ─── tiny on-screen status ────────────────────────────────────────────────────
func _show_status(text: String) -> void:
if _status_label == null:
var layer := CanvasLayer.new()
layer.name = "StatusLayer"
add_child(layer)
_status_label = Label.new()
_status_label.position = Vector2(16, 16)
_status_label.add_theme_font_size_override("font_size", 18)
layer.add_child(_status_label)
_status_label.text = text
_status_label.visible = true
func _hide_status() -> void:
if _status_label:
_status_label.visible = false
+77
View File
@@ -0,0 +1,77 @@
extends SceneTree
## One client process for the full networked game-flow test
## (scripts/run_game_multiplayer_test.sh): register -> POST /api/worlds/{id}/join
## -> Session.begin -> load scenes/game.tscn -> run networked.
##
## godot --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
## --role a|b --world 1
##
## role b: joins, holds still, prints RESULT b in_world and stays ~40 s.
## role a: joins, moves forward, prints RESULT a remotes=<n> moved=<bool>;
## exits 0 iff it saw a remote player node AND moved.
var role := "b"
var world_id := 1
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
for i in args.size():
if args[i] == "--role" and i + 1 < args.size():
role = args[i + 1]
elif args[i] == "--world" and i + 1 < args.size():
world_id = int(args[i + 1])
_run()
func _run() -> void:
await process_frame
var api := root.get_node("ApiClient")
var session := root.get_node("Session")
var uname := "mpflow_%s_%d" % [role, int(Time.get_unix_time_from_system() * 1000) % 1000000]
var reg: Dictionary = await api.register(uname, uname + "@e.com", "secret123", true)
if not reg.ok:
print("RESULT %s register_failed %s" % [role, reg.detail])
quit(1); return
var join: Dictionary = await api.join_world(world_id)
if not (join.ok and join.data is Dictionary):
print("RESULT %s join_failed status=%d %s" % [role, join.status, join.detail])
quit(1); return
var d: Dictionary = join.data
session.begin(world_id, str(d["host"]), int(d["port"]))
change_scene_to_file("res://scenes/game.tscn")
# Wait for the scene + NetworkClient connect (game.gd awaits internally).
for i in 180:
await process_frame
var game := current_scene
var netc = game.get_node_or_null("NetworkClient") if game else null
if netc == null or not netc.is_connected_to_server():
print("RESULT %s not_connected" % role)
quit(1); return
if role == "b":
print("RESULT b in_world")
for i in 1200:
await process_frame
quit(0); return
# role a: move and look for a remote node.
var player = game.get_node("Player")
var start: Vector3 = player.global_position
var max_remotes := 0
for i in 120:
# Drive the player forward by faking held input.
player._input_direction = Vector2(0, 1)
await process_frame
var n := 0
for c in game.get_children():
if c.name.begins_with("Remote_"):
n += 1
max_remotes = maxi(max_remotes, n)
var moved: bool = player.global_position.distance_to(start) > 1.0
print("RESULT a remotes=%d moved=%s" % [max_remotes, str(moved)])
quit(0 if (moved and max_remotes >= 1) else 1)
+19
View File
@@ -56,6 +56,8 @@ var _on_floor: bool = true
## peer_id -> latest authoritative state dict from the most recent snapshot,
## for every peer that isn't us. The game reads this to render other players.
var _remote_states: Dictionary = {}
## Our own latest authoritative state from the most recent snapshot.
var _own_state: Dictionary = {}
var _last_snapshot_tick: int = -1
@@ -188,6 +190,7 @@ func _handle_snapshot(snap: Dictionary) -> void:
var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null))
if not (mine is Dictionary):
return
_own_state = mine
var auth_pos: Vector3 = mine.get("position", position)
var auth_vel: Vector3 = mine.get("velocity", velocity)
var acked_seq: int = int(mine.get("last_seq", 0))
@@ -238,3 +241,19 @@ func _handle_snapshot(snap: Dictionary) -> void:
## recent snapshot. Read this each frame to spawn/update remote avatars.
func get_remote_states() -> Dictionary:
return _remote_states
## Our own latest authoritative state from the most recent snapshot, or {}.
func get_own_server_state() -> Dictionary:
return _own_state
## Send an input frame to the server WITHOUT running NetworkClient's own
## flat-plane prediction -- for callers that predict locally with their own
## collision-aware CharacterBody3D (scripts/game.gd) and only want the server
## as an authority/correction backstop.
func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> void:
_seq += 1
if _player_proxy and is_instance_valid(_player_proxy):
_player_proxy.rpc_id(1, "rpc_request_input",
{"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq)
+22
View File
@@ -154,14 +154,23 @@ func _handle_mouse_look(event: InputEventMouseMotion) -> void:
# Physics — gravity, jump, move_and_slide
# ---------------------------------------------------------------------------
## The input frame this player applied last physics tick, in the shape the
## network layer sends: world-space move direction (already yaw-transformed and
## normalized), jump edge, body yaw. scripts/game.gd reads this each frame in
## networked mode and forwards it to the server.
var net_input: Dictionary = {"move_x": 0.0, "move_z": 0.0, "jump": false, "look_yaw": 0.0}
func _physics_process(delta: float) -> void:
# Apply gravity.
if not is_on_floor():
velocity.y -= GRAVITY * delta
# Jump.
var jumped := false
if is_on_floor() and Input.is_action_just_pressed("ui_accept"):
velocity.y = JUMP_VELOCITY
jumped = true
# 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
@@ -173,3 +182,16 @@ func _physics_process(delta: float) -> void:
velocity.z = direction.z * WALK_SPEED
move_and_slide()
net_input = {
"move_x": direction.x, "move_z": direction.z,
"jump": jumped, "look_yaw": rotation.y,
}
## Server-authoritative correction: called by scripts/game.gd when the local
## predicted position has drifted too far from the server's.
func apply_server_correction(pos: Vector3) -> void:
global_position = pos
velocity.x = 0.0
velocity.z = 0.0
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Full networked game flow, two real client processes:
# register -> POST /api/worlds/{id}/join -> Session -> game.tscn -> networked.
# Client A must see client B rendered as a remote player and its own movement
# must round-trip. Requires a backend with GAME_SERVERS_ENABLED and the Godot
# binary reachable from it.
#
# Usage: scripts/run_game_multiplayer_test.sh <backend_base_url> <world_id>
set -uo pipefail
cd "$(dirname "$0")/.."
BASE_URL="${1:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
WORLD_ID="${2:?usage: run_game_multiplayer_test.sh <backend_url> <world_id>}"
GODOT_BIN="${GODOT_BIN:-godot}"
command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot"
export AGP_API_BASE_URL="$BASE_URL"
cleanup() { kill "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; }
trap cleanup EXIT
echo "=== client B: join world $WORLD_ID and hold ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
--role b --world "$WORLD_ID" >/tmp/mpflow_b.log 2>&1 &
B_PID=$!
for _ in $(seq 1 40); do grep -q 'RESULT b in_world' /tmp/mpflow_b.log && break; sleep 1; done
grep -q 'RESULT b in_world' /tmp/mpflow_b.log || { echo "B never entered the world:"; tail -5 /tmp/mpflow_b.log; echo "=== SMOKE RESULT: FAIL ==="; exit 1; }
echo "=== client A: join world $WORLD_ID, move, look for B ==="
"$GODOT_BIN" --headless --path . --script res://scripts/mp_game_flow_client.gd -- \
--role a --world "$WORLD_ID" 2>&1 | tee /tmp/mpflow_a.log
A_LINE=$(grep '^RESULT a' /tmp/mpflow_a.log || true)
echo
echo "A: ${A_LINE:-<none>}"
echo "B: $(grep '^RESULT b' /tmp/mpflow_b.log || echo '<none>')"
if echo "$A_LINE" | grep -q 'moved=true' && echo "$A_LINE" | grep -qE 'remotes=[1-9]'; then
echo "=== SMOKE RESULT: PASS ==="
exit 0
fi
echo "=== SMOKE RESULT: FAIL ==="
exit 1
+27
View File
@@ -0,0 +1,27 @@
extends Node
## Autoload "Session" -- carries the "which world am I joining" handoff from
## the world browser to the game scene.
##
## world_list.gd calls begin() with the address returned by
## POST /api/worlds/{id}/join; game.gd reads it in _ready() to decide whether
## to run networked or fall back to local single-player (e.g. game.tscn opened
## directly, or the backend has no orchestration).
var world_id: int = 0
var host: String = ""
var port: int = 0
var active: bool = false
func begin(p_world_id: int, p_host: String, p_port: int) -> void:
world_id = p_world_id
host = p_host
port = p_port
active = true
func clear() -> void:
world_id = 0
host = ""
port = 0
active = false
+29 -8
View File
@@ -135,15 +135,16 @@ 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.
# "Enter World" asks the backend to host this world (POST /join) and then
# loads scenes/game.tscn networked. If the backend has no game-server
# orchestration (a plain dev instance), it falls back to local
# single-player. All worlds still load the same test level -- per-world
# content is a later milestone.
var world_id := int(world_dict.get("id", 0))
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)
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
card.add_child(name_label)
card.add_child(desc_label)
@@ -153,5 +154,25 @@ func _create_world_card(world_dict: Dictionary) -> Control:
return panel
func _on_enter_world_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true
button.text = "Joining…"
var result := await ApiClient.join_world(world_id)
if result.ok and result.data is Dictionary:
var d: Dictionary = result.data
Session.begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if result.status == 503:
# This backend can't host game servers -- go in local single-player.
Session.clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
button.disabled = false
button.text = "Enter World"
error_label.text = "Couldn't join: %s" % result.detail
error_label.visible = true