Merge pull request #13 from jcreek/multiplayer-phase1-transport

Add multiplayer functionality
This commit is contained in:
Josh Creek
2026-08-21 20:50:24 +01:00
committed by GitHub
181 changed files with 14699 additions and 325 deletions
@@ -0,0 +1,14 @@
name: Dedicated Server Smoke Test
on:
push:
pull_request:
jobs:
dedicated-server-smoke:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- name: Build and verify exported dedicated server
run: make verify-phase6
+16
View File
@@ -0,0 +1,16 @@
name: ENet Integration Tests
on:
push:
pull_request:
jobs:
enet-integration:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Build the pinned Godot test image
run: docker build --target enet-test -t cosmic-clash-enet-tests .
- name: Run multi-process ENet smoke tests
run: docker run --rm cosmic-clash-enet-tests bash scripts/verify_enet_integration.sh
+9
View File
@@ -17,5 +17,14 @@ training/checkpoints/*/ppo_*_steps.zip
# export_linux.sh / run_training.sh), not a training result.
training/build/
# Exported dedicated server binary (task 6.1): same reasoning — an 85MB
# regenerable artifact, rebuilt by `godot --headless --path Game
# --export-release "Linux Dedicated Server"`.
server/build/
# Steam exports and local App ID configuration are developer-machine inputs.
steam/build/
steam_appid.txt
# Texture generator scripts: throwaway env, not the scripts themselves.
tools/textures/.venv/
+5 -2
View File
@@ -53,11 +53,14 @@ Upstream ships telemetry, and there are **two independent switches** — turning
## Commands
There is no build step, linter, or automated test suite for the GDScript project itself — Godot projects run directly from source.
There is no build step or linter for the GDScript project itself — Godot projects run directly from source.
- **Open the project**: open `Game/` as a project in the Godot 4.7 editor, or run `godot --path Game` from the repo root.
- **Run the game**: press Play in the editor, or `godot --path Game res://scenes/main_menu.tscn`.
- **Headless smoke test** (RL/CI precondition — the game must run without rendering): `godot --headless --path Game res://scenes/free_play.tscn`.
- **Unit tests** (pure-function assertions, see `multiplayer-todo.md` task 1.0): `godot --headless --path Game res://tests/test_runner.tscn`. Exits 0/1. Add a test by dropping a `*.gd` file under `Game/tests/cases/` that extends `res://tests/test_case.gd` (path-based `extends`, not the bare `class_name` — see that file for why) with any number of `test_*()` methods; the runner discovers it, no registration needed.
- **Networking smoke tests** (real two-process ENet connect/disconnect, see `multiplayer-todo.md` §7 Phase 1 tasks): each starts a host then a client, each in its own `godot --headless` process, printing `SMOKE PASS/FAIL: ...` and exiting 0/1. Not part of `test_runner.tscn` — a live ENet handshake needs two real processes. `res://tests/net_smoke.tscn` (task 1.2 — `--role=host|client`, now also confirms the host observes `client_disconnected`, not just that each side exits cleanly on its own), `res://tests/match_net_smoke.tscn` (task 1.4 — `--role=host|client|client-badversion|client-longname|host_recycle`; `client-longname` sends an oversized player name and expects rejection, `host_recycle` hosts, lets a client join, leaves, re-hosts, and confirms the roster is actually empty — run a plain `client` role against it), `res://tests/clock_smoke.tscn` (task 1.8 — `--role=host|client`, clock convergence cross-checked against independent OS-wall-clock ground truth, not just self-consistency), `res://tests/lobby_smoke.tscn` (task 1.5 — `--role=host|client`; **both** roles load `lobby.tscn` for real via `change_scene_to_file` now, exercising the server's read-only view as well as the client's interactive one). See `network_manager.gd`'s header comment and `multiplayer-todo.md` §9 gotchas 2530 for the non-obvious Godot/ENet failure modes these caught (`OfflineMultiplayerPeer` sentinel, premature peer teardown, `change_scene_to_file` off the real `current_scene`, unbounded `connection_failed`, the `is_client`-before-actually-connected race, `load()` not returning null on a broken script).
- **`main_menu.tscn`'s Host/Join flow** (task 1.7) is verified the same way but needs a temporary autoload since it's the real main scene, not a wrapper: add `MainMenuTestHooks="*res://tests/main_menu_test_hooks.gd"` to `project.godot [autoload]`, run `godot --headless --path Game res://scenes/main_menu.tscn -- --role=<host|join_ok|join_refused|join_cancel>` (host first, sleep ~1s, then the join role), then remove the autoload line again — it must never ship registered.
- The `mcp/godot-mcp` submodule is a separate Node/TypeScript project with its own `npm install` / `npm run build` (see above) — it is tooling, not part of the game itself.
## Architecture
@@ -66,7 +69,7 @@ The structure was deliberately chosen so an RL-trained AI opponent and, later, m
- **Scene flow**: `scenes/main_menu.tscn` (`main_menu.gd`, one handler per mode) → `scenes/free_play.tscn` (practice: no timer, R resets ball) or `scenes/match.tscn` (150s timer, per-team score, kickoff resets). Esc returns to the menu from either mode.
- **Controller seam (do not bypass)**: `Ship` (`scripts/ship.gd`, `RigidBody3D`) never reads `Input`. Each physics tick, `_integrate_forces` pulls one `ShipAction` (`scripts/ship_action.gd`: thrust `Vector3`, rotation `Vector3`, turbo `bool`, each axis -1..1) from its `ShipController` child (`scripts/ship_controller.gd`, base returns a zero action). `PlayerShipController` reads input actions; a future `AIShipController` (RL policy) or network-replication controller implements the same `get_action()` interface. A ship with no controller is inert but simulated. The ShipAction shape *is* the future RL action space — change it deliberately.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (inner x ±12, z ±18, height 12, goal lines z ±17) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
- **Arena vs game mode**: `scenes/arena_01.tscn` (`scripts/arena.gd`, group `"arena"`) is a stateless stadium — a setting (space-platform floor, starfield sky, lighting), an enclosing `Boundary` (instance of `objects/arena_boundary.tscn`: floor/walls/ceiling colliders), two `Goal` instances (team 0 and 1), `BallSpawn` and `SpawnsTeam0/1` Marker3Ds — queried via `get_ball_spawn()`/`get_ship_spawns(team)`/`get_goals()`. All arenas are a standard size: they instance the shared `arena_boundary.tscn`, and `scripts/arena_boundary.gd` (`ArenaBoundary`) holds the canonical play-volume constants (`INNER_HALF_X` 18, `INNER_HALF_Z` 27, `INNER_HEIGHT` 18, `GOAL_LINE_Z` = `INNER_HALF_Z`) that field-size logic must derive from instead of restating numbers. Game modes extend `GameMode` (`scripts/game_mode.gd`, group `"game"`): the mode's scene contains an Arena + HUD, and the mode spawns ball/ships/controllers/camera **in code** (`spawn_ship(team, index, controller)` etc.) so ship counts and controller mixes stay flexible. `free_play.gd` and `match_mode.gd` override `_start()` and `_on_goal_scored(conceding_team)`.
- **Goals are dumb sensors**: `scripts/goal.gd` (`Area3D`, group `"goal"`, `@export team`) only emits `goal_scored(team)` when a body in group `"ball"` enters; `GameMode` debounces it (`_handle_goal_scored`) and modes decide consequences. Never put scoring/reset logic in the goal.
- **Ship physics**: all movement is force/torque-based (`_integrate_forces`), not kinematic — inputs become world-space forces/torques relative to ship orientation, with manual drag and speed clamps per tick. Physics formulas are commented inline; see `FLIGHT_MANUAL.md` for the player-facing flight model. Physics properties (mass, inertia, friction material) live in `objects/ship.tscn`, not in `_ready` overrides — keep the scene truthful; RL tuning depends on it.
- **Surface pull (wall/ceiling grav-plating)**: `ArenaBoundary.get_surface_pull()` is a wall+ceiling-only proximity force field (the floor stays plain default gravity) that `Ship` and `Ball` (`scripts/ball.gd`) each apply in their own `_integrate_forces` with independently-tuned strength/range, discovered via the `"arena_boundary"` group — enabling wall-rides and ceiling shots with no collision-shape changes. Because it runs inside `Ship`'s shared `_integrate_forces`, it reaches trained bots too; see `TRAINING.md` for the retrain this warrants.
+43
View File
@@ -0,0 +1,43 @@
# Local-only dedicated-server build and verification image. Pin the Godot
# release family used by project.godot; no image is pushed by this repository.
FROM --platform=linux/amd64 barichello/godot-ci:4.7.1 AS project-imported
WORKDIR /workspace
RUN apt-get update \
&& apt-get install -y --no-install-recommends libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
COPY Game /workspace/Game
# `--import` starts the editor, waits for resource import to finish, then
# exits. Do not combine it with `--quit`, which ends the editor after one
# iteration and can interrupt generation of `.godot/imported` resources.
RUN godot --headless --path Game --import \
&& test -f Game/.godot/imported/nebula_station.glb-fa9a6dd87ae3789d04205b52215e2e76.scn \
&& test -f Game/.godot/imported/nebula_debris.glb-39af77a17c0998b5b73172577d906cb9.scn \
&& test -f Game/.godot/imported/nebula_planet.glb-2431590907ff85cf1057e7d6ad614ed7.scn
# Test the source client from an untouched, fully imported project. The
# dedicated-server export below rewrites the main scene and must not be used
# to run client integration tests.
FROM project-imported AS enet-test
COPY scripts/verify_enet_integration.sh /workspace/scripts/verify_enet_integration.sh
# Godot dedicated exports disallow command-line scene overrides. Bake the
# server scene into this export (the interactive project's source stays
# unchanged), then generate the global-script/autoload metadata it needs.
FROM project-imported AS exporter
RUN sed -i 's|^run/main_scene=.*$|run/main_scene="res://scenes/server_boot.tscn"|' Game/project.godot \
&& mkdir -p /opt/cosmic-clash \
&& godot --headless --path Game --export-release "Linux Dedicated Server" /opt/cosmic-clash/CosmicClashServer.x86_64
FROM --platform=linux/amd64 ubuntu:24.04 AS server
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 libgl1 libstdc++6 && rm -rf /var/lib/apt/lists/*
COPY --from=exporter /opt/cosmic-clash/ /opt/cosmic-clash/
COPY deploy/cosmic-clash-server /opt/cosmic-clash/cosmic-clash-server
RUN chmod 0755 /opt/cosmic-clash/cosmic-clash-server
WORKDIR /opt/cosmic-clash
EXPOSE 7777/udp
ENTRYPOINT ["/opt/cosmic-clash/cosmic-clash-server"]
# Test-only target: runs the source client harness against the exported server.
FROM exporter AS smoke-client
WORKDIR /workspace
ENTRYPOINT ["godot", "--headless", "--path", "Game", "res://tests/export_server_smoke.tscn", "--"]
+3
View File
@@ -0,0 +1,3 @@
# Blender authoring sources live here, but the game consumes the exported
# runtime assets under res://assets/models. Keep Godot's project scanner from
# requiring Blender when importing or testing in headless environments.
@@ -1,63 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ckohaa5ebxym2"
path="res://.godot/imported/ball.blend-22aebbee9e0a3f479241b5a042aee325.scn"
[deps]
source_file="res://assets/blender_models/ball.blend"
dest_files=["res://.godot/imported/ball.blend-22aebbee9e0a3f479241b5a042aee325.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/vertex_colors=1
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=2
gltf/texture_map_mode=1
@@ -1,63 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://d2mrvrt1h305x"
path="res://.godot/imported/nebula_decoration.blend-2f6fe244eedad258eac5faa9b1684142.scn"
[deps]
source_file="res://assets/blender_models/nebula_decoration.blend"
dest_files=["res://.godot/imported/nebula_decoration.blend-2f6fe244eedad258eac5faa9b1684142.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/vertex_colors=1
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=2
gltf/texture_map_mode=1
@@ -1,63 +0,0 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bp2eqsu8o3082"
path="res://.godot/imported/ship.blend-1bdca1ba6b72cf6be2f2eb32002cf7c8.scn"
[deps]
source_file="res://assets/blender_models/ship.blend"
dest_files=["res://.godot/imported/ship.blend-1bdca1ba6b72cf6be2f2eb32002cf7c8.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
blender/nodes/visible=0
blender/nodes/active_collection_only=false
blender/nodes/punctual_lights=true
blender/nodes/cameras=true
blender/nodes/custom_properties=true
blender/nodes/modifiers=1
blender/meshes/vertex_colors=1
blender/meshes/uvs=true
blender/meshes/normals=true
blender/meshes/export_geometry_nodes_instances=false
blender/meshes/gpu_instances=false
blender/meshes/tangents=true
blender/meshes/skins=2
blender/meshes/export_bones_deforming_mesh_only=false
blender/materials/unpack_enabled=true
blender/materials/export_materials=1
blender/animation/limit_playback=true
blender/animation/always_sample=true
blender/animation/group_tracks=true
gltf/naming_version=2
gltf/texture_map_mode=1
+87
View File
@@ -26,3 +26,90 @@ texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
[preset.1]
name="Linux Dedicated Server"
platform="Linux"
runnable=true
dedicated_server=true
custom_features="dedicated_server"
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../server/build/CosmicClashServer.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_encryption_key=""
[preset.1.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_script=1
binary_format/embed_pck=true
texture_format/bptc=false
texture_format/s3tc=false
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
[preset.2]
name="Linux Steam Client"
platform="Linux"
runnable=true
dedicated_server=false
custom_features="steam"
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../steam/build/CosmicClashSteam.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_encryption_key=""
[preset.2.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_script=1
binary_format/embed_pck=true
texture_format/bptc=true
texture_format/s3tc=true
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
[preset.3]
name="Linux Steam Dedicated Server"
platform="Linux"
runnable=true
dedicated_server=true
custom_features="dedicated_server,steam"
export_filter="all_resources"
include_filter=""
exclude_filter=""
export_path="../steam/build/CosmicClashSteamServer.x86_64"
encryption_include_filters=""
encryption_exclude_filters=""
encrypt_pck=false
encrypt_directory=false
script_encryption_key=""
[preset.3.options]
custom_template/debug=""
custom_template/release=""
debug/export_console_script=1
binary_format/embed_pck=true
texture_format/bptc=false
texture_format/s3tc=false
texture_format/etc=false
texture_format/etc2=false
binary_format/architecture="x86_64"
+4 -1
View File
@@ -15,6 +15,7 @@ collision_mask = 13
mass = 3
physics_material_override = SubResource("PhysicsMaterial_ball")
continuous_cd = true
can_sleep = false
inertia = Vector3(3, 3, 3)
gravity_scale = 0.8
linear_damp = 0.1
@@ -25,6 +26,8 @@ metadata/_edit_group_ = true
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
shape = SubResource("SphereShape3D_c5p07")
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
[node name="Visual" type="Node3D" parent="."]
[node name="MeshInstance3D" type="MeshInstance3D" parent="Visual"]
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0)
mesh = ExtResource("1_ball")
+6 -2
View File
@@ -17,13 +17,17 @@ collision_mask = 7
mass = 5.0
physics_material_override = SubResource("PhysicsMaterial_ship")
inertia = Vector3(7, 1, 7)
can_sleep = false
continuous_cd = true
script = ExtResource("1_efag7")
[node name="Nose" type="MeshInstance3D" parent="."]
[node name="Visual" type="Node3D" parent="."]
[node name="Nose" type="MeshInstance3D" parent="Visual"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
mesh = ExtResource("3_nose")
[node name="TailFin" type="MeshInstance3D" parent="."]
[node name="TailFin" type="MeshInstance3D" parent="Visual"]
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0.24, 0.72)
mesh = ExtResource("5_talfin")
+57
View File
@@ -21,19 +21,61 @@ run/main_scene="uid://bcq14356s3e2i"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
run/main_scene.training="res://scenes/training.tscn"
# Dedicated exports select the server boot scene before the interactive menu
# is loaded. This is the same project-setting feature override used above by
# the training export.
run/main_scene.dedicated_server="res://scenes/server_boot.tscn"
[autoload]
GameSettings="*res://scripts/game_settings.gd"
VideoSettings="*res://scripts/video_settings.gd"
BackgroundFPS="*res://scripts/background_fps.gd"
PerfOverlay="*res://scripts/perf_overlay.gd"
NetSim="*res://scripts/net_sim.gd"
NetworkManager="*res://scripts/network_manager.gd"
MatchNet="*res://scripts/match_net.gd"
MatchSim="*res://scripts/match_sim.gd"
NetDebugOverlay="*res://scripts/net_debug_overlay.gd"
[display]
window/size/viewport_width=1920
window/size/viewport_height=1080
window/size/mode=2
; Task 0.17c: kept fixed at "viewport" + 1080p rather than moved to
; "disabled", deliberately. A player on a 1440p/4K display cannot render
; native this way, and a 1080p player cannot render lower than 1080p through
; window scaling alone — but task 0.17b's Viewport.scaling_3d_scale already
; covers "render lower than the window" independently of stretch mode (it
; scales the 3D viewport's own internal resolution before this blit, not the
; window itself), and task 0.15b found an unexplained ~6% non-uniform width
; scaling on this project's one tested (Mac/Retina) machine — see
; multiplayer-todo.md §5.5.1 — that needs understanding before stretch mode
; is touched, not blindly carried into a resolution-dependent change.
window/stretch/mode="viewport"
window/stretch/aspect="expand"
; Task 0.17: default matches VideoSettings.gd's VsyncMode.ADAPTIVE default —
; VideoSettings.apply_vsync() overwrites this at runtime via DisplayServer as
; soon as the autoload initializes, so this is only what's in effect for the
; brief pre-autoload window and if VideoSettings ever fails to load.
window/vsync/vsync_mode=2
[editor_plugins]
@@ -119,6 +161,17 @@ roll_right={
]
}
toggle_perf_overlay={
"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":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
toggle_net_overlay={
"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":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[layer_names]
3d_physics/layer_1/name="Ships"
@@ -130,9 +183,13 @@ roll_right={
3d/physics_engine="Jolt Physics"
common/physics_interpolation=true
common/physics_jitter_fix=0.0
[rendering]
anti_aliasing/quality/msaa_3d=2
anti_aliasing/quality/screen_space_aa=1
anti_aliasing/quality/use_debanding=true
lights_and_shadows/positional_shadow/atlas_size=2048
lights_and_shadows/directional_shadow/size=2048
lights_and_shadows/soft_shadow_filter_quality=2
+112
View File
@@ -0,0 +1,112 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/lobby.gd" id="1_lobby"]
[node name="Lobby" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_lobby")
[node name="CenterContainer" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
custom_minimum_size = Vector2(520, 0)
layout_mode = 2
theme_override_constants/separation = 10
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 40
text = "Lobby"
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 1, 1, 0.65)
layout_mode = 2
theme_override_font_sizes/font_size = 14
text = "Connecting..."
horizontal_alignment = 1
autowrap_mode = 2
[node name="TeamsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="TeamsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Team0Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="Team0Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Team 1"
[node name="Team0List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team0Panel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
[node name="TeamsVSeparator" type="VSeparator" parent="CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
[node name="Team1Panel" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 4
[node name="Team1Header" type="Label" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Team 2"
[node name="Team1List" type="VBoxContainer" parent="CenterContainer/VBoxContainer/TeamsRow/Team1Panel"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 2
[node name="ControlsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="ControlsRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 10
[node name="SwitchTeamButton" type="Button" parent="CenterContainer/VBoxContainer/ControlsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Switch Team"
[node name="ReadyButton" type="CheckButton" parent="CenterContainer/VBoxContainer/ControlsRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
size_flags_horizontal = 3
text = "Ready"
[node name="LeaveButton" type="Button" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Leave"
[connection signal="pressed" from="CenterContainer/VBoxContainer/ControlsRow/SwitchTeamButton" to="." method="_on_switch_team_pressed"]
[connection signal="toggled" from="CenterContainer/VBoxContainer/ControlsRow/ReadyButton" to="." method="_on_ready_toggled"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/LeaveButton" to="." method="_on_leave_pressed"]
+96
View File
@@ -100,6 +100,51 @@ custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Play Match"
[node name="MultiplayerSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
[node name="MultiplayerHeader" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Multiplayer"
[node name="MultiplayerHint" type="Label" parent="CenterContainer/VBoxContainer"]
modulate = Color(1, 1, 1, 0.55)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = "LAN / direct IP — host a match or join one"
[node name="HostButton" type="Button" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Host"
[node name="JoinRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="JoinAddressEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/JoinRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
text = "127.0.0.1"
placeholder_text = "IP address"
[node name="JoinButton" type="Button" parent="CenterContainer/VBoxContainer/JoinRow"]
custom_minimum_size = Vector2(96, 40)
layout_mode = 2
text = "Join"
[node name="MultiplayerErrorLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
modulate = Color(1, 0.5, 0.5, 1)
layout_mode = 2
theme_override_font_sizes/font_size = 13
text = ""
autowrap_mode = 2
visible = false
[node name="SettingsSeparator" type="HSeparator" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
@@ -180,7 +225,58 @@ custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Watch Match"
[node name="ConnectingOverlay" type="Control" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 1
[node name="Backdrop" type="ColorRect" parent="ConnectingOverlay"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0, 0, 0, 0.7)
[node name="CenterContainer" type="CenterContainer" parent="ConnectingOverlay"]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="VBoxContainer" type="VBoxContainer" parent="ConnectingOverlay/CenterContainer"]
custom_minimum_size = Vector2(360, 0)
layout_mode = 2
theme_override_constants/separation = 14
[node name="ConnectingStatusLabel" type="Label" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 18
text = "Connecting..."
horizontal_alignment = 1
autowrap_mode = 2
[node name="ConnectingCancelButton" type="Button" parent="ConnectingOverlay/CenterContainer/VBoxContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 48)
layout_mode = 2
text = "Cancel"
[connection signal="pressed" from="CenterContainer/VBoxContainer/FreePlayButton" to="." method="_on_free_play_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/MatchButton" to="." method="_on_match_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/HostButton" to="." method="_on_host_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/JoinRow/JoinButton" to="." method="_on_join_pressed"]
[connection signal="text_submitted" from="CenterContainer/VBoxContainer/JoinRow/JoinAddressEdit" to="." method="_on_join_address_submitted"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/SettingsButton" to="." method="_on_settings_pressed"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/DevSection/SpectateButton" to="." method="_on_spectate_pressed"]
[connection signal="pressed" from="ConnectingOverlay/CenterContainer/VBoxContainer/ConnectingCancelButton" to="." method="_on_connecting_cancel_pressed"]
-1
View File
@@ -7,5 +7,4 @@
script = ExtResource("1_m")
bot_model_path = "res://bots/promoted/easy.json"
[node name="HUD" parent="." instance=ExtResource("3_m")]
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/networked_match.gd" id="1_nm"]
[node name="NetworkedMatch" type="Node3D"]
script = ExtResource("1_nm")
+6
View File
@@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/server_boot.gd" id="1_sb"]
[node name="ServerBoot" type="Node"]
script = ExtResource("1_sb")
+91
View File
@@ -34,6 +34,21 @@ horizontal_alignment = 1
custom_minimum_size = Vector2(0, 14)
layout_mode = 2
[node name="PresetRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="PresetLabel" type="Label" parent="CenterContainer/VBoxContainer/PresetRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Graphics preset"
[node name="PresetDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/PresetRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="AARow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
@@ -49,6 +64,33 @@ custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="ResolutionRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="ResolutionLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Resolution scale"
[node name="ResolutionSlider" type="HSlider" parent="CenterContainer/VBoxContainer/ResolutionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 24)
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 4
min_value = 0.5
max_value = 1.0
step = 0.05
value = 1.0
[node name="ResolutionValueLabel" type="Label" parent="CenterContainer/VBoxContainer/ResolutionRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(48, 0)
layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="GlowRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
@@ -103,6 +145,51 @@ layout_mode = 2
text = "100%"
horizontal_alignment = 2
[node name="VsyncRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="VsyncLabel" type="Label" parent="CenterContainer/VBoxContainer/VsyncRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "VSync"
[node name="VsyncDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/VsyncRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="FpsCapRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="FpsCapLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsCapRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "FPS cap"
[node name="FpsCapDropdown" type="OptionButton" parent="CenterContainer/VBoxContainer/FpsCapRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
size_flags_horizontal = 3
[node name="FpsReadoutRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="FpsReadoutTitleLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
text = "Current"
[node name="FpsReadoutLabel" type="Label" parent="CenterContainer/VBoxContainer/FpsReadoutRow"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "0 fps"
[node name="ButtonSpacer" type="Control" parent="CenterContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 14)
layout_mode = 2
@@ -112,7 +199,11 @@ custom_minimum_size = Vector2(0, 56)
layout_mode = 2
text = "Back"
[connection signal="item_selected" from="CenterContainer/VBoxContainer/PresetRow/PresetDropdown" to="." method="_on_preset_dropdown_item_selected"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/AARow/AADropdown" to="." method="_on_aa_dropdown_item_selected"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/ResolutionRow/ResolutionSlider" to="." method="_on_resolution_slider_value_changed"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/GlowRow/GlowSlider" to="." method="_on_glow_slider_value_changed"]
[connection signal="value_changed" from="CenterContainer/VBoxContainer/BrightnessRow/BrightnessSlider" to="." method="_on_brightness_slider_value_changed"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/VsyncRow/VsyncDropdown" to="." method="_on_vsync_dropdown_item_selected"]
[connection signal="item_selected" from="CenterContainer/VBoxContainer/FpsCapRow/FpsCapDropdown" to="." method="_on_fps_cap_dropdown_item_selected"]
[connection signal="pressed" from="CenterContainer/VBoxContainer/BackButton" to="." method="_on_back_pressed"]
+33
View File
@@ -30,6 +30,11 @@ class_name HUDController
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
var ship: Node
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
# the local peer has no ship of its own. Distinct from `ship == null` by
# accident: a missing ship is still an error for a player, and silently
# degrading to a spectator HUD would hide that.
var spectator_mode := false
var _last_score := {0: 0, 1: 0}
var _goal_tween: Tween
@@ -43,6 +48,16 @@ func _initialize_hud():
# this runs — not discovered via group, since the "ship" group can have
# 2+ members and there's no reliable way to tell which one is "ours".
if not ship:
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
# must still get the score, clock and goal celebration. Only the
# per-ship instrument cluster is meaningless without one, so hide that
# and carry on wiring everything else — this used to push_error and
# bail, which left a spectator with a completely dead HUD.
if spectator_mode:
print("HUDController: spectator mode — hiding ship instruments")
_hide_ship_instruments()
_connect_mode_signals()
return
push_error("HUDController: No ship assigned")
return
@@ -59,6 +74,24 @@ func _initialize_hud():
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
_connect_mode_signals()
func _hide_ship_instruments() -> void:
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
# meaning without a ship. Everything else on the HUD still does.
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
if node and is_instance_valid(node):
node.visible = false
var cluster := get_node_or_null("Control/Instruments/Cluster")
if cluster and is_instance_valid(cluster):
cluster.visible = false
# Everything that depends on the MODE rather than on owning a ship: clock,
# score, team identity, match-ended, kickoff countdown. A spectator gets all
# of it.
func _connect_mode_signals() -> void:
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game")
@@ -0,0 +1,37 @@
class_name AdaptiveInputDepthController
extends RefCounted
# Client-only policy for choosing whether the server's input jitter buffer may
# run at depth zero. It deliberately does not change server buffering, action
# encoding, or bot behavior; NetworkedMatch pins --test-bot clients at depth 1.
const TARGET_DEPTH_SAFE := 1
const TARGET_DEPTH_LOW_LATENCY := 0
const CLEAN_JITTER_MS := 3.0
const EXIT_JITTER_MS := 5.0
const REQUIRED_STABLE_TICKS := 240
const REENTRY_COOLDOWN_TICKS := 120
var target_depth := TARGET_DEPTH_SAFE
var stable_low_jitter_ticks := 0
var cooldown_ticks := 0
func update(rtt_ms: float, jitter_ms: float, advertised_depth: int) -> int:
if cooldown_ticks > 0:
cooldown_ticks -= 1
# -2 is a genuine server starvation sentinel. -1 means no header yet and
# must not be mistaken for starvation.
if advertised_depth < -1 or jitter_ms > EXIT_JITTER_MS:
target_depth = TARGET_DEPTH_SAFE
stable_low_jitter_ticks = 0
cooldown_ticks = REENTRY_COOLDOWN_TICKS
return target_depth
if rtt_ms >= 0.0 and jitter_ms < CLEAN_JITTER_MS:
stable_low_jitter_ticks += 1
if stable_low_jitter_ticks >= REQUIRED_STABLE_TICKS and cooldown_ticks == 0:
target_depth = TARGET_DEPTH_LOW_LATENCY
else:
stable_low_jitter_ticks = 0
target_depth = TARGET_DEPTH_SAFE
return target_depth
@@ -0,0 +1 @@
uid://dofgtukllr7yr
+19
View File
@@ -1,6 +1,13 @@
class_name AIShipController
extends ShipController
# Emitted if a cached teammate/opponent reference is found freed and dropped
# from the roster (see _decide). No despawn path exists anywhere in this
# codebase today — rosters are fixed at match start — so this never fires in
# practice; it's cheap insurance against ShipObservations.build() crashing on
# a stale reference if that ever changes.
signal roster_changed
# Drives a ship from a trained self-play policy (see TRAINING.md). Builds the
# same canonical observation as training (ShipObservations) and runs the
# policy MLP in GDScript (PolicyNetwork) — the shipped bot has no Python,
@@ -50,6 +57,13 @@ var _scene_refs_ready := false
func _ready():
if not model_path.is_empty():
load_policy(model_path)
# Stagger the first decision across [1, reaction_ticks] so bots sharing a
# reaction cadence don't all run policy inference on the same physics
# tick — six bots landing together is a ~2.4 ms spike in a 16.7 ms budget
# (policy_network.gd's forward pass). The phase offset this establishes
# persists across subsequent decisions since each one re-arms the same
# period from wherever _ticks_until_decision currently sits.
_ticks_until_decision = randi_range(1, maxi(reaction_ticks, 1))
# League training swaps a frozen opponent's policy between episodes without
@@ -83,6 +97,11 @@ func get_action() -> ShipAction:
func _decide() -> void:
if _teammates.any(func(s): return not is_instance_valid(s)) \
or _opponents.any(func(s): return not is_instance_valid(s)):
_teammates = _teammates.filter(is_instance_valid)
_opponents = _opponents.filter(is_instance_valid)
roster_changed.emit()
var obs := ShipObservations.build(_ship, _teammates, _opponents, _ball, _attack_goal_position)
var out := _policy.forward(obs)
# See ShipActionCodec for the decode — the single source of truth shared
+48 -12
View File
@@ -19,23 +19,59 @@ extends Node3D
@export var glow_hdr_threshold := 1.0
var _env: Environment
# Lights authored with shadow_enabled = true (the DirectionalLight3D + 4
# PitchLights omnis) — captured once, before gating ever touches them. Every
# call after the first re-applies VideoSettings.shadows_enabled to exactly
# these lights, so the set can't self-poison (if it were re-derived from
# current state, a light this same code just turned off would look
# indistinguishable from FillLight, which is authored off on purpose and must
# never be turned on by the preset ladder).
var _shadow_capable_lights: Array[Light3D] = []
func _ready():
add_to_group("arena")
# A headless server never renders, so duplicating and configuring a full
# Environment (glow/SSAO/SSIL/SDFGI) for it is pure waste — mirrors the
# same guard at ship.gd and arena_boundary.gd.
if DisplayServer.get_name() == "headless":
return
var world_env := get_node_or_null("WorldEnvironment") as WorldEnvironment
if world_env and world_env.environment:
var env := world_env.environment.duplicate(true) as Environment
world_env.environment = env
_env = world_env.environment.duplicate(true) as Environment
world_env.environment = _env
if sky_material:
if not env.sky:
env.sky = Sky.new()
env.sky.sky_material = sky_material
env.ambient_light_color = ambient_light_color
env.ambient_light_energy = ambient_light_energy
env.glow_intensity = glow_intensity
env.glow_strength = glow_strength
env.glow_bloom = glow_bloom
env.glow_hdr_threshold = glow_hdr_threshold
VideoSettings.apply_to_environment(env)
if not _env.sky:
_env.sky = Sky.new()
_env.sky.sky_material = sky_material
_env.ambient_light_color = ambient_light_color
_env.ambient_light_energy = ambient_light_energy
_env.glow_intensity = glow_intensity
_env.glow_strength = glow_strength
_env.glow_bloom = glow_bloom
_env.glow_hdr_threshold = glow_hdr_threshold
for light in find_children("*", "Light3D", true, false):
if (light as Light3D).shadow_enabled:
_shadow_capable_lights.append(light)
_apply_video_settings()
# Task 0.17: a preset change from the settings menu must take effect
# on the arena that's already loaded, not just the next one — this is
# the "settings persist and apply without a restart" acceptance bar.
VideoSettings.settings_changed.connect(_apply_video_settings)
# Re-run on every VideoSettings.settings_changed (preset or individual
# toggle) as well as once at load. Shadow gating lives here rather than in
# VideoSettings.apply_to_environment() because it targets Light3D nodes in
# this arena's own tree, not the Environment resource.
func _apply_video_settings() -> void:
if not is_instance_valid(_env):
return
VideoSettings.apply_to_environment(_env)
for light in _shadow_capable_lights:
if is_instance_valid(light):
light.shadow_enabled = VideoSettings.shadows_enabled
func get_ball_spawn() -> Transform3D:
+17 -1
View File
@@ -117,6 +117,10 @@ var _field_material: ShaderMaterial
# Cached active camera for _process(), mirroring ship_camera.gd's _get_ball()
# pattern so the viewport lookup isn't repeated every frame.
var _camera: Camera3D
var _last_camera_local_pos := Vector3.INF
# Below this, the shader's per-pixel facing test can't produce a visibly
# different result — skip the to_local()/set_shader_parameter() call.
const CAMERA_UNIFORM_UPDATE_THRESHOLD := 0.05
# Group every generated collider is tagged with. A CollisionShape3D only
# registers a shape with a CollisionObject3D that is its DIRECT parent — an
@@ -184,6 +188,14 @@ func get_surface_pull(
global_pos: Vector3, wall_strength: float, wall_range: float,
ceiling_strength: float, ceiling_range: float
) -> Vector3:
# Early-out: every dynamic body pays to_local() plus five _falloff calls
# every tick even mid-arena, where every term is exactly zero. Compared
# directly against global_pos, matching the same identity-transform
# assumption GameMode._is_escaped already makes against these constants.
if absf(global_pos.x) < INNER_HALF_X - wall_range \
and absf(global_pos.z) < INNER_HALF_Z - wall_range \
and global_pos.y < INNER_HEIGHT - ceiling_range:
return Vector3.ZERO
var p := to_local(global_pos)
var pull := Vector3.ZERO
pull += Vector3(1, 0, 0) * _falloff(INNER_HALF_X - p.x, wall_range) * wall_strength
@@ -214,7 +226,11 @@ func _process(_delta: float) -> void:
var camera := _get_camera()
if camera == null:
return # headless (RL/CI) has no camera
_field_material.set_shader_parameter("camera_local_pos", to_local(camera.global_position))
var local_pos := to_local(camera.global_position)
if local_pos.distance_to(_last_camera_local_pos) < CAMERA_UNIFORM_UPDATE_THRESHOLD:
return
_last_camera_local_pos = local_pos
_field_material.set_shader_parameter("camera_local_pos", local_pos)
# Caches the viewport's active camera; a plain is_instance_valid revalidation
+23
View File
@@ -24,3 +24,26 @@ const ARENAS := [
static func random_path() -> String:
var candidates := ARENAS.filter(func(arena): return arena["random"])
return candidates[randi() % candidates.size()]["path"]
# The arenas a server may rotate through, in declaration order. Same filter as
# random_path(): an elevated-goal variant is Free-Play-only until a checkpoint
# trained on it is promoted, and a dedicated server rotating onto one would
# hand every bot-filled slot an arena it cannot score in.
static func rotation_paths() -> Array:
return ARENAS.filter(func(arena): return arena["random"]).map(func(arena): return arena["path"])
# Task 6.5's arena rotation, as pure arithmetic so it is unit-testable without
# a server: given how many matches have already been played, which arena is
# next. `random` deliberately still uses the global RNG (the caller wants
# variety, not reproducibility); `sequential` is a pure function of the count,
# which is what makes "the server cycles arenas" an assertable claim rather
# than an observation about luck.
static func path_for_match(match_index: int, mode: String) -> String:
var paths := rotation_paths()
if paths.is_empty():
return ARENAS[0]["path"]
if mode == "random":
return paths[randi() % paths.size()]
return paths[posmod(match_index, paths.size())]
+22
View File
@@ -0,0 +1,22 @@
extends Node
# Autoload: drops Engine.max_fps while the window is unfocused, so an idle
# background window doesn't keep rendering at whatever uncapped rate the
# hardware can hit. Independent of — and complementary to — the per-menu
# refresh-rate cap in main_menu.gd/settings_menu.gd, which only covers menu
# screens; this covers every scene, including gameplay.
const BACKGROUND_FPS := 30
# 0 means "uncapped"; also what we restore to if focus is lost before any
# menu/gameplay scene has had a chance to set its own cap.
var _foreground_max_fps := 0
func _notification(what: int) -> void:
match what:
NOTIFICATION_APPLICATION_FOCUS_OUT:
_foreground_max_fps = Engine.max_fps
Engine.max_fps = BACKGROUND_FPS
NOTIFICATION_APPLICATION_FOCUS_IN:
Engine.max_fps = _foreground_max_fps
+1
View File
@@ -0,0 +1 @@
uid://kkge43vtwhyv
+73 -1
View File
@@ -18,6 +18,63 @@ const MAX_SPEED := 32.0
var _boundary: ArenaBoundary
var _trail: GPUParticles3D
@onready var visual: Node3D = $Visual
var _pending_teleport: Transform3D
var _has_pending_teleport := false
var _pending_teleport_linear_velocity := Vector3.ZERO
var _pending_teleport_angular_velocity := Vector3.ZERO
var _pending_teleport_has_velocity := false
# Queues an authoritative teleport, applied at the top of the next
# _integrate_forces — the only Jolt-safe place to write state.transform
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
# physics step via set_deferred("global_transform", ...).
func queue_teleport(to: Transform3D) -> void:
_pending_teleport = to
_has_pending_teleport = true
_pending_teleport_has_velocity = false
# Kept parallel to Ship's network correction hook. A locally predicted ball
# must resume from the authoritative velocity after a correction; gameplay
# resets still deliberately use queue_teleport() and zero both velocities.
# The queued-but-not-yet-applied teleport target, or null when none is
# pending. queue_teleport() defers the actual write to the next
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
# in between — anything that needs to broadcast where a body is ABOUT to be
# (networked_match.gd's kickoff) must read this instead, or it ships the
# pre-reset position and corrects it a tick later.
func get_pending_teleport():
return _pending_teleport if _has_pending_teleport else null
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
_pending_teleport = to
_pending_teleport_linear_velocity = new_linear_velocity
_pending_teleport_angular_velocity = new_angular_velocity
_pending_teleport_has_velocity = true
_has_pending_teleport = true
# -1 = use the real linear_velocity (default; see _physics_process below).
# A frozen remote ball (Phase 4) holds zero velocity — Godot/Jolt zeroes and
# ignores velocity writes on frozen bodies — so the trail needs a
# presentation-only speed fed in from outside instead of reading physics
# state that will never reflect the ball's true remote motion.
var _visual_speed_override: float = -1.0
# Prediction correction hook: exactly like Ship's visual offset, but kept
# here so a locally predicted ball can move its collider to authority while
# the mesh catches up over a short presentation-only decay.
var net_visual_offset := Vector3.ZERO
const NET_VISUAL_OFFSET_DECAY := 0.88
const MAX_NET_VISUAL_OFFSET := 0.4
func set_visual_speed(speed: float) -> void:
_visual_speed_override = speed
func _ready() -> void:
@@ -31,8 +88,15 @@ func _ready() -> void:
func _physics_process(_delta: float) -> void:
if net_visual_offset != Vector3.ZERO:
net_visual_offset = net_visual_offset.limit_length(MAX_NET_VISUAL_OFFSET)
net_visual_offset *= pow(NET_VISUAL_OFFSET_DECAY, _delta * 60.0)
if net_visual_offset.length_squared() < 0.0001:
net_visual_offset = Vector3.ZERO
visual.position = net_visual_offset
if _trail:
var speed_ratio := clampf(linear_velocity.length() / MAX_SPEED, 0.0, 1.0)
var speed := _visual_speed_override if _visual_speed_override >= 0.0 else linear_velocity.length()
var speed_ratio := clampf(speed / MAX_SPEED, 0.0, 1.0)
_trail.emitting = speed_ratio > 0.12
_trail.amount_ratio = smoothstep(0.12, 1.0, speed_ratio)
@@ -68,6 +132,14 @@ func _build_trail() -> void:
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
if _has_pending_teleport:
_has_pending_teleport = false
state.transform = _pending_teleport
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
_pending_teleport_has_velocity = false
reset_physics_interpolation()
if _boundary:
var pull := _boundary.get_surface_pull(
global_position, wall_pull_strength, wall_pull_range,
+21
View File
@@ -0,0 +1,21 @@
class_name EnetTransport
extends NetTransport
func transport_id() -> String:
return "enet"
func is_available() -> bool:
return true
func create_server(port: int, max_clients: int) -> Dictionary:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
func create_client(address: String, port: int) -> Dictionary:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
+96 -72
View File
@@ -17,16 +17,10 @@ var hud: HUDController
var ball: RigidBody3D
var ships: Array[Ship] = []
var _ship_spawn_transforms := {}
var _hit_stop_generation := 0
var _hit_stop_active := false
var _time_scale_before_hit_stop := 1.0
var _camera_rig: ShipCameraRig
var _goal_slowmo_active := false
var _time_scale_before_goal := 1.0
var _goal_in_progress := false
const GOAL_CELEBRATION_SECONDS := 1.6
const GOAL_SLOWMO_SCALE := 0.22
# Shared by modes that keep score (Match, Spectate); Free Play never
# references this or emits a score_changed signal, and HUDController relies
@@ -38,6 +32,18 @@ var score := {0: 0, 1: 0}
func _ready():
# Group lets the HUD discover the game mode for timer/score signals
add_to_group("game")
# At the default 8, a client hitching to ~20 fps runs up to 8 physics
# ticks in one rendered frame — and each of those ticks costs roughly as
# much as the frame that caused the hitch, so the client can spiral
# further behind instead of recovering. 4 trades a lower worst-case
# catch-up rate for bounded per-frame cost.
Engine.max_physics_steps_per_frame = 4
# A fresh RandomNumberGenerator defaults to a fixed internal state (unlike
# the global randf_range, which Godot auto-randomizes at startup), so an
# explicit randomize() is required unless a seed was set for reproducible
# kickoffs (see kickoff_rng_seed above).
if kickoff_rng_seed == 0:
_kickoff_rng.randomize()
for child in get_children():
if child is Arena:
arena = child
@@ -51,8 +57,9 @@ func _ready():
if not arena:
push_error("GameMode: scene has no Arena child")
return
for goal in arena.get_goals():
goal.goal_scored.connect(_handle_goal_scored)
if _owns_goal_logic():
for goal in arena.get_goals():
goal.goal_scored.connect(_handle_goal_scored)
_start()
@@ -68,6 +75,31 @@ func _start() -> void:
pass
# Virtual: whether this mode decides goals from its own local Goal sensors.
# True for every mode today. A future networked client mode overrides this
# false — it must learn a goal happened from an authoritative server message,
# not from an interpolated remote ball wandering through its local Goal
# Area3D, which would score client-side against no one.
func _owns_goal_logic() -> bool:
return true
# Virtual: whether this mode simulates and enforces its own world (escaped-
# body respawn runs locally in _physics_process). True for every mode today.
# A future networked client mode overrides this false — the server is
# authoritative for body positions, and a client respawning a body itself
# would fight that authority.
func _owns_world_simulation() -> bool:
return true
# Virtual: how long the goal cinematic holds before resuming play. Subclasses
# that want a different cadence override this instead of touching
# GOAL_CELEBRATION_SECONDS directly.
func _goal_pause_seconds() -> float:
return GOAL_CELEBRATION_SECONDS
# Virtual: the ball entered the goal owned (conceded) by `_conceding_team`.
func _on_goal_scored(_conceding_team: int) -> void:
pass
@@ -88,7 +120,14 @@ func _handle_goal_scored(conceding_team: int) -> void:
_goal_in_progress = true
_on_goal_registered(conceding_team)
await _play_goal_celebration(1 - conceding_team, conceding_team)
# A scene change (Esc, match end) queued during the celebration removes
# this node from the tree before the await chain finishes; resuming past
# that point would touch arena/hud state that is mid-teardown.
if not is_inside_tree():
return
await _on_goal_scored(conceding_team)
if not is_inside_tree():
return
_goal_in_progress = false
@@ -97,34 +136,23 @@ func _play_goal_celebration(scoring_team: int, conceding_team: int) -> void:
# real-time presentation delay between episodes.
if DisplayServer.get_name() == "headless" or not is_instance_valid(_camera_rig):
return
_restore_hit_stop()
_goal_slowmo_active = true
_time_scale_before_goal = Engine.time_scale
Engine.time_scale = minf(Engine.time_scale, GOAL_SLOWMO_SCALE)
var goal_position := Vector3.ZERO
for goal in arena.get_goals():
if goal.team == conceding_team:
goal_position = goal.global_position
break
# The cinematic camera cut itself (hard FOV change, cut to a fixed angle)
# carries the "moment" that Engine.time_scale slow-mo used to sell —
# world simulation speed is never touched, so this behaves identically
# for a future networked client watching a shared server sim.
_camera_rig.begin_goal_cut(goal_position)
if hud:
hud.show_goal_celebration(scoring_team)
await get_tree().create_timer(GOAL_CELEBRATION_SECONDS, true, false, true).timeout
await get_tree().create_timer(_goal_pause_seconds(), true, false, true).timeout
if is_instance_valid(_camera_rig):
_camera_rig.end_goal_cut()
if hud and is_instance_valid(hud):
hud.hide_goal_celebration()
# Defensive unwind: impact feedback is suppressed while goal slow-mo owns
# time scale, but restore any hit-stop that was already queued this frame.
_restore_hit_stop()
_restore_goal_slowmo()
func _restore_goal_slowmo() -> void:
if not _goal_slowmo_active:
return
Engine.time_scale = _time_scale_before_goal
_goal_slowmo_active = false
func spawn_ball() -> RigidBody3D:
@@ -136,7 +164,7 @@ func spawn_ball() -> RigidBody3D:
func spawn_ship(team: int, spawn_index: int = 0, controller: ShipController = null) -> Ship:
var ship: Ship = ship_scene.instantiate()
ship.name = "ShipTeam%d_%d" % [team, ships.size()]
ship.name = "Ship_T%d_S%d" % [team, spawn_index]
add_child(ship)
var spawns := arena.get_ship_spawns(team)
var spawn_transform := spawns[spawn_index] if spawn_index < spawns.size() else Transform3D.IDENTITY
@@ -155,7 +183,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
add_child(rig)
_camera_rig = rig
rig.target = target
rig.impact_feedback.connect(_on_player_impact)
# Also wires the scene's static HUD (if any) to the same ship, rather
# than letting it guess via the "ship" group.
if hud:
@@ -163,42 +190,6 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig:
return rig
func _on_player_impact(intensity: float) -> void:
if not _goal_slowmo_active:
_run_hit_stop(intensity)
func _run_hit_stop(intensity: float) -> void:
if _goal_slowmo_active:
return
_hit_stop_generation += 1
var generation := _hit_stop_generation
if not _hit_stop_active:
_time_scale_before_hit_stop = Engine.time_scale
_hit_stop_active = true
Engine.time_scale = minf(
Engine.time_scale, lerpf(0.22, 0.06, clampf(intensity, 0.0, 1.0))
)
await get_tree().create_timer(
lerpf(0.025, 0.065, clampf(intensity, 0.0, 1.0)), true, false, true
).timeout
if generation == _hit_stop_generation:
_restore_hit_stop()
func _restore_hit_stop() -> void:
if not _hit_stop_active:
return
Engine.time_scale = _time_scale_before_hit_stop
_hit_stop_active = false
func _exit_tree() -> void:
# A scene change during the unscaled timer must never strand global time.
_restore_hit_stop()
_restore_goal_slowmo()
# Given an already-resolved (path, reaction_ticks, action_noise) — callers
# apply their own GameSettings-override logic first, which differs between
# modes (Match lets GameSettings override all three fields, Spectate only
@@ -233,6 +224,16 @@ func _record_goal(scoring_team: int) -> void:
const KICKOFF_POSITION_JITTER := 0.3
const KICKOFF_YAW_JITTER := deg_to_rad(15.0)
# Owned rather than global `randf_range`, so a fixed seed makes kickoffs
# exactly reproducible (replay logs, deterministic tests) without disturbing
# any other system's random stream.
@export var kickoff_rng_seed: int = 0:
set(value):
kickoff_rng_seed = value
if value != 0:
_kickoff_rng.seed = value
var _kickoff_rng := RandomNumberGenerator.new()
func reset_ball() -> void:
if is_instance_valid(ball):
@@ -243,24 +244,33 @@ func reset_ships() -> void:
for ship in ships:
if is_instance_valid(ship):
_reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER))
if is_instance_valid(_camera_rig):
# _reset_body's queue_teleport defers the actual transform write to
# the ship's next _integrate_forces (task 0.15) — snapping the camera
# now would read the pre-teleport position. Wait one physics tick so
# the teleport has already landed; without this the camera would also
# smoothly chase the teleported ship across the arena instead of
# cutting with it.
await get_tree().physics_frame
if is_instance_valid(_camera_rig):
_camera_rig.snap_to_target()
func _jittered(to: Transform3D, position_jitter: float, yaw_jitter: float) -> Transform3D:
var offset := Vector3(randf_range(-position_jitter, position_jitter), 0.0, randf_range(-position_jitter, position_jitter))
var offset := Vector3(_kickoff_rng.randf_range(-position_jitter, position_jitter), 0.0, _kickoff_rng.randf_range(-position_jitter, position_jitter))
var basis := to.basis
if yaw_jitter > 0.0:
basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter))
basis = basis.rotated(Vector3.UP, _kickoff_rng.randf_range(-yaw_jitter, yaw_jitter))
return Transform3D(basis, to.origin + offset)
func _reset_body(body: RigidBody3D, to: Transform3D) -> void:
# Deferred: a RigidBody3D transform can't be set mid-physics-step
body.set_deferred("global_transform", to)
body.set_deferred("linear_velocity", Vector3.ZERO)
body.set_deferred("angular_velocity", Vector3.ZERO)
# A kickoff reset is a teleport: without this, physics interpolation
# smears the body across the arena for a frame
body.call_deferred("reset_physics_interpolation")
# Queued and applied inside the body's own _integrate_forces — the only
# Jolt-safe place to write state.transform — instead of racing the
# physics step via set_deferred (task 0.15). Dynamic dispatch: Ship and
# Ball both implement queue_teleport(), but RigidBody3D itself doesn't,
# so a statically-typed call here won't resolve.
body.call("queue_teleport", to)
func _unhandled_input(event):
@@ -282,17 +292,31 @@ const ESCAPE_MARGIN := 15.0
func _physics_process(_delta: float) -> void:
_respawn_escaped_bodies()
if _owns_world_simulation():
_respawn_escaped_bodies()
func _respawn_escaped_bodies() -> void:
var respawned := false
for ship in ships:
if is_instance_valid(ship) and _is_escaped(ship.global_position):
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
_reset_body(ship, _ship_spawn_transforms[ship])
respawned = true
if is_instance_valid(ball) and _is_escaped(ball.global_position):
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
_reset_body(ball, arena.get_ball_spawn())
respawned = true
if respawned:
_on_bodies_respawned()
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
# interpolating toward it would smoothly slide a body the width of the arena
# and then fight the correction. NetworkedMatch overrides this to bump
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
func _on_bodies_respawned() -> void:
pass
func _is_escaped(position: Vector3) -> bool:
+1 -1
View File
@@ -38,7 +38,7 @@ func _process(delta: float) -> void:
_pitch = new_pitch
_roll = new_roll
if changed:
queue_redraw()
_throttled_redraw(delta)
func _draw() -> void:
+1 -1
View File
@@ -33,7 +33,7 @@ func _process(delta: float) -> void:
var changed := absf(new_value - _value) > max_value * 0.001
_value = new_value
if changed:
queue_redraw()
_throttled_redraw(delta)
func _draw() -> void:
+1 -1
View File
@@ -35,7 +35,7 @@ func _process(delta: float) -> void:
var changed := absf(angle_delta_deg(_heading, new_heading)) > REDRAW_EPSILON_DEG
_heading = new_heading
if changed:
queue_redraw()
_throttled_redraw(delta)
func _draw() -> void:
+16
View File
@@ -7,6 +7,13 @@ extends Control
# _process (smoothing 1-2 distinct fields) and _draw (entirely bespoke).
const SMOOTHING := 12.0
# _draw does real work (text shaping, building point arrays); nobody can
# perceive an instrument repainting faster than this, so redraws are paced
# to it independently of the render frame rate — value smoothing itself
# still runs every _process call, only the (expensive) repaint is throttled.
const REDRAW_INTERVAL := 1.0 / 60.0
var _time_since_redraw := 0.0
static func lerp_angle_deg(from: float, to: float, weight: float) -> float:
@@ -23,3 +30,12 @@ static func angle_delta_deg(from: float, to: float) -> float:
func _smoothing_weight(delta: float) -> float:
return 1.0 - exp(-SMOOTHING * delta) # frame-rate independent
# Call instead of queue_redraw() directly once a subclass's _process has
# decided the smoothed value moved enough to warrant a repaint.
func _throttled_redraw(delta: float) -> void:
_time_since_redraw += delta
if _time_since_redraw >= REDRAW_INTERVAL:
_time_since_redraw = 0.0
queue_redraw()
+178
View File
@@ -0,0 +1,178 @@
class_name InputJitterBuffer
extends RefCounted
# Per-player server-side input state (multiplayer-todo.md §3, task 3.2).
# Deliberately a standalone RefCounted with no scene/RPC dependency — same
# reason net_codec.gd and net_interpolator.gd are pure classes — so task
# 3.5's unit tests can drive it with scripted arrival traces with no live
# match. NetworkedMatch owns one instance per connected slot and is the only
# thing that talks to the network layer; this class only knows about
# sequence numbers and ShipActions.
#
# Ring is fixed-size and slot-tagged (§3.1 step 5's "a client can never make
# the server allocate"): ingest() writes seq % RING_SIZE regardless of how
# large or malicious seq is, and consume() only ever trusts a slot whose
# stored seq exactly matches the one it expects — a stale or wrapped-around
# entry is indistinguishable from an empty one. Range/rate validation of seq
# against the current server tick is the CALLER's job (task 3.4), not this
# class's, since only the caller knows the current server tick.
const RING_SIZE := 32
# 500ms at 60Hz (multiplayer-todo.md §3.2's own numbers) — a duration, not a
# tick-rate-derived constant, so left as a literal rather than pulling in
# SimConstants for one number.
const STARVE_ZERO_TICKS := 30
var last_applied_seq := -1 # -1: consume() has never been called yet
var last_action := ShipAction.new()
var starved_ticks := 0
var stalled := false
var _ring_action: Array = []
var _ring_seq: PackedInt32Array = PackedInt32Array()
# True once ingest() has ever been called for real. Consumption is a no-op
# (no starvation counted, no advancement) until then — see ingest()'s own
# comment for why an un-seeded buffer would otherwise never converge with
# what the client is actually sending.
var _seeded := false
# Highest seq ever seen by ingest(), regardless of whether it's still in the
# ring — consume()'s only way to tell "the data is gone because the ring
# overflowed" apart from "the data just hasn't arrived yet". See consume()'s
# own comment for why this exists: an adversarial review found that without
# it, a backlog bigger than RING_SIZE (a host stall, or persistent client/
# server clock drift) permanently zeroed a connected player's input for the
# rest of the match.
#
# Deliberately public (no underscore), same as last_applied_seq: the
# networked_match.gd caller's seq-range guard (§3.1 step 4) must bound
# against THIS, not against last_applied_seq. A second adversarial review
# found that bounding against last_applied_seq caps every accepted seq at
# last_applied_seq + RING_SIZE, which in turn caps this field at the same
# ceiling — making the resync condition below (which needs this field to
# reach expected + RING_SIZE) arithmetically unreachable on the only call
# path that exists in production. The two fixes looked independent but
# shared a variable and silently cancelled each other out. highest_ingested
# tracks the client's own send epoch instead, which the guard can safely
# let run ahead of a lagging consumer.
var highest_ingested_seq := -1
func _init() -> void:
_ring_action.resize(RING_SIZE)
_ring_seq.resize(RING_SIZE)
for i in RING_SIZE:
_ring_seq[i] = -1
# newest_seq/actions match NetCodec.unpack_input's own "seq"/"actions"
# fields directly: actions[i] is the action for sequence (newest_seq - i),
# newest-first. Already-consumed or stale entries are silently discarded
# (§3.1 step 5) — this is what makes redundant re-delivery of an already-
# applied tick harmless.
func ingest(newest_seq: int, actions: Array) -> void:
if not _seeded:
# The server starts calling consume() every tick the instant this
# slot exists — well before this player's first packet has had time
# to arrive (connection handshake, arena/ship spawn, first
# _physics_process tick on the client all take real time first). An
# un-seeded last_applied_seq of -1 would have consume() "expecting"
# sequence 0, 1, 2, ... via pure starvation the whole time, racing
# arbitrarily far ahead of whatever the client's own from-1
# numbering has actually reached by the time real packets show up —
# and since both sides only ever advance monotonically with no
# resync mechanism, that gap would never close. Seed to align
# "expected" with reality the moment real data first exists.
last_applied_seq = newest_seq - actions.size()
_seeded = true
if newest_seq > highest_ingested_seq:
highest_ingested_seq = newest_seq
for i in actions.size():
var seq: int = newest_seq - i
if seq <= last_applied_seq:
continue
var idx := seq % RING_SIZE
_ring_seq[idx] = seq
_ring_action[idx] = actions[i]
# Contiguous run of not-yet-applied entries starting right after
# last_applied_seq — reported as input_buffer_depth in every snapshot
# (§3.3) and consumed client-side by the input_lead control loop (task 3.3).
func depth() -> int:
if not _seeded or last_applied_seq < 0:
return 0
var d := 0
var seq := last_applied_seq + 1
while d < RING_SIZE and _ring_seq[seq % RING_SIZE] == seq:
d += 1
seq += 1
return d
# Called once per server physics tick, before the step (§3.2). A no-op
# (returns the zero-initialized last_action, no starvation counted) until
# this player's first real packet has ever arrived — see ingest()'s comment.
func consume() -> ShipAction:
if not _seeded:
return last_action
var expected := last_applied_seq + 1
var idx := expected % RING_SIZE
# Ring-overflow resync. A fixed-size ring can only ever hold RING_SIZE
# ticks of not-yet-consumed data at once — if the caller has fallen
# further behind the newest data actually arriving than that (a host
# stall, or persistent client/server clock drift), every tick between
# "expected" and "highest_ingested_seq - RING_SIZE" has already been
# irrecoverably overwritten by more recent arrivals landing on the same
# ring slots. Waiting for it tick-by-tick would starve — and, past
# STARVE_ZERO_TICKS, zero this player's ship — for the ENTIRE gap even
# though fresh, real input already exists in the ring right now. An
# adversarial review found and reproduced this exact failure (a ~0.7s
# host freeze permanently zeroed a connected player's input for the
# rest of the match, with no self-recovery). Skip the unrecoverable
# span and resync directly to what the ring can still actually provide.
if highest_ingested_seq - expected >= RING_SIZE:
last_applied_seq = highest_ingested_seq - RING_SIZE
expected = last_applied_seq + 1
idx = expected % RING_SIZE
if _ring_seq[idx] == expected:
last_action = _ring_action[idx]
starved_ticks = 0
stalled = false
last_applied_seq = expected
return last_action
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
# and the client already predicted with the real input either way,
# so repeating minimises expected divergence (§3.2). Only zero after
# a sustained stall, so a disconnecting player's ship doesn't fly
# into a wall at full throttle forever.
starved_ticks += 1
if starved_ticks > STARVE_ZERO_TICKS:
last_action = ShipAction.new()
stalled = true
# Only GIVE UP on `expected` when strictly newer data has actually
# arrived, which proves it was lost or reordered rather than merely late.
#
# Advancing unconditionally (what this did originally) is catastrophic
# rather than merely lossy, because ingest() discards anything
# `seq <= last_applied_seq`. One starve on a sequence the client has not
# even sent yet leaves the server permanently one ahead of arrivals:
# both sides then advance one per tick, the gap never closes, and every
# honest packet is discarded on arrival for the rest of the match. An
# adversarial review reproduced exactly that on a clean LAN — the client's
# own input_lead RELEASE (delta == 0, which deliberately issues no new
# sequence for one tick) is sufficient to trigger it, so it fired roughly
# every 6.5s of ordinary play, blacking out input for 30 ticks until the
# lead controller's debounce allowed a +3 attack to jump the client clear.
#
# Holding cannot deadlock: if the client genuinely goes silent,
# highest_ingested_seq stops moving, starved_ticks still climbs, and the
# STARVE_ZERO_TICKS zeroing plus `stalled` above still fire on schedule.
# If it falls far behind instead, the ring-overflow resync above still
# jumps the cursor forward. Both escape paths are unchanged.
if highest_ingested_seq > expected:
last_applied_seq = expected
return last_action
+1
View File
@@ -0,0 +1 @@
uid://cp818kexskb34
+120
View File
@@ -0,0 +1,120 @@
class_name InputLeadController
extends RefCounted
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
# so it's directly unit-testable against scripted depth traces.
#
# §3.3's own rationale for why this is the CLIENT's job alone, not shared
# with any server-side adaptation: three control loops acting on one plant
# (buffer occupancy) with different time constants is a textbook
# oscillation, and on a jittery link it presents to the player as
# intermittent sticky controls that are nearly impossible to attribute.
# The server (InputJitterBuffer, §3.2) only ever reports input_buffer_depth
# — it does nothing adaptive with it.
#
# "Lead" is realized concretely as extra distance between this client's own
# outgoing sequence numbers and what the server has actually consumed:
# skipping a sequence number (jumping the client's own seq counter by more
# than 1 for one tick) buys the server one more tick of buffered depth
# before it would starve; duplicating one (not incrementing the seq counter
# for one tick — the same seq gets sent again) narrows that margin by one
# tick of latency. The server's own ring buffer doesn't need to know this
# happened: a skipped seq just means "the redundant copies of it never
# existed, it's an ordinary drop" (already handled), and a duplicated seq
# is a same-seq resend, already discarded harmlessly once consumed
# (InputJitterBuffer.ingest()'s "seq <= last_applied_seq" check).
#
# Fast attack, slow release — a symmetric ±1-per-N-ticks slew would take
# two full seconds to absorb a single wifi spike, during which the player
# steers and the ship does not turn, "the most rage-inducing failure mode
# in any netcode" per §3.3's own words.
const LEAD_MIN := 1
const LEAD_MAX := 12
# "Never change it more than once per 30 ticks" (§3.3) — the floor that
# binds the fast-attack side; slow-release's own 60-tick cadence already
# exceeds it, so this one constant covers both.
const MIN_CHANGE_INTERVAL_TICKS := 30
const RELEASE_INTERVAL_TICKS := 60
const CLEAN_SURPLUS_TICKS := 120 # 2s at 60Hz
# §3.3: "target_depth = 1 (16.7 ms), not 2." Release only fires when the
# server-reported depth is genuinely ABOVE this — see update()'s own
# comment for why gating on `lead` alone (an adversarial review's original
# finding here) was wrong.
const TARGET_DEPTH := 1
var lead := LEAD_MIN
var _ticks_since_change := 0
var _clean_surplus_ticks := 0
# Call once per client physics tick with the most recently known server-
# reported input_buffer_depth for THIS client's own slot (echoed in every
# snapshot, §3.2) — or -1 if no snapshot carrying that field has arrived
# yet. Returns the seq delta the caller should add for this tick's
# outgoing packet: ordinarily 1 (ship normally increments its send
# sequence by exactly one tick's worth), or 1+N / 0 on a tick where a lead
# change actually fires (skip N extra / duplicate the current one).
func update(input_buffer_depth: int, target_depth: int = TARGET_DEPTH) -> int:
_ticks_since_change += 1
if input_buffer_depth == -1:
return 1 # no server depth has arrived yet
if input_buffer_depth < -1:
# -1 is an explicit server starvation sentinel, distinct from a
# healthy zero-depth buffer on an adaptive clean link.
_clean_surplus_ticks = 0
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
var starve_lead := mini(lead + 3, LEAD_MAX)
var starve_delta := starve_lead - lead
lead = starve_lead
_ticks_since_change = 0
return 1 + starve_delta
return 1
target_depth = maxi(0, target_depth)
if input_buffer_depth <= target_depth - 1:
# A starve: the server's ring was empty for this player when it
# built that snapshot. React immediately, not after 2 seconds of
# evidence like release requires — but still debounced against
# MIN_CHANGE_INTERVAL_TICKS so a burst of consecutive starve
# reports doesn't compound into repeated, overlapping jumps.
_clean_surplus_ticks = 0
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
var new_lead := mini(lead + 3, LEAD_MAX)
var delta := new_lead - lead
lead = new_lead
_ticks_since_change = 0
return 1 + delta
return 1
# Release must react to the ACTUAL server-reported depth, not to this
# controller's own memory of past attacks. A first pass at this fix
# added the depth check above but left the OLD gate, `lead > LEAD_MIN`,
# still ANDed onto the final condition below — so a backlog this
# controller did NOT itself cause (a server hitch, persistent client/
# server clock drift, a ring resync) still could never be drained:
# with lead pinned at its starting floor, that clause always failed
# even while input_buffer_depth sat well above target. A second
# adversarial review caught it, confirmed by this file's own
# test_release_drains_a_backlog_it_never_caused_itself, whose original
# assertion text literally said "lead cannot release below its own
# floor even under large surplus" as if that were correct.
#
# The fix splits the one gate into two separate decisions: whether to
# duplicate this tick's seq (the only thing that actually narrows real
# buffered depth) follows the real signal alone, below; whether to
# keep decrementing `lead`'s own bookkeeping below its documented
# floor is a separate, cosmetic-only choice made inside that branch.
if input_buffer_depth > target_depth:
_clean_surplus_ticks += 1
else:
_clean_surplus_ticks = 0
if _clean_surplus_ticks >= CLEAN_SURPLUS_TICKS and _ticks_since_change >= RELEASE_INTERVAL_TICKS:
if lead > LEAD_MIN:
lead -= 1
_ticks_since_change = 0
return 0 # duplicate this tick's seq — one tick of latency recovered
return 1
@@ -0,0 +1 @@
uid://bvwwwkf82nkdk
+116
View File
@@ -0,0 +1,116 @@
extends Control
# Lobby (task 1.5): roster list split by team, team swap, ready toggle,
# leave. Reads/writes MatchNet.roster — this scene owns no state of its
# own, it's a view over the autoload. Reached via main_menu.gd's Host/Join
# flow (task 1.7) calling change_scene_to_file("res://scenes/lobby.tscn")
# after NetworkManager.host()/join() succeeds — this scene must always be
# loaded that way (as the real current_scene), not instantiated as a child
# of something else: change_scene_to_file() operates on
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
# below call it themselves, which hangs if this scene isn't actually the
# tree's current_scene when that happens (see multiplayer-todo.md §9
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
@onready var _status_label: Label = %StatusLabel
@onready var _team0_list: VBoxContainer = %Team0List
@onready var _team1_list: VBoxContainer = %Team1List
@onready var _controls_row: HBoxContainer = %ControlsRow
@onready var _switch_team_button: Button = %SwitchTeamButton
@onready var _ready_button: CheckButton = %ReadyButton
@onready var _leave_button: Button = %LeaveButton
func _ready() -> void:
MatchNet.welcomed.connect(_on_welcomed)
MatchNet.player_joined.connect(_on_roster_changed)
MatchNet.player_left.connect(_on_roster_changed)
MatchNet.player_state_changed.connect(_on_roster_changed)
MatchNet.rejected.connect(_on_rejected)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
# The server process is never a roster member (§1.1 decision 2) — it
# gets a read-only view, no team/ready controls to operate on itself.
_controls_row.visible = NetworkManager.is_client
_refresh()
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
_leave()
func _on_roster_changed(_a = null, _b = null, _c = null) -> void:
_refresh()
func _on_welcomed() -> void:
_refresh()
func _on_rejected(reason: String) -> void:
_status_label.text = "Connection rejected: %s" % reason
func _on_disconnected_from_server() -> void:
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _on_switch_team_pressed() -> void:
var my_id := multiplayer.get_unique_id()
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
if info == null:
return
MatchNet.request_set_team((info.team + 1) % MatchNet.TEAM_COUNT)
func _on_ready_toggled(pressed: bool) -> void:
MatchNet.request_set_ready(pressed)
func _on_leave_pressed() -> void:
_leave()
func _leave() -> void:
NetworkManager.shutdown()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _refresh() -> void:
if NetworkManager.is_server:
_status_label.text = "Hosting — %d player(s) connected" % MatchNet.roster.size()
elif NetworkManager.is_client:
_status_label.text = "Connected" if not MatchNet.roster.is_empty() else "Connecting..."
else:
_status_label.text = "Not connected"
for child in _team0_list.get_children():
child.queue_free()
for child in _team1_list.get_children():
child.queue_free()
var my_id := multiplayer.get_unique_id()
var infos: Array = MatchNet.roster.values()
infos.sort_custom(func(a: MatchNet.PlayerInfo, b: MatchNet.PlayerInfo) -> bool: return a.peer_id < b.peer_id)
for info: MatchNet.PlayerInfo in infos:
var row := Label.new()
var marker := " (you)" if info.peer_id == my_id else ""
var ready_mark := "" if info.ready else ""
row.text = "%s %s%s" % [ready_mark, info.player_name, marker]
var target_list := _team0_list if info.team == 0 else _team1_list
target_list.add_child(row)
if NetworkManager.is_client:
var my_info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
if my_info != null:
_ready_button.set_pressed_no_signal(my_info.ready)
+1
View File
@@ -0,0 +1 @@
uid://qd513s2cqls3
+78
View File
@@ -0,0 +1,78 @@
class_name LocalInputTimeline
extends RefCounted
# Client-only sequence/action timeline. It mirrors the stream the server's
# InputJitterBuffer will consume: an attack fills its deliberate sequence gap
# with repeat-last actions, while a release retransmits immutable data.
const ShipActionScript = preload("res://scripts/ship_action.gd")
const RETAINED_REDUNDANCY := 4
var latest_issued_seq := 0
var latest_applied_seq := -1
var configured := false
var _actions := {}
var _last_issued_action = ShipActionScript.new()
var _last_applied_action = ShipActionScript.new()
func configure_initial_delay(delay_ticks: int) -> void:
if configured:
return
latest_applied_seq = -maxi(delay_ticks, 1)
configured = true
func issue(delta: int, intent) -> int:
if delta <= 0:
# An already-issued sequence may be in flight or consumed. Never mutate
# it; carry current raw intent to the next unique command instead.
return latest_issued_seq
var from_seq := latest_issued_seq + 1
latest_issued_seq += delta
for seq in range(from_seq, latest_issued_seq):
_actions[seq] = _last_issued_action.copy()
_actions[latest_issued_seq] = intent.copy()
_last_issued_action = intent.copy()
_prune_consumed_actions()
return latest_issued_seq
func consume() -> Dictionary:
latest_applied_seq += 1
if _actions.has(latest_applied_seq):
_last_applied_action = _actions[latest_applied_seq].copy()
_prune_consumed_actions()
return {"seq": latest_applied_seq, "action": _last_applied_action.copy()}
# The action actually issued for a sequence, or null if it is no longer
# retained. Returns a copy: the timeline's stored actions are immutable once
# issued (see issue()), and handing out the live object would let a caller
# break that from the outside.
func action_for(seq: int):
if not _actions.has(seq):
return null
return _actions[seq].copy()
func packet_actions(max_count: int) -> Array:
var out: Array = []
for seq in range(latest_issued_seq, maxi(0, latest_issued_seq - max_count), -1):
if not _actions.has(seq):
break
out.append(_actions[seq].copy())
return out
func retained_action_count() -> int:
return _actions.size()
func _prune_consumed_actions() -> void:
# Preserve the local command needed for the server's redundancy window,
# then discard actions that are older than both consumption and backup use.
var keep_from := latest_issued_seq - RETAINED_REDUNDANCY + 1
for seq in _actions.keys():
if int(seq) < keep_from:
_actions.erase(seq)
+1
View File
@@ -0,0 +1 @@
uid://b8nwh3anyddm5
+27
View File
@@ -0,0 +1,27 @@
class_name LocalNetShipController
extends ShipController
const LocalInputTimeline = preload("res://scripts/local_input_timeline.gd")
var source: ShipController
var timeline: LocalInputTimeline
var last_applied_seq := -1
var last_sampled_intent: ShipAction
func _init(new_source: ShipController, new_timeline: LocalInputTimeline) -> void:
source = new_source
timeline = new_timeline
last_sampled_intent = ShipAction.new()
func get_action() -> ShipAction:
# Ship invokes this exactly once per local physics tick. Prediction must use
# the player's current intent immediately; the timeline is transmission and
# immutable-redundancy bookkeeping only. Advance its cursor solely to label
# this post-step state at the estimated server-consumption sequence; never
# use its queued action to delay local control.
last_sampled_intent = source.get_action().copy()
var label := timeline.consume()
last_applied_seq = int(label["seq"])
return last_sampled_intent.copy()
@@ -0,0 +1 @@
uid://dyaoxrjb006a8
+294
View File
@@ -0,0 +1,294 @@
class_name LocalPredictionHistory
extends RefCounted
const NetBodyState = preload("res://scripts/net_body_state.gd")
# Client-owned local-ship prediction history (multiplayer-todo.md §4.3).
# This is deliberately independent of NetworkedMatch and the scene tree so
# sequence/ring behaviour can be tested from scripted traces. Each entry is
# tagged with its full sequence number: an old value in a wrapped slot is
# never accepted as a prediction for a newer sequence.
#
# Acknowledge and record are separate producer/consumer clocks. The input
# sender can continue producing while snapshots stop arriving, so record()
# explicitly marks overflow once more than RING_SIZE unacknowledged sequence
# positions exist. It still retains the newest representable window, but
# callers can see that an authoritative resync is required instead of
# mistaking a wrapped overwrite for a valid comparison.
#
# resync_required is a live condition, NOT a latch: compare_authoritative()
# clears it again once acknowledgements have genuinely caught back up (see
# that method). This mirrors input_jitter_buffer.gd's `stalled`, which
# likewise drops back to false the moment a normal tick is consumed again.
# A latched flag would mean one transient ~2s stall anywhere in a match
# permanently pinned every later tick into "needs a hard resync", which is
# exactly the behaviour soft correction exists to avoid — and it would also
# cap overflow_count at 1 forever, since a second episode could never
# observe the flag going false again.
#
# Two things a "matched" result does NOT guarantee, flagged for whoever
# builds task 4.3's actual correction logic on top of this:
#
# 1. A "matched" result can still be reporting stale data. The slot-tag
# equality check in get_prediction() guarantees a match's payload
# genuinely belongs to the queried seq (never wrong-seq data mislabeled
# as right), but nothing in the "matched" status itself says HOW OLD
# that entry is. Under sparse recording (record() is not called with
# strictly consecutive seqs — see the record() comment below), an entry
# from well over RING_SIZE ticks ago can still report "matched" for a
# query landing on its untouched residue. resync_required correctly
# stays true in that case (the span guard below is exact), but the
# comparison payload itself carries no matched_stale/age distinction. A
# caller wanting to reject "matched but ancient" needs to separately
# check newest_recorded_seq - seq itself.
#
# 2. HISTORICAL, now fixed — kept because the reasoning still constrains
# callers. record() used to be called twice for the same seq with a
# DIFFERENT action on the release path (delta == 0), the later call
# silently overwriting the slot. That was wrong, not merely imprecise:
# LocalInputTimeline.issue() deliberately does NOT mutate _actions[seq]
# for an already-issued sequence ("may be in flight or consumed"), so
# the overwrite made this ring contradict the wire — it claimed an
# action for S that was never sent for S. networked_match.gd now skips
# recording entirely on a release tick, leaving the original (correct)
# predicted[S] in place. Callers must keep it that way: an already-
# recorded sequence's ACTION is immutable here, exactly as it is in the
# timeline. Only overwrite_state()/rebase_state_range() may revise an
# entry, and only its state.
#
# 3. A sequence can be ISSUED without ever being locally SIMULATED. The
# input_lead controller's attack path (delta > 1) skips sequence numbers
# to buy server-side buffer margin: those gap sequences are filled with
# repeat-last actions and sent, but the client took exactly ONE physics
# step that tick, so no post-step state exists for them. They are
# recorded via record_unsimulated() and report "unsimulated_gap" rather
# than "missing_not_recorded" — a routine consequence of this client's
# own lead control, NOT evidence of history loss, and specifically not a
# hard-snap condition. Distinguishing them matters: treating them as
# missing history teleported the ship and armed resync suppression
# several times a minute during ordinary play.
const RING_SIZE := 128
var _ring_seq: PackedInt32Array = PackedInt32Array()
var _ring_entry: Array = []
var _has_recorded := false
var newest_recorded_seq := -1
var last_acknowledged_seq := 0
var overflow_count := 0
var resync_required := false
func _init() -> void:
_ring_seq.resize(RING_SIZE)
_ring_entry.resize(RING_SIZE)
for i in RING_SIZE:
_ring_seq[i] = -1
# A reset starts a new authoritative epoch. Retained inputs/states describe
# the old world and must never be compared to the new kickoff state.
func begin_epoch() -> void:
for i in RING_SIZE:
_ring_seq[i] = -1
_ring_entry[i] = null
_has_recorded = false
newest_recorded_seq = -1
last_acknowledged_seq = 0
resync_required = false
# Stores a private copy of both action and state. Returns true when this
# record crossed the unacknowledged-capacity boundary; the caller does not
# need that return today, but it makes the eviction event observable rather
# than silent when reconciliation starts applying corrections in Phase 4.3.
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if seq - last_acknowledged_seq > RING_SIZE:
# Only the LEADING edge of an episode counts: resync_required is
# still true for every subsequent tick of the same stall, and
# counting those would report one outage as hundreds. Because
# compare_authoritative() can now clear the flag, a genuinely
# separate later episode does increment this again.
overflowed_now = not resync_required
resync_required = true
if overflowed_now:
overflow_count += 1
newest_recorded_seq = seq
_has_recorded = true
var idx := posmod(seq, RING_SIZE)
_ring_seq[idx] = seq
_ring_entry[idx] = {
"action": action.copy(),
"state": state.copy(),
"contact_window": contact_window,
"unsimulated": false,
}
return overflowed_now
# Records a sequence that was issued and sent but never locally simulated —
# an attack's skipped sequence numbers (see note 3 in this file's header).
# It advances the same newest/overflow bookkeeping record() does, because the
# sequence genuinely is outstanding and the server will genuinely acknowledge
# it; only the post-step state is absent, because the client never computed
# one. Deliberately carries the action anyway: it is what went on the wire, so
# a caller diagnosing an acknowledgement still has the honest command, and
# nothing here has to invent a state to keep the ring dense.
func record_unsimulated(seq: int, action: ShipAction) -> bool:
var overflowed_now := false
if not _has_recorded or seq > newest_recorded_seq:
if seq - last_acknowledged_seq > RING_SIZE:
overflowed_now = not resync_required
resync_required = true
if overflowed_now:
overflow_count += 1
newest_recorded_seq = seq
_has_recorded = true
var idx := posmod(seq, RING_SIZE)
_ring_seq[idx] = seq
_ring_entry[idx] = {
"action": action.copy(),
"state": null,
"contact_window": false,
"unsimulated": true,
}
return overflowed_now
# Returns independent copies so diagnostic/reconciliation consumers cannot
# mutate a retained prediction by accident.
func get_prediction(seq: int) -> Dictionary:
var idx := posmod(seq, RING_SIZE)
if _ring_seq[idx] != seq:
return {}
var entry: Dictionary = _ring_entry[idx]
if bool(entry.get("unsimulated", false)):
# No state to hand back — see note 3. Callers must check this flag
# before touching "state"; it is null, not a zeroed NetBodyState,
# specifically so a caller that forgets fails loudly instead of
# silently comparing against the origin.
return {
"seq": seq,
"action": (entry["action"] as ShipAction).copy(),
"state": null,
"contact_window": false,
"unsimulated": true,
}
return {
"seq": seq,
"action": (entry["action"] as ShipAction).copy(),
"state": (entry["state"] as NetBodyState).copy(),
"contact_window": bool(entry.get("contact_window", false)),
"unsimulated": false,
}
# Reconciliation changes the state paired with already-sent input, never the
# input itself. This is deliberately a no-op for an absent/skipped sequence:
# input-lead control permits sparse sequence numbers, so there is no honest
# action to invent for such a slot.
func overwrite_state(seq: int, state: NetBodyState) -> bool:
var idx := posmod(seq, RING_SIZE)
if _ring_seq[idx] != seq:
return false
var entry: Dictionary = _ring_entry[idx]
if bool(entry.get("unsimulated", false)):
# Writing a state here would manufacture a local prediction for a
# sequence this client never simulated, which is exactly the fabricated
# history §4.4 forbids. The slot stays stateless.
return false
entry["state"] = state.copy()
return true
func overwrite_state_range(from_seq: int, to_seq: int, state: NetBodyState) -> void:
for seq in range(from_seq, to_seq + 1):
overwrite_state(seq, state)
# Carries an authoritative same-sequence correction through the retained
# future. This is intentionally a transport operation, not a synthetic
# physics replay: the live Jolt body has already advanced through the real
# contact world, and a soft correction must not leave its later comparisons
# describing the old trajectory.
func rebase_state_range(from_seq: int, to_seq: int, position_delta: Vector3, rotation_delta: Quaternion, linear_velocity_delta: Vector3, angular_velocity_delta: Vector3) -> void:
for seq in range(from_seq, to_seq + 1):
var prediction := get_prediction(seq)
if prediction.is_empty() or bool(prediction.get("unsimulated", false)):
continue
var state: NetBodyState = prediction["state"]
state.position += position_delta
state.rotation = (rotation_delta * state.rotation).normalized()
state.linear_velocity += linear_velocity_delta
state.angular_velocity += angular_velocity_delta
overwrite_state(seq, state)
# Produces comparison data only. Applying a snap, teleport, velocity delta,
# or visual offset belongs to later Phase 4 tasks.
func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
if seq > last_acknowledged_seq:
last_acknowledged_seq = seq
var prediction := get_prediction(seq)
if prediction.is_empty():
return {
"status": _missing_status(seq),
"seq": seq,
"authoritative_state": authoritative.copy(),
}
# A successful match is the only evidence that the acknowledgement clock
# has genuinely caught back up, so it is the only thing allowed to clear
# resync_required — a "missing_evicted"/"missing_not_recorded" result
# proves the opposite, and must leave the flag alone.
#
# The extra span check is not redundant. record() is not guaranteed to be
# called with consecutive sequences: input_lead_controller.update() can
# return 0 or up to 1+3, so the client's seq can skip forward, leaving a
# ring slot holding a tag OLDER than newest_recorded_seq - RING_SIZE
# (its residue was simply never rewritten). get_prediction() would still
# report that as "matched", so matching alone does not imply the
# outstanding window is back within capacity. Gate on the exact inverse
# of record()'s own trip inequality instead, which holds regardless of
# how sparsely sequences were recorded.
if newest_recorded_seq - last_acknowledged_seq <= RING_SIZE:
resync_required = false
if bool(prediction.get("unsimulated", false)):
# Reaching this sequence at all proves the acknowledgement clock is
# healthy — the entry is present and correctly tagged — so the
# resync_required clear above still applies. There is simply nothing
# to compare, because the client never simulated this sequence.
return {
"status": "unsimulated_gap",
"seq": seq,
"action": prediction["action"],
"authoritative_state": authoritative.copy(),
}
var predicted_state: NetBodyState = prediction["state"]
var position_error := authoritative.position - predicted_state.position
var rotation_error_radians := predicted_state.rotation.angle_to(authoritative.rotation)
return {
"status": "matched",
"seq": seq,
"action": prediction["action"],
"predicted_state": predicted_state,
"authoritative_state": authoritative.copy(),
"position_error": position_error,
"position_error_magnitude": position_error.length(),
"rotation_error_radians": rotation_error_radians,
"rotation_error_degrees": rad_to_deg(rotation_error_radians),
"linear_velocity_error": authoritative.linear_velocity - predicted_state.linear_velocity,
"angular_velocity_error": authoritative.angular_velocity - predicted_state.angular_velocity,
"contact_window": bool(prediction.get("contact_window", false)),
}
func _missing_status(seq: int) -> String:
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
return "missing_evicted"
return "missing_not_recorded"
@@ -0,0 +1 @@
uid://goarfpbthyf6
+126 -3
View File
@@ -31,9 +31,17 @@ const DIFFICULTIES := [
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
@onready var bot_a_dropdown: OptionButton = %BotADropdown
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
@onready var join_address_edit: LineEdit = %JoinAddressEdit
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
@onready var connecting_overlay: Control = %ConnectingOverlay
@onready var connecting_status_label: Label = %ConnectingStatusLabel
func _ready() -> void:
# An idle menu has no reason to render past the display's own refresh
# rate; gameplay scenes are uncapped again by _leave_to_gameplay below.
var refresh_rate := DisplayServer.screen_get_refresh_rate()
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
_populate_difficulty_dropdown()
_populate_arena_dropdown()
dev_section.visible = OS.is_debug_build()
@@ -42,9 +50,27 @@ func _ready() -> void:
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
NetworkManager.connected_to_server.connect(_on_connected_to_server)
NetworkManager.connection_failed.connect(_on_connection_failed)
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
# main_menu.gd's first async flow (task 1.7): Host is synchronous
# (NetworkManager.host() either succeeds immediately or fails immediately),
# but Join is not — it can take anywhere from a clean local-network round
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
# path that returns the player to a sane, retryable menu state rather than
# just hanging with no feedback.
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _populate_difficulty_dropdown() -> void:
difficulty_dropdown.clear()
for tier in DIFFICULTIES:
@@ -115,10 +141,19 @@ func _selected_path(dropdown: OptionButton) -> String:
return ""
# The menu's own refresh-rate fps cap (see _ready) is a menu-only concern;
# gameplay scenes respect the player's own VideoSettings fps cap instead
# (task 0.17), which only actually caps anything when vsync is Disabled and a
# divisor is chosen — otherwise this uncaps exactly like the old hardcoded 0.
func _leave_to_gameplay(scene_path: String) -> void:
VideoSettings.apply_fps_cap()
get_tree().change_scene_to_file(scene_path)
func _on_free_play_pressed() -> void:
var chosen: Dictionary = ArenaRegistry.ARENAS[0] if arena_dropdown.selected < 0 else ArenaRegistry.ARENAS[arena_dropdown.selected]
GameSettings.selected_arena_path = chosen["path"]
get_tree().change_scene_to_file("res://scenes/free_play.tscn")
_leave_to_gameplay("res://scenes/free_play.tscn")
func _on_match_pressed() -> void:
@@ -134,7 +169,7 @@ func _on_match_pressed() -> void:
GameSettings.selected_bot_path = override_path
GameSettings.selected_bot_reaction_ticks = -1
GameSettings.selected_bot_action_noise = -1.0
get_tree().change_scene_to_file("res://scenes/match.tscn")
_leave_to_gameplay("res://scenes/match.tscn")
func _on_settings_pressed() -> void:
@@ -144,4 +179,92 @@ func _on_settings_pressed() -> void:
func _on_spectate_pressed() -> void:
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
get_tree().change_scene_to_file("res://scenes/spectate.tscn")
_leave_to_gameplay("res://scenes/spectate.tscn")
func _on_host_pressed() -> void:
_clear_multiplayer_error()
var err := NetworkManager.host()
if err != OK:
_show_multiplayer_error("Could not host: %s" % error_string(err))
return
_leave_to_lobby()
func _on_join_pressed() -> void:
_start_join()
func _on_join_address_submitted(_new_text: String) -> void:
_start_join()
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
# anything a menu should make a player wait for — verified empirically
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
# refused loopback connection: connection_failed never fired within 14s.
# This timer is what actually guarantees "connection-refused reaches a sane
# UI state" rather than leaving the overlay up indefinitely.
const CONNECT_TIMEOUT_SECONDS := 6.0
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
func _start_join() -> void:
_clear_multiplayer_error()
var address := join_address_edit.text.strip_edges()
if address.is_empty():
_show_multiplayer_error("Enter an IP address to join")
return
var err := NetworkManager.join(address)
if err != OK:
_show_multiplayer_error("Could not join: %s" % error_string(err))
return
connecting_status_label.text = "Connecting to %s..." % address
connecting_overlay.visible = true
_connect_timeout_token += 1
var my_token := _connect_timeout_token
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
func _on_connect_timeout(token: int) -> void:
if token != _connect_timeout_token or not connecting_overlay.visible:
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
NetworkManager.shutdown()
connecting_overlay.visible = false
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
func _on_connecting_cancel_pressed() -> void:
_connect_timeout_token += 1
NetworkManager.shutdown()
connecting_overlay.visible = false
func _on_connected_to_server() -> void:
if not connecting_overlay.visible:
return # e.g. a stray/late signal after Cancel already shut the peer down
_connect_timeout_token += 1
connecting_overlay.visible = false
_leave_to_lobby()
func _on_connection_failed() -> void:
if not connecting_overlay.visible:
return
_connect_timeout_token += 1
connecting_overlay.visible = false
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
func _leave_to_lobby() -> void:
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
func _show_multiplayer_error(message: String) -> void:
multiplayer_error_label.text = message
multiplayer_error_label.visible = true
func _clear_multiplayer_error() -> void:
multiplayer_error_label.visible = false
+283
View File
@@ -0,0 +1,283 @@
extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
# hello/welcome, strict protocol_version and physics_ticks_per_second
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
# somewhere durable to keep it across the lobby→match scene transition —
# each player's team and ready state. Slot assignment (fixed spawn index
# within a team) is NOT here; that's match spawn's job in Phase 2, derived
# from this roster's team field at spawn time, not stored redundantly here.
const NetCodec = preload("res://scripts/net_codec.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
signal player_joined(peer_id: int, player_name: String)
signal player_left(peer_id: int)
signal player_state_changed(peer_id: int, team: int, ready: bool)
signal rejected(reason: String) # client-side only: the server refused our hello
signal welcomed() # client-side only: our hello was accepted
const TEAM_COUNT := 2
# player_name is the one client-supplied value in _hello that gets broadcast
# verbatim to every other peer (protocol_version/tick_hz are checked, never
# relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching
# the string at all — a legitimate client only ever sends local_player_name,
# which the UI already keeps short, so anything past this is a bug or an
# attacker, not a real name to truncate politely. Adversarial review found
# an unbounded name relayed to every peer head-of-line-blocks the reliable
# control channel hard enough that a concurrently-joining client's own
# _welcome never arrived — this is what closes that.
const MAX_INPUT_LENGTH := 256
const MAX_PLAYER_NAME_LENGTH := 24
class PlayerInfo:
var peer_id: int
var player_name: String
var team: int = 0
var ready: bool = false
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
peer_id = p_peer_id
player_name = p_player_name
team = p_team
ready = p_ready
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
# mismatched one instead to exercise the rejection path.
var _auto_hello := true
func _ready() -> void:
NetworkManager.client_disconnected.connect(_on_peer_disconnected)
NetworkManager.connected_to_server.connect(_on_connected_to_server)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
NetworkManager.shutting_down.connect(_on_shutting_down)
func _on_connected_to_server() -> void:
roster.clear()
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
func _on_disconnected_from_server() -> void:
roster.clear()
# Covers the case _on_disconnected_from_server doesn't: a HOST calling
# NetworkManager.shutdown() itself (Leave, or hosting again after already
# hosting) never fires disconnected_from_server — that signal only fires
# from an incoming multiplayer.server_disconnected event, which a server
# never receives about itself. Without this, roster (and every peer's team/
# ready state in it) would persist forever across a host/re-host cycle in
# the same process.
func _on_shutting_down() -> void:
roster.clear()
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
# proper hello just needs its (possibly absent) roster entry cleaned up.
# The normal leave path also goes through here after the server erases it,
# guarded by roster.erase()'s own has-check below.
func _on_peer_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
_remove_player(peer_id)
func _remove_player(peer_id: int) -> void:
if not roster.has(peer_id):
return
roster.erase(peer_id)
player_left.emit(peer_id)
# rpc() broadcasts to every peer in multiplayer.get_peers() — including,
# transiently, the very peer that just disconnected: this fires from
# NetworkManager's client_disconnected signal, and empirically that
# peer's own ENetConnection can still be momentarily present in the
# broadcast's target set with its channels already torn down, which
# logs "Unable to send packet on channel 0, max channels: 0" on every
# single disconnect (found by a second adversarial review — harmless to
# the game, since the departing peer obviously doesn't need to hear
# about its own departure, but it meant "clean stderr" wasn't actually
# clean for any test in this project).
#
# A first attempt filtered the broadcast down to rpc_id() calls that
# explicitly skip `peer_id`. That's necessary but not sufficient: when
# two peers disconnect within the same poll() batch (both bots quitting
# at the end of a CI run land within the same tick), get_peers() here
# can still list the SECOND peer as connected while its own disconnect
# event just hasn't been dispatched yet in this same batch — sending to
# it hits the identical error, one hop later. Defer the whole
# notification to the next idle frame instead of sending synchronously
# from inside signal-handling: by then poll() has fully returned, every
# disconnect event in this batch has been dispatched, and get_peers()
# reflects the settled, genuinely-still-connected set.
call_deferred("_broadcast_player_left", peer_id)
func _broadcast_player_left(peer_id: int) -> void:
for other_peer_id in multiplayer.get_peers():
if other_peer_id != peer_id:
_player_left.rpc_id(other_peer_id, peer_id)
# Balances a new joiner onto whichever team currently has fewer players
# (ties go to team 0). Server only.
func _pick_balanced_team() -> int:
var counts := []
counts.resize(TEAM_COUNT)
counts.fill(0)
for info: PlayerInfo in roster.values():
counts[info.team] += 1
var best_team := 0
for team in range(TEAM_COUNT):
if counts[team] < counts[best_team]:
best_team = team
return best_team
@rpc("any_peer", "call_remote", "reliable")
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if roster.has(peer_id):
return # duplicate hello from an already-accepted peer; ignore
if protocol_version != NetCodec.PROTOCOL_VERSION:
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
return
if tick_hz != SimConstants.TICK_HZ:
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
return
if player_name.length() > MAX_INPUT_LENGTH:
await _reject(peer_id, "player name too long")
return
var clean_name := _sanitize_player_name(player_name)
# Tell the new peer about everyone already here before anyone is told
# about them, so no client ever observes an unknown peer_id in a
# player_joined it didn't get a prior player_joined for.
for existing_id: int in roster.keys():
var existing: PlayerInfo = roster[existing_id]
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
var team := _pick_balanced_team()
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
_welcome.rpc_id(peer_id)
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
# Strips control/formatting characters (so a name can't corrupt a log line
# or blow out UI layout with e.g. embedded newlines) and clamps to display
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
# this runs, so this never iterates an attacker-sized string. static: pure
# function of its argument, doesn't touch roster/multiplayer — also lets
# tests/cases/test_match_net.gd call it with no Node instantiation.
static func _sanitize_player_name(raw: String) -> String:
var clean := ""
for c in raw:
var code := c.unicode_at(0)
if code >= 0x20 and code != 0x7F:
clean += c
clean = clean.strip_edges()
if clean.length() > MAX_PLAYER_NAME_LENGTH:
clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH)
if clean.is_empty():
clean = "Player"
return clean
func _reject(peer_id: int, reason: String) -> void:
_rejected.rpc_id(peer_id, reason)
# §9 gotcha 26: a reliable RPC just queued still needs a beat of polling
# to actually reach the wire before we pull the connection out from
# under it.
await get_tree().create_timer(0.3).timeout
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
# Client-callable requests. Both are fire-and-forget: the authoritative
# change comes back through _state_changed once the server applies it, same
# as everyone else's — a client never mutates its own roster entry directly.
func request_set_team(team: int) -> void:
_set_team.rpc_id(1, team)
func request_set_ready(ready: bool) -> void:
_set_ready.rpc_id(1, ready)
@rpc("any_peer", "call_remote", "reliable")
func _set_team(team: int) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
return
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
@rpc("any_peer", "call_remote", "reliable")
func _set_ready(ready: bool) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id):
return
var info: PlayerInfo = roster[peer_id]
if info.ready == ready:
return
info.ready = ready
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
@rpc("authority", "call_remote", "reliable")
func _state_changed(peer_id: int, team: int, ready: bool) -> void:
if not roster.has(peer_id):
return
var info: PlayerInfo = roster[peer_id]
info.team = team
info.ready = ready
player_state_changed.emit(peer_id, team, ready)
@rpc("authority", "call_remote", "reliable")
func _welcome() -> void:
welcomed.emit()
@rpc("authority", "call_remote", "reliable")
func _rejected(reason: String) -> void:
rejected.emit(reason)
@rpc("authority", "call_remote", "reliable")
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
player_joined.emit(peer_id, player_name)
@rpc("authority", "call_remote", "reliable")
func _player_left(peer_id: int) -> void:
if not roster.has(peer_id):
return
roster.erase(peer_id)
player_left.emit(peer_id)
+1
View File
@@ -0,0 +1 @@
uid://b8300uu0s6jqt
+536
View File
@@ -0,0 +1,536 @@
extends Node
# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs:
# match_config (server assigns arena + deterministic slot order from
# MatchNet.roster), input (client -> server, per-tick action), snapshot
# (server -> client, NetCodec-packed body state), and a small score_update
# for the HUD. Lives on an autoload per §1.3's derived decision ("All
# hot-path RPCs live on autoloads") even though these are scoped to
# whichever match happens to be running — a scene-node RPC target would
# need matching NodePaths across peers, which an autoload sidesteps
# entirely, and it's what lets NetworkedMatch itself stay a plain scene
# node with no networking-identity concerns of its own.
#
# Channel intent per §2.1: 0 reliable (match_config, score_update), 1
# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet
# verified against ENet's own reserved system channel offset (§2.1's own
# "verify empirically" hedge); if that turns out to matter these indices
# will need adjusting, not the RPC design itself.
const NetCodec = preload("res://scripts/net_codec.gd")
signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array)
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
# Task 5.10. A packet this autoload dropped before it could ever reach a match,
# with the verbatim bytes — the replay log's whole reason to exist is the field
# report "my input did nothing", and an accepted-input-only log has thrown away
# exactly the evidence that would explain it. `reason` is an InputRejectReason;
# the transport layer deliberately does not know about the replay format's own
# record kinds, so the mapping lives at the listener.
signal input_rejected(peer_id: int, reason: int, bytes: PackedByteArray)
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
signal score_update_received(score: Dictionary)
signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.State
# §6.2 step 6. positions/rotations are body-order: every slot in order, then
# the ball — the same order the snapshot uses, so one convention covers both.
# rotations is 4 floats per body (x, y, z, w).
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int)
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int)
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
signal slot_assigned_received(peer_id: int, slot_index: int)
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
# level concern independent of any particular match's roster/slot state, and
# this autoload already owns the RPC that receives the raw bytes.
#
# 60Hz * 1.5 + 20, per §3.1 step 2's own numbers.
const RATE_LIMIT_PACKETS_PER_SEC := 110
# "Same for a byte budget" (§3.1 step 2) — the worst-case legitimate packet
# is a full-redundancy input (INPUT_HEADER_SIZE + MAX_REDUNDANCY entries,
# the "40 B input" §2.3 sizes to), so the byte budget is just the packet
# budget scaled by that worst-case size — no separate constant to keep in
# sync by hand.
const RATE_LIMIT_BYTES_PER_SEC := RATE_LIMIT_PACKETS_PER_SEC * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
const RATE_LIMIT_WINDOW_MS := 1000
# Leaky-bucket excess tolerance, expressed in the same "N seconds' worth of
# budget" terms the original consecutive-streak design used. An adversarial
# review found that design — a streak counter that HARD-RESET to 0 on any
# single clean window — was trivially evaded by a duty-cycled flood (burst,
# then one clean window, repeat): reproduced sustaining ~33x the packet
# budget indefinitely with zero disconnect warnings. A leaky bucket doesn't
# care how the excess is distributed in time — see the window-roll logic
# below for how it accumulates and drains.
const RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT := RATE_LIMIT_PACKETS_PER_SEC * 3
const RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT := RATE_LIMIT_BYTES_PER_SEC * 3
const MALFORMED_LIMIT_TO_DISCONNECT := 20
# Task 5.10: how many rejected packets per peer per rate-limit window are
# forwarded to `input_rejected`. Sized so an honest client — whose rejects are
# occasional by definition, since a client rejected every tick is a bug the log
# is meant to catch — is never sampled away, while a flood cannot turn the log
# into unbounded attacker-controlled disk writes.
const REJECTS_RECORDED_PER_WINDOW := 8
# Server-stall grace (found by task 5.10's own reject recording, which is the
# only reason it was visible at all).
#
# When the server stalls — a 2s SIGSTOP stands in for a GC/IO/scheduler hitch —
# the client keeps sending at 60Hz throughout, and ENet delivers that entire
# backlog in the first window after resume. Measured: 70 of an HONEST client's
# input packets rejected as "rate limit exceeded", against a limit the client
# never came close to violating on its own. Redundancy does not cover it: the
# dropped packets are CONTIGUOUS, so each one's redundancy window falls inside
# the same dropped run — 0 of 70 were rescued, and 82 of 923 sequences (8.88%,
# ~1.4s of that player's input) never reached the server at all, versus 0.00%
# missing on an otherwise identical run with no stall. Every prediction gate
# still passed, which is exactly why this needed the log to find.
#
# So: don't rate-limit a backlog the server itself caused. The grace is capped,
# expires after two windows, and is granted only to peers already being
# tracked, so it cannot be farmed by a peer that connects during the stall. An
# attacker who can induce server stalls to earn budget already has a strictly
# worse capability than sending extra input packets.
const STALL_DETECT_MS := 250
const MAX_STALL_GRACE_PACKETS := SimConstants.TICK_HZ * 4 # 4s of a 60Hz client's backlog
const STALL_GRACE_WINDOWS := 2
enum InputRejectReason {
MALFORMED = 0,
RATE_LIMIT = 1,
}
class _PeerInputState:
var window_start_ms := 0
var packets_this_window := 0
var bytes_this_window := 0
# Leaky bucket: grows by this window's actual total, drains by one
# window's worth of budget, every window — regardless of whether that
# window was itself over or under budget. A steady rate at or under
# budget nets to zero forever (never accumulates); any sustained AVERAGE
# above budget accumulates over time no matter how it's shaped into
# bursts, unlike a streak counter a clean gap can reset to 0.
var excess_packets := 0.0
var excess_bytes := 0.0
var malformed_count := 0
# Reject-recording budget for the current window. Without it the diagnostic
# is a remote disk-fill amplifier: the attacker chooses the flood rate, and
# every dropped packet would otherwise become a disk write. Capped per
# window, reset with the window itself, so an honest client's occasional
# reject is always captured while a flood contributes a bounded sample.
var rejects_recorded_this_window := 0
# Extra packets this peer may send before the limiter treats it as abuse,
# granted when the SERVER stalls and expiring shortly after.
var grace_packets := 0
var grace_windows_left := 0
var logged_rate_limit_this_window := false
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only
# Uncapped lifetime reject totals, so the sampled log can be read against the
# true figure — "8 rate-limit rejects recorded" means nothing on its own when
# the recorder itself stops at 8 per window. Deliberately NOT part of
# _PeerInputState, which is erased the moment a peer disconnects: a departed
# peer's reject history is exactly what the post-mortem wants, and the first
# version of this lost it (every summary printed an empty dictionary, because
# the client had always disconnected by the time the server tore the match
# down). peer_id -> {"malformed": int, "rate_limit": int}.
var _reject_totals: Dictionary = {}
var _last_physics_ms := 0
# Bandwidth (task 3.7's debug overlay): only the two 60Hz hot-path channels
# (input, snapshot) — match_config/score_update are low-frequency control
# messages, not what §2's byte-budget analysis or a live overlay cares
# about. Rolling per-second counters, recomputed opportunistically on each
# send/receive rather than on a timer — nothing needs the rate outside of
# an on-demand overlay read anyway. Use get_bytes_sent_per_sec() /
# get_bytes_received_per_sec() to READ these, not the raw fields directly
# — see those functions for why.
const BANDWIDTH_WINDOW_MS := 1000
var bytes_sent_per_sec := 0.0
var bytes_received_per_sec := 0.0
var _sent_window_start_ms := 0
var _sent_window_bytes := 0
var _received_window_start_ms := 0
var _received_window_bytes := 0
# An adversarial review found bytes_*_per_sec only ever gets recomputed
# INSIDE _track_sent()/_track_received() — i.e. only when traffic actually
# arrives — so if traffic stops entirely (right before a disconnect, or
# during exactly the kind of outage this overlay exists to diagnose), the
# last computed rate displays forever instead of decaying toward zero.
# Report zero once meaningfully more than one window has passed with
# nothing tracked, rather than trusting a stale field.
func get_bytes_sent_per_sec() -> float:
if Time.get_ticks_msec() - _sent_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
return 0.0
return bytes_sent_per_sec
func get_bytes_received_per_sec() -> float:
if Time.get_ticks_msec() - _received_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
return 0.0
return bytes_received_per_sec
func _ready() -> void:
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id))
# Seeded here, not left at 0, so the first physics frame measures a frame
# gap rather than the whole process uptime.
_last_physics_ms = Time.get_ticks_msec()
# Server-side stall watchdog. A SIGSTOPped or hitching process doesn't run this
# either, so the first physics frame after the stall is the one that sees the
# whole wall-clock gap — which is precisely the size of the client backlog
# about to arrive. Grace is handed only to peers ALREADY sending input, so a
# peer that connects during the stall gets none of it.
func _physics_process(_delta: float) -> void:
var now := Time.get_ticks_msec()
var gap := now - _last_physics_ms
_last_physics_ms = now
if not multiplayer.is_server() or _peer_input_state.is_empty():
return
if gap < STALL_DETECT_MS:
return
var credit: int = mini(int(float(gap) * SimConstants.TICK_HZ / 1000.0), MAX_STALL_GRACE_PACKETS)
for peer_id in _peer_input_state:
var state: _PeerInputState = _peer_input_state[peer_id]
state.grace_packets = mini(state.grace_packets + credit, MAX_STALL_GRACE_PACKETS)
state.grace_windows_left = STALL_GRACE_WINDOWS
push_warning("MatchSim: server stalled %dms — granting %d packets of rate-limit grace to %d peer(s)" % [
gap, credit, _peer_input_state.size()
])
ServerLog.warn("server_stalled", {"gap_ms": gap, "grace_packets": credit, "peers": _peer_input_state.size()})
func _track_sent(n: int) -> void:
var now := Time.get_ticks_msec()
if now - _sent_window_start_ms >= BANDWIDTH_WINDOW_MS:
bytes_sent_per_sec = _sent_window_bytes * 1000.0 / maxf(1.0, float(now - _sent_window_start_ms))
_sent_window_start_ms = now
_sent_window_bytes = 0
_sent_window_bytes += n
func _track_received(n: int) -> void:
var now := Time.get_ticks_msec()
if now - _received_window_start_ms >= BANDWIDTH_WINDOW_MS:
bytes_received_per_sec = _received_window_bytes * 1000.0 / maxf(1.0, float(now - _received_window_start_ms))
_received_window_start_ms = now
_received_window_bytes = 0
_received_window_bytes += n
# Server only: the last match_config actually sent, so a client whose own
# scene load (and therefore its match_config_received listener) finishes
# AFTER the server already broadcast can still get it — a one-shot
# broadcast alone is racy against however long the client takes to reach
# the point where it's listening, and Godot signals never buffer for a
# late connection. request_match_config() closes that race by turning
# delivery into "ask until you get it" instead of "hope you were already
# listening." Also covers a late joiner mid-match (Phase 5 will still need
# to add live match *state*, not just this static config, for that case).
var _last_match_config: Dictionary = {}
func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
_last_match_config = {
"arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices,
}
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
# Also the client's cue to ask for live match state — see
# NetworkedMatch._on_match_config_requested. A late joiner's bootstrap has the
# SAME race match_config has: the server sends it when the peer joins the
# roster, which is before that peer has loaded the match scene and connected
# its listeners, so a one-shot send is simply missed. Delivery has to be
# "ask until you get it" for both.
signal match_config_requested(peer_id: int)
func request_match_config() -> void:
_request_match_config.rpc_id(1)
func send_input(bytes: PackedByteArray) -> void:
_track_sent(bytes.size())
# bytes is already fully packed (any timestamps it carries are already
# fixed), so wrapping the dispatch itself is enough — task 2.8.
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
_track_sent(bytes.size())
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
func send_score_update(score: Dictionary) -> void:
_score_update.rpc(score)
# §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the
# transition happened on rather than a duration — §6.2's closing note: on a
# lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a
# duration would then be applied from whenever it happened to arrive.
# The same state also rides every snapshot's match_state byte, so a client
# that misses this entirely still converges (see NetworkedMatch's own
# _on_snapshot_received) — this RPC exists to make the transition PROMPT and
# to carry `at_tick`, not to be the sole channel.
func send_state_change(state: int, at_tick: int) -> void:
_state_change.rpc(state, at_tick)
# §1's "seeded RNG for kickoff jitter" decision, enforced: the server sends the
# resulting TRANSFORMS, never a seed. Shared-seed determinism would require
# both sides to consume the RNG stream in identical order forever, and the
# first randf() anyone later adds to the reset path silently desyncs kickoff
# positions with no error message. A few hundred bytes once per kickoff cannot
# rot that way.
func send_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
_kickoff.rpc(positions, rotations, countdown_start_tick, reset_gen)
func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
_goal_scored.rpc(scoring_team, score, goal_tick, resume_tick)
# remaining_ticks is authoritative while `running` is false: a stopped clock
# cannot be derived from end_tick minus the current tick, or it drains through
# every goal pause and kickoff countdown.
func send_clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
_clock_state.rpc(running, end_tick, remaining_ticks, at_tick)
# §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match
# on arrival, sent to one peer rather than broadcast.
#
# match_config alone is not enough and never was: it carries arena and roster
# only, so a late joiner or a reconnecting player had no score, no clock, and
# no match state until the next goal or transition happened to fire. An
# adversarial review caught that; §6.2 step 2's `welcome` is specified to carry
# exactly this set, so this is that message under a name that does not clash
# with MatchNet's own lobby-level welcome.
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
# §6.3's late-joiner promotion. BROADCAST, not addressed to the new owner
# alone: every client holds its own copy of the slot list, and a peer_id that
# only the promoted client learns about leaves everyone else's copy naming a
# player who is no longer in that seat. Reliable channel 0 — a client that
# misses this keeps flying somebody else's ship as a remote body forever, and
# unlike match_state there is no per-snapshot field that would re-converge it.
func send_slot_assigned(peer_id: int, slot_index: int) -> void:
_slot_assigned.rpc(peer_id, slot_index)
@rpc("authority", "call_remote", "reliable", 0)
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
@rpc("authority", "call_remote", "reliable", 0)
func _slot_assigned(peer_id: int, slot_index: int) -> void:
slot_assigned_received.emit(peer_id, slot_index)
@rpc("any_peer", "call_remote", "reliable", 0)
func _request_match_config() -> void:
if not multiplayer.is_server() or _last_match_config.is_empty():
return
var peer_id := multiplayer.get_remote_sender_id()
_match_config.rpc_id(
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
_last_match_config["teams"], _last_match_config["spawn_indices"]
)
match_config_requested.emit(peer_id)
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func _recv_input(bytes: PackedByteArray) -> void:
if not multiplayer.is_server():
return
_track_received(bytes.size())
var peer_id := multiplayer.get_remote_sender_id()
var state: _PeerInputState = _peer_input_state.get(peer_id)
if state == null:
state = _PeerInputState.new()
_peer_input_state[peer_id] = state
# Rolling 1s window (§3.1 step 2). Rolled over lazily on the first
# packet past the window boundary, not on a timer — this RPC only ever
# runs when a packet actually arrives, so there's nothing to roll over
# when nothing is arriving anyway.
var now_ms := Time.get_ticks_msec()
if now_ms - state.window_start_ms >= RATE_LIMIT_WINDOW_MS:
# The leaky bucket drains against the SAME budget the window itself was
# policed with, grace included — otherwise a server stall would still
# accumulate excess toward a disconnect for traffic the server just
# explicitly allowed.
state.excess_packets = maxf(0.0, state.excess_packets + float(state.packets_this_window) - float(_packet_budget(state)))
state.excess_bytes = maxf(0.0, state.excess_bytes + float(state.bytes_this_window) - float(_byte_budget(state)))
state.window_start_ms = now_ms
state.packets_this_window = 0
state.bytes_this_window = 0
state.rejects_recorded_this_window = 0
state.logged_rate_limit_this_window = false
if state.grace_windows_left > 0:
state.grace_windows_left -= 1
if state.grace_windows_left == 0:
state.grace_packets = 0
if state.excess_packets > RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT or state.excess_bytes > RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT:
# Record before disconnecting, same reasoning as _count_malformed:
# the log should contain the packet that ended the connection, not
# stop one short of it.
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
_disconnect_abusive_peer(peer_id, "input rate limit exceeded (excess_packets=%.0f excess_bytes=%.0f)" % [state.excess_packets, state.excess_bytes])
return
state.packets_this_window += 1
state.bytes_this_window += bytes.size()
if state.packets_this_window > _packet_budget(state) or state.bytes_this_window > _byte_budget(state):
# Over budget for the current window — drop, counted above at the next
# window roll.
if not state.logged_rate_limit_this_window:
# ONCE per window, not per packet: a flood is thousands of packets a
# second and the log line must not become the amplifier the replay
# recorder was capped to avoid being.
state.logged_rate_limit_this_window = true
ServerLog.warn("rate_limited", {
"peer_id": peer_id, "packets": state.packets_this_window,
"budget": _packet_budget(state), "grace": state.grace_packets,
})
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
return
# Framing (§3.1 step 3), validated before decoding — unpack_input can't
# be trusted to catch this itself: StreamPeerBuffer silently zero-fills
# past EOF rather than erroring (found during Phase 2's adversarial
# review's hostile-client stress test), so a too-short or size-mismatched
# payload would otherwise decode "successfully" into garbage actions
# instead of being rejected.
if bytes.size() < NetCodec.INPUT_HEADER_SIZE:
_count_malformed(peer_id, state, bytes)
return
var count: int = bytes[5] # type_version(1) + seq(4) precede count — see pack_input's own layout
if count == 0 or count > NetCodec.MAX_REDUNDANCY or bytes.size() != NetCodec.INPUT_HEADER_SIZE + count * NetCodec.INPUT_ENTRY_SIZE:
_count_malformed(peer_id, state, bytes)
return
var decoded := NetCodec.unpack_input(bytes)
# Carry the verbatim wire bytes alongside the decode. Task 5.10's replay
# log stores exactly what arrived rather than a re-serialisation, which is
# the whole reason it can reproduce a reported snap: a re-encode would
# launder away precisely the malformed or edge-case payload being chased.
decoded["raw"] = bytes
input_received.emit(peer_id, decoded)
# The budget a peer is actually policed against right now: the standing limit
# plus any outstanding server-stall grace. Bytes scale with packets by the same
# worst-case-packet factor RATE_LIMIT_BYTES_PER_SEC itself is derived from, so
# the two budgets can never drift apart by hand.
func _packet_budget(state: _PeerInputState) -> int:
return RATE_LIMIT_PACKETS_PER_SEC + state.grace_packets
func _byte_budget(state: _PeerInputState) -> int:
return _packet_budget(state) * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
func _count_malformed(peer_id: int, state: _PeerInputState, bytes: PackedByteArray) -> void:
state.malformed_count += 1
# Emitted before the disconnect check so the packet that finally crossed
# the limit is itself in the log, not just the 19 before it.
_emit_reject(peer_id, state, InputRejectReason.MALFORMED, bytes)
if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT:
_disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count)
func _emit_reject(peer_id: int, state: _PeerInputState, reason: int, bytes: PackedByteArray) -> void:
var totals: Dictionary = _reject_totals.get(peer_id, {"malformed": 0, "rate_limit": 0})
var key := "rate_limit" if reason == InputRejectReason.RATE_LIMIT else "malformed"
totals[key] = int(totals[key]) + 1
_reject_totals[peer_id] = totals
if state.rejects_recorded_this_window >= REJECTS_RECORDED_PER_WINDOW:
return
state.rejects_recorded_this_window += 1
input_rejected.emit(peer_id, reason, bytes)
# Server-side, diagnostic. peer_id -> {"malformed": int, "rate_limit": int},
# uncapped and surviving the peer's disconnect. Peers with no rejects at all
# never appear, so an empty dictionary means a clean session.
func get_reject_totals() -> Dictionary:
return _reject_totals.duplicate(true)
func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason])
# Task 6.4: the one server event an operator is most likely to be asked
# about ("why was I kicked?"), and it was previously only a push_warning —
# which does not carry the peer, the reason or a timestamp into the log
# stream a container actually captures.
ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason})
_peer_input_state.erase(peer_id)
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func _snapshot(bytes: PackedByteArray) -> void:
_track_received(bytes.size())
var decoded := NetCodec.unpack_snapshot(bytes)
snapshot_received.emit(decoded)
@rpc("authority", "call_remote", "reliable", 0)
func _state_change(state: int, at_tick: int) -> void:
# "authority" already means a forging client is rejected by Godot itself
# (verified for _match_config/_score_update/_snapshot during Phase 2), but
# an authoritative server sending a state this build doesn't know about is
# a real forward-compatibility case — drop it rather than driving the
# client into an undefined state.
if not MatchState.is_valid(state):
push_warning("MatchSim: ignoring unknown match_state %d from server" % state)
return
state_change_received.emit(state, at_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
# 4 quaternion floats per body. A mismatch means a corrupt or hostile
# payload; dropping it is safe because the snapshot stream still carries
# authoritative poses and the next kickoff will re-sync.
if rotations.size() != positions.size() * 4:
push_warning("MatchSim: kickoff payload mismatch (%d positions, %d rotation floats)" % [positions.size(), rotations.size()])
return
kickoff_received.emit(positions, rotations, countdown_start_tick, reset_gen)
@rpc("authority", "call_remote", "reliable", 0)
func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
goal_scored_received.emit(scoring_team, score, goal_tick, resume_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
clock_state_received.emit(running, end_tick, remaining_ticks, at_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
if not MatchState.is_valid(state):
push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state)
return
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
@rpc("authority", "call_remote", "reliable", 0)
func _score_update(score: Dictionary) -> void:
score_update_received.emit(score)
+1
View File
@@ -0,0 +1 @@
uid://bk81de78uwut
+88
View File
@@ -0,0 +1,88 @@
class_name MatchState
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
#
# Pure data + a transition table, deliberately with no scene, RPC or
# NetworkedMatch dependency — same reason net_codec.gd and
# input_jitter_buffer.gd are standalone: the table can then be exhaustively
# unit-tested without a live match.
#
# The integer values ARE the wire format. `match_state` has been a u8 in the
# snapshot header since §2.4 (net_codec.gd's pack_snapshot_body_segment), so
# these numbers are protocol, not an implementation detail: never renumber an
# existing state, only append. LOBBY is 0 so a zeroed/placeholder snapshot
# body decodes to a state that is obviously "not in a match" rather than to
# something mid-play.
enum State {
LOBBY = 0,
LOADING = 1,
WARMUP = 2,
PLAYING = 3,
GOAL_PAUSE = 4,
FULL_TIME = 5,
OVERTIME_WARMUP = 6,
OVERTIME = 7,
RESULTS = 8,
}
# Legal successors, straight from §6.1's diagram. Enforced rather than
# documented: an illegal transition is a server logic bug, and the failure it
# otherwise produces (clients following the server into a state its own code
# never expected to broadcast) is exactly the kind that shows up as an
# unreproducible field report three phases later.
#
# LOBBY is reachable from ANY state and is handled separately in
# can_transition() rather than being listed nine times — §6.4's "if the last
# human leaves, abort to LOBBY" can fire at any point, including mid-goal.
const _SUCCESSORS := {
State.LOBBY: [State.LOADING],
State.LOADING: [State.WARMUP],
State.WARMUP: [State.PLAYING],
# A goal, or the clock running out. FULL_TIME is entered on the clock even
# if a goal is in flight — §6.2 step 9's clock is authoritative.
State.PLAYING: [State.GOAL_PAUSE, State.FULL_TIME],
# Back to a kickoff, or straight to results when the goal that caused the
# pause also ended the match (golden goal in overtime, or a goal on the
# final tick).
State.GOAL_PAUSE: [State.WARMUP, State.OVERTIME_WARMUP, State.RESULTS],
State.FULL_TIME: [State.OVERTIME_WARMUP, State.RESULTS],
State.OVERTIME_WARMUP: [State.OVERTIME],
State.OVERTIME: [State.GOAL_PAUSE, State.RESULTS],
State.RESULTS: [State.LOBBY],
}
# States in which the simulation is live and inputs drive ships. Everything
# else freezes bodies (§6.2 steps 6 and 8). Kept as a set here rather than as
# an `if state == PLAYING or state == OVERTIME` scattered through
# NetworkedMatch, so adding a future live state can't miss a site.
const _LIVE := [State.PLAYING, State.OVERTIME]
static func is_valid(state: int) -> bool:
return state in State.values()
static func is_live(state: int) -> bool:
return state in _LIVE
# True when the match is over and the clock should not advance. Distinct from
# `not is_live()`: a WARMUP is not live but the match is very much ongoing.
static func is_terminal(state: int) -> bool:
return state == State.RESULTS or state == State.LOBBY
static func can_transition(from_state: int, to_state: int) -> bool:
if not is_valid(from_state) or not is_valid(to_state):
return false
if to_state == State.LOBBY:
return from_state != State.LOBBY # §6.4 abort, from anywhere
return to_state in _SUCCESSORS.get(from_state, [])
static func to_name(state: int) -> String:
for key in State.keys():
if State[key] == state:
return key
return "UNKNOWN(%d)" % state
+1
View File
@@ -0,0 +1 @@
uid://b1etnxbdelq1p
+47
View File
@@ -0,0 +1,47 @@
extends RefCounted
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
# node — NetCodec's pack/unpack must stay callable from pure-function tests
# with no live scene. Phase 2's snapshot writer fills one of these per body
# per tick from the real RigidBody3D state; Phase 2's interpolator does the
# reverse.
#
# avel_range must match what the sender quantised with (SHIP_AVEL_RANGE vs
# BALL_AVEL_RANGE in net_codec.gd) — it is not carried on the wire, because
# slot order already tells both peers which body is which (§1.3: "entities
# are addressed by integer slot, never by path").
var position := Vector3.ZERO
var rotation := Quaternion.IDENTITY
var linear_velocity := Vector3.ZERO
var angular_velocity := Vector3.ZERO
var frozen := false
var turbo := false
var thrust_z := 0.0 # -1..1; re-quantised to a 3-bit bin on the wire
var stalled := false
var avel_range := 4.0 # NetCodec.SHIP_AVEL_RANGE; set to BALL_AVEL_RANGE for the ball
# Self-referential preload, not get_script().new() — this file deliberately
# has no class_name (same cache-timing reason as test_case.gd and other
# path-`extends`d files in this project), and get_script().new() throws
# "Nonexistent function 'new' in base 'GDScript'" from within the script's
# own body in this Godot version.
const _NetBodyState = preload("res://scripts/net_body_state.gd")
# Same contract as ShipAction.copy() (see its own comment): a distinct
# instance with equal fields, for callers that hold onto a state past the
# tick/comparison it was returned in.
func copy() -> RefCounted:
var c := _NetBodyState.new()
c.position = position
c.rotation = rotation
c.linear_velocity = linear_velocity
c.angular_velocity = angular_velocity
c.frozen = frozen
c.turbo = turbo
c.thrust_z = thrust_z
c.stalled = stalled
c.avel_range = avel_range
return c
+1
View File
@@ -0,0 +1 @@
uid://bc1r0cqvtbqec
+295
View File
@@ -0,0 +1,295 @@
class_name NetCodec
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
# no autoload state — so they're testable head-on by tests/test_runner.tscn
# without a live connection.
#
# Referenced from elsewhere via preload(), not the bare class_name, per the
# same global-script-class-cache caveat documented in tests/test_case.gd and
# sim_constants.gd.
const SimConstants = preload("res://scripts/sim_constants.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
const NetBodyState = preload("res://scripts/net_body_state.gd")
# --- Protocol ---
const PROTOCOL_VERSION := 1
const TICK_HZ: int = SimConstants.TICK_HZ
# --- Channels (logical intent; NetworkManager may need to offset these on
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
const CHANNEL_CONTROL := 0
const CHANNEL_INPUT := 1
const CHANNEL_SNAPSHOT := 2
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
# --- Input packet (§2.3) ---
const MAX_REDUNDANCY := 4
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
const INPUT_HEADER_SIZE := 12
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
const INPUT_FLAG_TURBO := 1 << 0
# --- Snapshot packet (§2.4) ---
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
const SNAPSHOT_BODY_HEADER_SIZE := 8
const SNAPSHOT_BODY_SIZE := 22
const BODY_FLAG_FROZEN := 1 << 0
const BODY_FLAG_TURBO := 1 << 1
const BODY_FLAG_THRUST_Z_SHIFT := 2
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
const BODY_FLAG_STALLED := 1 << 5
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
# constants these are sized against) ---
const POS_RANGE := 64.0 # metres, ±
const VEL_RANGE := 64.0 # m/s, ±
const QUAT_COMPONENT_RANGE := 1.0
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
const I16_MAX := 32767
const I8_MAX := 127
const THRUST_Z_BIN_MAX := 7 # 3 bits
# ============================================================
# Quantisers — pure, reusable, independently testable.
# ============================================================
static func quantize_i16(value: float, range_max: float) -> int:
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
static func dequantize_i16(raw: int, range_max: float) -> float:
return (float(raw) / I16_MAX) * range_max
static func quantize_i8(value: float, range_max: float) -> int:
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
static func dequantize_i8(raw: int, range_max: float) -> float:
return (float(raw) / I8_MAX) * range_max
static func quantize_thrust_z_bin(thrust_z: float) -> int:
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
static func dequantize_thrust_z_bin(bin_value: int) -> float:
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
static func type_version_byte(type: PacketType) -> int:
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
static func packet_type_of(type_version: int) -> int:
return (type_version >> 4) & 0x0F
static func protocol_version_of(type_version: int) -> int:
return type_version & 0x0F
# ============================================================
# Input packet — client -> server, channel 1 (§2.3)
# ============================================================
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
var buf := StreamPeerBuffer.new()
buf.put_u8(type_version_byte(PacketType.INPUT))
buf.put_u32(seq)
buf.put_u8(count)
buf.put_u32(ack_snapshot_tick)
buf.put_u16(client_send_ms & 0xFFFF)
for i in count:
var action: ShipAction = actions[i]
buf.put_8(quantize_i8(action.thrust.x, 1.0))
buf.put_8(quantize_i8(action.thrust.y, 1.0))
buf.put_8(quantize_i8(action.thrust.z, 1.0))
buf.put_8(quantize_i8(action.rotation.x, 1.0))
buf.put_8(quantize_i8(action.rotation.y, 1.0))
buf.put_8(quantize_i8(action.rotation.z, 1.0))
var flags := 0
if action.turbo:
flags |= INPUT_FLAG_TURBO
buf.put_u8(flags)
return buf.data_array
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
# client_send_ms, actions (Array[ShipAction], newest first).
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
var type_version := buf.get_u8()
var seq := buf.get_u32()
var count := buf.get_u8()
var ack_snapshot_tick := buf.get_u32()
var client_send_ms := buf.get_u16()
var actions: Array[ShipAction] = []
for i in count:
var a := ShipAction.new()
a.thrust = Vector3(
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0)
)
a.rotation = Vector3(
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0)
)
var flags := buf.get_u8()
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
actions.append(a)
return {
"type_version": type_version,
"seq": seq,
"count": count,
"ack_snapshot_tick": ack_snapshot_tick,
"client_send_ms": client_send_ms,
"actions": actions,
}
# ============================================================
# Snapshot packet — server -> client, channel 2 (§2.4)
# ============================================================
# Shared across every peer this tick — build once, reuse (§2.4's stated
# intent). Returns type_version + server_tick + match_state + reset_gen +
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
buf.put_u32(server_tick)
buf.put_u8(match_state & 0xFF)
buf.put_u8(reset_gen & 0xFF)
buf.put_u8(bodies.size())
for body in bodies:
var b: NetBodyState = body
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
var flags := 0
if b.frozen:
flags |= BODY_FLAG_FROZEN
if b.turbo:
flags |= BODY_FLAG_TURBO
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
if b.stalled:
flags |= BODY_FLAG_STALLED
if b.rotation.w < 0.0:
flags |= BODY_FLAG_QUAT_W_SIGN
buf.put_u8(flags)
return buf.data_array
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u32(last_input_seq)
buf.put_8(clampi(input_buffer_depth, -128, 127))
buf.put_u16(echo_client_send_ms & 0xFFFF)
return buf.data_array
# Convenience: one full per-client packet = per-client header + shared body segment.
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
var out := PackedByteArray()
out.append_array(header)
out.append_array(body_segment)
return out
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
var last_input_seq := buf.get_u32()
var input_buffer_depth := buf.get_8()
var echo_client_send_ms := buf.get_u16()
var type_version := buf.get_u8()
var server_tick := buf.get_u32()
var match_state := buf.get_u8()
var reset_gen := buf.get_u8()
var body_count := buf.get_u8()
var bodies: Array[NetBodyState] = []
for i in body_count:
var b := NetBodyState.new()
b.position = Vector3(
dequantize_i16(buf.get_16(), POS_RANGE),
dequantize_i16(buf.get_16(), POS_RANGE),
dequantize_i16(buf.get_16(), POS_RANGE)
)
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
b.linear_velocity = Vector3(
dequantize_i16(buf.get_16(), VEL_RANGE),
dequantize_i16(buf.get_16(), VEL_RANGE),
dequantize_i16(buf.get_16(), VEL_RANGE)
)
# avel_range is unknown to the codec at this point (it isn't on the
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
# the caller, which knows this slot's body kind, rescale if it's the
# ball's slot. Storing the raw i8 would avoid this, but every other
# field in this struct is already physical units; consistency wins.
b.angular_velocity = Vector3(
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
)
var flags := buf.get_u8()
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
b.turbo = (flags & BODY_FLAG_TURBO) != 0
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
b.thrust_z = dequantize_thrust_z_bin(bin_value)
b.stalled = (flags & BODY_FLAG_STALLED) != 0
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
var w := sqrt(maxf(w_sq, 0.0))
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
w = -w
b.rotation = Quaternion(qx, qy, qz, w)
bodies.append(b)
return {
"last_input_seq": last_input_seq,
"input_buffer_depth": input_buffer_depth,
"echo_client_send_ms": echo_client_send_ms,
"type_version": type_version,
"server_tick": server_tick,
"match_state": match_state,
"reset_gen": reset_gen,
"bodies": bodies,
}
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
# assumption unpack_snapshot() decoded it with to the range it was actually
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
# immediately after unpack_snapshot(), using slot order to know which.
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
body.avel_range = actual_range
return
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
body.avel_range = actual_range
+1
View File
@@ -0,0 +1 @@
uid://bbb72h1ue0hdp
+87
View File
@@ -0,0 +1,87 @@
extends CanvasLayer
# Autoload: toggleable network debug overlay (F4 by default — see
# toggle_net_overlay in project.godot's [input]). Read-only against
# NetworkManager's clock state (task 1.8). Mirrors perf_overlay.gd's pattern
# — headless-guarded, hidden by default, no gameplay-state writes.
var _label: Label
func _ready() -> void:
if DisplayServer.get_name() == "headless":
set_process(false)
return
layer = 100
_label = Label.new()
_label.add_theme_font_size_override("font_size", 14)
_label.add_theme_color_override("font_color", Color(0.5, 0.8, 1.0))
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
_label.add_theme_constant_override("shadow_offset_x", 1)
_label.add_theme_constant_override("shadow_offset_y", 1)
_label.position = Vector2(12, 90)
_label.visible = false
add_child(_label)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_net_overlay") and _label:
_label.visible = not _label.visible
return
if not _label or not _label.visible or not (event is InputEventKey) or not event.pressed or event.echo:
return
var game := get_tree().get_first_node_in_group("game")
if game == null or not game.has_method("adjust_prediction_tuning"):
return
# Client-only live tuning: [/] threshold, -/= visual decay, ,/. visual
# offset, P present-time A/B. Deliberately no project input actions: these
# diagnostics never enter ShipAction or server/controller code.
match event.keycode:
KEY_BRACKETLEFT: game.adjust_prediction_tuning(-0.1)
KEY_BRACKETRIGHT: game.adjust_prediction_tuning(0.1)
KEY_MINUS: game.adjust_prediction_tuning(0.0, -0.01)
KEY_EQUAL: game.adjust_prediction_tuning(0.0, 0.01)
KEY_COMMA: game.adjust_prediction_tuning(0.0, 0.0, -0.05)
KEY_PERIOD: game.adjust_prediction_tuning(0.0, 0.0, 0.05)
KEY_P: game.adjust_prediction_tuning(0.0, 0.0, 0.0, true)
func _process(_delta: float) -> void:
if not _label or not _label.visible:
return
if NetworkManager.is_server:
_label.text = "NET: server, %d peer(s) out %s in %s" % [
MatchNet.roster.size(), _format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
]
elif NetworkManager.is_client:
if NetworkManager.rtt_ms < 0.0:
_label.text = "NET: client, connecting (no clock sample yet)"
else:
# task 3.7: RTT, jitter, loss, buffer depth, snapshot age,
# bandwidth all live here now. Prediction error is intentionally
# absent — there is no client-side prediction until Phase 4, so
# there is nothing honest to show for it yet. STALLED shows the
# server's own InputJitterBuffer.stalled bit for this client's
# slot, round-tripped through the wire.
var stats := {}
var game := get_tree().get_first_node_in_group("game")
if game and game.has_method("get_net_debug_stats"):
stats = game.get_net_debug_stats()
var stalled_suffix := " STALLED" if stats.get("server_stalled", false) else ""
var prediction: Dictionary = stats.get("prediction", {})
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s (target %s) lead %s loss %.1f%% snap age %.1fms%s\npred pos p50/p95/p99 %.3f / %.3f / %.3fm\npred rot p50/p95/p99 %.2f / %.2f / %.2fdeg snaps %.2f/min\nremote residual p99 %.3fm / %.2fdeg A/B present=%s\ntune [/] pos %.2f -/= decay ,/. offset %.2f P toggle\nout %s in %s" % [
NetworkManager.rtt_ms, NetworkManager.jitter_ms, NetworkManager.clock_offset_ms,
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_target_depth", "-")), str(stats.get("input_lead", "-")),
stats.get("snapshot_loss_pct", 0.0), stats.get("snapshot_age_ms", 0.0), stalled_suffix,
prediction.get("position_error_p50", 0.0), prediction.get("position_error_p95", 0.0), prediction.get("position_error_p99", 0.0),
prediction.get("rotation_error_p50", 0.0), prediction.get("rotation_error_p95", 0.0), prediction.get("rotation_error_p99", 0.0), prediction.get("hard_snap_rate_per_min", 0.0),
stats.get("remote_residual_position_p99", 0.0), stats.get("remote_residual_rotation_p99", 0.0), str(game.remote_visual_present_time_enabled if game else false),
prediction.get("position_threshold", 0.0), prediction.get("max_visual_offset", 0.0),
_format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
]
else:
_label.text = "NET: offline"
func _format_kbps(bytes_per_sec: float) -> String:
return "%.2f KB/s" % (bytes_per_sec / 1000.0)
+1
View File
@@ -0,0 +1 @@
uid://cn2rdmwfo7phi
+126
View File
@@ -0,0 +1,126 @@
class_name NetInterpolator
extends RefCounted
# Buffers recent snapshot samples for ONE remote body and produces
# interpolated states at any requested (possibly fractional) server tick —
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
# entities"): once at the present-time estimate for the collider, once
# further back at present-minus-INTERP_DELAY for $Visual.
#
# server_tick (Engine.get_physics_frames() at send time) maps to an
# estimated server wall-clock time via TICK_HZ without any extra
# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec()
# count from the same process-start epoch, and physics has been running at
# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ)
# is a valid estimate of "what Time.get_ticks_msec() read on the server when
# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms()
# reading into the same tick-space with to_tick(ms) before calling sample_at().
const NetBodyState = preload("res://scripts/net_body_state.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
const MAX_SAMPLES := 16
# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one
# flying through a wall."
const MAX_EXTRAPOLATION_MS := 150.0
const TICK_MS := 1000.0 / SimConstants.TICK_HZ
var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first
var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset
static func to_tick(server_time_ms: float) -> float:
return server_time_ms / TICK_MS
# Returns true if this sample's reset_gen differs from the last one seen —
# the caller's cue to hard-snap instead of interpolating across a
# server-authoritative teleport (kickoff, goal reset) rather than sliding
# across the arena. Clears buffered history on a reset so a stale
# pre-reset sample can never bracket a post-reset one.
func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool:
# Never let a stale unreliable snapshot rewrite the epoch. The previous
# ordering cleared samples on its reset byte before checking tick order,
# so a delayed pre-reset packet could alternately flip generations and
# repeatedly cancel an active local ball handoff.
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
return false
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
if is_reset:
_samples.clear()
reset_gen = sample_reset_gen
_samples.append({"tick": server_tick, "state": state})
if _samples.size() > MAX_SAMPLES:
_samples.pop_front()
return is_reset
func has_samples() -> bool:
return not _samples.is_empty()
func accepts_tick(server_tick: int) -> bool:
return _samples.is_empty() or server_tick > int(_samples.back()["tick"])
func latest() -> NetBodyState:
return _samples.back()["state"] if not _samples.is_empty() else null
# target_tick may be fractional (a point in time between two integer ticks).
func sample_at(target_tick: float) -> NetBodyState:
if _samples.is_empty():
return null
if _samples.size() == 1:
return _samples[0]["state"]
if target_tick <= _samples[0]["tick"]:
return _samples[0]["state"]
var newest: Dictionary = _samples.back()
if target_tick >= newest["tick"]:
return _extrapolate(newest, target_tick)
for i in range(_samples.size() - 1):
var a: Dictionary = _samples[i]
var b: Dictionary = _samples[i + 1]
if a["tick"] <= target_tick and target_tick <= b["tick"]:
var a_tick: float = a["tick"]
var b_tick: float = b["tick"]
var span := b_tick - a_tick
var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0
return _lerp_state(a["state"], b["state"], t)
return newest["state"]
func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState:
var out := NetBodyState.new()
out.position = a.position.lerp(b.position, t)
out.rotation = a.rotation.slerp(b.rotation, t)
out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t)
out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t)
out.frozen = b.frozen
out.turbo = b.turbo
out.thrust_z = b.thrust_z
out.stalled = b.stalled
out.avel_range = b.avel_range
return out
func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
var state: NetBodyState = newest["state"]
var ticks_ahead: float = target_tick - float(newest["tick"])
var ms_ahead := ticks_ahead * TICK_MS
var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS)
var out := NetBodyState.new()
out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0)
var angular_speed := state.angular_velocity.length()
if angular_speed > 0.00001:
out.rotation = (Quaternion(state.angular_velocity / angular_speed, angular_speed * (clamped_ms / 1000.0)) * state.rotation).normalized()
else:
out.rotation = state.rotation
out.linear_velocity = state.linear_velocity
out.angular_velocity = state.angular_velocity
out.frozen = state.frozen
out.turbo = state.turbo
out.thrust_z = state.thrust_z
out.stalled = state.stalled
out.avel_range = state.avel_range
return out
+1
View File
@@ -0,0 +1 @@
uid://cgb1vcapxami7
+273
View File
@@ -0,0 +1,273 @@
extends RefCounted
# Local-ship reconciliation policy (multiplayer-todo.md §4.4). Kept out of
# NetworkedMatch so the decision table is pure-testable; the imperative half
# only writes Ship's existing Jolt-safe queued correction hooks.
const DEFAULT_HARD_POSITION_ERROR := 2.0
const DEFAULT_HARD_ROTATION_ERROR_DEGREES := 60.0
const DEFAULT_MAX_VISUAL_OFFSET := 0.4
const METRIC_SAMPLE_CAPACITY := 3600 # one minute at the 60Hz snapshot rate
const NetBodyState = preload("res://scripts/net_body_state.gd")
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
var _last_reset_gen := -1 # first snapshot establishes baseline, never resets
var _position_errors: Array[float] = []
var _rotation_errors: Array[float] = []
var _free_flight_position_errors: Array[float] = []
var _free_flight_rotation_errors: Array[float] = []
var _visual_correction_errors: Array[float] = []
var _free_flight_visual_correction_errors: Array[float] = []
var _hard_snap_count := 0
var _decision_count := 0
var _resync_until_seq := -1
var _metrics_started_ms := -1
var hard_position_error := DEFAULT_HARD_POSITION_ERROR
var hard_rotation_error_degrees := DEFAULT_HARD_ROTATION_ERROR_DEGREES
var max_visual_offset := DEFAULT_MAX_VISUAL_OFFSET
var _hard_snap_reasons := {}
var _hard_snap_cohorts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
var _cohort_counts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bool, position_threshold: float = DEFAULT_HARD_POSITION_ERROR, rotation_threshold_degrees: float = DEFAULT_HARD_ROTATION_ERROR_DEGREES) -> Dictionary:
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
if reset_changed:
return {"mode": "hard", "reason": "reset_gen"}
# An attack's skipped sequence is issued, sent, and acknowledged, but never
# locally simulated — there is no predicted state to compare and nothing is
# wrong. It is not history loss and must not teleport the ship or arm resync
# suppression: the lead controller produces these during ordinary play, and
# treating them as missing history cost several unnecessary hard snaps a
# minute. Skip the acknowledgement; the next simulated sequence (at most a
# tick or two later, since the server consumes one per tick) reconciles
# normally against real data.
if comparison.get("status", "") == "unsimulated_gap":
return {"mode": "skip", "reason": "unsimulated_gap"}
if comparison.get("status", "missing_not_recorded") != "matched":
return {"mode": "hard", "reason": comparison.get("status", "missing")}
if authoritative == null or authoritative.frozen != local_frozen:
return {"mode": "hard", "reason": "frozen_mismatch"}
if float(comparison["position_error_magnitude"]) > position_threshold:
return {"mode": "hard", "reason": "position_error"}
if float(comparison["rotation_error_degrees"]) > rotation_threshold_degrees:
return {"mode": "hard", "reason": "rotation_error"}
return {"mode": "soft", "reason": "within_thresholds"}
static func soft_corrected_transform(current_transform: Transform3D, comparison: Dictionary) -> Transform3D:
var authoritative: NetBodyState = comparison["authoritative_state"]
var predicted: NetBodyState = comparison["predicted_state"]
var position_delta: Vector3 = authoritative.position - predicted.position
var rotation_delta := Basis(authoritative.rotation.normalized()) * Basis(predicted.rotation.normalized()).inverse()
return Transform3D(
(rotation_delta * current_transform.basis).orthonormalized(),
current_transform.origin + position_delta
)
func reconcile(comparison: Dictionary, ship: Ship, reset_gen: int, current_seq: int, history: LocalPredictionHistory) -> Dictionary:
var reset_changed := _last_reset_gen != -1 and reset_gen != _last_reset_gen
_last_reset_gen = reset_gen
var comparison_seq := int(comparison.get("seq", -1))
# A reset is an epoch boundary, never ordinary stale traffic. It must
# preempt an outstanding missing-history suppression or the first reset
# snapshot could be discarded and every later snapshot share its generation.
if reset_changed:
_resync_until_seq = -1
var reset_decision := decide(comparison, ship.freeze, true, hard_position_error, hard_rotation_error_degrees)
_record_metrics(comparison, reset_decision)
var reset_authority: NetBodyState = comparison.get("authoritative_state", null)
if reset_authority != null:
ship.queue_teleport_with_velocity(Transform3D(Basis(reset_authority.rotation), reset_authority.position), reset_authority.linear_velocity, reset_authority.angular_velocity)
ship.net_visual_offset = Vector3.ZERO
ship.net_visual_rotation_offset = Quaternion.IDENTITY
if is_instance_valid(ship.visual):
ship.visual.position = Vector3.ZERO
ship.visual.basis = Basis.IDENTITY
_resync_until_seq = current_seq + 1
return reset_decision
if _resync_until_seq >= 0:
if comparison.get("status", "") == "matched" and comparison_seq >= _resync_until_seq:
_resync_until_seq = -1
else:
return {"mode": "suppressed", "reason": "awaiting_resync"}
var decision := decide(comparison, ship.freeze, false, hard_position_error, hard_rotation_error_degrees)
_record_metrics(comparison, decision)
if decision["mode"] == "skip":
# Deliberately before the authority write below: a skipped acknowledgement
# leaves the body, the visual offset and _resync_until_seq exactly as they
# were. Nothing about this sequence is unhealthy, so nothing is corrected
# and nothing is suppressed.
return decision
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
if authoritative == null:
return decision
if comparison.get("status", "") == "matched" and decision["reason"] != "reset_gen":
# Transport the same-sequence authority error through current Jolt state
# and retained predictions. This deliberately avoids fake single-body
# replay, which cannot reproduce contact impulses/friction.
var predicted: NetBodyState = comparison["predicted_state"]
var position_delta: Vector3 = comparison["position_error"]
var velocity_error: Vector3 = comparison["linear_velocity_error"]
var angular_velocity_error: Vector3 = comparison["angular_velocity_error"]
var rotation_delta := (authoritative.rotation.normalized() * predicted.rotation.normalized().inverse()).normalized()
history.overwrite_state(int(comparison["seq"]), authoritative)
history.rebase_state_range(int(comparison["seq"]) + 1, current_seq, position_delta, rotation_delta, velocity_error, angular_velocity_error)
var old_basis := ship.global_transform.basis
var corrected_transform := soft_corrected_transform(ship.global_transform, comparison)
# Apply both velocity deltas to the live body atomically with pose. The
# same deltas are transported through retained history above.
ship.queue_teleport_with_velocity(corrected_transform, ship.linear_velocity + velocity_error, ship.angular_velocity + angular_velocity_error)
var position_error: Vector3 = comparison["position_error"]
if decision["mode"] == "soft":
ship.net_visual_offset = (ship.global_transform.basis.inverse() * -position_error).limit_length(max_visual_offset)
# The body rotates in world space. Convert the inverse correction to
# the child visual's local basis so its global orientation is preserved
# through the physical correction (B_old^-1 Δ^-1 B_old).
var local_visual_delta: Basis = old_basis.inverse() * Basis(rotation_delta.inverse()) * old_basis
ship.net_visual_rotation_offset = local_visual_delta.get_rotation_quaternion() * ship.net_visual_rotation_offset
else:
ship.net_visual_offset = Vector3.ZERO
ship.net_visual_rotation_offset = Quaternion.IDENTITY
if is_instance_valid(ship.visual):
ship.visual.position = Vector3.ZERO
ship.visual.basis = Basis.IDENTITY
else:
# Reset/missing state has no trustworthy delta. Place authority once;
# callers must wait for a new matched history entry before correction.
ship.queue_teleport_with_velocity(Transform3D(Basis(authoritative.rotation), authoritative.position), authoritative.linear_velocity, authoritative.angular_velocity)
ship.net_visual_offset = Vector3.ZERO
ship.net_visual_rotation_offset = Quaternion.IDENTITY
if is_instance_valid(ship.visual):
ship.visual.position = Vector3.ZERO
ship.visual.basis = Basis.IDENTITY
# Retain no fabricated future. Once local input history contains a
# newly acknowledged sequence, normal delta reconciliation resumes.
_resync_until_seq = current_seq + 1
return decision
func get_metrics() -> Dictionary:
return {
"sample_count": _position_errors.size(),
"position_error_p50": _percentile(0.50),
"position_error_p95": _percentile(0.95),
"position_error_p99": _percentile(0.99),
"rotation_error_p50": _rotation_percentile(0.50),
"rotation_error_p95": _rotation_percentile(0.95),
"rotation_error_p99": _rotation_percentile(0.99),
"free_flight_sample_count": _free_flight_position_errors.size(),
"free_flight_position_error_p95": _percentile_from(_free_flight_position_errors, 0.95),
"free_flight_position_error_p99": _percentile_from(_free_flight_position_errors, 0.99),
"free_flight_rotation_error_p95": _percentile_from(_free_flight_rotation_errors, 0.95),
"free_flight_rotation_error_p99": _percentile_from(_free_flight_rotation_errors, 0.99),
"visual_correction_p95": _percentile_from(_visual_correction_errors, 0.95),
"visual_correction_p99": _percentile_from(_visual_correction_errors, 0.99),
"free_flight_visual_correction_p95": _percentile_from(_free_flight_visual_correction_errors, 0.95),
"free_flight_visual_correction_p99": _percentile_from(_free_flight_visual_correction_errors, 0.99),
"hard_snap_count": _hard_snap_count,
"hard_snap_rate_per_min": _hard_snap_rate_per_min(),
"hard_snap_reasons": _hard_snap_reasons.duplicate(),
"hard_snap_cohorts": _hard_snap_cohorts.duplicate(),
"cohorts": _cohort_counts.duplicate(),
"position_threshold": hard_position_error,
"rotation_threshold_degrees": hard_rotation_error_degrees,
"max_visual_offset": max_visual_offset,
}
func clear_metrics() -> void:
_position_errors.clear()
_rotation_errors.clear()
_free_flight_position_errors.clear()
_free_flight_rotation_errors.clear()
_visual_correction_errors.clear()
_free_flight_visual_correction_errors.clear()
_hard_snap_count = 0
_decision_count = 0
_resync_until_seq = -1
_metrics_started_ms = -1
_hard_snap_reasons.clear()
_hard_snap_cohorts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
_cohort_counts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
func _record_metrics(comparison: Dictionary, decision: Dictionary) -> void:
if _metrics_started_ms < 0:
_metrics_started_ms = Time.get_ticks_msec()
_decision_count += 1
var cohort := _cohort_for(comparison, decision)
if decision["mode"] == "hard":
_hard_snap_count += 1
var reason := str(decision.get("reason", "unknown"))
_hard_snap_reasons[reason] = int(_hard_snap_reasons.get(reason, 0)) + 1
_hard_snap_cohorts[cohort] = int(_hard_snap_cohorts.get(cohort, 0)) + 1
_cohort_counts[cohort] = int(_cohort_counts.get(cohort, 0)) + 1
if comparison.get("status", "") == "matched":
# Only same-sequence predictions are quality samples. Recovery events
# still count in their own cohorts/reason ledger, but must not distort
# p95/p99 with an error that cannot honestly be measured.
_position_errors.append(float(comparison["position_error_magnitude"]))
_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
if cohort == "free_flight":
_free_flight_position_errors.append(float(comparison["position_error_magnitude"]))
_free_flight_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
# The visual offset hides at most max_visual_offset of a soft correction.
# Record the exposed remainder, never the capped hidden component; hard
# corrections are independently gated by their cohort count above.
var visual_error := maxf(0.0, float(comparison.get("position_error_magnitude", 0.0)) - max_visual_offset) if decision["mode"] == "soft" else 0.0
_visual_correction_errors.append(visual_error)
if cohort == "free_flight":
_free_flight_visual_correction_errors.append(visual_error)
if _position_errors.size() > METRIC_SAMPLE_CAPACITY:
_position_errors.pop_front()
if _rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
_rotation_errors.pop_front()
if _free_flight_position_errors.size() > METRIC_SAMPLE_CAPACITY:
_free_flight_position_errors.pop_front()
if _free_flight_rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
_free_flight_rotation_errors.pop_front()
if _visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
_visual_correction_errors.pop_front()
if _free_flight_visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
_free_flight_visual_correction_errors.pop_front()
func _cohort_for(comparison: Dictionary, decision: Dictionary) -> String:
if decision.get("reason", "") == "reset_gen":
return "reset"
if decision.get("reason", "") == "unsimulated_gap":
# Its own cohort, not free_flight: these carry no error sample, and
# folding them into a quality cohort would silently inflate its count
# with rows that contributed no measurement.
return "unsimulated"
if decision.get("reason", "").begins_with("missing") or _resync_until_seq >= 0:
return "resync"
if comparison.get("contact_window", false):
return "contact"
return "free_flight"
func _hard_snap_rate_per_min() -> float:
if _metrics_started_ms < 0:
return 0.0
var elapsed_seconds := maxf(float(Time.get_ticks_msec() - _metrics_started_ms) / 1000.0, 0.001)
return float(_hard_snap_count) * 60.0 / elapsed_seconds
func _percentile(fraction: float) -> float:
return _percentile_from(_position_errors, fraction)
func _percentile_from(samples: Array[float], fraction: float) -> float:
if samples.is_empty():
return 0.0
var sorted := samples.duplicate()
sorted.sort()
var index := clampi(roundi((sorted.size() - 1) * fraction), 0, sorted.size() - 1)
return sorted[index]
func _rotation_percentile(fraction: float) -> float:
return _percentile_from(_rotation_errors, fraction)
+1
View File
@@ -0,0 +1 @@
uid://c0qjwh4af8pbn
+134
View File
@@ -0,0 +1,134 @@
extends Node
# Autoload (project.godot [autoload] NetSim). Debug-only, seeded
# latency/jitter/loss/duplicate decorator around outgoing RPC dispatch —
# task 2.8. A pure passthrough (send() calls dispatch.call() immediately)
# unless CLI flags are given, so every existing test and the real game are
# byte-for-byte unaffected by this autoload merely existing.
#
# CLI (read once, in this process's own OS.get_cmdline_user_args()):
# --net-sim-latency=<ms> one-way delay added before each wrapped send
# --net-sim-jitter=<ms> extra uniform-random 0..jitter added per send
# --net-sim-loss=<0..1> fraction of sends dropped entirely (never sent)
# --net-sim-dup=<0..1> probability a send is ALSO sent a second time
# --net-sim-seed=<int> RNG seed (default fixed, so a bad run reproduces
# unless a CI/local run deliberately wants a
# different one — same "seeded so failures
# reproduce" bar as §11's testing section sets)
#
# "Asymmetric-capable" per §7 task 2.8 is not a separate feature: each
# process reads only its own CLI args and only delays its own outgoing
# sends, so running the host and client with different flags (e.g. a
# lossy-upload client against a clean host) already produces asymmetric
# behaviour with no extra plumbing.
#
# Call sites build a zero-argument Callable that performs the actual
# rpc_id()/rpc() dispatch, so NetSim never needs to know per-call argument
# shapes. IMPORTANT for callers that embed a timestamp in the call (e.g.
# NetworkManager's _ping/_pong): capture Time.get_ticks_msec() *before*
# calling send(), not inside the wrapped Callable — the delay is meant to
# simulate wire transit *after* the packet is "sent", so a timestamp taken
# inside the delayed closure would silently absorb this process's own
# outbound leg out of any round-trip measurement built on top of it.
#
# Wraps MatchSim.send_input / send_snapshot per the doc's task 2.8 scope,
# plus NetworkManager's _ping/_pong dispatch — the latter is a deliberate
# addition beyond the literal task text: it's the only RTT measurement that
# already exists and is already tested (tests/clock_smoke.gd, task 1.8), so
# routing it through NetSim is what makes "`--net-sim-latency 80` measurably
# raises observed RTT" (this task's own stated acceptance criterion)
# checkable today, without waiting on Phase 3's per-peer snapshot echo.
const DEFAULT_SEED := 20260820
var latency_ms := 0.0
var jitter_ms := 0.0
var loss_fraction := 0.0
var dup_fraction := 0.0
var _rng := RandomNumberGenerator.new() # owned instance — never the global RNG, task 0.7's rule
func _ready() -> void:
var seed_value := DEFAULT_SEED
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--net-sim-latency="):
latency_ms = maxf(0.0, arg.get_slice("=", 1).to_float())
elif arg.begins_with("--net-sim-jitter="):
jitter_ms = maxf(0.0, arg.get_slice("=", 1).to_float())
elif arg.begins_with("--net-sim-loss="):
loss_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0)
elif arg.begins_with("--net-sim-dup="):
dup_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0)
elif arg.begins_with("--net-sim-seed="):
seed_value = arg.get_slice("=", 1).to_int()
_rng.seed = seed_value
func is_active() -> bool:
return latency_ms > 0.0 or jitter_ms > 0.0 or loss_fraction > 0.0 or dup_fraction > 0.0
# target_peer_id: the specific remote peer this dispatch is addressed to
# (rpc_id's target), or -1 for a broadcast / not a targeted send. Only used
# to re-validate a delayed send right before it actually fires — see _fire.
func send(dispatch: Callable, target_peer_id: int = -1) -> void:
if not is_active():
dispatch.call()
return
if _rng.randf() < loss_fraction:
return
_schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0)
if _rng.randf() < dup_fraction:
_schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0)
func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> void:
if delay_sec <= 0.0:
dispatch.call()
return
# process_always = true: a simulated wire delay must keep counting down
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
# this today; multiplayer-todo.md §8 already flags get_tree().paused
# stopping the client's own send/receive loop as a separate refactor
# item). Pausing this timer too would let a paused client's in-flight
# packets pile up and arrive in a burst on unpause instead of on their
# simulated schedule.
get_tree().create_timer(delay_sec, true).timeout.connect(func() -> void: _fire(dispatch, target_peer_id))
# Re-validates the target right before a DELAYED send actually fires.
# NetSim's whole point is to hold a packet in flight past the moment it was
# queued, and in that window the target peer (or this process's own
# connection) can legitimately be gone — a disconnect mid-match, or this
# process's own shutdown() already having reset multiplayer_peer to a fresh
# OfflineMultiplayerPeer. Firing anyway reproduced two real bugs while
# building this task: "Attempt to call RPC with unknown peer ID" (stale
# remote target — networked_match.gd's own get_peers() filter on
# _broadcast_snapshot only checked validity at *schedule* time, and the
# target had disconnected by the time the delayed send actually fired) and
# "'_recv_input' on yourself is not allowed by selected mode" (this
# process's own peer was already torn down, so peer id 1 now refers to
# itself instead of the server). The synchronous (delay_sec <= 0 / NetSim
# inactive) path is deliberately NOT re-validated here — nothing has had
# time to change since the caller's own validation, and matching the
# pre-NetSim behaviour exactly there is what keeps NetSim a true no-op when
# no CLI flags are given.
#
# Known residual gap, judged not worth the complexity for debug-only
# tooling: if this process shuts down AND reconnects (a fresh host()/join())
# within one delayed send's hold time, multiplayer_peer is a real peer again
# and get_peers() may coincidentally contain the same target_peer_id from
# the new session, so a stale send from the old session could slip through.
# Closing that fully would need a generation counter bumped on every
# shutdown/host/join and stamped on each scheduled send — disproportionate
# for a latency simulator that only ever runs in manual/CI testing.
func _fire(dispatch: Callable, target_peer_id: int) -> void:
var peer := multiplayer.multiplayer_peer
if peer == null or peer is OfflineMultiplayerPeer:
return
if peer is ENetMultiplayerPeer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
if target_peer_id != -1 and target_peer_id not in multiplayer.get_peers():
return
dispatch.call()
+1
View File
@@ -0,0 +1 @@
uid://cboh4k3bka8vu
+26
View File
@@ -0,0 +1,26 @@
class_name NetTransport
extends RefCounted
# Narrow construction boundary for Godot's MultiplayerPeer implementations.
# NetworkManager owns polling, RPC policy, and lifecycle; a transport only
# creates a peer. Keeping that split means ENet remains a first-class path
# while Steam can use SDR without duplicating the rest of the networking code.
func transport_id() -> String:
return ""
func is_available() -> bool:
return false
func unavailable_reason() -> String:
return "transport is unavailable"
func create_server(_port: int, _max_clients: int) -> Dictionary:
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
func create_client(_address: String, _port: int) -> Dictionary:
return {"error": ERR_UNAVAILABLE, "peer": null, "reason": unavailable_reason()}
+281
View File
@@ -0,0 +1,281 @@
extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
# hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
#
# server_relay = false is set the moment a peer exists: the default `true`
# lets any client rpc() any other client *through the server*, which this
# project's server-authoritative model must never allow — §2.1 calls this
# out as the single highest-value one-line security change in the document.
#
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
# shutdown()/close the peer the instant connected_to_server or peer_connected
# fires. ENet's connect handshake isn't fully settled on the *other* side the
# moment your own side's signal fires — the final ACK still needs a couple
# more poll() cycles to actually reach the wire. Closing immediately drops
# it and leaves the other side's handshake permanently incomplete (it will
# never see peer_connected/connected_to_server at all). Callers that shut
# down right after a fresh connection should let a frame or two pass first.
#
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
# full frame before it's actually pushed onto the wire — and the return leg
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
# that off; every caller that sends or expects to receive on a tight cadence
# must now call NetworkManager.poll() itself. The intended placement per
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
# sending input, plus top of both _process and _physics_process for receive;
# server: tick start to drain, tick end to flush) has no real per-tick caller
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
# Until then, anything driving a connection (tests/net_smoke.gd included)
# must poll() every frame itself or nothing will ever be sent or received.
signal client_connected(peer_id: int)
signal client_disconnected(peer_id: int)
signal connected_to_server()
signal connection_failed()
signal disconnected_from_server()
signal clock_updated(rtt_ms: float, offset_ms: float)
# Fires at the top of every shutdown() call, whether this process was
# hosting, joined, or already offline, and regardless of *why* (deliberate
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
# review found MatchNet.roster had no path that cleared it when a HOST
# stopped hosting — connected_to_server/disconnected_from_server only cover
# the client side — so a host -> lobby -> leave -> host-again cycle left a
# permanent phantom player. Listeners that need per-role cleanup should
# still use the more specific signals above; this one exists so "something
# is about to reset the connection, drop anything you were keeping" has
# exactly one place to hook regardless of role.
signal shutting_down()
const DEFAULT_PORT := 7777
const MAX_CLIENTS := 32
const TRANSPORT_ENET := "enet"
const TRANSPORT_STEAM := "steam"
const EnetTransportScript = preload("res://scripts/enet_transport.gd")
const SteamTransportScript = preload("res://scripts/steam_transport.gd")
# Clock (task 1.8, §4.7): client pings the server once a second on the
# reliable control channel; clock_offset_ms is the min-RTT sample in a
# rolling window, because the lowest-RTT sample has the least queueing
# error. get_server_time_estimate_ms() is the thing every later phase
# (interpolation delay, tick_offset seeding) actually wants — everything
# else here exists to produce it.
const PING_INTERVAL_SEC := 1.0
const CLOCK_WINDOW_SEC := 5.0
var is_server := false
var is_client := false
var _peer: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var active_transport := ""
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
var _clock_samples: Array[Dictionary] = []
var _ping_accum_sec := 0.0
# Jitter (task 3.7's debug overlay): RFC3550-style EWMA of the deviation
# between consecutive RAW (not min-filtered) RTT samples — rtt_ms itself is
# a min-RTT, deliberately insensitive to jitter by design (§4.7), so a
# separate, unfiltered running estimate is needed to actually see it.
const JITTER_EWMA_ALPHA := 1.0 / 16.0 # matches RFC3550's own smoothing factor
var jitter_ms := 0.0
var _last_raw_rtt_ms := -1.0
func _ready() -> void:
get_tree().set_multiplayer_poll_enabled(false)
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func _process(delta: float) -> void:
# is_client turns true the instant join() is called, before the ENet
# handshake actually completes (or fails) — a slow or refused connect
# attempt would otherwise leave this trying to rpc_id() on a peer
# that's still CONNECTING (or already failed), which Godot logs as
# "Trying to call an RPC via a multiplayer peer which is not
# connected." every single frame. Require the real transport state.
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
_ping_accum_sec += delta
if _ping_accum_sec >= PING_INTERVAL_SEC:
_ping_accum_sec = 0.0
# Capture the timestamp now, before NetSim (task 2.8) can add any
# simulated delay — see net_sim.gd's header comment for why.
var send_ms := Time.get_ticks_msec()
NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1)
# Estimate of what the server's Time.get_ticks_msec() reads right now.
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
func get_server_time_estimate_ms() -> float:
return float(Time.get_ticks_msec()) + clock_offset_ms
# The single entry point every per-tick caller uses instead of relying on
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
# polling the default OfflineMultiplayerPeer is a no-op.
func poll() -> void:
multiplayer.poll()
func available_transports() -> PackedStringArray:
var transports := PackedStringArray([TRANSPORT_ENET])
if SteamTransportScript.new().is_available():
transports.append(TRANSPORT_STEAM)
return transports
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS, transport: String = TRANSPORT_ENET) -> Error:
shutdown()
var implementation := _make_transport(transport)
if implementation == null:
return ERR_INVALID_PARAMETER
var result: Dictionary = implementation.create_server(port, max_clients)
var err := int(result.error)
if err != OK:
push_error("NetworkManager.host(%s): create_server failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
return err
_peer = result.peer as MultiplayerPeer
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false
active_transport = transport
is_server = true
is_client = false
return OK
func join(address: String, port: int = DEFAULT_PORT, transport: String = TRANSPORT_ENET) -> Error:
shutdown()
var implementation := _make_transport(transport)
if implementation == null:
return ERR_INVALID_PARAMETER
var result: Dictionary = implementation.create_client(address, port)
var err := int(result.error)
if err != OK:
push_error("NetworkManager.join(%s): create_client failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
return err
_peer = result.peer as MultiplayerPeer
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false
active_transport = transport
is_server = false
is_client = true
return OK
func shutdown() -> void:
shutting_down.emit()
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
# sentinel, never null — closing that sentinel is a no-op, but assigning
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
# leaves the API in a state distinct from its own default, which is a
# known source of confusing follow-on bugs (godotengine/godot#81540).
# Always reset to a real OfflineMultiplayerPeer, never raw null.
var peer := multiplayer.multiplayer_peer
if peer != null and not (peer is OfflineMultiplayerPeer):
peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_peer = null
active_transport = ""
is_server = false
is_client = false
rtt_ms = -1.0
clock_offset_ms = 0.0
_clock_samples.clear()
_ping_accum_sec = 0.0
jitter_ms = 0.0
_last_raw_rtt_ms = -1.0
func _make_transport(transport: String) -> NetTransport:
match transport:
TRANSPORT_ENET:
return EnetTransportScript.new()
TRANSPORT_STEAM:
return SteamTransportScript.new()
_:
push_error("NetworkManager: unknown transport '%s'" % transport)
return null
@rpc("any_peer", "call_remote", "reliable")
func _ping(client_send_ms: int) -> void:
if not multiplayer.is_server():
return
# Same rule as the client's send above: read the server's clock now, at
# true receipt time, before NetSim can delay the reply — otherwise the
# server's own outbound leg would be silently absorbed out of both the
# RTT sample and the offset estimate instead of adding to them.
var server_now := Time.get_ticks_msec()
var sender_id := multiplayer.get_remote_sender_id()
# A single poll() call can process several queued RPCs from the same
# peer in one batch — an earlier one in that same batch (e.g. task 3.4's
# abuse-triggered match_sim.gd disconnect_peer() call, or the peer
# disconnecting for any other reason mid-batch) can leave this ping's
# sender no longer a valid peer by the time its own turn in the batch
# comes up. Empirically confirmed reachable with disconnect_peer()'s
# default arguments (a graceful, non-forced disconnect — match_sim.gd's
# own disconnect call tried force=true as an alternative and reverted
# it, since that left Godot's own peer-list bookkeeping inconsistent
# and produced far MORE of this exact class of error, not fewer:
# hundreds vs. one, verified). NetSim's inactive/passthrough path (the
# common case — no CLI flags) dispatches immediately with no
# validation of its own, so check here rather than relying on it.
if sender_id not in multiplayer.get_peers():
return
NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id)
@rpc("authority", "call_remote", "reliable")
func _pong(client_send_ms: int, server_now_ms: int) -> void:
var now_ms := Time.get_ticks_msec()
var sample_rtt := float(now_ms - client_send_ms)
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
if _last_raw_rtt_ms >= 0.0:
var deviation := absf(sample_rtt - _last_raw_rtt_ms)
jitter_ms += (deviation - jitter_ms) * JITTER_EWMA_ALPHA
_last_raw_rtt_ms = sample_rtt
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
var best: Dictionary = _clock_samples[0]
for sample: Dictionary in _clock_samples:
if sample["rtt"] < best["rtt"]:
best = sample
rtt_ms = best["rtt"]
clock_offset_ms = best["offset"]
clock_updated.emit(rtt_ms, clock_offset_ms)
func _on_peer_connected(peer_id: int) -> void:
client_connected.emit(peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
client_disconnected.emit(peer_id)
func _on_connected_to_server() -> void:
connected_to_server.emit()
func _on_connection_failed() -> void:
is_client = false
connection_failed.emit()
func _on_server_disconnected() -> void:
is_server = false
is_client = false
disconnected_from_server.emit()
+1
View File
@@ -0,0 +1 @@
uid://bd1g4evti23ab
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://da8db6ofcbjt2
+62
View File
@@ -0,0 +1,62 @@
extends CanvasLayer
# Autoload: toggleable frame-time/bottleneck overlay (F3 by default — see
# toggle_perf_overlay in project.godot's [input]). Read-only against
# Performance monitors; never touches rendering or gameplay state. Exists so
# 0.17/0.17b's graphics presets and resolution scaling are self-diagnosing —
# TIME_PROCESS vs total frame time tells the player whether they're CPU- or
# GPU-bound. See multiplayer-todo.md task 0.20.
# ~2s of history at 60 fps; enough to make p50/p99 meaningful without the
# history itself being a rate-dependent quantity.
const HISTORY_SIZE := 120
var _label: Label
var _frame_times_ms: PackedFloat32Array = PackedFloat32Array()
var _history_index := 0
var _history_filled := 0
func _ready() -> void:
# A headless server never renders and has no input to toggle this with.
if DisplayServer.get_name() == "headless":
set_process(false)
return
layer = 100
_label = Label.new()
_label.add_theme_font_size_override("font_size", 14)
_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
_label.add_theme_constant_override("shadow_offset_x", 1)
_label.add_theme_constant_override("shadow_offset_y", 1)
_label.position = Vector2(12, 12)
_label.visible = false
add_child(_label)
_frame_times_ms.resize(HISTORY_SIZE)
_frame_times_ms.fill(0.0)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_perf_overlay") and _label:
_label.visible = not _label.visible
func _process(_delta: float) -> void:
if not _label or not _label.visible:
return
var frame_ms := Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
var physics_ms := Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
_frame_times_ms[_history_index] = frame_ms
_history_index = (_history_index + 1) % HISTORY_SIZE
_history_filled = mini(_history_filled + 1, HISTORY_SIZE)
var sorted := _frame_times_ms.slice(0, _history_filled)
sorted.sort()
var p50 := sorted[sorted.size() / 2]
var p99 := sorted[int(sorted.size() * 0.99)]
_label.text = "FPS %d (p50 %.2fms p99 %.2fms)\nprocess %.2fms physics %.2fms\ndraw calls %d" % [
Performance.get_monitor(Performance.TIME_FPS),
p50, p99, frame_ms, physics_ms,
Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
]
+1
View File
@@ -0,0 +1 @@
uid://dlis4s1io7tnd
+171
View File
@@ -0,0 +1,171 @@
class_name ReplayLog
extends RefCounted
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
#
# The highest-value debuggability investment in Phase 5, and cheap precisely
# because the packets are ALREADY flat bytes: this stores them verbatim rather
# than re-serialising game state. Without it, "my ship snapped" is permanently
# unreproducible from a field report — the CI gate catches regressions, but it
# cannot debug a player's bad night.
#
# Deliberately a standalone RefCounted with no scene/RPC dependency, like
# net_codec.gd and input_jitter_buffer.gd, so it can be unit-tested against a
# scripted record/read cycle with no live match.
#
# Format. Little-endian throughout, matching StreamPeerBuffer's own defaults
# and NetCodec's wire encoding:
#
# magic u32 'CCRP' (0x50524343)
# version u16 FORMAT_VERSION
# tick_hz u16 so a reader can convert ticks to seconds without guessing
# then, repeated:
# kind u8 RecordKind
# tick u32 server tick (Engine.get_physics_frames())
# peer_id u32 sender for INPUT, 0 for SNAPSHOT
# length u16 payload byte count
# payload length bytes, exactly as it went on the wire
#
# `length` is a u16 because both hot-path packets are far under 64KB (a 1v1
# snapshot is ~59 bytes) and MatchSim.MAX_INPUT_LENGTH already rejects
# anything larger on the way in.
#
# Framing is kind-agnostic, so new RecordKind values are additive — a reader
# that doesn't know a kind still walks past it correctly. The version bump to 2
# exists anyway because absence is otherwise ambiguous: without it, a log with
# no REJECTED_* records cannot be told apart from one written by a build that
# never recorded rejections in the first place, which is exactly the question
# "the server dropped my input" needs answered.
const MAGIC := 0x50524343
const FORMAT_VERSION := 2
const HEADER_SIZE := 8
const RECORD_HEADER_SIZE := 11
enum RecordKind {
INPUT = 0, # client -> server, accepted and handed to the jitter buffer
SNAPSHOT = 1, # server -> client, as sent
# Rejections. An accepted-input-only log answers "what did the server
# simulate", but the field report that actually needs a replay is usually
# "my input did nothing" — and the packets that would explain it are
# precisely the ones the old log discarded. Each reason is its own kind
# rather than a reason field, so the framing above is unchanged.
REJECTED_MALFORMED = 2, # failed §3.1 step 3 framing validation
REJECTED_RATE_LIMIT = 3, # over budget for the current 1s window
REJECTED_SEQ_GUARD = 4, # seq beyond the slot's ingest bound (§3.1 step 4)
}
var _file: FileAccess = null
var records_written := 0
var bytes_written := 0
# Set once a write actually fails (disk full, removed volume). Everything after
# it is dropped: a partial record would desync the framing of every record that
# follows, turning a truncation into a corrupt file.
var write_failed := false
var records_dropped := 0
# Returns OK, or an error code. A replay log is diagnostic: a caller that
# cannot open one should carry on serving the match, not refuse to start.
func open_for_write(path: String) -> Error:
_file = FileAccess.open(path, FileAccess.WRITE)
if _file == null:
return FileAccess.get_open_error()
_file.store_32(MAGIC)
_file.store_16(FORMAT_VERSION)
_file.store_16(SimConstants.TICK_HZ)
bytes_written = HEADER_SIZE
return OK
func is_open() -> bool:
return _file != null
func record_input(tick: int, peer_id: int, payload: PackedByteArray) -> void:
_write(RecordKind.INPUT, tick, peer_id, payload)
func record_snapshot(tick: int, payload: PackedByteArray) -> void:
_write(RecordKind.SNAPSHOT, tick, 0, payload)
# `kind` must be one of the REJECTED_* values; the caller knows why it dropped
# the packet and nothing here can re-derive it.
func record_rejected_input(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
_write(kind, tick, peer_id, payload)
func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
if _file == null or write_failed:
return
if payload.size() > 0xFFFF:
# Cannot happen through the real ingress paths (see the header note),
# but truncating silently would corrupt every later record's framing.
push_warning("ReplayLog: dropping an oversized %d-byte payload" % payload.size())
records_dropped += 1
return
_file.store_8(kind)
_file.store_32(tick)
_file.store_32(peer_id)
_file.store_16(payload.size())
if payload.size() > 0:
_file.store_buffer(payload)
# Checked via get_error() rather than the store_* return values because it
# reports the same condition once for the whole record instead of six times,
# and because a diagnostic log that has quietly stopped writing is worse
# than no log at all — the reader would see a plausible short match rather
# than a failure. One write error ends the log permanently.
var err := _file.get_error()
if err != OK:
write_failed = true
records_dropped += 1
push_warning("ReplayLog: write failed (%s) after %d records — log closed early" % [error_string(err), records_written])
_file.close()
_file = null
return
records_written += 1
bytes_written += RECORD_HEADER_SIZE + payload.size()
func close() -> void:
if _file == null:
return
_file.close()
_file = null
# Reads a whole log back. Returns {"tick_hz": int, "records": Array} or an
# empty Dictionary if the file is missing/not a replay log. Static and
# self-contained so an offline tool — or a test — can consume a log without
# instantiating anything.
static func read_all(path: String) -> Dictionary:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return {}
if f.get_length() < HEADER_SIZE or f.get_32() != MAGIC:
f.close()
return {}
var version := f.get_16()
var tick_hz := f.get_16()
var records: Array = []
# Bound every read on the declared length rather than trusting EOF:
# FileAccess silently zero-fills past the end, exactly as StreamPeerBuffer
# does, so a truncated file would otherwise decode as an endless run of
# zero-length records at tick 0.
while f.get_position() + RECORD_HEADER_SIZE <= f.get_length():
var kind := f.get_8()
var tick := f.get_32()
var peer_id := f.get_32()
var length := f.get_16()
if f.get_position() + length > f.get_length():
push_warning("ReplayLog: truncated final record in %s" % path)
break
records.append({
"kind": kind,
"tick": tick,
"peer_id": peer_id,
"payload": f.get_buffer(length) if length > 0 else PackedByteArray(),
})
f.close()
return {"version": version, "tick_hz": tick_hz, "records": records}
+1
View File
@@ -0,0 +1 @@
uid://bfrexwrkq3cia
+12
View File
@@ -1,3 +1,15 @@
class_name ScenePaths
const MAIN_MENU := "res://scenes/main_menu.tscn"
# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu —
# a community server whose players are all dumped back to their own menus
# every 2.5 minutes has no way to keep a lobby together.
const LOBBY := "res://scenes/lobby.tscn"
# Task 6.5: the dedicated server's match loop needs this by name, and it was
# previously only ever reached by test harnesses hardcoding the string.
const NETWORKED_MATCH := "res://scenes/networked_match.tscn"
const SERVER_BOOT := "res://scenes/server_boot.tscn"
# Arena variants add presentation-only scenery. The dedicated server uses the
# common physical layout instead, while MatchSim still tells clients which
# variant to render.
const SERVER_ARENA := "res://scenes/arena_base.tscn"
+112
View File
@@ -0,0 +1,112 @@
extends Node
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
# via NetworkManager, logs structured lines, and watches for physics-tick
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
# a tick overrunning 16.7ms backs up the accumulator and the next frame
# runs multiple ticks, spiking CPU further — worth logging, not just
# silently absorbing).
#
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
#
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
# scene. This is just the process shell: listen, log, idle cheaply.
var _last_physics_frame := 0
var config: ServerConfig = null
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
func _ready() -> void:
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
# Task 6.3. This process owns the whole command line, so it parses STRICTLY:
# an unknown flag or an out-of-range value stops the server with a message
# rather than starting one that silently ignores half of what it was told.
config = ServerConfig.parse(OS.get_cmdline_user_args())
if config.help_requested:
print(ServerConfig.help_text())
get_tree().quit(0)
return
if not config.is_valid():
# Straight to stderr-ish plain print rather than through _log: the log
# level itself may be one of the things that failed to parse, and an
# operator running this by hand needs to see every problem at once, not
# the first one.
printerr("cosmic-clash-server: refusing to start")
for problem in config.errors:
printerr(" %s" % problem)
printerr("try --help")
get_tree().quit(1)
return
ServerLog.configure(String(config.get_value("log-level")))
var port := int(config.get_value("port"))
var max_clients := int(config.get_value("max-clients"))
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
MatchNet.player_joined.connect(_on_player_joined)
MatchNet.player_left.connect(_on_player_left)
var err := NetworkManager.host(port, max_clients)
if err != OK:
ServerLog.error("server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
_install_match_loop()
ServerLog.info("server_started", {
"port": port, "max_clients": max_clients, "log_level": ServerLog.level_name(),
"min_players": int(config.get_value("min-players")),
"max_matches": int(config.get_value("max-matches")),
"arena_rotation": String(config.get_value("arena-rotation")),
})
_last_physics_frame = Engine.get_physics_frames()
# Task 6.5. Parented to the ROOT rather than to this node: the loop calls
# change_scene_to_file, which frees the current scene — and this boot scene IS
# the current scene, so a loop parented here would be freed by the first match
# it started. Same constraint the smoke-test hooks document.
func _install_match_loop() -> void:
var loop := ServerMatchLoop.new()
loop.name = "ServerMatchLoop"
loop.min_players = int(config.get_value("min-players"))
loop.start_countdown_seconds = float(config.get_value("start-countdown"))
loop.max_matches = int(config.get_value("max-matches"))
loop.rotation_mode = String(config.get_value("arena-rotation"))
get_tree().root.add_child.call_deferred(loop)
func _process(_delta: float) -> void:
NetworkManager.poll()
var current := Engine.get_physics_frames()
var steps := current - _last_physics_frame
_last_physics_frame = current
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
# alternate between 0 and 2 ticks even on an idle, healthy server —
# that's expected quantisation, not backlog. A real overrun is the
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
if steps > 2 and _watchdog_armed:
ServerLog.warn("physics_overrun", {"steps": steps})
_watchdog_armed = true
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_client_connected(peer_id: int) -> void:
ServerLog.debug("peer_connected", {"peer_id": peer_id})
func _on_client_disconnected(peer_id: int) -> void:
ServerLog.debug("peer_disconnected", {"peer_id": peer_id})
func _on_player_joined(peer_id: int, player_name: String) -> void:
ServerLog.info("player_joined", {"peer_id": peer_id, "name": player_name, "roster": MatchNet.roster.size()})
func _on_player_left(peer_id: int) -> void:
ServerLog.info("player_left", {"peer_id": peer_id, "roster": MatchNet.roster.size()})
+1
View File
@@ -0,0 +1 @@
uid://ci6xqqmag4axj
+295
View File
@@ -0,0 +1,295 @@
class_name ServerConfig
extends RefCounted
# Dedicated-server configuration (multiplayer-todo.md task 6.3): one
# declaration of every server flag, one parser, one `--help`.
#
# Standalone RefCounted with no scene or RPC dependency — same reason as
# net_codec.gd, match_state.gd and input_jitter_buffer.gd — so the precedence
# rules and every validation path are unit-testable against a scripted argv
# with no live server.
#
# Why this exists rather than more `arg.begins_with(...)` chains: the flags had
# grown to roughly thirty across server_boot.gd and networked_match.gd, each
# parsed inline, none documented anywhere, and — the part that actually bites —
# **an unrecognised flag was silently ignored**. `--max-clientss=8` ran a server
# on the default player cap and said nothing about it. A dedicated server whose
# operator cannot tell a typo from a working setting is the wrong kind of quiet,
# so unknown flags and unparseable values are hard errors here.
#
# Precedence, highest first:
# 1. the command line
# 2. the config file (--config=<path>, a Godot ConfigFile under [server])
# 3. the declared default
#
# That order is the conventional one and it is the one an operator expects when
# they override a mounted config file for a single run.
enum Kind { BOOL, INT, FLOAT, STRING }
class Spec:
var key: String # canonical name, without the leading dashes
var kind: int
var default_value: Variant
var help: String
# Flags the match scene reads rather than the boot scene. Recorded so
# `--help` can group them honestly instead of implying one consumer.
var section: String
func _init(p_key: String, p_kind: int, p_default: Variant, p_section: String, p_help: String) -> void:
key = p_key
kind = p_kind
default_value = p_default
section = p_section
help = p_help
# The single source of truth. A flag that is not here does not exist, and
# adding one here is all that is needed for it to be parsed, validated,
# type-checked, config-file-backed and documented.
static func specs() -> Array[Spec]:
var out: Array[Spec] = []
out.append(Spec.new("port", Kind.INT, 7777, "network", "UDP port to listen on"))
out.append(Spec.new("max-clients", Kind.INT, 12, "network", "Maximum simultaneous connected peers"))
out.append(Spec.new("max-spectators", Kind.INT, -1, "network", "Spectator cap; 0 disables spectating, negative means unlimited"))
out.append(Spec.new("log-level", Kind.STRING, "info", "logging", "One of debug, info, warn, error"))
out.append(Spec.new("replay-log", Kind.STRING, "", "logging", "Path to record a binary replay log to; empty disables (see tools/replay_dump.gd)"))
out.append(Spec.new("match-length", Kind.FLOAT, 150.0, "match", "Regulation length in seconds"))
out.append(Spec.new("max-matches", Kind.INT, 0, "match", "Exit cleanly after this many completed matches; 0 runs forever"))
out.append(Spec.new("min-players", Kind.INT, 1, "match", "Players required before a match starts"))
out.append(Spec.new("start-countdown", Kind.FLOAT, 5.0, "match", "Seconds to wait after min-players is met before starting"))
out.append(Spec.new("arena-rotation", Kind.STRING, "sequential", "match", "How the next arena is picked: sequential or random"))
out.append(Spec.new("smoke-force-goal-after", Kind.FLOAT, -1.0, "match", "LOCAL TEST ONLY: force one server-authoritative goal this many seconds after play starts; -1 disables"))
out.append(Spec.new("fill-bots", Kind.BOOL, false, "match", "Give a disconnected player's ship to a bot instead of leaving it inert"))
out.append(Spec.new("slot-reservation-seconds", Kind.FLOAT, 30.0, "match", "How long a departed player's slot is held for their return"))
out.append(Spec.new("config", Kind.STRING, "", "general", "Path to a config file supplying defaults for any flag above"))
return out
var values: Dictionary = {} # key -> parsed value
var errors: PackedStringArray = [] # human-readable, in the order encountered
var help_requested := false
var config_path := ""
func is_valid() -> bool:
return errors.is_empty()
func get_value(key: String) -> Variant:
return values.get(key)
# `argv` is OS.get_cmdline_user_args() in production. Taking it as a parameter
# is what makes every branch below testable without a process.
#
# `strict` controls what an unrecognised flag means, and the distinction is
# load-bearing rather than a convenience. server_boot.gd owns the whole command
# line, so an unknown flag there is an operator error and must stop the process.
# networked_match.gd is ONE CONSUMER of a shared argv — the smoke harnesses put
# --role=, --drive-seconds= and a dozen client-side flags on the same line — so
# it reads leniently. Nothing is lost: every server flag is declared here, so
# the strict pass in server_boot.gd already validated all of them before the
# match scene ever re-reads its own.
static func parse(argv: PackedStringArray, strict: bool = true) -> ServerConfig:
var config := ServerConfig.new()
var by_key := {}
for spec in specs():
by_key[spec.key] = spec
config.values[spec.key] = spec.default_value
# Two passes. --config has to be resolved before the file can be read, and
# the file must be applied UNDER the command line rather than over it, so
# the file cannot be loaded lazily as flags stream past.
var seen: Array[String] = []
var pending: Array = []
for arg in argv:
if arg == "--help" or arg == "-h":
config.help_requested = true
continue
if not arg.begins_with("--"):
if strict:
config.errors.append("unrecognised argument '%s' (flags start with --)" % arg)
continue
var body := arg.substr(2)
var key := body
var raw := ""
var has_value := false
var eq := body.find("=")
if eq >= 0:
key = body.substr(0, eq)
raw = body.substr(eq + 1)
has_value = true
# --no-<bool> is the conventional off switch and is NOT declared as its
# own Spec, or `--help` would list every boolean twice. Rewrite it into
# the positive flag with an inverted value before anything else looks
# at it.
var negated := _is_negation(key, by_key)
if not negated.is_empty():
if has_value:
config.errors.append("flag '--%s' does not take a value" % key)
continue
key = negated
raw = "false"
has_value = true
if not by_key.has(key):
if strict:
config.errors.append("unknown flag '--%s' (see --help)" % key)
continue
var spec: Spec = by_key[key]
# A bare --flag is only meaningful for a bool, and --no-flag is the
# conventional way to turn one off. Every other kind needs a value, and
# a missing one is an error rather than a silent default.
if not has_value:
if spec.kind == Kind.BOOL:
raw = "true"
elif strict:
config.errors.append("flag '--%s' needs a value (--%s=<%s>)" % [key, key, _kind_name(spec.kind)])
continue
else:
continue
if key in seen:
if strict:
config.errors.append("flag '--%s' given more than once" % key)
continue
seen.append(key)
if key == "config":
config.config_path = raw
continue
pending.append([spec, raw])
# Config file first, so the command line lands on top of it.
if not config.config_path.is_empty():
config._apply_config_file(by_key)
for entry in pending:
var spec: Spec = entry[0]
var parsed = _coerce(spec, entry[1])
if parsed == null:
config.errors.append("flag '--%s' expects %s, got '%s'" % [spec.key, _kind_name(spec.kind), entry[1]])
continue
config.values[spec.key] = parsed
config._validate()
return config
# --no-<bool-flag>, handled by declaring the negation as a synonym rather than
# as its own Spec — otherwise `--help` lists every boolean twice.
static func _is_negation(key: String, by_key: Dictionary) -> String:
if not key.begins_with("no-"):
return ""
var positive := key.substr(3)
if by_key.has(positive) and (by_key[positive] as Spec).kind == Kind.BOOL:
return positive
return ""
func _apply_config_file(by_key: Dictionary) -> void:
var file := ConfigFile.new()
var err := file.load(config_path)
if err != OK:
errors.append("could not read config file '%s' (%s)" % [config_path, error_string(err)])
return
for key in file.get_section_keys("server") if file.has_section("server") else []:
if not by_key.has(key):
errors.append("unknown key '%s' in config file '%s'" % [key, config_path])
continue
var spec: Spec = by_key[key]
var raw = file.get_value("server", key)
var parsed = _coerce(spec, str(raw))
if parsed == null:
errors.append("config file key '%s' expects %s, got '%s'" % [key, _kind_name(spec.kind), str(raw)])
continue
values[key] = parsed
# Returns null on failure — deliberately, so "unparseable" is distinguishable
# from a legitimately falsy 0/false/"" result.
static func _coerce(spec: Spec, raw: String) -> Variant:
match spec.kind:
Kind.BOOL:
var lowered := raw.to_lower()
if lowered in ["true", "1", "yes", "on"]:
return true
if lowered in ["false", "0", "no", "off"]:
return false
return null
Kind.INT:
return int(raw) if raw.is_valid_int() else null
Kind.FLOAT:
# is_valid_float() accepts integers too, which is what an operator
# writing --match-length=150 expects.
return float(raw) if raw.is_valid_float() else null
Kind.STRING:
return raw
return null
# Range and enum checks the type system cannot express. Kept separate from
# coercion so an error says "out of range" rather than "expects int".
func _validate() -> void:
var port := int(values["port"])
if port < 1 or port > 65535:
errors.append("--port must be 1-65535, got %d" % port)
if int(values["max-clients"]) < 1:
errors.append("--max-clients must be at least 1, got %d" % int(values["max-clients"]))
if float(values["match-length"]) <= 0.0:
errors.append("--match-length must be positive, got %s" % str(values["match-length"]))
if int(values["max-matches"]) < 0:
errors.append("--max-matches must be 0 or more, got %d" % int(values["max-matches"]))
if int(values["min-players"]) < 1:
errors.append("--min-players must be at least 1, got %d" % int(values["min-players"]))
if float(values["slot-reservation-seconds"]) < 0.0:
errors.append("--slot-reservation-seconds cannot be negative, got %s" % str(values["slot-reservation-seconds"]))
if float(values["smoke-force-goal-after"]) < -1.0:
errors.append("--smoke-force-goal-after must be -1 (disabled) or 0 or more, got %s" % str(values["smoke-force-goal-after"]))
var level := String(values["log-level"])
if not level in ["debug", "info", "warn", "error"]:
errors.append("--log-level must be one of debug, info, warn, error; got '%s'" % level)
var rotation := String(values["arena-rotation"])
if not rotation in ["sequential", "random"]:
errors.append("--arena-rotation must be sequential or random, got '%s'" % rotation)
static func _kind_name(kind: int) -> String:
match kind:
Kind.BOOL: return "bool"
Kind.INT: return "int"
Kind.FLOAT: return "number"
Kind.STRING: return "string"
return "value"
static func help_text() -> String:
var lines := PackedStringArray()
lines.append("Cosmic Clash dedicated server")
lines.append("")
lines.append(" CosmicClashServer.x86_64 -- --port=7777 --max-clients=6")
lines.append("")
lines.append("Flags may also be supplied by a config file:")
lines.append("")
lines.append(" --config=/etc/cosmicclash/server.cfg")
lines.append("")
lines.append(" [server]")
lines.append(" port=7777")
lines.append(" max-clients=6")
lines.append("")
lines.append("The command line overrides the config file, which overrides the defaults")
lines.append("shown below. An unknown flag is an error, not a warning.")
var sections := ["general", "network", "match", "logging"]
var all := specs()
for section in sections:
lines.append("")
lines.append("%s:" % section)
for spec in all:
if spec.section != section:
continue
var value_hint := "" if spec.kind == Kind.BOOL else "=<%s>" % _kind_name(spec.kind)
var flag := "--%s%s" % [spec.key, value_hint]
var default_hint := ""
if spec.kind == Kind.BOOL:
default_hint = " [default: %s, disable with --no-%s]" % [str(spec.default_value), spec.key]
elif not str(spec.default_value).is_empty():
default_hint = " [default: %s]" % str(spec.default_value)
lines.append(" %-34s %s%s" % [flag, spec.help, default_hint])
return "\n".join(lines)
+1
View File
@@ -0,0 +1 @@
uid://ddo2ye666o0am
+92
View File
@@ -0,0 +1,92 @@
class_name ServerLog
extends RefCounted
# Structured server logging (multiplayer-todo.md task 6.4).
#
# Extracted from server_boot.gd's private `_log`, which could only ever see
# what the boot scene itself observed: connects, disconnects, roster changes
# and tick overruns. The events an operator actually asks about — who scored,
# who got kicked and why, which peer is flooding — happen inside
# networked_match.gd and match_sim.gd, neither of which could reach a logger
# living on a scene node that gets freed at the first change_scene_to_file.
# Static state on a class_name is reachable from all three with no autoload
# and no ordering dependency.
#
# Format: `[<seconds since boot>] LEVEL event key=value key=value`. One line
# per event, no wrapping, no multi-line payloads, keys before values — so
# `grep 'player_joined'` and `awk` both work on it without a parser.
#
# ROTATION IS DELIBERATELY NOT IMPLEMENTED HERE. The server logs to stdout and
# stops there, because every way this is actually run already owns log
# rotation and does it better: `docker logs` with its json-file driver's
# max-size/max-file, journald under the systemd unit, or a redirect into
# logrotate for a bare process. A server that also writes and rotates its own
# file would duplicate all of that and fight it in a container, where stdout is
# the interface. SERVER.md documents the three configurations; task 6.6 ships
# them. Godot's own `debug/file_logging` remains available for anyone who wants
# a file as well, and it rotates via `max_log_files`.
const LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
static var _level := 1 # info
static var _boot_ms := -1
static var _enabled := false # servers only; a client process logs nothing
# Called once by the process that owns the command line. Until then nothing is
# emitted at all — a client, an editor session or a unit-test run must not
# start printing server telemetry just because it loaded these scripts.
static func configure(level_name: String) -> void:
_level = int(LEVELS.get(level_name, 1))
_boot_ms = Time.get_ticks_msec()
_enabled = true
static func is_enabled() -> bool:
return _enabled
static func level_name() -> String:
for key in LEVELS:
if int(LEVELS[key]) == _level:
return key
return "info"
static func debug(event: String, fields: Dictionary = {}) -> void:
_write("debug", event, fields)
static func info(event: String, fields: Dictionary = {}) -> void:
_write("info", event, fields)
static func warn(event: String, fields: Dictionary = {}) -> void:
_write("warn", event, fields)
static func error(event: String, fields: Dictionary = {}) -> void:
_write("error", event, fields)
static func _write(level: String, event: String, fields: Dictionary) -> void:
if not _enabled:
return
if int(LEVELS.get(level, 1)) < _level:
return
var parts := PackedStringArray()
for key in fields:
parts.append("%s=%s" % [key, _flatten(fields[key])])
var elapsed_sec := float(Time.get_ticks_msec() - _boot_ms) / 1000.0
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])
# One line per event is the whole contract, so a value containing a space or a
# newline would break every downstream `awk '{print $4}'`. Quote rather than
# silently mangle: a player name is operator-supplied and can contain anything.
static func _flatten(value: Variant) -> String:
var text := str(value)
text = text.replace("\n", "\\n").replace("\r", "\\r")
if " " in text or text.is_empty():
return "\"%s\"" % text.replace("\"", "'")
return text
+1
View File
@@ -0,0 +1 @@
uid://6oqo5tyiayu3
+122
View File
@@ -0,0 +1,122 @@
class_name ServerMatchLoop
extends Node
# The dedicated server's match loop (multiplayer-todo.md task 6.5).
#
# THIS CLOSES A GAP NO TASK OWNED. Task 6.2 asks for "the exported binary runs
# a full match headless", but nothing in the product ever started a match:
# lobby.gd has no start path, and every match in this project's history was
# begun by a test harness calling change_scene_to_file directly. The dedicated
# server booted, listened, and could never play anything. 6.5 was written as
# "arena rotation between matches", which presumes a first match that nothing
# produced — so the whole loop lives here, not just the rotation.
#
# Lifecycle:
#
# wait for --min-players (roster, not raw peers: a peer that has
# connected but not completed the hello
# handshake is not a player yet)
# -> --start-countdown seconds (so a second player joining 200ms later
# is in THIS match, not the next one)
# -> networked_match.tscn on the arena --arena-rotation picked
# -> the match runs itself and returns to the lobby at RESULTS
# -> repeat, or exit(0) once --max-matches have completed
#
# Parented to the scene tree ROOT, never to current_scene: change_scene_to_file
# frees whatever scene is live, and an orchestrator that gets freed by the
# transition it just requested cannot orchestrate the next one. This is the
# same constraint tests/networked_match_test_hooks.gd documents, arrived at the
# same way — it is a property of Godot's scene switching, not of testing.
#
# The countdown is deliberately NOT a Timer: §6.1's tick-derived-clock rule
# applies to anything whose timing a client can observe, and the wait before a
# match is exactly that.
signal match_starting(arena_path: String, match_index: int)
const POLL_INTERVAL_MS := 250
var min_players := 1
var start_countdown_seconds := 5.0
var max_matches := 0 # 0 = run forever
var rotation_mode := "sequential"
var matches_completed := 0
var _countdown_started_ms := -1
var _match_active := false
var _next_poll_ms := 0
var _shutting_down := false
func _process(_delta: float) -> void:
if _shutting_down or not multiplayer.is_server():
return
var now := Time.get_ticks_msec()
if now < _next_poll_ms:
return
_next_poll_ms = now + POLL_INTERVAL_MS
if _match_active:
_poll_match_end()
else:
_poll_match_start(now)
# A match is over when the match scene is gone. NetworkedMatch returns both
# peers to the lobby itself at RESULTS (§6.2 step 10) and aborts to the lobby
# when everyone has left (§6.4), so "the scene we started is no longer the
# current scene" covers the clean end and the abandoned one identically —
# without this node having to duplicate either rule or reach into match state.
func _poll_match_end() -> void:
var scene := get_tree().current_scene
if is_instance_valid(scene) and scene.is_in_group("game"):
return
_match_active = false
matches_completed += 1
ServerLog.info("match_completed", {
"completed": matches_completed, "of": max_matches if max_matches > 0 else "unlimited",
})
if max_matches > 0 and matches_completed >= max_matches:
# §6's drain-and-exit: the point of --max-matches is that a supervisor
# can restart the process on a new build between matches instead of
# killing players mid-game. Exiting anywhere else would defeat it.
_shutting_down = true
ServerLog.info("server_draining", {"reason": "max_matches_reached", "matches": matches_completed})
get_tree().quit(0)
return
# Straight back to waiting. The countdown restarts from scratch rather than
# carrying over, so players who left during the last match are not counted
# toward starting the next one.
_countdown_started_ms = -1
func _poll_match_start(now: int) -> void:
var players := MatchNet.roster.size()
if players < min_players:
if _countdown_started_ms >= 0:
ServerLog.info("match_start_cancelled", {"players": players, "needed": min_players})
_countdown_started_ms = -1
return
if _countdown_started_ms < 0:
_countdown_started_ms = now
ServerLog.info("match_start_countdown", {
"players": players, "seconds": start_countdown_seconds,
})
return
if now - _countdown_started_ms < int(start_countdown_seconds * 1000.0):
return
_start_match()
func _start_match() -> void:
var arena_path := ArenaRegistry.path_for_match(matches_completed, rotation_mode)
# The match scene picks its own arena at random by default. Handing it one
# explicitly is what makes rotation a rotation rather than a coincidence.
NetworkedMatch.server_arena_override = arena_path
_match_active = true
_countdown_started_ms = -1
ServerLog.info("match_starting", {
"index": matches_completed + 1, "arena": arena_path,
"players": MatchNet.roster.size(), "rotation": rotation_mode,
})
match_starting.emit(arena_path, matches_completed)
get_tree().change_scene_to_file.call_deferred(ScenePaths.NETWORKED_MATCH)
+1
View File
@@ -0,0 +1 @@
uid://xnvwnqushvvt
+153 -9
View File
@@ -1,26 +1,90 @@
extends Control
# Settings screen: a small set of player-facing video knobs on top of
# VideoSettings (the autoload holding + persisting them). Anti-aliasing
# applies immediately since it's a Viewport-wide setting; glow/brightness
# apply the next time an arena loads (see arena.gd), since they scale each
# arena's own tuned Environment values rather than something viewport-wide.
# Settings screen: player-facing video knobs on top of VideoSettings (the
# autoload holding + persisting them). Preset/AA/vsync/fps-cap/resolution
# scale apply immediately since they're Viewport- or DisplayServer-wide;
# glow/brightness/shadow/SDFGI/SSIL/SSAO apply the next time an arena loads
# an Environment, or instantly to an already-loaded one via
# VideoSettings.settings_changed (see arena.gd) — task 0.17.
const AA_OPTIONS := [
{"name": "Off", "mode": VideoSettings.AAMode.OFF},
{"name": "FXAA", "mode": VideoSettings.AAMode.FXAA},
{"name": "MSAA 2x", "mode": VideoSettings.AAMode.MSAA_2X},
{"name": "MSAA 4x", "mode": VideoSettings.AAMode.MSAA},
{"name": "MSAA 4x + FXAA", "mode": VideoSettings.AAMode.MSAA_FXAA},
]
const PRESET_OPTIONS := [
{"name": "Low", "preset": VideoSettings.Preset.LOW},
{"name": "Medium", "preset": VideoSettings.Preset.MEDIUM},
{"name": "High", "preset": VideoSettings.Preset.HIGH},
{"name": "Custom", "preset": VideoSettings.Preset.CUSTOM},
]
const VSYNC_OPTIONS := [
{"name": "Disabled", "mode": VideoSettings.VsyncMode.DISABLED},
{"name": "Enabled", "mode": VideoSettings.VsyncMode.ENABLED},
{"name": "Adaptive", "mode": VideoSettings.VsyncMode.ADAPTIVE},
]
@onready var preset_dropdown: OptionButton = %PresetDropdown
@onready var aa_dropdown: OptionButton = %AADropdown
@onready var glow_slider: HSlider = %GlowSlider
@onready var glow_value_label: Label = %GlowValueLabel
@onready var brightness_slider: HSlider = %BrightnessSlider
@onready var brightness_value_label: Label = %BrightnessValueLabel
@onready var resolution_slider: HSlider = %ResolutionSlider
@onready var resolution_value_label: Label = %ResolutionValueLabel
@onready var vsync_dropdown: OptionButton = %VsyncDropdown
@onready var fps_cap_dropdown: OptionButton = %FpsCapDropdown
@onready var fps_readout_label: Label = %FpsReadoutLabel
# fps_cap_dropdown item index -> VideoSettings divisor (0 = uncapped). Built
# in _ready() from the live refresh rate so the menu never hardcodes a
# specific display's numbers.
var _fps_cap_divisors: Array[int] = []
var _populating := false
func _ready() -> void:
# An idle settings screen has no reason to render past the display's own
# refresh rate; _on_back_pressed only returns to another capped menu, so
# no uncap is needed there (contrast main_menu.gd's _leave_to_gameplay).
var refresh_rate := DisplayServer.screen_get_refresh_rate()
Engine.max_fps = int(refresh_rate) if refresh_rate > 0 else 0
_populating = true
_populate_preset_dropdown()
_populate_aa_dropdown()
_populate_vsync_dropdown()
_populate_fps_cap_dropdown(refresh_rate)
_populating = false
glow_slider.value = VideoSettings.glow_scale
brightness_slider.value = VideoSettings.brightness
resolution_slider.value = VideoSettings.resolution_scale
_update_glow_label()
_update_brightness_label()
_update_resolution_label()
_update_fps_cap_enabled()
func _process(_delta: float) -> void:
fps_readout_label.text = "%d fps" % Performance.get_monitor(Performance.TIME_FPS)
func _populate_preset_dropdown() -> void:
preset_dropdown.clear()
var selected := 0
for i in PRESET_OPTIONS.size():
preset_dropdown.add_item(PRESET_OPTIONS[i]["name"])
if PRESET_OPTIONS[i]["preset"] == VideoSettings.preset:
selected = i
preset_dropdown.select(selected)
func _populate_aa_dropdown() -> void:
aa_dropdown.clear()
var selected := 0
for i in AA_OPTIONS.size():
@@ -29,10 +93,39 @@ func _ready() -> void:
selected = i
aa_dropdown.select(selected)
glow_slider.value = VideoSettings.glow_scale
brightness_slider.value = VideoSettings.brightness
_update_glow_label()
_update_brightness_label()
func _populate_vsync_dropdown() -> void:
vsync_dropdown.clear()
var selected := 0
for i in VSYNC_OPTIONS.size():
vsync_dropdown.add_item(VSYNC_OPTIONS[i]["name"])
if VSYNC_OPTIONS[i]["mode"] == VideoSettings.vsync_mode:
selected = i
vsync_dropdown.select(selected)
# Options derive from the live refresh rate rather than a fixed list, per
# task 0.17's "fps cap derived from screen_get_refresh_rate() divisors".
# A -1 (or otherwise non-positive) query falls back to "Uncapped" only,
# rather than presenting cap choices the engine can't compute a value for.
func _populate_fps_cap_dropdown(refresh_rate: float) -> void:
fps_cap_dropdown.clear()
_fps_cap_divisors.clear()
fps_cap_dropdown.add_item("Uncapped")
_fps_cap_divisors.append(0)
if refresh_rate > 0.0:
for divisor in [1, 2, 3, 4]:
var hz := refresh_rate / float(divisor)
fps_cap_dropdown.add_item("%d fps (refresh / %d)" % [roundi(hz), divisor])
_fps_cap_divisors.append(divisor)
var selected := _fps_cap_divisors.find(VideoSettings.fps_cap_divisor)
fps_cap_dropdown.select(maxi(selected, 0))
# The fps cap dropdown only means anything with vsync Disabled — vsync
# itself already caps to (a multiple of) the refresh rate otherwise.
func _update_fps_cap_enabled() -> void:
fps_cap_dropdown.disabled = VideoSettings.vsync_mode != VideoSettings.VsyncMode.DISABLED
func _update_glow_label() -> void:
@@ -43,9 +136,26 @@ func _update_brightness_label() -> void:
brightness_value_label.text = "%d%%" % roundi(brightness_slider.value * 100.0)
func _update_resolution_label() -> void:
resolution_value_label.text = "%d%%" % roundi(resolution_slider.value * 100.0)
func _on_preset_dropdown_item_selected(index: int) -> void:
VideoSettings.apply_preset(PRESET_OPTIONS[index]["preset"])
# The bundle may have changed AA/resolution scale underneath the other
# controls — resync them without re-triggering their own "user changed
# this by hand" mark_custom() path.
_populating = true
_populate_aa_dropdown()
resolution_slider.value = VideoSettings.resolution_scale
_populating = false
_update_resolution_label()
func _on_aa_dropdown_item_selected(index: int) -> void:
VideoSettings.aa_mode = AA_OPTIONS[index]["mode"]
VideoSettings.apply_aa()
_mark_custom_if_user_driven()
func _on_glow_slider_value_changed(value: float) -> void:
@@ -58,6 +168,40 @@ func _on_brightness_slider_value_changed(value: float) -> void:
_update_brightness_label()
func _on_resolution_slider_value_changed(value: float) -> void:
VideoSettings.resolution_scale = value
VideoSettings.apply_resolution_scale()
_update_resolution_label()
_mark_custom_if_user_driven()
func _on_vsync_dropdown_item_selected(index: int) -> void:
VideoSettings.vsync_mode = VSYNC_OPTIONS[index]["mode"]
VideoSettings.apply_vsync()
_update_fps_cap_enabled()
func _on_fps_cap_dropdown_item_selected(index: int) -> void:
VideoSettings.fps_cap_divisor = _fps_cap_divisors[index]
VideoSettings.apply_fps_cap()
# Preset-gated fields (AA, resolution scale — glow/shadows/SDFGI/SSIL/SSAO
# have no direct control in this menu yet) flip the preset dropdown to
# Custom when the player overrides them by hand, so the dropdown never shows
# a preset name next to settings that preset doesn't actually produce. Not
# called while _populating (initial load) or while apply_preset() itself is
# writing these same fields (VideoSettings.mark_custom() no-ops there too,
# but skipping the redundant dropdown repopulation here is cheap).
func _mark_custom_if_user_driven() -> void:
if _populating:
return
VideoSettings.mark_custom()
_populating = true
_populate_preset_dropdown()
_populating = false
func _on_back_pressed() -> void:
VideoSettings.save()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
+131 -7
View File
@@ -1,5 +1,6 @@
class_name Ship
extends RigidBody3D
const SimConstants = preload("res://scripts/sim_constants.gd")
# Physics-driven spaceship. All movement is force/torque-based, applied in
# _integrate_forces from a ShipAction supplied by a pluggable ShipController
@@ -44,7 +45,8 @@ extends RigidBody3D
# Non-tinted hull meshes, runtime-merged into one ArrayMesh by
# _build_merged_hull() (Nose/TailFin stay separate MeshInstance3Ds since
# _apply_team_color() retints them per-team and must keep addressing them by
# name). Verified via get_surface_count()/surface_get_material() before
# name, under $Visual — see that function). Verified via
# get_surface_count()/surface_get_material() before
# writing this: hull and canopy are each a single surface with their own
# distinct opaque StandardMaterial3D (canopy is NOT alpha/transparent despite
# the name), and engine_l/engine_r are each 2 surfaces, also all distinct
@@ -105,6 +107,88 @@ var _current_action: ShipAction = ShipAction.new()
var _inert_action: ShipAction = ShipAction.new()
var _boundary: ArenaBoundary
var _pending_teleport: Transform3D
var _has_pending_teleport := false
var _pending_teleport_linear_velocity := Vector3.ZERO
var _pending_teleport_angular_velocity := Vector3.ZERO
var _pending_teleport_has_velocity := false
# Queues an authoritative teleport, applied at the top of the next
# _integrate_forces — the only Jolt-safe place to write state.transform
# directly (see GameMode._reset_body / task 0.15) — instead of racing the
# physics step via set_deferred("global_transform", ...).
func queue_teleport(to: Transform3D) -> void:
_pending_teleport = to
_has_pending_teleport = true
_pending_teleport_has_velocity = false
# Network hard snaps need the server velocity as their new starting point,
# unlike gameplay resets which deliberately zero it. Keep the write queued:
# Jolt only permits state mutation from _integrate_forces.
# The queued-but-not-yet-applied teleport target, or null when none is
# pending. queue_teleport() defers the actual write to the next
# _integrate_forces (task 0.15), so global_transform still reads the OLD pose
# in between — anything that needs to broadcast where a body is ABOUT to be
# (networked_match.gd's kickoff) must read this instead, or it ships the
# pre-reset position and corrects it a tick later.
func get_pending_teleport():
return _pending_teleport if _has_pending_teleport else null
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
_pending_teleport = to
_pending_teleport_linear_velocity = new_linear_velocity
_pending_teleport_angular_velocity = new_angular_velocity
_pending_teleport_has_velocity = true
_has_pending_teleport = true
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
# Both stay zero until Phase 4 wires a reconciliation pass in, so the guarded
# hook in _integrate_forces below is a no-op today.
# Velocity delta from a soft correction, consumed once then cleared —
# applied in full immediately (invisible to the player, and it's the
# *cause* of future position error, so blending it just prolongs
# divergence).
var net_vel_correction := Vector3.ZERO
# Rendered offset between the body and $Visual while a soft correction
# decays away, so a position correction moves the collider in full without
# visibly teleporting the mesh. Same decay convention as drag/righting
# torque (_tick_scaled) above.
var net_visual_offset := Vector3.ZERO
var net_visual_rotation_offset := Quaternion.IDENTITY
const NET_VISUAL_OFFSET_DECAY := 0.88
const MAX_VISUAL_OFFSET := 0.4
var net_prediction_contact_window := false # client telemetry only
var net_visual_offset_decay := NET_VISUAL_OFFSET_DECAY
var net_visual_offset_max := MAX_VISUAL_OFFSET
func set_network_visual_tuning(decay: float, max_offset: float) -> void:
# Called only by the local client debug overlay. Server/training ships keep
# the constants above and therefore retain their exact existing behavior.
net_visual_offset_decay = clampf(decay, 0.5, 0.99)
net_visual_offset_max = clampf(max_offset, 0.05, 2.0)
# Feeds thrust_z/turbo into the movement VFX for a ship with no local
# controller driving _integrate_forces (a frozen remote ship never calls
# get_action(), so _update_movement_vfx's engine glow/flame would otherwise
# read a stale or zeroed action and show dead engines).
func set_visual_action(thrust_z: float, turbo: bool) -> void:
_current_action.thrust.z = thrust_z
_current_action.turbo = turbo
# The local network sender reads this after this tick's _integrate_forces,
# rather than pulling PlayerShipController a second time. That preserves the
# one get_action() call per physics tick contract.
func get_current_action_copy() -> ShipAction:
return _current_action.copy()
# Instrument signals for efficient data distribution
signal speed_changed(speed: float)
signal attitude_changed(pitch: float, roll: float, yaw: float)
@@ -135,6 +219,13 @@ var _engine_cores: Array[MeshInstance3D] = []
var _engine_flames: Array[MeshInstance3D] = []
var _engine_lights: Array[OmniLight3D] = []
# All rendered geometry (hull, canopy, engine cores/flames/lights, Nose,
# TailFin) parents under this instead of the RigidBody3D directly, so a
# future prediction correction (task 0.14) can offset the visual without
# moving the collider — see multiplayer-todo.md task 0.2. CollisionShape3D
# and the controller child correctly stay on the body itself.
@onready var visual: Node3D = $Visual
func _ready():
# Add ship to group for instrument discovery
@@ -177,7 +268,7 @@ func _apply_team_color() -> void:
return
var accent := _get_team_material(team)
for mesh_name in ["Nose", "TailFin"]:
var mesh := get_node_or_null(mesh_name) as MeshInstance3D
var mesh := get_node_or_null("Visual/" + mesh_name) as MeshInstance3D
if mesh:
mesh.material_override = accent
@@ -205,7 +296,7 @@ func _build_merged_hull() -> void:
var instance := MeshInstance3D.new()
instance.name = "MergedHull"
instance.mesh = mesh
add_child(instance)
visual.add_child(instance)
# Attach the node that drives this ship (player, AI, or network). Replaces
@@ -238,7 +329,7 @@ func _build_movement_vfx() -> void:
core.position = engine_pos
core.mesh = core_mesh
core.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(core)
visual.add_child(core)
_engine_cores.append(core)
# A single conventional orange flame replaces the layered particle plume
@@ -265,7 +356,7 @@ func _build_movement_vfx() -> void:
flame.mesh = flame_mesh
flame.visible = false
flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
add_child(flame)
visual.add_child(flame)
_engine_flames.append(flame)
var light := OmniLight3D.new()
@@ -275,7 +366,7 @@ func _build_movement_vfx() -> void:
light.omni_range = 3.5
light.omni_attenuation = 2.0
light.shadow_enabled = false
add_child(light)
visual.add_child(light)
_engine_lights.append(light)
func _vfx_material(color: Color, energy: float) -> StandardMaterial3D:
@@ -343,6 +434,39 @@ func _has_telemetry_listeners() -> bool:
func _integrate_forces(state):
# Reconciliation telemetry needs to distinguish genuine free flight from
# Jolt contact windows. This is read only by the locally predicted client;
# it never changes forces, actions, collision state, or server behavior.
if not multiplayer.is_server():
net_prediction_contact_window = state.get_contact_count() > 0
if _has_pending_teleport:
_has_pending_teleport = false
state.transform = _pending_teleport
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
_pending_teleport_has_velocity = false
reset_physics_interpolation()
if is_instance_valid(visual):
visual.reset_physics_interpolation()
# --- Netcode correction hook (Phase 4) --- guarded: both fields default
# to Vector3.ZERO and nothing writes them yet, so neither branch runs
# today.
if net_vel_correction != Vector3.ZERO:
state.linear_velocity += net_vel_correction
net_vel_correction = Vector3.ZERO
if net_visual_offset != Vector3.ZERO:
net_visual_offset = net_visual_offset.limit_length(net_visual_offset_max)
net_visual_offset *= _tick_scaled(net_visual_offset_decay, state.step)
if net_visual_offset.length_squared() < 0.0001:
net_visual_offset = Vector3.ZERO
visual.position = net_visual_offset
if net_visual_rotation_offset != Quaternion.IDENTITY:
net_visual_rotation_offset = net_visual_rotation_offset.slerp(Quaternion.IDENTITY, 1.0 - _tick_scaled(net_visual_offset_decay, state.step))
if absf(net_visual_rotation_offset.angle_to(Quaternion.IDENTITY)) < 0.001:
net_visual_rotation_offset = Quaternion.IDENTITY
visual.basis = Basis(net_visual_rotation_offset)
# One action per physics tick, pulled from the controller (deterministic)
_current_action = controller.get_action() if controller else _inert_action
@@ -448,7 +572,7 @@ func apply_rotation_forces(state: PhysicsDirectBodyState3D, rotation_input: Vect
# by the actual elapsed tick time `step`, so `v *= _tick_scaled(k, step)`
# decays at the same rate per second regardless of physics_ticks_per_second.
func _tick_scaled(k: float, step: float) -> float:
return pow(k, step * 60.0)
return pow(k, step * SimConstants.TICK_HZ)
func apply_drag_and_limits(state: PhysicsDirectBodyState3D, rotation_input: Vector3):
+12
View File
@@ -9,3 +9,15 @@ extends RefCounted
var thrust := Vector3.ZERO # Per-axis -1..1: x = strafe, y = vertical, z = forward/back
var rotation := Vector3.ZERO # Per-axis -1..1: x = pitch, y = yaw, z = roll
var turbo := false
# Returns a distinct ShipAction with equal fields. Callers that hold onto an
# action past the tick it was returned in (input history, prediction ring)
# must copy() it — get_action() implementations are free to return a reused
# instance, and player_ship_controller.gd's does.
func copy() -> ShipAction:
var c := ShipAction.new()
c.thrust = thrust
c.rotation = rotation
c.turbo = turbo
return c
+111 -8
View File
@@ -24,6 +24,15 @@ signal impact_feedback(intensity: float)
@export_group("Impact Shake")
@export var max_shake_offset := 0.32
@export var shake_decay := 8.0
@export_group("Impact Punch")
# Replaces Engine.time_scale hit-stop / goal slow-mo (see game_mode.gd):
# a camera-only FOV kick + PostFX flash that decays over real time, so it
# works identically for hit-stop and goal moments without touching global
# simulation speed — which a networked client could never do to a shared sim.
@export var punch_fov_kick := 9.0
@export var punch_vignette_kick := 0.28
@export var punch_chroma_kick := 0.012
@export var punch_decay := 5.0
var target: Ship:
set(value):
@@ -33,6 +42,16 @@ var target: Ship:
target.ball_contact.disconnect(_on_target_ball_contact)
target = value
_connect_target()
# Priming: a freshly assigned target's Visual has no interpolation
# history yet (or is about to be reparented mid-spawn), and the rig
# itself would otherwise lerp in from wherever it was previously
# (world origin on first spawn, the old target on a Spectate switch)
# over camera_smoothing seconds. Both read as a visible swoop/smear;
# neither is a real camera move.
if is_instance_valid(target) and is_instance_valid(target.visual):
target.visual.reset_physics_interpolation()
if is_inside_tree():
snap_to_target()
var ball_cam_enabled := true
@onready var camera: Camera3D = $Camera3D
@@ -50,14 +69,22 @@ var _shake_strength := 0.0
var _shake_noise := FastNoiseLite.new()
var _shake_time := 0.0
var _last_shake_offset := Vector3.ZERO
var _punch_strength := 0.0
var _goal_cut_active := false
var _goal_cut_position := Vector3.ZERO
var _goal_cut_look_at := Vector3.ZERO
const SHAKE_UPDATE_HZ := 60.0
func _ready():
# Group lets the HUD discover the rig for the camera-mode instrument
add_to_group("ship_camera")
# The rig moves itself every rendered frame in _process now (task 0.16),
# not on the physics tick — Godot's built-in physics interpolation would
# otherwise smooth between _physics_process-era transforms this rig no
# longer writes, fighting the manual smoothing below.
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
camera.fov = base_fov
_effective_distance = camera_distance
_shake_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
@@ -83,7 +110,7 @@ func _input(event):
camera_mode_changed.emit(ball_cam_enabled)
func _physics_process(delta):
func _process(delta):
# Camera position smoothing operates on the unshaken chase position. Remove
# last frame's presentation-only offset first so shake never accumulates or
# exceeds its configured amplitude during a low-time-scale hit-stop.
@@ -93,6 +120,10 @@ func _physics_process(delta):
return
if _goal_cut_active:
_smooth_look_at(_goal_cut_look_at, delta)
# The cinematic cut freezes camera position, but leftover shake from
# an impact right before the goal must still bleed off in real time —
# otherwise it resumes at full pre-cut magnitude when play returns.
_decay_shake(delta)
return
_update_speed_feel(delta)
var ball := _get_ball()
@@ -100,6 +131,7 @@ func _physics_process(delta):
_update_ball_cam(delta, ball)
else:
_update_ship_cam(delta)
_apply_punch(delta)
func _get_ball() -> Node3D:
@@ -112,7 +144,14 @@ func _update_ball_cam(delta, ball: Node3D):
# Ball cam keeps the ship between the camera and the ball: the camera sits
# on the ball→ship line (horizontal component only), looking at the ball,
# so the ship stays low-centre in frame and the ball stays centred.
var ship_pos: Vector3 = target.global_transform.origin
# Reads target.visual, not target itself: once prediction correction
# (task 0.14) lands, the body can be offset from what's on screen — the
# camera must always frame what the player sees, not the collider.
# get_global_transform_interpolated() (not global_transform) because this
# now runs in _process: the physics body only moves once per 60Hz tick,
# so sampling its raw transform every rendered frame at 240fps would
# repeat the same value 4 times in a row and read as stutter.
var ship_pos: Vector3 = target.visual.get_global_transform_interpolated().origin
var ball_pos: Vector3 = ball.global_transform.origin
# Smooth the orbit direction in angle space rather than lerping the camera
@@ -145,9 +184,12 @@ func _update_ball_cam(delta, ball: Node3D):
func _update_ship_cam(delta):
# In ship cam, camera follows and looks in the same direction as the ship
var ship_pos: Vector3 = target.global_transform.origin
var ship_forward: Vector3 = -target.global_transform.basis.z
# In ship cam, camera follows and looks in the same direction as the ship.
# Reads target.visual, not target itself, via the interpolated transform —
# see _update_ball_cam.
var visual_xform := target.visual.get_global_transform_interpolated()
var ship_pos: Vector3 = visual_xform.origin
var ship_forward: Vector3 = -visual_xform.basis.z
# Position camera behind and above the ship
var camera_target_pos := ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
@@ -189,6 +231,7 @@ func _update_speed_feel(delta: float) -> void:
func _on_target_ball_contact(intensity: float, _world_position: Vector3) -> void:
_shake_strength = maxf(_shake_strength, clampf(intensity, 0.0, 1.0))
_punch_strength = maxf(_punch_strength, clampf(intensity, 0.0, 1.0))
for device in Input.get_connected_joypads():
Input.start_joy_vibration(
device, lerpf(0.18, 0.65, intensity), lerpf(0.32, 1.0, intensity),
@@ -201,17 +244,77 @@ func _apply_shake(delta: float) -> void:
if _shake_strength <= 0.001:
_shake_strength = 0.0
return
_shake_time += delta * 60.0
_shake_time += delta
# Quantized to a fixed 60Hz cadence rather than sampled once per rendered
# frame. The noise domain's total distance travelled per second is the
# same either way, but that's not what "reads the same" means here: at
# 60fps, consecutive samples are frequency (2.5) domain-units apart —
# far enough that FastNoiseLite's simplex correlation has decayed, so it
# reads as sharp, uncorrelated jitter. At 240fps the same per-second
# distance is split across 4x the samples, so consecutive samples are
# ~4x closer together and highly correlated — a completely different,
# much gentler wobble. Freezing the domain input to whole 60Hz ticks
# makes every render frame within one tick reuse the exact same sample,
# so the perceived shake texture is identical at any frame rate.
var tick: float = floori(_shake_time * SHAKE_UPDATE_HZ)
var amplitude := max_shake_offset * _shake_strength * _shake_strength
var noise := Vector2(
_shake_noise.get_noise_1d(_shake_time),
_shake_noise.get_noise_1d(_shake_time + 100.0)
_shake_noise.get_noise_1d(tick),
_shake_noise.get_noise_1d(tick + 100.0)
).limit_length(1.0) * amplitude
_last_shake_offset = camera.global_basis.x * noise.x + camera.global_basis.y * noise.y
camera.global_position += _last_shake_offset
_decay_shake(delta)
func _decay_shake(delta: float) -> void:
_shake_strength = move_toward(_shake_strength, 0.0, shake_decay * delta)
# Additive FOV kick + PostFX flash on top of _update_speed_feel's base
# values, decaying over real time. Replaces the weight that Engine.time_scale
# hit-stop used to sell on ball impact — see the Impact Punch export group.
func _apply_punch(delta: float) -> void:
if _punch_strength <= 0.001:
_punch_strength = 0.0
return
camera.fov += punch_fov_kick * _punch_strength
var chroma: float = post_material.get_shader_parameter("chromatic_aberration")
var vignette: float = post_material.get_shader_parameter("vignette_strength")
post_material.set_shader_parameter("chromatic_aberration", chroma + punch_chroma_kick * _punch_strength)
post_material.set_shader_parameter("vignette_strength", vignette + punch_vignette_kick * _punch_strength)
_punch_strength = move_toward(_punch_strength, 0.0, punch_decay * delta)
# Places the camera at its resting chase position instantly, bypassing
# camera_smoothing/orbit_smoothing/look_smoothing entirely. Used whenever the
# thing being framed just teleported (kickoff, target reassignment) — without
# this the rig would lerp smoothly across the whole arena over
# camera_smoothing seconds, which reads as an unintended camera move rather
# than a reset.
func snap_to_target() -> void:
if not is_instance_valid(target) or not is_instance_valid(camera):
return
_shake_strength = 0.0
camera.global_position -= _last_shake_offset
_last_shake_offset = Vector3.ZERO
var visual_xform := target.visual.get_global_transform_interpolated()
var ship_pos: Vector3 = visual_xform.origin
var ball := _get_ball()
if ball_cam_enabled and ball:
var ball_pos: Vector3 = ball.global_transform.origin
var flat := Vector3(ship_pos.x - ball_pos.x, 0.0, ship_pos.z - ball_pos.z)
_orbit_dir = flat.normalized() if flat.length() > 0.01 else Vector3.BACK
camera.global_position = ship_pos + _orbit_dir * _effective_distance + Vector3.UP * camera_height
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
camera.look_at(ball_pos + Vector3.UP * 0.5, Vector3.UP)
else:
var ship_forward: Vector3 = -visual_xform.basis.z
camera.global_position = ship_pos - ship_forward * _effective_distance + Vector3.UP * camera_height
camera.global_position.y = maxf(camera.global_position.y, min_camera_height)
camera.look_at(ship_pos + ship_forward * 10.0, Vector3.UP)
func begin_goal_cut(goal_position: Vector3) -> void:
_goal_cut_active = true
# The hard cut replaces the chase camera's presentation offset entirely;
+14
View File
@@ -0,0 +1,14 @@
class_name SimConstants
# Single source of truth for the physics tick rate. Every script-side timing
# constant derived from "60 Hz" (Ship._tick_scaled's decay reference,
# reaction_ticks' export range, TrainingMode.TICKS_PER_SIM_SECOND) reads this
# instead of restating the literal, so changing it changes every derived
# constant coherently — see multiplayer-todo.md §5.6 on why a future 120 Hz
# simulation needs to be a config change plus a retrain, not a protocol
# rewrite hunting down bare 60s.
#
# NOT wired to project.godot's physics/common/physics_ticks_per_second — an
# engine setting, not a script constant, so it must still be changed by hand
# to match (currently unset, engine default 60 — see task 0.18).
const TICK_HZ := 60
+1
View File
@@ -0,0 +1 @@
uid://bkn4t3jpiekba
+42
View File
@@ -0,0 +1,42 @@
class_name SteamBootstrap
extends RefCounted
# Spacewar is Valve's development App ID. It is intentionally a development
# default, never a public-server identity or discovery configuration.
const SPACEWAR_APP_ID := 480
const APP_ID_ENV := "COSMIC_CLASH_STEAM_APP_ID"
static func app_id() -> int:
var configured := OS.get_environment(APP_ID_ENV).strip_edges()
if configured.is_valid_int() and int(configured) > 0:
return int(configured)
return SPACEWAR_APP_ID
static func is_runtime_available() -> bool:
return OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer") and Engine.has_singleton("Steam")
static func unavailable_reason() -> String:
if not OS.has_feature("steam"):
return "this export was not built with the steam feature"
if not ClassDB.class_exists("SteamMultiplayerPeer"):
return "SteamMultiplayerPeer is missing from this custom Godot build"
if not Engine.has_singleton("Steam"):
return "the GodotSteam Steam singleton is missing from this custom Godot build"
return "Steam is unavailable"
static func initialize() -> Dictionary:
if not is_runtime_available():
return {"error": ERR_UNAVAILABLE, "reason": unavailable_reason()}
var steam := Engine.get_singleton("Steam")
# `steamInit` is deliberately called dynamically: stock Godot must be able
# to parse and run this project without GodotSteam symbols installed.
var result = steam.call("steamInit")
if result is bool and result:
return {"error": OK, "app_id": app_id()}
if result is Dictionary and bool(result.get("status", false)):
return {"error": OK, "app_id": app_id()}
return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()}
+44
View File
@@ -0,0 +1,44 @@
class_name SteamTransport
extends NetTransport
const SteamBootstrapScript = preload("res://scripts/steam_bootstrap.gd")
# The SteamMultiplayerPeer extension is looked up dynamically so a stock ENet
# build never references an unavailable native class while parsing scripts.
const VIRTUAL_PORT := 0
func transport_id() -> String:
return "steam"
func is_available() -> bool:
return SteamBootstrapScript.is_runtime_available()
func unavailable_reason() -> String:
return SteamBootstrapScript.unavailable_reason()
func create_server(_port: int, _max_clients: int) -> Dictionary:
var boot: Dictionary = SteamBootstrapScript.initialize()
if int(boot.error) != OK:
return boot
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
if peer == null:
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
var err := int(peer.call("create_host", VIRTUAL_PORT))
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
func create_client(address: String, _port: int) -> Dictionary:
var steam_id_text := address.strip_edges()
if not steam_id_text.is_valid_int() or int(steam_id_text) <= 0:
return {"error": ERR_INVALID_PARAMETER, "reason": "Steam transport requires the server's numeric Steam ID"}
var boot: Dictionary = SteamBootstrapScript.initialize()
if int(boot.error) != OK:
return boot
var peer := ClassDB.instantiate("SteamMultiplayerPeer") as MultiplayerPeer
if peer == null:
return {"error": ERR_UNAVAILABLE, "reason": "SteamMultiplayerPeer could not be instantiated"}
var err := int(peer.call("create_client", int(steam_id_text), VIRTUAL_PORT))
return {"error": err, "peer": peer if err == OK else null, "reason": error_string(err)}
+4 -2
View File
@@ -1,5 +1,6 @@
class_name TrainingMode
extends GameMode
const SimConstants = preload("res://scripts/sim_constants.gd")
# Headless self-play training mode: two RL-driven ships, no HUD, no camera.
# The scene also contains the godot_rl_agents Sync node, which speaks TCP to
@@ -120,8 +121,9 @@ const MAX_RANDOM_SHIP_SPEED := 8.0
# marker spacing, which uses the same margin for the same reason.
const MIN_SHIP_SEPARATION := 4.5
# Sim runs at 60 physics ticks per sim-second regardless of speedup.
const TICKS_PER_SIM_SECOND := 60.0
# Sim runs at SimConstants.TICK_HZ physics ticks per sim-second regardless
# of speedup.
const TICKS_PER_SIM_SECOND := float(SimConstants.TICK_HZ)
var _agents: Array[ShipAIController] = []
+182 -8
View File
@@ -1,44 +1,164 @@
extends Node
# Autoload: persisted player-facing video preferences (AA, glow, brightness).
# AA is a Viewport-wide setting applied immediately via apply_aa(). Glow and
# Autoload: persisted player-facing video preferences (preset, AA, glow,
# brightness, vsync, fps cap, resolution scale). AA/vsync/fps-cap/resolution
# scale are Viewport- or DisplayServer-wide and applied immediately. Glow and
# brightness instead scale each arena's own tuned Environment values (see
# arena.gd's _ready(), which calls apply_to_environment() once per arena
# load) rather than overwriting them outright, so the per-arena bloom tuning
# in arena_01/02/03.tscn survives underneath the user's preference.
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA }
signal settings_changed # Arenas re-apply preset-gated Environment/light state live.
# MSAA_2X appended at the end, not inserted, so existing user://settings.cfg
# files (which store this as a bare integer ordinal) keep meaning the same
# thing after this rung was added — see task 0.19.
enum AAMode { OFF, FXAA, MSAA, MSAA_FXAA, MSAA_2X }
# Ordinals are persisted the same way as AAMode above — append only.
enum Preset { LOW, MEDIUM, HIGH, CUSTOM }
enum VsyncMode { DISABLED, ENABLED, ADAPTIVE }
const SETTINGS_PATH := "user://settings.cfg"
var aa_mode: AAMode = AAMode.MSAA_FXAA
# preset -> bundle applied to the individual fields below. CUSTOM has no
# bundle: selecting it just stops future preset changes from overwriting
# whatever the individual fields currently hold. Task 0.15b's measured
# per-effect costs (multiplayer-todo.md §5.5.1) were too noisy to rank these
# against each other, so each rung is "meaningfully fewer full-screen passes
# than the one above it" rather than a precisely tuned ladder.
const PRESET_BUNDLES := {
Preset.LOW: {
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": false,
"shadows_enabled": false, "glow_enabled": false, "aa_mode": AAMode.OFF,
"resolution_scale": 0.8,
},
Preset.MEDIUM: {
"sdfgi_enabled": false, "ssil_enabled": false, "ssao_enabled": true,
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
"resolution_scale": 1.0,
},
Preset.HIGH: {
"sdfgi_enabled": true, "ssil_enabled": true, "ssao_enabled": true,
"shadows_enabled": true, "glow_enabled": true, "aa_mode": AAMode.FXAA,
"resolution_scale": 1.0,
},
}
var preset: Preset = Preset.HIGH
var sdfgi_enabled: bool = true
var ssil_enabled: bool = true
var ssao_enabled: bool = true
var shadows_enabled: bool = true
var glow_enabled: bool = true
# FXAA alone, not MSAA_FXAA: 4x MSAA *and* FXAA stacked is redundant blur for
# most scenes and costs more than either alone (see multiplayer-todo.md 0.19).
var aa_mode: AAMode = AAMode.FXAA
var glow_scale: float = 1.0
var brightness: float = 1.0
# 0.17b: Viewport.scaling_3d_scale, 0.5-1.0. Distinct from window stretch
# (0.17c) — this scales the 3D viewport's own internal render resolution
# before the fixed-1080p blit, so it works regardless of the stretch
# decision. FSR2 rather than bilinear: a fixed-1080p target already discards
# native resolution (see 0.17c), so FSR2's per-pixel sharpening recovers more
# of that loss than a plain bilinear upscale at the same internal scale.
var resolution_scale: float = 1.0
var fsr_sharpness: float = 0.2
var vsync_mode: VsyncMode = VsyncMode.ADAPTIVE
# 0 = uncapped; otherwise divides DisplayServer.screen_get_refresh_rate() at
# apply time (not stored as a raw fps number) so the same preference re-derives
# correctly if the game later runs on a different-refresh-rate display. Only
# takes effect when vsync_mode == DISABLED — vsync itself already caps to the
# refresh rate (or an unpredictable multiple of it, for ADAPTIVE) otherwise.
var fps_cap_divisor: int = 0
var _applying_preset := false
func _ready() -> void:
_load()
apply_aa()
# A headless server never renders; applying any of this to its root
# viewport or window is pure waste (mirrors the same guard at ship.gd and
# arena_boundary.gd).
if DisplayServer.get_name() != "headless":
apply_aa()
apply_resolution_scale()
apply_vsync()
func _load() -> void:
var cfg := ConfigFile.new()
if cfg.load(SETTINGS_PATH) != OK:
return
preset = cfg.get_value("video", "preset", preset) as Preset
sdfgi_enabled = cfg.get_value("video", "sdfgi_enabled", sdfgi_enabled)
ssil_enabled = cfg.get_value("video", "ssil_enabled", ssil_enabled)
ssao_enabled = cfg.get_value("video", "ssao_enabled", ssao_enabled)
shadows_enabled = cfg.get_value("video", "shadows_enabled", shadows_enabled)
glow_enabled = cfg.get_value("video", "glow_enabled", glow_enabled)
aa_mode = cfg.get_value("video", "aa_mode", aa_mode) as AAMode
glow_scale = cfg.get_value("video", "glow_scale", glow_scale)
brightness = cfg.get_value("video", "brightness", brightness)
resolution_scale = cfg.get_value("video", "resolution_scale", resolution_scale)
fsr_sharpness = cfg.get_value("video", "fsr_sharpness", fsr_sharpness)
vsync_mode = cfg.get_value("video", "vsync_mode", vsync_mode) as VsyncMode
fps_cap_divisor = cfg.get_value("video", "fps_cap_divisor", fps_cap_divisor)
func save() -> void:
var cfg := ConfigFile.new()
cfg.set_value("video", "preset", preset)
cfg.set_value("video", "sdfgi_enabled", sdfgi_enabled)
cfg.set_value("video", "ssil_enabled", ssil_enabled)
cfg.set_value("video", "ssao_enabled", ssao_enabled)
cfg.set_value("video", "shadows_enabled", shadows_enabled)
cfg.set_value("video", "glow_enabled", glow_enabled)
cfg.set_value("video", "aa_mode", aa_mode)
cfg.set_value("video", "glow_scale", glow_scale)
cfg.set_value("video", "brightness", brightness)
cfg.set_value("video", "resolution_scale", resolution_scale)
cfg.set_value("video", "fsr_sharpness", fsr_sharpness)
cfg.set_value("video", "vsync_mode", vsync_mode)
cfg.set_value("video", "fps_cap_divisor", fps_cap_divisor)
cfg.save(SETTINGS_PATH)
# Pushes a preset's bundle into the individual fields and applies everything
# live. CUSTOM is a no-op bundle-wise — it only matters as a marker so
# set_custom_field() below knows not to silently revert to Custom itself.
func apply_preset(new_preset: Preset) -> void:
preset = new_preset
if PRESET_BUNDLES.has(new_preset):
var bundle: Dictionary = PRESET_BUNDLES[new_preset]
_applying_preset = true
sdfgi_enabled = bundle["sdfgi_enabled"]
ssil_enabled = bundle["ssil_enabled"]
ssao_enabled = bundle["ssao_enabled"]
shadows_enabled = bundle["shadows_enabled"]
glow_enabled = bundle["glow_enabled"]
aa_mode = bundle["aa_mode"]
resolution_scale = bundle["resolution_scale"]
_applying_preset = false
apply_aa()
apply_resolution_scale()
settings_changed.emit()
# Called by the settings menu whenever the player edits an individual
# preset-gated field directly (not via the preset dropdown) — flips to
# Custom so the dropdown reflects reality instead of silently lying about
# which preset is "selected". No-ops during apply_preset's own writes above.
func mark_custom() -> void:
if not _applying_preset:
preset = Preset.CUSTOM
func apply_aa() -> void:
if DisplayServer.get_name() == "headless":
return
var viewport := get_tree().root
match aa_mode:
AAMode.OFF:
@@ -53,13 +173,67 @@ func apply_aa() -> void:
AAMode.MSAA_FXAA:
viewport.msaa_3d = Viewport.MSAA_4X
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_FXAA
AAMode.MSAA_2X:
viewport.msaa_3d = Viewport.MSAA_2X
viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED
# Called once by each arena's _ready() to fold the user's glow/brightness
# preference into that arena's own baked Environment tuning.
func apply_resolution_scale() -> void:
if DisplayServer.get_name() == "headless":
return
var viewport := get_tree().root
if resolution_scale >= 0.999:
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_BILINEAR
viewport.scaling_3d_scale = 1.0
else:
viewport.scaling_3d_mode = Viewport.SCALING_3D_MODE_FSR2
viewport.scaling_3d_scale = clampf(resolution_scale, 0.5, 1.0)
viewport.fsr_sharpness = fsr_sharpness
func apply_vsync() -> void:
if DisplayServer.get_name() == "headless":
return
match vsync_mode:
VsyncMode.DISABLED:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_DISABLED)
VsyncMode.ENABLED:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ENABLED)
VsyncMode.ADAPTIVE:
DisplayServer.window_set_vsync_mode(DisplayServer.VSYNC_ADAPTIVE)
apply_fps_cap()
# Public (not just called from apply_vsync) because gameplay-scene transitions
# (main_menu.gd's _leave_to_gameplay) need to apply the player's chosen cap
# rather than hardcoding an uncapped 0 — the menu's own refresh-rate cap is a
# separate, menu-only concern (settings_menu.gd/main_menu.gd _ready()).
func apply_fps_cap() -> void:
if DisplayServer.get_name() == "headless":
return
if vsync_mode != VsyncMode.DISABLED or fps_cap_divisor <= 0:
Engine.max_fps = 0
return
var refresh := DisplayServer.screen_get_refresh_rate()
# refresh_rate query returning -1 (or 0, unlikely but not contractually
# excluded) falls back to uncapped rather than dividing by a negative
# number into a nonsense cap.
if refresh <= 0.0:
Engine.max_fps = 0
return
Engine.max_fps = maxi(1, roundi(refresh / float(fps_cap_divisor)))
# Called once by each arena's _ready() (and again on settings_changed, so an
# already-loaded arena updates live) to fold the user's glow/brightness
# preference into that arena's own baked Environment tuning, and to gate the
# preset-controlled full-screen passes (§5.5 of multiplayer-todo.md).
func apply_to_environment(env: Environment) -> void:
if env == null:
return
env.glow_enabled = glow_scale > 0.0
env.glow_enabled = glow_enabled and glow_scale > 0.0
env.glow_intensity *= glow_scale
env.adjustment_brightness *= brightness
env.sdfgi_enabled = sdfgi_enabled
env.ssil_enabled = ssil_enabled
env.ssao_enabled = ssao_enabled
@@ -0,0 +1,31 @@
extends "res://tests/test_case.gd"
const AdaptiveInputDepthController = preload("res://scripts/adaptive_input_depth_controller.gd")
func test_starts_safe_and_enters_zero_only_after_sustained_clean_samples() -> void:
var policy := AdaptiveInputDepthController.new()
assert_eq(policy.target_depth, 1, "starts at one buffered tick")
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS - 1:
assert_eq(policy.update(8.0, 2.0, 0), 1, "does not enter zero before enough stable observations")
assert_eq(policy.update(8.0, 2.0, 0), 0, "enters zero after the stable observation threshold")
func test_starvation_exits_zero_immediately_and_enforces_cooldown() -> void:
var policy := AdaptiveInputDepthController.new()
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
policy.update(8.0, 2.0, 0)
assert_eq(policy.target_depth, 0, "precondition: clean link entered zero")
assert_eq(policy.update(8.0, 2.0, -2), 1, "starvation sentinel immediately restores one tick")
for _i in AdaptiveInputDepthController.REENTRY_COOLDOWN_TICKS:
assert_eq(policy.update(8.0, 2.0, 0), 1, "cooldown prevents immediate zero-depth re-entry")
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
policy.update(8.0, 2.0, 0)
assert_eq(policy.target_depth, 0, "zero-depth can re-enter only after cooldown plus a fresh stable window")
func test_high_jitter_exits_zero_immediately() -> void:
var policy := AdaptiveInputDepthController.new()
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
policy.update(8.0, 2.0, 0)
assert_eq(policy.update(8.0, 5.1, 0), 1, "jitter above exit threshold restores safe depth immediately")
@@ -0,0 +1 @@
uid://crkx670s4ma3j
+60
View File
@@ -0,0 +1,60 @@
extends "res://tests/test_case.gd"
# Task 6.5. "The server cycles arenas" has to be an assertable claim rather
# than an observation about luck, which is why sequential rotation is a pure
# function of the completed-match count.
func test_sequential_rotation_visits_every_arena_before_repeating() -> void:
var paths := ArenaRegistry.rotation_paths()
assert_true(paths.size() >= 2, "rotation needs at least two arenas to mean anything")
var seen := {}
for i in paths.size():
seen[ArenaRegistry.path_for_match(i, "sequential")] = true
assert_eq(seen.size(), paths.size(), "every rotation arena appears in the first cycle")
func test_sequential_rotation_wraps_rather_than_running_out() -> void:
var paths := ArenaRegistry.rotation_paths()
var first: String = ArenaRegistry.path_for_match(0, "sequential")
var wrapped: String = ArenaRegistry.path_for_match(paths.size(), "sequential")
assert_eq(wrapped, first, "match N wraps back to the first arena")
# And a long-running server must not drift or fault at large counts.
assert_eq(ArenaRegistry.path_for_match(paths.size() * 1000, "sequential"), first, "still correct after a thousand cycles")
func test_consecutive_matches_are_never_the_same_arena_in_sequential_mode() -> void:
# The point of rotation is that players do not play the same arena twice in
# a row; wrapping must not produce a repeat at the seam either.
var paths := ArenaRegistry.rotation_paths()
for i in paths.size() * 2:
var current: String = ArenaRegistry.path_for_match(i, "sequential")
var next: String = ArenaRegistry.path_for_match(i + 1, "sequential")
assert_true(current != next, "match %d and %d differ" % [i, i + 1])
func test_rotation_never_offers_an_arena_bots_cannot_score_in() -> void:
# Elevated-goal variants are Free-Play-only until a checkpoint trained on
# them is promoted. A server rotating onto one would hand every bot-filled
# slot an arena it cannot score in.
var rotation := ArenaRegistry.rotation_paths()
for arena in ArenaRegistry.ARENAS:
if not arena["random"]:
assert_true(not (arena["path"] in rotation), "%s is excluded from rotation" % arena["name"])
assert_true(rotation.size() > 0, "and something is left to rotate through")
func test_random_mode_stays_inside_the_rotation_set() -> void:
for i in 50:
var path: String = ArenaRegistry.path_for_match(i, "random")
assert_true(path in ArenaRegistry.rotation_paths(), "random picks are still rotation-eligible")
func test_an_unknown_mode_falls_back_to_sequential_rather_than_faulting() -> void:
# The CLI already rejects an undeclared mode, so this is the belt to that's
# braces — but a server must not crash between matches over a string.
assert_eq(
ArenaRegistry.path_for_match(1, "spiral"),
ArenaRegistry.path_for_match(1, "sequential"),
"an unrecognised mode behaves as sequential"
)
@@ -0,0 +1,234 @@
extends "res://tests/test_case.gd"
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
func _action(thrust_z: float) -> ShipAction:
var a := ShipAction.new()
a.thrust = Vector3(0.0, 0.0, thrust_z)
return a
func test_sequential_ingest_and_consume() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.1)])
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "tick 0")
buf.ingest(1, [_action(0.2)])
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "tick 1")
assert_eq(buf.last_applied_seq, 1, "last_applied_seq after 2 ticks")
assert_eq(buf.starved_ticks, 0, "no starvation on a clean sequential stream")
# §3.1's own acceptance criterion: "a 3-packet burst loss produces no
# starvation." Redundancy-4 means a single surviving packet after 3 losses
# still carries all 4 of the most recent ticks' actions.
func test_redundancy_survives_3_packet_burst_loss() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
assert_almost_eq(buf.consume().thrust.z, 0.0, 0.0001, "seq 0")
# Packets for seq 1, 2, 3 are "lost" (never ingested individually) — only
# the seq=4 packet, carrying seq 4,3,2,1 (newest-first, redundancy 4),
# actually arrives.
buf.ingest(4, [_action(0.4), _action(0.3), _action(0.2), _action(0.1)])
assert_almost_eq(buf.consume().thrust.z, 0.1, 0.0001, "seq 1 recovered from redundancy")
assert_eq(buf.starved_ticks, 0, "seq 1 was not a starve")
assert_almost_eq(buf.consume().thrust.z, 0.2, 0.0001, "seq 2 recovered from redundancy")
assert_almost_eq(buf.consume().thrust.z, 0.3, 0.0001, "seq 3 recovered from redundancy")
assert_almost_eq(buf.consume().thrust.z, 0.4, 0.0001, "seq 4 recovered from redundancy")
assert_eq(buf.starved_ticks, 0, "no starvation anywhere across the whole burst-loss window")
func test_starvation_repeats_last_action_then_zeroes_after_500ms() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.7)])
buf.consume()
# Nothing else ever arrives — every consume() from here on starves.
for i in InputJitterBuffer.STARVE_ZERO_TICKS:
var a := buf.consume()
assert_almost_eq(a.thrust.z, 0.7, 0.0001, "repeat-last during starve, tick %d" % i)
assert_true(not buf.stalled, "not yet stalled at tick %d" % i)
# One more tick past STARVE_ZERO_TICKS (30 = 500ms at 60Hz) crosses the
# "> 30" threshold and zeroes rather than keeps repeating forever.
var stalled_action := buf.consume()
assert_almost_eq(stalled_action.thrust.z, 0.0, 0.0001, "zeroed after sustained stall")
assert_true(buf.stalled, "stalled flag set after 500ms of starvation")
func test_late_stale_packet_is_discarded_harmlessly() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(5, [_action(0.5)])
buf.consume() # seeded to 4 by ingest() (newest_seq - 1 action), one consume reaches 5
assert_eq(buf.last_applied_seq, 5, "consumed up through seq 5")
# A reordered/duplicated packet for an already-consumed seq arrives late.
buf.ingest(3, [_action(0.3)])
assert_eq(buf.depth(), 0, "a stale packet below last_applied_seq must not appear as buffered depth")
buf.ingest(6, [_action(0.6)])
assert_almost_eq(buf.consume().thrust.z, 0.6, 0.0001, "the genuinely-next seq still consumes correctly")
func test_depth_reports_contiguous_buffered_run() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
buf.consume() # last_applied_seq = 0
assert_eq(buf.depth(), 0, "nothing buffered ahead yet")
buf.ingest(3, [_action(0.3), _action(0.2), _action(0.1)])
assert_eq(buf.depth(), 3, "seq 1,2,3 all buffered and contiguous with last_applied_seq")
# A gap (seq 5 arrives but seq 4 never does) caps depth at the gap, not
# the highest seq seen.
buf.ingest(5, [_action(0.5)])
assert_eq(buf.depth(), 3, "seq 5 sits past a gap at seq 4, so it doesn't extend the contiguous run")
func test_ring_wraparound_does_not_confuse_a_stale_slot_with_a_fresh_one() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
buf.consume()
# Advance last_applied_seq well past one full lap of the ring (32 entries)
# so every stored slot tag is far behind "expected" and none may be
# misread as valid.
#
# This used to drive that purely by starvation with nothing re-ingested.
# It can't any more, and shouldn't: starvation only gives up on a sequence
# once strictly newer data proves it lost, because advancing past a
# sequence the client has not sent yet permanently strands the stream (see
# test_starving_ahead_of_the_client_does_not_permanently_discard_its_input).
# Drive it the way the real failure does instead — the client's epoch runs
# ahead while the intervening packets are lost.
var far := InputJitterBuffer.RING_SIZE * 3
buf.ingest(far, [_action(0.1)])
for i in InputJitterBuffer.RING_SIZE * 2:
buf.consume()
assert_true(buf.last_applied_seq > InputJitterBuffer.RING_SIZE, "advanced past a full lap of the ring")
# Now a fresh packet lands at the seq the ring slot for "expected" was
# LAST used for, one full lap ago — if slot-tagging didn't work, this
# would be misread as already-fresh data from the stale write.
var expected := buf.last_applied_seq + 1
buf.ingest(expected, [_action(0.9)])
var a := buf.consume()
assert_almost_eq(a.thrust.z, 0.9, 0.0001, "correctly reads the fresh same-slot-index seq, not a stale wraparound ghost")
assert_eq(buf.starved_ticks, 0, "starvation clears once fresh data resumes")
# The under-full direction (above) was covered before an adversarial review
# found the OVER-full direction was not: a backlog bigger than RING_SIZE
# (a host stall, or persistent client/server clock drift) made consume()
# starve — and, past STARVE_ZERO_TICKS, zero the player's ship — forever,
# because both last_applied_seq and the client's own seq only ever advance
# with no resync, so the gap never closed even though fresh, real input
# kept arriving the whole time.
func test_ring_overflow_resyncs_to_fresh_data_instead_of_starving_forever() -> void:
var buf := InputJitterBuffer.new()
buf.ingest(0, [_action(0.0)])
buf.consume() # last_applied_seq = 0
# A burst of packets arriving all at once, exactly what poll() delivers
# in one batch once a stalled server resumes — the client kept sending
# normally the whole time (a real packet every tick, last-4 redundancy,
# newest-first), nothing consumed in between. 50 ticks' worth, well
# past one full lap of the 32-entry ring.
for seq in range(1, 51):
var window: Array = []
for k in 4:
window.append(_action(float(seq - k) * 0.01))
buf.ingest(seq, window)
assert_eq(buf.last_applied_seq, 0, "nothing consumed yet, only ingested")
# The gap (50 - 1 = 49) exceeds RING_SIZE (32): everything older than
# "50 - RING_SIZE" has already been irrecoverably overwritten by more
# recent arrivals landing on the same ring slots. A single consume()
# must resync directly to the oldest data the ring can still actually
# provide, not starve through the entire abandoned span.
var a := buf.consume()
var expected_resync_seq := 50 - InputJitterBuffer.RING_SIZE + 1
assert_eq(buf.last_applied_seq, expected_resync_seq, "resynced to exactly RING_SIZE behind the newest data")
assert_almost_eq(a.thrust.z, float(expected_resync_seq) * 0.01, 0.0001, "recovered the resynced tick's real action from the ring, not a stale ghost or a zeroed one")
assert_eq(buf.starved_ticks, 0, "resyncing to real data is not starvation")
assert_true(not buf.stalled, "a recovered player must not be reported as stalled")
# Normal sequential consumption resumes correctly from the resync point.
var next := buf.consume()
assert_almost_eq(next.thrust.z, float(expected_resync_seq + 1) * 0.01, 0.0001, "next tick continues in order from the resync point")
# --- Starvation must not strand the stream (adversarial review, B2) ---------
# consume() used to advance last_applied_seq on EVERY tick including a starve.
# Because ingest() discards anything `seq <= last_applied_seq`, one starve on a
# sequence the client had not sent yet left the server permanently one ahead of
# arrivals: both sides then advance one per tick, the gap never closes, and
# every honest packet is discarded on arrival. Reproduced on a clean LAN — the
# client's own input_lead release (delta == 0, which issues no new sequence for
# one tick) was enough to trigger it, roughly every 6.5s of ordinary play.
func _thrust(value: float) -> ShipAction:
var a := ShipAction.new()
a.thrust = Vector3(0.0, 0.0, value)
return a
func test_starving_ahead_of_the_client_does_not_permanently_discard_its_input() -> void:
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "seq 1 applies normally")
# The client issues NO new sequence this tick (an input_lead release), so
# nothing newer than seq 1 exists. The server must keep expecting seq 2
# rather than consuming — and discarding — it.
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "a starve repeats the last action")
assert_eq(buffer.last_applied_seq, 1, "and does NOT advance past a sequence the client has not sent")
# The client's next real packet must still be accepted and applied.
buffer.ingest(2, [_thrust(-1.0)])
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "the next honest input is still applied, not discarded")
func test_sustained_release_pattern_does_not_black_out_input() -> void:
# The full B2 shape: client and server both advance one per tick, but the
# client duplicates one sequence (a release). Pre-fix, every packet from
# this point on was discarded and the ship froze for 30 ticks.
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
buffer.consume()
buffer.consume() # release tick: server starves
var applied_real_input := 0
for seq in range(2, 40):
buffer.ingest(seq, [_thrust(1.0)])
if absf(buffer.consume().thrust.z - 1.0) < 0.001:
applied_real_input += 1
assert_true(applied_real_input >= 35, "input keeps flowing after a release (applied %d/38)" % applied_real_input)
assert_true(not buffer.stalled, "and the buffer never reports a stall")
func test_a_genuinely_lost_packet_is_still_skipped_rather_than_waited_on() -> void:
# The control for the two tests above: holding must not become "wait
# forever". When strictly newer data has arrived, the missing sequence is
# provably lost or reordered and must be given up on immediately.
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
buffer.consume()
buffer.ingest(3, [_thrust(-1.0)]) # seq 2 never arrives; 3 does
buffer.consume() # starves on 2, but 3 is newer -> skip it
assert_eq(buffer.last_applied_seq, 2, "a lost sequence is skipped once newer data exists")
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "and the newer sequence applies on the next tick")
func test_a_silent_client_still_zeroes_and_stalls_on_schedule() -> void:
# The other control: holding must not defeat the disconnect behaviour.
var buffer := InputJitterBuffer.new()
buffer.ingest(1, [_thrust(1.0)])
buffer.consume()
for i in InputJitterBuffer.STARVE_ZERO_TICKS + 2:
buffer.consume()
assert_true(buffer.stalled, "a silent client still stalls")
assert_almost_eq(buffer.last_action.thrust.z, 0.0, 0.001, "and its ship still stops")
@@ -0,0 +1 @@
uid://cl18xku0mr8d0
@@ -0,0 +1,154 @@
extends "res://tests/test_case.gd"
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
func test_starts_at_minimum() -> void:
var c := InputLeadController.new()
assert_eq(c.lead, InputLeadController.LEAD_MIN, "initial lead")
func test_unknown_depth_is_a_normal_tick() -> void:
var c := InputLeadController.new()
assert_eq(c.update(-1), 1, "no snapshot info yet -> ordinary +1 seq increment")
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead unchanged with no info")
func test_healthy_depth_is_a_normal_tick_and_no_immediate_release() -> void:
var c := InputLeadController.new()
for i in 10:
assert_eq(c.update(1), 1, "healthy depth -> ordinary +1 tick %d" % i)
assert_eq(c.lead, InputLeadController.LEAD_MIN, "release needs 2s clean, not 10 ticks")
func test_zero_target_treats_an_empty_clean_link_buffer_as_healthy() -> void:
var c := InputLeadController.new()
for i in 10:
assert_eq(c.update(0, 0), 1, "adaptive zero-depth target does not attack on a clean empty buffer")
assert_eq(c.lead, InputLeadController.LEAD_MIN, "clean-link target preserves the minimum lead")
# §3.3: "on any starve, increase by up to 3 immediately" — but debounced by
# MIN_CHANGE_INTERVAL_TICKS so it isn't literally same-tick.
func test_starve_triggers_fast_attack_after_debounce_floor() -> void:
var c := InputLeadController.new()
var deltas: Array[int] = []
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
deltas.append(c.update(0))
# Every tick before the debounce floor is an ordinary +1 (no jump yet).
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1:
assert_eq(deltas[i], 1, "no lead change before the debounce floor, tick %d" % i)
assert_eq(deltas[InputLeadController.MIN_CHANGE_INTERVAL_TICKS - 1], 4, "attack fires on the debounce-floor tick: +1 ordinary + 3 skip")
assert_eq(c.lead, InputLeadController.LEAD_MIN + 3, "lead jumped by 3")
func test_repeated_starvation_climbs_toward_max_and_clamps() -> void:
var c := InputLeadController.new()
# Enough sustained starvation to trigger several attack steps.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS * 6:
c.update(0)
assert_eq(c.lead, InputLeadController.LEAD_MAX, "clamps at LEAD_MAX under sustained starvation, never exceeds it")
func test_release_requires_both_clean_surplus_and_its_own_interval() -> void:
var c := InputLeadController.new()
# Force lead above minimum first via one attack step.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0)
var lead_after_attack := c.lead
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "lead raised above minimum before testing release")
# Fewer than CLEAN_SURPLUS_TICKS of surplus depth (above TARGET_DEPTH):
# must not release yet.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack, "no release before 2s of clean surplus has elapsed")
# One more surplus tick crosses the clean-surplus threshold AND the
# release interval (both are already satisfied by now since the
# debounce timer has been running the whole time) -> releases by 1.
var delta := c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(delta, 0, "release tick duplicates rather than incrementing seq")
assert_eq(c.lead, lead_after_attack - 1, "lead released by exactly 1")
func test_release_stops_at_minimum() -> void:
var c := InputLeadController.new()
# Sustained surplus depth with lead already at LEAD_MIN: `lead` itself
# must never drop below the floor, but release must still fire
# (duplicate a seq) once its own timing conditions are met, since a
# real reported surplus at floor lead is exactly the "backlog this
# controller never caused" case — capping `lead` is cosmetic, it must
# not also block the seq-duplicate action that drains real depth.
var released := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS * 3:
if c.update(InputLeadController.TARGET_DEPTH + 1) == 0:
released = true
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead never drops below the floor, tick %d" % i)
assert_true(released, "release still fires (duplicates a seq) even though lead itself is pinned at minimum")
func test_starve_resets_clean_surplus_counter() -> void:
var c := InputLeadController.new()
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0) # raise lead above minimum via one attack step
var lead_after_attack := c.lead
# Some, but not all, of a clean surplus window — and well under the
# 30-tick attack debounce floor too, so the interrupting starve below
# can't accidentally retrigger a second attack step of its own.
var partial_clean_ticks := 10
for i in partial_clean_ticks:
c.update(InputLeadController.TARGET_DEPTH + 1)
c.update(0) # a lone starve tick, resetting _clean_surplus_ticks
assert_eq(c.lead, lead_after_attack, "the lone starve tick was too soon after the last change to trigger another attack")
# A full clean window from this fresh starting point is required before
# release fires — one tick short must not be enough.
for i in InputLeadController.CLEAN_SURPLUS_TICKS - 1:
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack, "the starve interruption forced a fresh 2s clean window, so no release yet")
c.update(InputLeadController.TARGET_DEPTH + 1)
assert_eq(c.lead, lead_after_attack - 1, "release finally fires once a full fresh clean window has elapsed since the interruption")
# A first attempt at fixing this gated the whole release branch on `lead >
# LEAD_MIN` — this controller's own memory of past attacks — so a backlog
# it did NOT itself create (a server hitch, persistent client/server clock
# drift, a burst re-delivery) was never drained: lead stayed at 1 forever
# even while the server kept reporting a deep, real backlog, and — because
# that gate blocked the seq-duplicate action too, not just lead's own
# bookkeeping — the actual buffered depth was never drained either. A
# second adversarial review caught that the depth check added alongside
# it didn't remove the old gate, just sat next to it. This reproduces the
# scenario directly: lead never attacks (depth is never reported as a
# starve, <= 0), yet release must still fire from sustained real surplus
# alone, even while lead itself stays pinned at its floor throughout.
func test_release_drains_a_backlog_it_never_caused_itself() -> void:
var c := InputLeadController.new()
assert_eq(c.lead, InputLeadController.LEAD_MIN, "starts at minimum, never attacked")
# A large, externally-caused surplus (e.g. right after the server's own
# ring-overflow resync) reported for well over 2s — lead never moves
# via attack since depth is never <= 0.
var released_at_floor := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
if c.update(10) == 0:
released_at_floor = true
assert_eq(c.lead, InputLeadController.LEAD_MIN, "lead's own bookkeeping never drops below its floor")
assert_true(released_at_floor, "release still fires (duplicates a seq, actually draining real depth) even while lead is pinned at the floor")
# Raise it above the floor via one real attack, then confirm sustained
# external surplus (not self-caused) still drains it back down.
for i in InputLeadController.MIN_CHANGE_INTERVAL_TICKS:
c.update(0)
var lead_after_attack := c.lead
assert_true(lead_after_attack > InputLeadController.LEAD_MIN, "attack raised lead")
var released := false
for i in InputLeadController.CLEAN_SURPLUS_TICKS + InputLeadController.RELEASE_INTERVAL_TICKS:
if c.update(10) == 0:
released = true
break
assert_true(released, "sustained externally-caused surplus (depth=10) must eventually trigger a release")
assert_true(c.lead < lead_after_attack, "lead actually decreased in response to real depth, not just internal bookkeeping")

Some files were not shown because too many files have changed in this diff Show More