From 04691aaa488a06ee16207f3f75f9afd084ce2ec9 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:37:17 +0100 Subject: [PATCH] chore(multiplayer): Phase 0 refactors + graphics/perf settings groundwork Lands the non-networked Phase 0 tasks from multiplayer-todo.md (ship/camera/ arena refactors, sim constants, background FPS handling) plus a first pass at exposing graphics/performance settings (presets, resolution scaling, vsync, FPS cap, perf overlay) and a GPU profiling harness for the real-hardware follow-up in task 0.15b. --- CLAUDE.md | 2 +- Game/objects/ball.tscn | 1 + Game/objects/ship.tscn | 8 +- Game/project.godot | 43 + Game/scenes/match.tscn | 2 +- Game/scenes/settings.tscn | 91 +++ Game/scripts/ai_ship_controller.gd | 19 + Game/scripts/arena.gd | 60 +- Game/scripts/arena_boundary.gd | 18 +- Game/scripts/background_fps.gd | 22 + Game/scripts/ball.gd | 34 +- Game/scripts/game_mode.gd | 155 ++-- Game/scripts/hud_attitude_indicator.gd | 2 +- Game/scripts/hud_gauge.gd | 2 +- Game/scripts/hud_heading_tape.gd | 2 +- Game/scripts/hud_instrument.gd | 16 + Game/scripts/main_menu.gd | 19 +- Game/scripts/perf_overlay.gd | 62 ++ Game/scripts/settings_menu.gd | 162 +++- Game/scripts/ship.gd | 79 +- Game/scripts/ship_action.gd | 12 + Game/scripts/ship_camera.gd | 119 ++- Game/scripts/sim_constants.gd | 14 + Game/scripts/training_mode.gd | 6 +- Game/scripts/video_settings.gd | 190 ++++- Game/tools/gpu_profile_harness.gd | 182 +++++ Game/tools/gpu_profile_harness.tscn | 6 + TODO.md | 11 +- multiplayer-todo.md | 1007 ++++++++++++++++++++++++ 29 files changed, 2212 insertions(+), 134 deletions(-) create mode 100644 Game/scripts/background_fps.gd create mode 100644 Game/scripts/perf_overlay.gd create mode 100644 Game/scripts/sim_constants.gd create mode 100644 Game/tools/gpu_profile_harness.gd create mode 100644 Game/tools/gpu_profile_harness.tscn create mode 100644 multiplayer-todo.md diff --git a/CLAUDE.md b/CLAUDE.md index 463276f4..13dace60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,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. diff --git a/Game/objects/ball.tscn b/Game/objects/ball.tscn index 670f27fc..1fc7c188 100644 --- a/Game/objects/ball.tscn +++ b/Game/objects/ball.tscn @@ -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 diff --git a/Game/objects/ship.tscn b/Game/objects/ship.tscn index abdfff99..6f5ac000 100644 --- a/Game/objects/ship.tscn +++ b/Game/objects/ship.tscn @@ -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") diff --git a/Game/project.godot b/Game/project.godot index 587b1eb2..3e414e51 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -24,16 +24,49 @@ run/main_scene.training="res://scenes/training.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" [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 +152,12 @@ 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) +] +} + [layer_names] 3d_physics/layer_1/name="Ships" @@ -130,9 +169,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 diff --git a/Game/scenes/match.tscn b/Game/scenes/match.tscn index 700862d3..086d0012 100644 --- a/Game/scenes/match.tscn +++ b/Game/scenes/match.tscn @@ -6,6 +6,6 @@ [node name="Match" type="Node3D"] script = ExtResource("1_m") bot_model_path = "res://bots/promoted/easy.json" - +team_size = 3 [node name="HUD" parent="." instance=ExtResource("3_m")] diff --git a/Game/scenes/settings.tscn b/Game/scenes/settings.tscn index 5fecb6b5..40869e8b 100644 --- a/Game/scenes/settings.tscn +++ b/Game/scenes/settings.tscn @@ -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"] diff --git a/Game/scripts/ai_ship_controller.gd b/Game/scripts/ai_ship_controller.gd index 3dd5df27..ce6d9626 100644 --- a/Game/scripts/ai_ship_controller.gd +++ b/Game/scripts/ai_ship_controller.gd @@ -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 diff --git a/Game/scripts/arena.gd b/Game/scripts/arena.gd index a6b52a6e..5469d48f 100644 --- a/Game/scripts/arena.gd +++ b/Game/scripts/arena.gd @@ -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: diff --git a/Game/scripts/arena_boundary.gd b/Game/scripts/arena_boundary.gd index 9e8176b9..582bb15f 100644 --- a/Game/scripts/arena_boundary.gd +++ b/Game/scripts/arena_boundary.gd @@ -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 diff --git a/Game/scripts/background_fps.gd b/Game/scripts/background_fps.gd new file mode 100644 index 00000000..f93c2deb --- /dev/null +++ b/Game/scripts/background_fps.gd @@ -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 diff --git a/Game/scripts/ball.gd b/Game/scripts/ball.gd index 306845d3..e536409f 100644 --- a/Game/scripts/ball.gd +++ b/Game/scripts/ball.gd @@ -19,6 +19,30 @@ const MAX_SPEED := 32.0 var _boundary: ArenaBoundary var _trail: GPUParticles3D +var _pending_teleport: Transform3D +var _has_pending_teleport := 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 + + +# -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 + + +func set_visual_speed(speed: float) -> void: + _visual_speed_override = speed + func _ready() -> void: _boundary = get_tree().get_first_node_in_group("arena_boundary") @@ -32,7 +56,8 @@ func _ready() -> void: func _physics_process(_delta: float) -> void: 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 +93,13 @@ 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 = Vector3.ZERO + state.angular_velocity = Vector3.ZERO + reset_physics_interpolation() + if _boundary: var pull := _boundary.get_surface_pull( global_position, wall_pull_strength, wall_pull_range, diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index 5a7daba2..3d0df53f 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -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,7 +292,8 @@ 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: diff --git a/Game/scripts/hud_attitude_indicator.gd b/Game/scripts/hud_attitude_indicator.gd index b19dfba3..b38f3027 100644 --- a/Game/scripts/hud_attitude_indicator.gd +++ b/Game/scripts/hud_attitude_indicator.gd @@ -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: diff --git a/Game/scripts/hud_gauge.gd b/Game/scripts/hud_gauge.gd index 55cc9290..6a6f7a73 100644 --- a/Game/scripts/hud_gauge.gd +++ b/Game/scripts/hud_gauge.gd @@ -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: diff --git a/Game/scripts/hud_heading_tape.gd b/Game/scripts/hud_heading_tape.gd index 858b6151..ffd19a41 100644 --- a/Game/scripts/hud_heading_tape.gd +++ b/Game/scripts/hud_heading_tape.gd @@ -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: diff --git a/Game/scripts/hud_instrument.gd b/Game/scripts/hud_instrument.gd index 4817c976..afdd7a4c 100644 --- a/Game/scripts/hud_instrument.gd +++ b/Game/scripts/hud_instrument.gd @@ -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() diff --git a/Game/scripts/main_menu.gd b/Game/scripts/main_menu.gd index a21e90d9..920a57f9 100644 --- a/Game/scripts/main_menu.gd +++ b/Game/scripts/main_menu.gd @@ -34,6 +34,10 @@ const DIFFICULTIES := [ 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() @@ -115,10 +119,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 +147,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 +157,4 @@ 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") diff --git a/Game/scripts/perf_overlay.gd b/Game/scripts/perf_overlay.gd new file mode 100644 index 00000000..72c083b1 --- /dev/null +++ b/Game/scripts/perf_overlay.gd @@ -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), + ] diff --git a/Game/scripts/settings_menu.gd b/Game/scripts/settings_menu.gd index 849f3852..2ccb70d9 100644 --- a/Game/scripts/settings_menu.gd +++ b/Game/scripts/settings_menu.gd @@ -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) diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index 0ea03b92..2f0b8fe1 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -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,43 @@ var _current_action: ShipAction = ShipAction.new() var _inert_action: ShipAction = ShipAction.new() var _boundary: ArenaBoundary +var _pending_teleport: Transform3D +var _has_pending_teleport := 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 + + +# --- 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 +const NET_VISUAL_OFFSET_DECAY := 0.88 + + +# 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 + # Instrument signals for efficient data distribution signal speed_changed(speed: float) signal attitude_changed(pitch: float, roll: float, yaw: float) @@ -135,6 +174,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 +223,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 +251,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 +284,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 +311,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 +321,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 +389,25 @@ func _has_telemetry_listeners() -> bool: func _integrate_forces(state): + if _has_pending_teleport: + _has_pending_teleport = false + state.transform = _pending_teleport + state.linear_velocity = Vector3.ZERO + state.angular_velocity = Vector3.ZERO + 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 *= _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 + # One action per physics tick, pulled from the controller (deterministic) _current_action = controller.get_action() if controller else _inert_action @@ -448,7 +513,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): diff --git a/Game/scripts/ship_action.gd b/Game/scripts/ship_action.gd index 0ab8f5a6..b11cf601 100644 --- a/Game/scripts/ship_action.gd +++ b/Game/scripts/ship_action.gd @@ -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 diff --git a/Game/scripts/ship_camera.gd b/Game/scripts/ship_camera.gd index 0d63c75a..1533a7b0 100644 --- a/Game/scripts/ship_camera.gd +++ b/Game/scripts/ship_camera.gd @@ -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; diff --git a/Game/scripts/sim_constants.gd b/Game/scripts/sim_constants.gd new file mode 100644 index 00000000..6c299a65 --- /dev/null +++ b/Game/scripts/sim_constants.gd @@ -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 diff --git a/Game/scripts/training_mode.gd b/Game/scripts/training_mode.gd index e13b79f4..57ae0fd9 100644 --- a/Game/scripts/training_mode.gd +++ b/Game/scripts/training_mode.gd @@ -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] = [] diff --git a/Game/scripts/video_settings.gd b/Game/scripts/video_settings.gd index cacd86f3..39b8556d 100644 --- a/Game/scripts/video_settings.gd +++ b/Game/scripts/video_settings.gd @@ -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 diff --git a/Game/tools/gpu_profile_harness.gd b/Game/tools/gpu_profile_harness.gd new file mode 100644 index 00000000..b0e2b4ae --- /dev/null +++ b/Game/tools/gpu_profile_harness.gd @@ -0,0 +1,182 @@ +extends Node + +# One-off GPU frame-time profiling harness for task 0.15b's real-hardware +# follow-up (multiplayer-todo.md §5.5.1) — the automated Mac passes gave +# inconsistent, sometimes implausible numbers (stale-process contention, +# and Apple Silicon's tile-based GPU architecture is a poor stand-in for the +# target reference hardware). Run this directly on a machine with a real +# discrete desktop GPU instead: +# +# godot --path Game res://tools/gpu_profile_harness.tscn +# +# On a Linux box with no attached physical display, wrap it in a virtual +# framebuffer so it still gets a real windowing/rendering context (NOT +# --headless — that uses a dummy renderer with no GPU rendering at all, +# see CLAUDE.md's "Headless smoke test" note): +# +# xvfb-run -a --server-args="-screen 0 1920x1080x24" \ +# godot --path Game res://tools/gpu_profile_harness.tscn +# +# Prints a report to stdout and also writes it to +# user://gpu_profile_report.txt — on Linux that's typically +# ~/.local/share/godot/app_userdata/Cosmic Clash/gpu_profile_report.txt; the +# exact resolved path is printed at the end of the run, so just paste that +# back. Quits itself automatically when done (~90 seconds total). + +const SAMPLE_SECONDS := 4.0 +const SETTLE_SECONDS := 1.5 + +var _match: Node +var _env: Environment +var _shadow_lights: Array[Light3D] = [] +var _postfx: CanvasItem +var _viewport: Viewport +var _report_lines: Array[String] = [] + + +func _ready() -> void: + var adapter := RenderingServer.get_video_adapter_name() + var vendor := RenderingServer.get_video_adapter_vendor() + _log("GPU adapter: %s (%s)" % [adapter, vendor]) + if not ("NVIDIA" in adapter.to_upper() or "NVIDIA" in vendor.to_upper()): + _log("WARNING: this doesn't look like a real NVIDIA GPU context.") + _log(" If this is llvmpipe/softpipe/Mesa software rendering, every") + _log(" number below is meaningless for GPU profiling purposes —") + _log(" check `glxinfo | grep -i renderer` and your Xorg/Xvfb/driver") + _log(" setup before trusting this report.") + + var match_scene := load("res://scenes/match.tscn") as PackedScene + _match = match_scene.instantiate() + # 3v3 = 6 ships, matching the scenario multiplayer-todo.md §5.5 measures. + _match.team_size = 3 + # Direct-scene-run fallback path (see match_mode.gd:_make_opponent_controller) + # — gives every AI ship a real trained policy so thruster VFX/movement + # load matches actual play, not six stationary hulls. + _match.bot_model_path = "res://bots/promoted/medium.json" + add_child(_match) + + _log("Waiting for kickoff and bots to start moving...") + await get_tree().create_timer(5.0).timeout + + _viewport = get_tree().root + var world_env := _match.arena.get_node_or_null("WorldEnvironment") as WorldEnvironment + if not world_env or not world_env.environment: + _log("ERROR: no WorldEnvironment found on the spawned arena — aborting.") + get_tree().quit(1) + return + _env = world_env.environment + for light in _match.arena.find_children("*", "Light3D", true, false): + if (light as Light3D).shadow_enabled: + _shadow_lights.append(light) + var rig := get_tree().get_first_node_in_group("ship_camera") + _postfx = rig.get_node_or_null("PostProcess/PostFX") if rig else null + if not _postfx: + _log("WARNING: PostFX node not found — that pass won't be profiled.") + + await _run_all_configs() + + var report := "\n".join(_report_lines) + var f := FileAccess.open("user://gpu_profile_report.txt", FileAccess.WRITE) + if f: + f.store_string(report) + f.close() + _log("") + _log("Report written to: %s" % ProjectSettings.globalize_path("user://gpu_profile_report.txt")) + get_tree().quit() + + +func _log(s: String) -> void: + _report_lines.append(s) + print(s) + + +func _run_all_configs() -> void: + var base_sdfgi := _env.sdfgi_enabled + var base_ssil := _env.ssil_enabled + var base_ssao := _env.ssao_enabled + var base_glow := _env.glow_enabled + var base_msaa := _viewport.msaa_3d + var base_aa := _viewport.screen_space_aa + var base_postfx_visible: bool = _postfx.visible if _postfx else true + + await _measure("baseline_all_on") + + _env.sdfgi_enabled = false + _env.ssil_enabled = false + _env.ssao_enabled = false + _env.glow_enabled = false + for l in _shadow_lights: + l.shadow_enabled = false + _viewport.msaa_3d = Viewport.MSAA_DISABLED + _viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED + if _postfx: + _postfx.visible = false + await _measure("all_off_floor") + + _restore_baseline(base_sdfgi, base_ssil, base_ssao, base_glow, base_msaa, base_aa, base_postfx_visible) + + _env.sdfgi_enabled = false + await _measure("sdfgi_off") + _env.sdfgi_enabled = base_sdfgi + + _env.ssil_enabled = false + await _measure("ssil_off") + _env.ssil_enabled = base_ssil + + _env.ssao_enabled = false + await _measure("ssao_off") + _env.ssao_enabled = base_ssao + + _env.glow_enabled = false + await _measure("glow_off") + _env.glow_enabled = base_glow + + for l in _shadow_lights: + l.shadow_enabled = false + await _measure("shadows_off_all_%d_lights" % _shadow_lights.size()) + for l in _shadow_lights: + l.shadow_enabled = true + + _viewport.msaa_3d = Viewport.MSAA_DISABLED + await _measure("msaa_off") + _viewport.msaa_3d = base_msaa + + _viewport.screen_space_aa = Viewport.SCREEN_SPACE_AA_DISABLED + await _measure("fxaa_off") + _viewport.screen_space_aa = base_aa + + if _postfx: + _postfx.visible = false + await _measure("postfx_off") + _postfx.visible = base_postfx_visible + + +func _restore_baseline(sdfgi: bool, ssil: bool, ssao: bool, glow: bool, msaa: Viewport.MSAA, aa: Viewport.ScreenSpaceAA, postfx_visible: bool) -> void: + _env.sdfgi_enabled = sdfgi + _env.ssil_enabled = ssil + _env.ssao_enabled = ssao + _env.glow_enabled = glow + for l in _shadow_lights: + l.shadow_enabled = true + _viewport.msaa_3d = msaa + _viewport.screen_space_aa = aa + if _postfx: + _postfx.visible = postfx_visible + + +# Raw get_process_delta_time() per rendered frame, not Performance.TIME_FPS — +# TIME_FPS is itself a smoothed/rounded value, which would understate exactly +# the p99 variance this is trying to measure. +func _measure(label: String) -> void: + await get_tree().create_timer(SETTLE_SECONDS).timeout + var samples: PackedFloat32Array = [] + var elapsed := 0.0 + while elapsed < SAMPLE_SECONDS: + await get_tree().process_frame + var dt := get_process_delta_time() + samples.append(dt * 1000.0) + elapsed += dt + samples.sort() + var p50 := samples[samples.size() / 2] + var p99 := samples[mini(int(samples.size() * 0.99), samples.size() - 1)] + _log("%-28s p50=%6.2fms p99=%6.2fms fps(p50)=%6.1f n=%d" % [label, p50, p99, 1000.0 / p50, samples.size()]) diff --git a/Game/tools/gpu_profile_harness.tscn b/Game/tools/gpu_profile_harness.tscn new file mode 100644 index 00000000..0ab77ec9 --- /dev/null +++ b/Game/tools/gpu_profile_harness.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/gpu_profile_harness.gd" id="1_harness"] + +[node name="GpuProfileHarness" type="Node"] +script = ExtResource("1_harness") diff --git a/TODO.md b/TODO.md index fd8a1091..7de3bc60 100644 --- a/TODO.md +++ b/TODO.md @@ -19,7 +19,10 @@ The largest gap between this and a AAA-feeling product is presentation, not code ## Multiplayer (long term) -- [ ] `RemoteShipController extends ShipController` — feeds replicated `ShipAction`s from a network peer into the local ship simulation. -- [ ] Networked `GameMode` subclass: per-peer ship spawning (MultiplayerSpawner or custom), authoritative server for ball/score. -- [ ] C# backend / online servers per README roadmap (not started). -- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this). +Planned in **[`multiplayer-todo.md`](multiplayer-todo.md)** — architecture decisions (server-authoritative dedicated servers, client-side prediction, ENet then Steam), wire format, latency budget, and an eight-phase task breakdown. Nothing implemented yet. + +Phase 0 of that plan is a set of non-networked refactors that land independently and are verifiable in single-player today; start there. It now also carries the **graphics/performance work** — the project has never been profiled, and `video_settings.gd` exposes only AA, glow and brightness while SDFGI, SSIL, SSAO and five shadow-casting lights are on by default and unreachable (see §5.5 there). + +**Tasks 0.1–0.15, 0.18–0.25, 0.27, 0.29 are done** (see the Phase 0 table in `multiplayer-todo.md` for what each one actually changed — several deviated from the original plan for concrete GDScript/Godot reasons recorded inline). Remaining, all blocked on **0.15b (profile, on reference hardware, in the live editor — not done)**: 0.16 (camera to `_process`), 0.17/0.17b/0.17c/0.17d (graphics presets, vsync, resolution scaling), **0.26 (bake the arena GI to retire SDFGI — the largest frame-time win available, costs no image quality since the arena is fully static)**, and 0.28 (physics separate-thread prototype, flagged as the riskiest task in the phase). These need a human at the editor with real hardware to profile and eyeball, not further code changes. + +- [ ] Possible v0.2 split-screen: spawn one `ship_camera_rig` + viewport per local player (camera is already outside the ship scene to allow this). Unrelated to online play. diff --git a/multiplayer-todo.md b/multiplayer-todo.md new file mode 100644 index 00000000..28573b7b --- /dev/null +++ b/multiplayer-todo.md @@ -0,0 +1,1007 @@ +# Online multiplayer — architecture and task breakdown + +Working document for the online multiplayer effort. `TODO.md` points here. + +Everything below is written so an agent (or a person) can pick up a single numbered task, do it, verify it against a stated acceptance criterion, and stop. Sections 1–6 are the decisions those tasks assume; read them before picking up work in Phase 2 or later. + +**Status: nothing here is implemented.** The game has zero networking code today. The only network-adjacent code in the repo is the RL trainer's `StreamPeerTCP` bridge in the vendored `godot_rl_agents` addon, which is a dev-only training transport and unrelated. + +--- + +## 1. Architecture decisions + +### 1.1 Locked decisions + +| # | Decision | Why | +|---|---|---| +| 1 | **Server-authoritative simulation, with client-side prediction of the local ship and ball. No world rollback / resimulation.** | Jolt is not bit-deterministic across platforms or across differing contact orderings, and Godot exposes no world snapshot/restore API. Rollback netcode would be a research project. | +| 2 | **Dedicated servers only.** Headless Godot export; the server is never a player. | Fair for every player, no host advantage. Self-hostable community servers first, so nothing is blocked on paid infrastructure. | +| 3 | **ENet first**, GodotSteam later, behind a boundary. | ENet works in-editor, headless, on LAN, and in CI with no Steam client. Direct-IP connect stays permanently supported and **must never become the degraded path**. | +| 4 | **No custom backend.** | Steam's `ISteamGameServer` master-server listing covers discovery, `ISteamMatchmakingServers` covers the in-game browser, and Steam auth tickets cover identity and ban state. `README.md`'s C# backend stays unstarted. | + +### 1.2 Rejected alternatives + +- **Peer-authoritative ships** (each client owns its own transform). Easiest to build, feels perfect locally, and is trivially cheatable — it directly contradicts `README.md`'s stated anti-cheat position. Ship-vs-ship collisions also become ambiguous with no arbiter. +- **Deterministic lockstep / rollback.** See decision 1. +- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** The decisive objection is not bandwidth. It is that `last_processed_input_seq` **must** arrive in the same packet as the state it describes, or reconciliation is off by a snapshot — and a synchroniser gives you nowhere to put it. It also writes replicated properties directly onto the node, which is exactly wrong for a `RigidBody3D` under prediction: incoming state has to enter a compare-against-history pipeline, not be stamped onto `global_transform`. You would end up building the correction pipeline anyway, with the synchroniser as pure overhead. Secondary objections: one packet per body (7 bodies × ~50 B of UDP/IP/ENet framing versus one coalesced snapshot), no per-field quantisation, and no client-side interpolation. + + `MultiplayerSpawner` is unnecessary for a separate reason: the roster is fixed at match start and fully described by the `match_config` message, and **no ship is ever despawned** (§6.4). +- **Seeded RNG for kickoff jitter.** Shared-seed determinism requires both sides to consume the RNG stream in exactly the same order forever. The first `randf()` anyone later adds anywhere in the reset path — a spawn VFX variation, a cosmetic, a commentary line — silently desyncs kickoff positions with no error message. The server broadcasts the resulting transforms instead: 336 bytes, once per kickoff, cannot rot. + +### 1.3 Derived decisions + +**All hot-path RPCs live on autoloads.** `/root/NetworkManager` and `/root/MatchNet` exist at identical paths on every peer regardless of which scene is loaded, which side is headless, or whether a client is mid-scene-transition. This deletes the entire "NodePaths must match across peers" class of bugs, kills a family of late-join races where an RPC arrives before its target node exists, and warms Godot's RPC path cache once at connect so it never re-sends a full path on scene change. + +**Entities are addressed by integer slot, never by path.** The snapshot is `[slot 0..N-1]` in a fixed order established by `match_config`. `MatchNet` holds an `Array[Node] _slots` populated at spawn. + +**One server process hosts exactly one match.** This is forced, not chosen: `ship.gd:162` resolves the arena boundary via `get_tree().get_first_node_in_group("arena_boundary")` and `ai_ship_controller.gd` discovers its roster via `get_tree().get_nodes_in_group("ship")`. Both are tree-global, so two matches in one scene tree would cross-wire instantly. It is recorded here because it determines the RAM figure in §1.4. + +### 1.4 Server sizing — bandwidth and CPU are not the constraint + +Worth establishing up front, because §2 and §3 repeatedly trade bandwidth for latency and somebody will eventually want to trade back. + +`ArenaBoundary.bake_colliders()` generates roughly 168 box colliders (corner fillets, base wrap, ceiling, end walls) plus the scene's own slabs, 2 goal backstops, 2 `Area3D` sensors, and 7 dynamic bodies (the ball with `continuous_cd`). Estimated per-tick cost: + +| Component | ms/tick | +|---|---:| +| Jolt step | 0.15 – 0.4 | +| Godot headless main loop | 0.1 – 0.3 | +| Bot inference, amortised (see task 0.8) | ~0.3 | +| **Total, of a 16.7 ms budget** | **0.6 – 1.1** | + +→ **~6–10 concurrent matches per modern core**, ~150–250 MB RSS per process. 100 concurrent matches ≈ 12–16 cores and ~20 GB — a single mid-tier VPS. Upstream bandwidth for a full 6-player match is ~630 kbit/s (§2.4). + +**Neither CPU nor bandwidth is scarce. Latency is.** Optimise accordingly. + +--- + +## 2. Wire format + +Two peers must agree byte-for-byte, so this is specified rather than sketched. + +### 2.1 Channels + +| Channel | Transfer mode | Contents | +|---|---|---| +| 0 | reliable | handshake, `match_config`, kickoff, goal, clock, state changes, chat, admin | +| 1 | unreliable-ordered | client → server input | +| 2 | unreliable-ordered | server → client snapshots | + +Unreliable-**ordered** (ENet sequenced-unreliable, drops stale) rather than plain unreliable for both hot paths: we carry explicit sequence numbers, and a reordered late packet is worthless work. Separating them stops a large reliable `match_config` from head-of-line-blocking state on a lossy link. + +> **Verify at implementation time.** Godot's `ENetMultiplayerPeer` reserves low ENet channels for its own system messages and offsets `transfer_channel` on top. The intent above is "three logically distinct channels"; the concrete indices may need an offset. Confirm empirically, don't assume. + +**Set `ENetMultiplayerPeer.server_relay = false`.** It defaults to `true`, which lets any client `rpc()` any other client *through your server*. With it off, clients can only talk to peer 1. Single highest-value one-line security change in this document. + +### 2.2 Packet header + +**Every hot-path packet opens with a 1-byte type + version.** A capture then decodes standalone, and a mismatched build fails loudly instead of decoding garbage straight into `state.transform`. + +Hot paths carry a single `PackedByteArray` RPC argument (≈14 B of Godot RPC framing once the path cache is warm). Control messages on channel 0 use normal typed arguments — they are rare and readability beats bytes. + +### 2.3 Input packet — client → server, channel 1, 60 Hz + +``` +u8 type_version +u32 seq server-tick-space sequence of the NEWEST action +u8 count 1..4 (MAX_REDUNDANCY) +u32 ack_snapshot_tick newest snapshot tick this client has processed +u16 client_send_ms wrapping ms clock, echoed back for RTT +--- repeated `count` times, newest first --- +i8 thrust_x, thrust_y, thrust_z value = clamp(round(v*127), -127, 127) +i8 rot_x, rot_y, rot_z +u8 flags bit0 = turbo +``` + +**12 + 7×4 = 40 B payload**, ~90 B on the wire with UDP/IP/ENet framing → **~43 kbit/s up per client**. + +- **Redundancy 4** is what makes an unreliable input channel safe: starvation requires four consecutive losses (~66 ms). +- **`i8` per axis, not 3-bit bins.** Bins matching `ShipActionCodec.HEADS` would cut an action to 3 bytes, but they permanently foreclose analog gamepad sticks, which this game will want. `round(v*127)/127` round-trips `-1/0/+1` exactly, so today's digital input (`player_ship_controller.gd` is `is_action_pressed`-only) is lossless. +- **The encoding is itself a validator.** `i8/127` cannot express NaN, Inf, or a value outside `[-1.008, 1.008]`. Half of "sanitise untrusted client input" is solved by not using Variant encoding. + +### 2.4 Snapshot — server → client, channel 2, 60 Hz default + +Per-client header built per peer; body buffer built once per tick and reused across peers. + +``` +--- per-client header (7 B) --- +u32 last_input_seq newest input from THIS client the server has applied +i8 input_buffer_depth jitter-buffer occupancy; negative = starved +u16 echo_client_send_ms from that input packet, for RTT + +--- shared body header (8 B) --- +u8 type_version +u32 server_tick Engine.get_physics_frames() on the server +u8 match_state see §6.1 +u8 reset_gen increments on every authoritative teleport +u8 body_count + +--- repeated body_count times, slot order fixed by match_config (22 B each) --- +i16 pos_x, pos_y, pos_z range ±64 m -> 1.95 mm +i16 quat_x, quat_y, quat_z w = ±sqrt(1-x²-y²-z²), sign in flags +i16 vel_x, vel_y, vel_z range ±64 m/s -> 1.95 mm/s +i8 avel_x, avel_y, avel_z ships ±4 rad/s; ball ±32 rad/s +u8 flags bit0 frozen, bit1 turbo, bits2-4 thrust_z bin, + bit5 stalled, bit6 quat_w sign +``` + +7 bodies → **8 + 7 + 7×22 = 169 B payload**, ~219 B on the wire. + +| | per client down | server up, 6 clients | + 10 spectators | +|---|---:|---:|---:| +| 60 Hz | 105 kbit/s | 631 kbit/s | 1.68 Mbit/s | + +MTU headroom is ~6× (ENet fragments above ~1400 B); a hypothetical 10v10 at 21 bodies is 477 B and still fits. **This format does not need delta compression.** + +**Plain `i16` quaternion components, not smallest-three.** Smallest-three saves 4 B/body and is the textbook answer. It is also exactly where a hand-rolled codec goes subtly wrong — off-by-one in the 2-bit index, sign of the dropped component, renormalisation drift — in a project that has no test framework yet. Three `i16`s plus a sign bit give ~3e-5 rad with no bit-shifting, for 2 B/body (≈3 kbit/s). Take the bytes. + +**Quantisation ranges derive from constants, not from prose.** `ArenaBoundary.INNER_HALF_X = 18.0`, `INNER_HALF_Z = 27.0`, `INNER_HEIGHT = 18.0` (`arena_boundary.gd:8-10`) plus `GameMode.ESCAPE_MARGIN = 15.0`; `Ship.max_speed = 35.0` (`ship.gd:16`); `Ball.MAX_SPEED = 32.0` (`ball.gd:17`). + +> `CLAUDE.md`'s Architecture section states the play volume as "inner x ±12, z ±18, height 12, goal lines z ±17". **That is stale** — see the real constants above. Task 0.13 fixes the doc. + +**The flags byte must carry `turbo` and a 3-bit `thrust_z` bin.** `_integrate_forces` is not called on frozen bodies, so remote ships on a client never pull `get_action()`, and `Ship._update_movement_vfx()` (`ship.gd:293`) reads `_current_action.thrust.z` and `turbo`. Without those bits, every remote ship flies with dead engines. + +### 2.5 Reliable control messages, channel 0 + +`hello` · `welcome` · `player_joined` · `player_left` · `ready_state` · `match_config` · `scene_ready` · `kickoff` · `state_change` · `goal_scored` · `clock_state` · `match_ended` · `chat` · `server_shutdown`. + +--- + +## 3. Server-side input handling + +Per-player server state: + +```gdscript +class PlayerSlot: + var peer_id: int + var slot: int # snapshot index + var ring: Array[ShipAction] # FIXED 32 entries, indexed seq % 32 + var ring_seq: PackedInt32Array # 32 entries, seq stored at each index (-1 = empty) + var last_applied_seq: int + var last_action: ShipAction + var starved_ticks: int + var packets_this_second: int + var remote_controller: RLShipController # see §7 task 5.7 — null on takeover +``` + +### 3.1 Ingestion + +`@rpc("any_peer", "unreliable_ordered", channel = 1)`, in order: + +1. `multiplayer.get_remote_sender_id()` → look up slot. Unknown sender → drop and count. +2. **Rate limit.** `packets_this_second > 110` (60 Hz × 1.5 + 20) → drop. Three consecutive seconds over budget → disconnect with `RATE_LIMIT`. Same for a byte budget. +3. **Framing.** `count > 4` or `payload_size != 12 + count*7` → drop, count malformed. 20 malformed → disconnect. +4. **Sequence range.** `seq > server_tick + 20` → drop. (Not 120: `input_lead` is clamped to 12, so anything above ~20 is broken or hostile.) This is why the ring is fixed-size and indexed `seq % 32` — **a client can never make the server allocate.** +5. For each action, newest first at descending seq: `seq <= last_applied_seq` → discard (already consumed); else write `ring[seq % 32]`. +6. **Decode with per-axis clamp only:** + ```gdscript + action.thrust = Vector3(b[0]/127.0, b[1]/127.0, b[2]/127.0).clampf(-1.0, 1.0) + ``` + +> **Never normalise the thrust vector.** A player holding W+A+E legitimately produces `thrust = (1,1,1)`, length 1.73, and each axis uses a different power constant — `thrust_power 150`, `maneuvering_thrust 75`, `vertical_thrust 120` (`ship.gd:12-14`). Normalising would silently change the flight model for honest players. Per-axis clamp combined with the `i8` encoding is complete validation: the reachable value space is exactly what a legitimate client can produce. + +### 3.2 Consumption — once per server physics tick, before the step + +``` +expected = last_applied_seq + 1 +if ring holds expected: + action = ring[expected % 32]; starved_ticks = 0 +else: + action = last_action # REPEAT — do not zero + starved_ticks += 1 + if starved_ticks > 30: # 500 ms + action = ZERO_ACTION; flags.stalled = true +last_applied_seq = expected +last_action = action +remote_controller.action = action +``` + +**Repeat-last, not zero.** Player inputs are heavily autocorrelated at 60 Hz — the odds that a held thrust was released on exactly the dropped tick are low, and the client predicted with the real input either way, so repeating minimises expected divergence. It is also consistent with `AIShipController`, which already holds its action between decisions. Zeroing after 500 ms stops a disconnecting player's ship flying into a wall at full throttle forever. + +### 3.3 Jitter buffer — one control loop, not three + +An earlier draft had the server adapting `target_depth`, the server fast-forward-dropping queued actions, **and** the client slewing `input_lead`. Three integrators acting on one plant (buffer occupancy) with different time constants is a textbook oscillation; on a jittery link it hunts, and it presents to the player as intermittent sticky controls that are nearly impossible to attribute. + +**The server reports `input_buffer_depth` in every snapshot and does nothing else adaptive. The client owns `input_lead` exclusively.** + +- `target_depth = 1` (16.7 ms), not 2. With redundancy-4 you have already bought the insurance depth 2 provides; depth 2 is 16.7 ms of pure input latency for nothing. +- Client `input_lead` clamp `[1, 12]`, **fast attack / slow release**: on any starve, increase by up to 3 **immediately**; decrease by 1 per 60 ticks only after 2 s of clean surplus. A symmetric ±1-per-500 ms slew takes two seconds to absorb a wifi spike, during which the player steers and the ship does not turn — the most rage-inducing failure mode in any netcode. +- Changing `input_lead` means skipping or duplicating one tick's sequence number. Never change it more than once per 30 ticks. + +**Enforce `input_lead` server-side from observed arrival times.** A client that fakes starvation to drive `input_lead` to 1 gets its inputs applied with less server-side buffering than honest players — a small but real responsiveness edge. The `i8` encoding does nothing about this; only observing actual arrival timing does. + +--- + +## 4. Prediction and reconciliation + +### 4.1 Two clocks for remote entities — the load-bearing correction + +The obvious design runs remote ships and the ball as frozen kinematic proxies at `server_time_est - INTERP_DELAY` while predicting the local ship to *now*. **That is wrong**, and it is wrong in a way that only shows up over real latency: + +- Two ships closing at 50 m/s put the opponent's collider **3.5 m** from truth. The hull is a `BoxShape3D` of `(1.6, 0.6, 4)` (`ship.tscn:12`) — that is most of a ship length of positional lie. +- A fast ball is **2.2 m** off against a 0.5 m radius — four ball diameters. +- `ship.tscn:16` has `collision_mask = 7`: ships collide with ships, the ball, and the arena. Ship-vs-ship contact is *constant* in vehicle soccer, not incidental. + +So prediction would not diverge occasionally due to timing noise. It would diverge **deterministically and in the same direction on essentially every contact**, and the hard-snap threshold would become the steady state rather than a backstop. + +**Fix: separate the collider clock from the render clock.** + +| | runs at | why | +|---|---|---| +| remote body **collider** | `server_time_est`, extrapolated forward from the newest snapshot by ~one-way + half a snapshot interval | Extrapolation error over ~45 ms at real accelerations (`thrust_power 150 / mass 5` = 30 m/s², 75 m/s² on turbo — `ship.gd:12,15`, `ship.tscn:17`) is ~0.03–0.08 m. Two orders of magnitude better than 3.5 m. | +| remote **`$Visual`** | `server_time_est - INTERP_DELAY` | Smooth, jitter-free rendering. | + +This is the same trick applied to the local ship, pointed the other way. It costs one extra transform write per remote body per tick. + +### 4.2 Where each piece lives + +| Concern | Location | +|---|---| +| sample + send input | `LocalNetShipController._physics_process` — runs before the physics step, guarantees exactly one sample/tick | +| record predicted state | same, at top of tick N (state = result of N−1) | +| apply velocity / teleport correction | `Ship._integrate_forces`, ~15 guarded lines — the only Jolt-safe place to write `state.transform` / `state.linear_velocity` | +| visual smoothing | `Ship/$Visual.global_transform`, set in `_physics_process` | +| snap-vs-blend decision | `net_ship_predictor.gd` (child node) | +| remote bodies | `net_interpolator.gd` | + +### 4.3 Per-tick, own ship + +1. `predicted[current_tick - 1] = {transform, linear_velocity, angular_velocity}` — ring of 128. +2. `var a := _player.get_action().copy()` — **must copy.** `player_ship_controller.gd` reuses a single `ShipAction` across ticks (its own header warns about this); buffering it aliases every history entry to the same object. See task 0.1. +3. `_action = a`, returned by `get_action()` this tick so `Ship._integrate_forces` samples input exactly once. +4. `input_history[seq] = a`, `seq = predicted_server_tick + input_lead`. +5. Build and send the packet with the last 4 entries. + +`Ship._integrate_forces` then runs completely unchanged. + +### 4.4 On snapshot arrival + +``` +A = last_input_seq +if reset_gen changed OR predicted[A] missing OR flags.frozen != local frozen: + HARD SNAP +else if pos_err > 2.0 m OR rot_err > 60°: + HARD SNAP +else: + SOFT CORRECT +``` + +Comparing server state at tick `A` against **`predicted[A]`** — the client's own state at that same tick — makes the delta latency-free by construction. That is the entire reason for keeping the prediction ring, and it is why this works acceptably without resimulation: **never blend current state toward stale state.** + +**SOFT CORRECT** + +- **Velocity: applied in full, immediately.** `net_vel_correction += (srv.linvel - predicted[A].linvel)`, consumed once in `_integrate_forces`. Velocity error is invisible to the player but is the *cause* of future position error; blending it just prolongs divergence. +- **Position/rotation: physics moves in full, rendering does not.** Queue the body teleport, and simultaneously offset `$Visual` by the negation. Net visual movement at the instant of correction: zero. The body is where the server says; the rendered ship catches up. +- **Decay** each physics tick, reusing the existing convention at `ship.gd:450`: + ```gdscript + var k := _tick_scaled(0.88, delta) # 63% gone in ~130 ms, 95% in ~280 ms + ``` +- **`MAX_VISUAL_OFFSET = 0.4 m`**, not 2.0. The hull is 4 m long; a 2 m offset means being rendered half a ship-length from your own collider for ~280 ms, so you clip walls you visibly cleared — a felt bug in a game built around wall-riding. Beyond 0.4 m, show the correction. A visible correction is honest; an invisible 2 m lie is not. + +**HARD SNAP** + +- Set body transform and velocities from the server values, **caught up** to the current tick (below), `reset_physics_interpolation()` on the body *and* on `$Visual`, zero the visual offset. +- **Backfill the prediction ring** for ticks `A..current`. Do **not** clear it — "missing `predicted[A]`" is itself a snap condition, so clearing guarantees the next snapshot also snaps, turning isolated snaps into bursts. + +**Catch-up replays the ship's own force formulas, not ballistic dead-reckoning.** Input-free extrapolation is not unbiased: turbo acceleration is `150 × 2.5 / 5` = **75 m/s²**, so a 6-tick catch-up lands ~0.375 m short *in the direction the player is accelerating*, on every snap, and the ship feels permanently rubbery under sustained thrust. Replaying 6–12 stored `ShipAction`s through `apply_thruster_forces` / `apply_rotation_forces` / `apply_drag_and_limits` / `apply_righting_torque` (`ship.gd:361-486` — pure float math, no Jolt dependency) is ~30 lines and ~1000 float ops. + +> This is **not** world rollback and does not touch locked decision 1. It replays one body against a frozen world and needs no determinism guarantee. + +### 4.5 Camera and visuals + +**The camera must follow `$Visual`, not the body.** `ship_camera.gd:115`, `:149`, `:150` read `target.global_transform` directly. Left as-is, every soft correct makes the *camera* jump the full error while the *mesh* smoothly lags — strictly worse than snapping, because the world lurches around a player whose ship slides inside the frame. + +**And it must read `$Visual.get_global_transform_interpolated()` from `_process`, not `global_transform` from `_physics_process`** (task 0.16, rationale in §5.4). `Node3D.get_global_transform_interpolated()` exists precisely for a camera tracking a physics-interpolated body; `global_transform` returns the last physics tick's pose, so a `_process` camera reading it would chase a 60 Hz staircase at 240 fps. + +> **Ordering hazard**, straight from the engine docs: `get_global_transform_interpolated()` "creates an interpolation pump on the `Node3D` the first time it is called, which can respond to physics interpolation resets… be sure to call it at least once before resetting the `Node3D` physics interpolation." Every hard snap calls `reset_physics_interpolation()` on `$Visual`. **Prime the pump when the camera's `target` is assigned**, not lazily on the first frame, or the first snap of the match streaks the camera. + +`project.godot` has `physics_interpolation=true`, and `$Visual`'s own local transform is interpolated too — so `reset_physics_interpolation()` must be called on `$Visual` as well as the body, or every snap smears the mesh for a frame. (This is the same artefact `game_mode.gd:263` already exists to prevent.) + +### 4.6 Remote bodies on the client + +- `freeze = true`, `freeze_mode = FREEZE_MODE_KINEMATIC` — **not `STATIC`**, or Jolt cannot derive contact velocity from the per-tick transform delta and your predicted ship hits a static wall instead of a moving ship. +- `net_interpolator.gd` samples the snapshot buffer (last 8 per body); collider at `server_time_est` (§4.1), `$Visual` at `server_time_est - INTERP_DELAY`. +- **The two samples run on different clocks *and* different callbacks.** The collider is a physics concern: `_physics_process`, 60 Hz. `$Visual` is a render concern: `_process`, sampled at true render time with `physics_interpolation_mode = OFF` so Godot does not interpolate an already-per-frame transform. On a 240 Hz client this is 240 distinct remote-ship positions per second instead of 60, and one fewer tick of lag, for no extra cost — the buffer lerp is happening either way (§5.4). +- `INTERP_DELAY = one_way_ms + snapshot_interval * 1.5 + 2.5 * jitter_ewma`, clamped `[25, 200] ms`. At 60 ms RTT / 60 Hz / 5 ms jitter that is 30 + 25 + 12.5 ≈ **68 ms**. + +> **The `one_way_ms` term is not optional, and omitting it is a silent architectural failure.** `server_time_est` (§4.7) estimates what the server clock reads *right now*. The newest snapshot in hand was stamped `one_way` ago — §4.1 says exactly this when it extrapolates the collider forward "by ~one-way + half a snapshot interval". So rendering `$Visual` at `server_time_est - INTERP_DELAY` only interpolates if `INTERP_DELAY ≥ one_way`. Set it to the buffer alone (~38 ms at 60 Hz) and the render cursor lands *on or past* the newest sample: the bullet below about extrapolating past the newest snapshot becomes the steady state rather than the exception, and every remote entity is permanently dead-reckoned. **The 25 ms clamp floor is reachable on LAN only.** +- Past the newest snapshot, extrapolate on last known velocity for at most 150 ms, then hold. **Never extrapolate indefinitely** — a stuck ship reads better than one flying through a wall. +- **Never write `linear_velocity` to a frozen body.** Godot/Jolt zeroes and holds velocity on frozen bodies, so `ball.gd:35`'s `linear_velocity.length()` trail driver will not work that way. Add `Ball.set_visual_speed(speed)` mirroring the `Ship.set_visual_action(thrust_z, turbo)` pattern. Don't route presentation data through a property the physics server owns. +- Call `reset_physics_interpolation()` on remote bodies at every kickoff. + +### 4.7 Clock + +`server_time_est = local_ms + clock_offset`, `clock_offset` from ping/pong on channel 0 every 1 s using the **minimum-RTT sample in a rolling 5 s window** (the min-RTT sample has the least queueing error). + +**Freeze `tick_offset` at match start.** Seed it exactly from the handshake (`server_tick + round(one_way / tick_ms)`) and absorb all subsequent drift into `input_lead` alone. The prediction ring is indexed in server-tick space, so slewing `tick_offset` during play silently reinterprets every historical entry and produces sporadic, unreproducible false snaps. Re-seed only across a kickoff boundary. + +--- + +## 5. Latency and frame-rate budget + +Three of the largest terms are invisible to a netcode document that only counts network hops. Record the budget so future changes are argued against a number. + +Client at 60 Hz physics, 60 ms RTT, 5 ms jitter, 60 Hz snapshots. **Display at 60 Hz with vsync on** — the Godot default, and the worst case. §5.4 redoes the display-dependent rows for 120/144/165/240/360 Hz. + +### 5.1 Own ship (predicted) — input to pixel + +| Stage | ms | | scales with fps? | +|---|---:|---|---| +| OS input → `Input.is_action_pressed` | 10 | 0.5 × frame interval + device polling | partly — see below | +| wait for next physics tick | 8 | avg of 0–16.7 | **no — 60 Hz physics** | +| physics step applies force | 0 | | | +| Godot physics interpolation | 8 | `physics_interpolation=true`; mean, worst case 16.7 | **no — 60 Hz physics** | +| render + vsync present | 25 | 1.5 refresh intervals, vsync defaults on | yes | +| **Total** | **≈52** | | | + +This is the **existing single-player floor**, unchanged by netcode — and ~43 of those 52 ms are things no netcode document discusses. A low-latency present would take it to ~35 ms (§5.4). + +Two notes on the model, both corrected from an earlier draft that read ≈45: + +- **Input freshness is 0.5 of a frame interval, not 0.25.** Godot pumps OS input once per main-loop iteration and `Ship._integrate_forces` (`ship.gd:347`) consumes it once per physics tick; for arrivals distributed uniformly between pumps the mean staleness at the pump is half the interval. On top sits **device polling**, which does not scale with fps at all: ~1 ms at a 1000 Hz mouse or gamepad, ~8 ms at a 125 Hz USB device. The table assumes ~2 ms. +- **Physics interpolation's 8 ms is a mean.** Rendering happens between the two most recent completed ticks, so displayed pose lags the newest state by `(1 − fraction)` of a tick — 0 to 16.7 ms, averaging 8.3. The worst case matters for §5.4's discussion of frame-time variance. + +Note the right-hand column: **16 of the 52 ms do not move no matter how many frames the client draws.** That is the price of a 60 Hz simulation. + +### 5.2 World response — the number that decides whether this ships + +| Stage | ms | | +|---|---:|---| +| input freshness | 10 | 0.5 × frame interval + ~2 ms device polling | +| wait for next physics tick | 8 | | +| manual multiplayer flush | ~0 | **~8 with default idle-frame poll** — see §7 task 1.3 | +| client → server transit | 30 | RTT/2 | +| jitter buffer, `target_depth = 1` | 17 | | +| server tick + flush | 8 | | +| **server → client transit** | **30** | **RTT/2 — the return leg** | +| interpolation buffer beyond arrival | 38 | `interval × 1.5 + 2.5 × jitter`; the `one_way` half of `INTERP_DELAY` is the row above | +| client physics interpolation | 8 | | +| render + present | 25 | vsync on, 60 Hz display | +| **World response, opponents** | **≈174** | | +| **Ball, with local prediction** | **≈52** | same as own ship | +| Both, at 144 Hz + low-latency present | **148 / 26** | §5.4 | + +> **Correction — this table previously read ≈138 ms and omitted the server→client transit row entirely.** `INTERP_DELAY` was quoted as 38 ms, which is the interpolation buffer measured *from snapshot arrival*, while §4.6 defines the render cursor relative to `server_time_est` — server-*now*. The 30 ms return leg fell between the two definitions and was never counted. §4.6's formula is corrected to include `one_way`; this table keeps the two terms on separate rows because that is clearer to budget against. + +For reference, Rocket League runs 120 Hz physics and predicts both car and ball locally; its equivalent at 60 ms RTT is roughly 90–110 ms. + +**≈174 ms as designed here is not competitive, and this document should not pretend otherwise.** It is also not the end state: **§5.6 gets to ≈127 ms with two changes that touch no graphics setting and require no bot retrain, and to ≈103 ms with 120 Hz simulation** — inside the reference band. Read §5.6 before treating this table as a verdict. + +What *is* settled is the shape of the design: a locally-predicted ball and own ship at ≈52 ms is the difference between this being playable and not, and a 30 Hz / default-poll / interpolated-ball design would land near ≈250. + +### 5.3 Why 60 Hz snapshots, not 30 + +- Interpolation buffer: the `interval × 1.5` term is **50 ms at 30 Hz vs 25 at 60**, on top of the one-way term both share (§4.6), plus a half-interval of cadence quantisation. +- Interpolation fidelity: at `MAX_SPEED = 32` the ball moves **1.07 m between samples at 30 Hz** — more than its own diameter, so any wall bounce landing between two samples gets lerped as a straight line *through the wall*. At 60 Hz it is 0.53 m. +- Cost: 300 kbit/s. Per §1.4, bandwidth is not the constraint. + +Keep `--snapshot-hz 30` as an explicit degraded mode. + +### 5.4 High-refresh-rate clients — 120 / 144 / 165 / 240 / 360 Hz + +Players on high-refresh displays are the ones most sensitive to everything in this document, and the current code has three places where **the client draws 240 frames but only 60 of them contain new information**. Those are bugs, not tuning. + +#### What frame rate actually buys + +Modelling present as ~1.5 refresh intervals with vsync on (§5.1), and input freshness as 0.5 of a frame interval plus ~2 ms of device polling: + +| Display | present | own ship / ball (§5.1) | world response (§5.2) | with low-latency present | +|---|---:|---:|---:|---:| +| 60 Hz | 25.0 | **52** | **174** | 35 / 157 | +| 120 Hz | 12.5 | **35** | **158** | 27 / 149 | +| 144 Hz | 10.4 | **33** | **155** | 26 / 148 | +| 165 Hz | 9.1 | **31** | **153** | 25 / 147 | +| 240 Hz | 6.3 | **27** | **149** | 23 / 145 | +| 360 Hz | 4.2 | **24** | **146** | 21 / 144 | + +> **This table assumes the client can actually produce those frames. It cannot — see §5.5.** As configured today the project runs SDFGI, SSIL, SSAO, a 5-level glow pyramid, five shadow-casting lights, MSAA 4× *and* FXAA, and an unconditional full-screen backbuffer pass, none of which any player can switch off. Read §5.5 before treating any row below 60 Hz's as reachable. + +Three conclusions to design around: + +1. **60 → 144 Hz is worth ~19 ms on own-ship feel. 144 → 360 Hz is worth ~9.** The curve flattens hard, because 16 ms of the remaining budget is the 60 Hz physics tick plus its interpolation and does not move. +2. **A low-latency present is worth more at 60 Hz (−17 ms) than the entire jump from 144 to 360 Hz.** It costs one settings dropdown. +3. **Frame rate barely moves world response** — 174 → 146 across the whole 60–360 range, because that budget is dominated by RTT and the interpolation buffer. Frame rate is an *own-ship feel* lever, not a netcode one. Say this to players plainly; someone who buys a 360 Hz monitor to see opponents sooner has been mis-sold. + +#### Three things that must run per rendered frame, not per physics tick + +**a. The camera rig.** `ship_camera.gd:86` runs the entire rig in `_physics_process`. Global `physics_interpolation=true` smooths the resulting camera *transform*, so this is not visible as judder — but it costs an extra tick of camera latency on top of the ship's, and two things it does are **not** transforms and therefore **not** interpolated: `camera.fov` (`:182`) and the `PostFX` shader parameters (`:186-187`). At 240 fps those step at 60 Hz, which reads as a faint pulse in the turbo FOV kick. + +The rig moves to `_process`, reading `target.get_global_transform_interpolated()` (and `$Visual`'s, post-task 0.2) instead of `target.global_transform`, with `physics_interpolation_mode = PHYSICS_INTERPOLATION_MODE_OFF` on the rig itself so Godot does not re-interpolate an already-per-frame transform. + +**The move is cheap but it is not tuning-neutral.** Cost first: one call is ~15 engine-bound operations (2 × `get_noise_1d`, 2 × `set_shader_parameter`, `Basis.looking_at`, `slerp`, `orthonormalized`, `signed_angle_to`, `rotated`, several `global_basis` accesses) plus ~60–100 bytecode ops — call it 5–15 µs. At 360 Hz that is **1.8–5.4 ms/s, under 0.5% of a core.** Negligible, but negligible *because the absolute work is tiny*; `1-exp(-k·delta)` is a correctness property, not a cost argument, and it does not license moving arbitrarily expensive code into `_process`. + +> **The impact shake must be re-tuned, and in the opposite direction to what you would guess.** `ship_camera.gd:204` advances the noise coordinate by `delta * 60.0`, and `:64` sets `frequency = 2.5`, so each sample steps `delta × 150` noise units. At 60 fps that is **2.5 units per sample** — simplex noise decorrelates over roughly 1 unit, so the shake is currently *white noise*, and physics interpolation is lerping between independent samples. At 360 fps in `_process` it becomes **0.42 units per sample**, which is strongly correlated: the shake turns into a slow, smooth wobble that gets softer the better your monitor is. Re-derive `frequency` (or the `* 60.0`) for constant noise-units-per-*second*, then re-check amplitude by eye at 60 and 240 fps. + +Everything else in the rig genuinely is rate-independent and needs no attention: `1.0 - exp(-k * delta)` at `:126, 137, 156, 172, 177` and `move_toward(…, shake_decay * delta)` at `:212`. + +Two pre-existing bugs sit in the code this task touches, so fix them here rather than discovering them in Phase 5: + +- **The rig has no snap path.** `camera.global_position` is smoothed at `camera_smoothing = 10.0` (`:14, 137, 156`) with no reset anywhere in the file. At a kickoff teleport (`game_mode.gd:256-263`, becoming an `_integrate_forces` write under task 0.15) the camera *lerps across the arena* over ~300 ms. Add `snap_to_target()` — set `global_position`/`global_basis` directly, zero `_last_shake_offset` — and call it from the kickoff path. +- **Shake decay stalls during a goal cut.** `:94-96` returns before `_apply_shake`, so `_shake_strength`'s `move_toward` decay never runs for the length of the cinematic. Task 0.12 proposes building goal feel on exactly this system. + +**b. Remote-entity visuals.** §4.6's interpolator samples a snapshot buffer between two known states. Driving that from `_physics_process` quantises every remote ship and the ball to 60 distinct positions per second and then leans on Godot to interpolate between them — an extra tick of lag for no benefit, since we are *already* interpolating. Sample the buffer at true render time in `_process` instead: 240 distinct positions per second and one fewer tick of lag. + +The split is clean because the two consumers want different times anyway (§4.1): the **collider** is a physics concern and stays in `_physics_process` at `server_time_est`; **`$Visual`** is a render concern and moves to `_process` at `server_time_est - INTERP_DELAY`, with `physics_interpolation_mode = OFF`. Setting it `OFF` is coherent precisely *because* the node's `global_transform` is overwritten every rendered frame — there is nothing left for the engine to interpolate. Note this is the opposite of §4.5's rule for the **local** ship's `$Visual`, which is written per physics tick and therefore must stay interpolated and must be reset on snap. Same node name, two different regimes; task 0.16 lands in Phase 0 against local-ship semantics, task 2.4 adds the remote case. + +It is not free, though it is cheap: per body per frame you bracket-search a ring of 8, run two `Vector3.lerp`s and a `Quaternion.slerp`, build a `Transform3D`, and assign `global_transform` (which dirties and propagates to children). Estimate 3–6 µs per body → **~21–42 µs/frame for 7 bodies, ~1.5% of a core at 360 Hz.** That is 4–6× the work of sampling at 60 Hz. Measure it in task 0.15b rather than asserting it. + +**c. Receive polling.** Task 1.3 already flushes sends from `_physics_process`. Receiving is the other half: with (b) in place, a snapshot that lands 2 ms after a physics tick can be rendered 2 ms later at 240 fps instead of waiting 14 ms for the next tick. **Poll for receive unconditionally at the top of both `_process` and `_physics_process` — no rate limiter.** A zero-timeout `enet_host_service` on an empty socket is one non-blocking `recvfrom` returning `EWOULDBLOCK`, on the order of 1 µs; 360 of those per second costs ~0.36 ms/s. An earlier draft proposed a 2 ms limiter, which is worse than useless: at 240 fps the frame interval is already 4.17 ms so it never fires, and it only engages above ~500 fps where polling was already cheaper than the limiter. + +> **Manual polling relocates the connection signals.** With `set_multiplayer_poll(false)`, `peer_connected` / `peer_disconnected` now fire from inside your `poll()` call — mid-`_process`, during a render frame — rather than on the idle-frame boundary. Any handler that mutates the scene tree must defer. + +#### Frame-time variance, not mean frame rate, is the real target + +At 240 fps the frame budget is **4.17 ms**, and physics runs at 60 Hz — so **one frame in four carries the entire physics tick** and must still fit in 4.17 ms. On that frame the client pays, in one go: the Jolt step over 7 dynamic bodies against a 172-shape compound; 7 × `Ship._integrate_forces` (`ship.gd:346-357`), each running `apply_thruster_forces`, a full `ArenaBoundary.get_surface_pull` with five `_falloff` calls (`arena_boundary.gd:183-198`), `apply_rotation_forces`, `apply_righting_torque` and `apply_drag_and_limits` with two `pow()` calls via `_tick_scaled` (`:450`); 6 × `_update_movement_vfx` (`:296-315`, writing two material params and two `OmniLight3D` energies per ship); and on decision ticks, bot inference — `policy_network.gd` is a pure-GDScript MLP at **31→64→64→7 ≈ 6.5k multiply-accumulates per bot**, so five bots landing together is ~33k GDScript float ops in one frame. + +Task 0.8's decision stagger is framed above as a cosmetic hitch. It is not — **the physics tick sets a floor on 1%-low frame time that no graphics setting can lower.** A game that averages 240 fps but drops one frame in four to 8 ms is not a 240 fps game. Profile p99, not mean (task 0.15b). + +The same term matters at the bottom of the range, where most players actually are: see gotcha 22 and task 0.22 for the client-side `Engine.max_physics_steps_per_frame` cap that stops a hitching client from spiralling. + +#### What frame rate does *not* buy, so nobody optimises the wrong thing + +**Input sampling does not improve.** `player_ship_controller.gd:15-38` reads seven `Input.is_action_pressed` calls — all digital, all held-state — and `Ship._integrate_forces` pulls them once per physics tick. The state read at the tick *is* the freshest state; sampling it 240 times a second returns the same value 4 times in a row. The only thing lost is a press-and-release entirely inside one 16.7 ms tick, which is below human tap duration. **Do not build a sub-tick input accumulator.** If analog stick support is added later this changes, and the right answer is then a time-weighted average over the tick, not a higher sample rate. + +**Physics interpolation stays on.** It costs ~8 ms (§5.1) and is the single largest fps-independent term after the tick wait, so it will look like a target. It is not: without it a 60 Hz simulation presents 60 distinct world states per second regardless of frame rate, which is precisely the stepping a 240 Hz display was bought to avoid. Leave it on; do not expose a toggle. + +#### Why physics stays at 60 Hz, and what a bump would cost + +The honest answer to "our players want 240 fps responsiveness" is that **simulation rate, not frame rate, is the binding constraint** — 16 ms of own-ship latency and ~33 ms of world response sit behind it, and §5.2 shows frame rate alone cannot get world response under ~146 ms. Doubling to 120 Hz (Rocket League's rate, with snapshots raised alongside) would take world response from ≈174 to **≈141 ms** and own-ship from 52 to **≈44**, at 60 Hz display — or **≈115 ms** combined with a 144 Hz display and a low-latency present: + +| Term | 60 Hz sim | 120 Hz sim | | +|---|---:|---:|---| +| wait for next tick | 8.3 | 4.2 | | +| physics interpolation | 8.3 | 4.2 | | +| jitter buffer, depth 1 | 16.7 | 8.3 | | +| server tick + flush | 8 | 4 | | +| interpolation buffer | 37.5 | 25.0 | only the `interval × 1.5` term halves; the jitter term does not | +| client ↔ server transit | 60 | 60 | **does not move** | + +That is a bigger win than every tuning parameter in §3 and §4 combined. It is nonetheless **out of scope for v1**, for reasons that are about the project rather than the netcode: + +- **Every policy in `Game/bots/` is invalidated.** `ship.gd:450`'s `_tick_scaled` is defined against a 60 Hz reference and `ai_ship_controller.gd`'s `reaction_ticks` counts ticks. A bump means a full retrain — and per `TODO.md` the generation-5 curriculum is still running. +- **Server density halves**, ~6–10 matches per core to ~3–5 (§1.4). +- **Bandwidth roughly doubles**: input 43 → 86 kbit/s up, snapshots 105 → 210 kbit/s per client, 631 kbit/s → 1.26 Mbit/s per 6-player match. Still not the constraint, but 100 concurrent matches becomes ~126 Mbit/s of server uplink, which is a hosting-plan question rather than a rounding error. + +**The consequence for this plan is a hard rule: 60 is a constant named `NetCodec.TICK_HZ`, never a literal.** Ring sizes, `INTERP_DELAY`, `input_lead` clamps, seq-window bounds, snapshot cadence and the timeout constants all derive from it. Task 1.4's handshake already gates on `physics_ticks_per_second`, so a mismatched client is rejected rather than silently desynced. Done this way, a later bump is a config change plus a retrain — not a protocol rewrite. Done the other way, the literal `60` ends up in twelve files and the bump never happens. + +#### Client display settings + +`project.godot` sets neither `display/window/vsync_mode` (defaults to enabled/FIFO) nor `application/run/max_fps` (uncapped). `video_settings.gd:14-16` persists only AA, glow and brightness, and `settings_menu.gd` exposes only those three. Task 0.17 adds: + +**VSync**: Enabled (FIFO) · **Adaptive (default)** · Mailbox · Disabled. + +- **Adaptive** (`FIFO_RELAXED`) is FIFO while the renderer keeps up and tears only on a *missed* vblank. That is the right default for a game that will sometimes drop below refresh, because it avoids FIFO's half-rate cliff — miss 144 Hz by one millisecond under strict FIFO and you are pinned to 72. +- **Mailbox** only lowers latency when the renderer sustains *above* the refresh rate; below it there is never a second frame to replace the queued one, so it degenerates to FIFO latency at Mailbox power draw. Per §5.5 this build will not sustain above 144 Hz on typical hardware today, which makes Mailbox an opt-in for players with headroom, not a default. Defaulting to it would be a thermal regression for most players in exchange for nothing. + +**FPS cap**: derived from the display, not a fixed list. Query `DisplayServer.screen_get_refresh_rate(DisplayServer.window_get_current_screen())` and offer **"Match display" (default), the integer divisors of that rate, then Unlimited** — 144 Hz → 144/72/48, 165 Hz → 165/82/55, 240 Hz → 240/120/80/60. + +> **Non-divisor caps beat against scanout.** A fixed 60/75/90/…/360 list is wrong on every panel that is not 60 or 120 Hz. Cap at 100 on a 144 Hz display and `gcd(100,144) = 4`: the pattern repeats every 25 frames across 36 refreshes, with frames held for one or two intervals in an irregular sequence — visible micro-stutter. 120 on a 165 Hz panel is 8 frames per 11 refreshes, same failure. Offer the free-form list only behind an Advanced toggle with a warning. + +Three implementation constraints, all of which an earlier draft got wrong: + +- **`Engine.max_fps` is a throttle, not a pacer.** It pads each frame with a post-frame sleep to hit `1/max_fps`; it has no knowledge of scanout and never phase-locks to a vblank. *(Sleep-granularity jitter of roughly ±0.5–1 ms is inferred, not measured — verify on target platforms. The absence of phase locking is structural.)* +- **Grey out the FPS cap whenever VSync is not Disabled.** With both active, FIFO clamps presents to vblanks while `max_fps` pushes some frames past the next one and not others — frame pacing worse than either setting alone. The menu must not permit the combination. +- **Godot cannot report the *negotiated* present mode.** `DisplayServer.window_get_vsync_mode()` echoes back the mode you stored, not the `VkPresentModeKHR` the driver granted, and there is no GDScript API that exposes the latter. An earlier draft's "report what was actually applied" is not implementable, and neither is an in-engine present-latency measurement (that needs LDAT or a high-speed camera). Instead put a live `Performance.get_monitor(Performance.TIME_FPS)` readout next to the dropdown: whether the player is above or below their refresh rate is the fact every one of these settings depends on. + +The renderer is Forward+ (`project.godot:21`, `config/features=PackedStringArray("4.7", "Forward Plus")`), so the usual "Mailbox is unavailable on Compatibility" caveat does not apply as written — but `rendering/renderer/rendering_method` is not pinned in `project.godot`, so a `--rendering-method gl_compatibility` launch or a driver fallback loses it silently. Mailbox is also commonly unavailable on macOS/MoltenVK. *(Needs empirical verification on target OS versions.)* + +### 5.5 Can this build produce frames at all? + +**§5.4's table describes a machine this project is not.** Nothing in the repo has ever been profiled, and the render configuration is a showcase build, not a competitive one. Every item below is on by default and **none is reachable from `video_settings.gd`**, which persists exactly three values (`:14-16`: `aa_mode`, `glow_scale`, `brightness`). + +From `scenes/arena_base.tscn`, the Environment every arena inherits: + +| `arena_base.tscn` | Setting | Note | +|---|---|---| +| `:47-50` | `sdfgi_enabled`, `sdfgi_use_occlusion`, `sdfgi_bounce_feedback = 0.5` | Godot 4's most expensive GI path; cascades re-voxelise as the camera moves, and this camera never stops (`ship_camera.gd:126,137,156`) | +| `:42-46` | `ssil_enabled`, `ssil_radius = 4.0` | A full-resolution screen-space pass **on top of** SSAO | +| `:34-41` | `ssao_enabled`, `ssao_radius = 2.5`, `ssao_detail = 0.75` | | +| `:18-29` | `glow_enabled`, 5 levels | Mip pyramid built and resolved every frame | +| `:61, 78, 87, 96, 105` | 1 directional + **4 shadow-casting `OmniLight3D`s** | Omni shadows are cubemaps: **24 shadow-map faces per frame** before the directional | + +Plus `project.godot [rendering]`: `msaa_3d=2` (4×) **and** `screen_space_aa=1` (FXAA) **and** `use_debanding=true` — mirrored by `video_settings.gd:14` defaulting to `MSAA_FXAA`. Stacking FXAA on resolved MSAA is redundant blur, and the menu (`settings_menu.gd`) offers no 2× rung between "off" and "4×". + +Plus `shaders/post_process.gdshader:4`, `uniform sampler2D screen_texture : hint_screen_texture` — a **full-screen backbuffer copy every frame**, unconditionally. The shader's comment notes that non-turbo frames skip two texture taps, but the copy and the full-screen pass happen regardless because `vignette_strength` never reaches zero (`ship_camera.gd:187` writes `0.22 + …`, `:243` restores `0.22`). + +**What is *not* the problem**, so nobody optimises the wrong thing: + +- **The 168 colliders (§1.4) cost zero frame time.** They are `CollisionShape3D`s on a `StaticBody3D` — no draw calls, no vertices. The count is confirmed correct (168 generated + 4 authored slabs = 172 in `objects/arena_boundary.tscn`). +- **The scene is not geometry- or draw-call-bound.** `arena_boundary.gd`'s visual shell is ~1450 triangles in two surfaces of one `MeshInstance3D`; the whole match is on the order of 100–150 draw calls and well under 50k vertices. That is nothing. + +**The project is bound entirely by full-screen passes the player cannot switch off.** That inverts §5.4's conclusion about where the leverage is: the largest win per line of code is not a vsync dropdown, it is a graphics preset that gates SDFGI/SSIL/SSAO/omni shadows. Task **0.15b blocks 0.16 and 0.17** for exactly this reason — every number in §5.4 is a priori, and the first measurement may invalidate the fps list entirely. + +One mitigating subtlety, which cuts both ways: `project.godot [display]` sets `window/stretch/mode="viewport"` with a 1920×1080 base and `aspect="expand"`, so the 3D renders at a fixed ~1080p and is blitted to the window. A 1440p or 4K player therefore does **not** pay more for any of the above — but also **cannot render at native resolution**, and a 1080p player cannot render lower. Task 0.17c owns that decision; it interacts directly with render scaling (0.17b) and cannot be left implicit. + +#### 5.5.1 Measured (task 0.15b, 2026-08-18) + +6-ship Match, 1080p, non-headless. **Hardware: Apple M4 (Metal), 10-core — a development laptop, not a dedicated gaming reference machine**; treat absolute fps as directional, not a promise to players on other hardware. + +| | p50 | p99 | fps (p50 / p99) | +|---|---:|---:|---:| +| All effects on (project defaults) | 17.93 ms | 20.39 ms | 55.8 / 49.0 | +| All effects off | ~17.2 ms | — | ~58 | + +**This invalidates the a priori §5.4/§5.5 fps list exactly as flagged.** Default settings cannot sustain even 60 fps on this hardware, let alone 144 — and the surprising part is *why*: turning every toggleable effect off (SDFGI, SSIL, SSAO, glow, all 5 shadow casters, MSAA, FXAA, PostFX) only recovers the difference between ~56 and ~58 fps. The ~17 ms floor is **not** made of the full-screen passes this section blamed — something else (base forward-clustered shading, the ~150 draw calls, per-ship VFX materials, or fixed engine/CPU overhead at 6 ships) dominates, and 5.4's framing ("the project is bound entirely by full-screen passes") is wrong as measured on this hardware. + +Per-effect isolated cost (each toggled off individually against a fixed baseline sample), for reference — treat these as low-confidence: they cluster tightly at 2.9–3.8 ms each with no clear outlier, which is consistent with most of that spread being sampling noise from a ~1 ms-jittery baseline rather than real per-effect attribution: + +| Setting | Cost (ms) | +|---|---:| +| SSAO | 3.77 | +| PostFX | 3.82 | +| Omni shadows (×4) | 3.69 | +| SSIL | 3.44 | +| FXAA | 3.37 | +| Directional shadow | 3.30 | +| SDFGI | 3.24 | +| MSAA 4× | 3.12 | +| Glow | 2.89 | + +**Consequence for 0.17/0.26/0.28**: a graphics preset alone will not reach a 144 fps target on hardware in this class — Low-preset gets to only ~58 fps by this measurement, not the 2×+ jump §5.4 assumed. **0.26 (bake GI) and 0.28 (separate physics thread) need to re-justify their expected win against this floor before implementation.** + +**Root-cause follow-up, attempted and inconclusive (2026-08-18).** Three further remote-automated profiling passes (via `godot-mcp` `game_eval` sampling `Performance.get_monitor()` against a live instance, no human at the editor) were run to find what the ~17 ms floor actually is. They did not converge: + +| Pass | Setup | Result | +|---|---|---| +| 1 (above) | 6-ship 3v3, sustained | 17.93 / 20.39 ms (p50/p99), all-off floor ~17.2 ms | +| 2 | Reportedly 6-ship, actually 1v1 (misconfigured) | CPU 17.64 ms + frame 10.75 ms — internally inconsistent (CPU time exceeding frame time from non-atomic sampling); agent also reported the game becoming unresponsive mid-run | +| 3 | 6-ship 3v3, atomic single-`eval` sampling, retried after pass 2's failures | 8.7–10.2 ms (98–115 fps), reported CPU time 0.013 ms — implausibly low for a frame running Jolt physics + GDScript bot inference across 6 ships, so not trusted either | + +Passes 1 and 3 supposedly measured the same scenario and differ by ~2×. **The likely explanation is the measurement method itself, not the game**: each `game_eval` round-trip through the MCP bridge has its own latency and can perturb the very frame timing it's sampling, and nothing here confirms the scene state (ship count, bot activity, camera framing) was identical across passes. Read the specific numbers in this subsection as *evidence a floor well under 144 fps exists*, not as an attributed cause — **the SSAO on/off screenshot check in pass 3 did confirm effect toggles are visually real** (ruling out "the toggles are no-ops" as an explanation), which is the one finding that survived across passes. + +**What this needs next, and why an agent can't finish it remotely:** a proper frame-time attribution needs either a human at the Godot editor reading the Debugger's built-in Monitors/Visual Profiler (which breaks GPU time down by pass — opaque, shadow, post-process, etc. — instead of one aggregate number), or an external GPU profiler (RenderDoc, Xcode GPU capture on this hardware). Both require eyes on a live UI, not remote `eval` polling. **This is now the concrete blocker for 0.26/0.28**, not further scripted measurement passes. **0.15b's original acceptance criterion (write a max-frame-rate number into §5.5) is still met by pass 1** — the floor is real and under both 60 and 144 fps — but the deeper "why" is open and parked here rather than guessed at. + +**Root cause of the pass-to-pass inconsistency, found (2026-08-18):** a Godot editor and an orphaned headless training process had both been running on the profiling machine, untouched, for 11 days (since 2026-08-08) — leftover from earlier local work, unrelated to this investigation. `godot-mcp`'s automated launches were plausibly contending with that stale editor instance rather than getting a clean process every pass, which is a much better explanation for a ~2× swing between "identical" scenarios than genuine frame-time variance. Both processes were killed and a clean re-check was run. + +**Is it just that we're on a Mac?** Partly, but not via the mechanism first suspected. HiDPI/Retina resolution inflation was checked directly and **ruled out**: the live viewport renders at 2036×1080 against a target of 1920×1080 — about 6% more pixels, non-uniformly (width only; the 2× multiplier a true Retina backbuffer would apply is not happening, `display/window/dpi/allow_hidpi=true` notwithstanding). A 6% pixel-count difference cannot produce the ~2× frame-time swings seen above, so resolution is not the explanation for this session's inconsistency — that was the stale-process contention above. It's still worth a one-line fix later (0.17c owns display/stretch decisions) since 2036×1080 is a mildly wasteful, non-native render target. + +What Mac hardware **does** plausibly bias is the *shape* of the result, not the run-to-run noise: Apple Silicon GPUs are tile-based deferred renderers (TBDR), architecturally unlike the immediate-mode AMD/Nvidia GPUs the target "reference hardware" (a Windows/Linux gaming PC) uses. TBDR keeps a frame in on-chip tile memory and is comparatively cheap at MSAA resolve, but any pass needing to read arbitrary neighbouring pixels across the whole frame — SSAO, SSIL, the glow downsample/upsample chain, the PostFX shader's `screen_texture` read — forces a break out of tile memory into a full system-memory resolve, an overhead that is largely constant per pass rather than proportional to what the pass computes. That lines up with pass 1's finding that SDFGI/SSIL/SSAO/MSAA/FXAA/shadows/PostFX all cost within a tight 2.9–3.8 ms band regardless of what each one actually does — consistent with a shared TBDR resolve tax dominating over each effect's real cost. **Numbers measured on this machine should be treated as informative about relative ordering at best, not as a stand-in for target-platform (desktop GPU) behaviour** — a pre-release profiling pass on an actual Windows/Linux box with a discrete GPU is needed before 0.17/0.26/0.28 lock in specific preset thresholds. + +### 5.6 Closing the gap to the reference — without lowering settings + +§5.2 lands at ≈174 ms against a ~90–110 ms reference band. The instinct is that reaching it means trading visual quality for frames. **It does not.** Decompose the 174: + +At 60 ms RTT, 60 ms is transit and irreducible in code. That leaves **114 ms of local overhead**, of which frame rate governs only two terms — input freshness (10) and present (25) — and *quality settings* govern neither directly. Present latency is a function of vsync mode and swapchain depth, not of how many effects are enabled; a 60 fps client with a shallow present queue beats a 240 fps client with a deep one. **The entire 60 → 240 fps range is worth ~12 ms once a low-latency present is in place** (§5.4). The other ~100 ms is netcode time model and simulation rate. + +Four levers, none of which touches a graphics setting: + +| | Lever | Saves | Risk | +|---|---|---:|---| +| **L1** | **Extrapolate remote *visuals* to present time** instead of interpolating the past | **−30** | Mis-prediction pops | +| **L2** | 120 Hz simulation | −21 | Bot retrain, ½ server density, 2× bandwidth | +| **L3** | Adaptive jitter-buffer depth, 0 on clean links | −8 | Starvation on jittery links | +| **L4** | Shallow present queue + Adaptive vsync | −17 | Throughput loss if GPU-bound | + +#### L1 is the big one, and it is nearly free + +§4.1 already computes remote entities' **present-time** state — that was the fatal correction that put the collider at `server_time_est`. `$Visual` is then deliberately rendered ~68 ms in the past for smoothness. **Render it at present time too and the whole 37.5 ms interpolation buffer disappears**, leaving only a residual for error smoothing. + +The reason this is safe here is that ships have bounded acceleration and the hull is large. Extrapolating with known velocity, error is `½·a·t²` over the full 68 ms horizon: + +| | max accel | error @ 38 ms | error @ 68 ms | +|---|---:|---:|---:| +| position, cruise | 30 m/s² (`thrust_power 150` / `mass 5`) | 0.022 m | **0.069 m** | +| position, turbo | 75 m/s² (`turbo_multiplier 2.5`) | 0.054 m | **0.173 m** | +| yaw | 20 rad/s² (`rotation_power 20` / `inertia.y 1`) | 0.8° | **2.6°** | +| pitch / roll | 2.9 rad/s² (`inertia.x/z 7`) | 0.1° | **0.4°** | + +**0.17 m and 2.6° worst case, against a 4 m hull.** That is well under the width of the ship and an order of magnitude smaller than the 3.5 m staleness §4.1 was written to eliminate. Feed the residual through the same soft-correct pipeline already specified for the local ship (§4.4) and remote ships are visually at present time with a sub-decimetre wobble. + +Two bonuses: it **collapses §4.1's dual clock back into one** — collider and visual both at `server_time_est`, so §5.4b's `_process`/`_physics_process` split and the two-regimes-for-one-node-name hazard both go away — and it applies to the ball, which is near-ballistic between contacts and therefore extrapolates better than ships do. + +The cost is real but narrow: a remote ship that *reverses input* at the moment you sample it mispredicts by the numbers above and then visibly corrects. Interpolation never mispredicts; it is just always late. This is the genuine trade, and it is the one the reference class makes. + +#### The reachable budget + +| Term | today | L1 + L4 (v1) | + L2 + L3 | at 144 fps | +|---|---:|---:|---:|---:| +| input freshness | 10 | 10 | 10 | 5.5 | +| wait for next tick | 8.3 | 8.3 | 4.2 | 4.2 | +| client → server | 30 | 30 | 30 | 30 | +| jitter buffer | 16.7 | 16.7 | 4.2 | 4.2 | +| server tick + flush | 8 | 8 | 4 | 4 | +| server → client | 30 | 30 | 30 | 30 | +| interp buffer → extrapolation residual | 37.5 | 8 | 8 | 8 | +| client physics interpolation | 8.3 | 8.3 | 4.2 | 4.2 | +| present | 25 | 8.3 | 8.3 | 3.5 | +| **World response** | **≈174** | **≈127** | **≈103** | **≈94** | + +**≈103 ms at 60 fps with every effect enabled**, and ≈94 at 144 fps. That is inside the reference band, reached without disabling SDFGI, SSIL, SSAO or shadows. Even a client struggling at 30 fps on maximum settings lands near ≈120 ms. + +Sequencing follows ms-per-unit-of-risk: **L4 then L1 for v1 (≈127 ms, no bot retrain, no protocol change)**; L2 and L3 after, when a retrain is affordable. §5.5's preset system remains worth building — but for *frame rate and thermals*, which is what it actually buys, not for latency. + +> **The largest lever is not on this list.** All of the above assumes 60 ms RTT. Regional server siting that puts most players on a 30 ms RTT takes ≈127 to ≈97 and ≈103 to ≈73 with no code at all. Phase 6 owns it, and it should be argued against these numbers. + +> **Perspective on where this matters.** Own ship and ball are already at ≈52 ms and are unaffected by every lever here — they are predicted locally. World response governs *opponent ships*. In a game whose subject is a ball, that ordering is favourable: the two objects a player tracks most closely are the two already at single-digit-tick latency. + +### 5.7 The next tier — and where it stops paying + +§5.5 and §5.6 are the first-order work. This section is what remains after them, and it is deliberately honest about the point where further effort stops being worth it. + +#### Frame rate: SDFGI is the wrong tool for this arena + +**The single largest available win, and it costs no visual quality.** `arena.gd` and `goal.gd` have **no `_process`, no `_physics_process`, no `AnimationPlayer` and no `Tween`** — the floor, walls, ceiling, goals and every light are static for the entire match. The only things that move are 6 ships and a ball, all small and all self-lit. + +SDFGI exists to light *dynamic* worlds, and it pays for that by re-voxelising cascades as the camera moves — and this camera never stops moving (`ship_camera.gd:126, 137, 156`). It is the most expensive thing in the frame, doing continuous work to solve a problem this project does not have. + +- **Replace `sdfgi_enabled` with baked GI** — `LightmapGI` for the static shell, or `VoxelGI` if bounce onto moving ships matters. Bake cost is offline; runtime cost is a texture fetch. The look is preserved or improved (baked bounce is higher quality than SDFGI's cascades), and it survives on the High preset rather than being the first thing a preset has to switch off. +- **`ssil_enabled` becomes largely redundant** once bounce is baked. It is a full-resolution screen-space pass duplicating information the lightmap already has. + +This is the answer to "lowest lag *and* highest fps without lowering settings": the expensive setting was solving the wrong problem. + +#### Frame rate: expensive defaults that `project.godot` never overrides + +`[rendering]` contains exactly three keys (`msaa_3d`, `screen_space_aa`, `use_debanding`). Everything else runs at engine defaults, including: + +| Setting | Default | Note | +|---|---|---| +| `lights_and_shadows/positional_shadow/atlas_size` | 4096 | Shared by **all** shadowed positional lights; 2048 is usually indistinguishable here | +| `lights_and_shadows/directional_shadow/size` | 4096 | | +| `lights_and_shadows/directional_shadow/soft_shadow_filter_quality` | high | | +| `occlusion_culling/use_occlusion_culling` | off | Low value in an enclosed arena — measure before adding bake time | +| `mesh_lod/lod_change/threshold` | — | Irrelevant: the scene is ~1450 triangles of arena plus low-poly ships (§5.5) | + +Also worth counting: `_build_movement_vfx` creates **two `OmniLight3D`s per ship** (`ship.gd:270-278`), so a 3v3 has 12 dynamic lights on top of the arena's 5. They are correctly `shadow_enabled = false` and `omni_range = 3.5`, so they are cheap — noted so nobody "discovers" them and disables engine glow for nothing. + +#### Frame rate: the CPU side, which §5.5 does not cover + +§5.5 establishes the project is GPU-bound on full-screen passes. Once those are fixed it becomes CPU-bound, and §5.4's frame-time variance becomes the ceiling. Three levers: + +- **`physics/3d/run_on_separate_thread`** (not set; defaults off). This decouples the physics step from the render thread and directly attacks "one frame in four carries the whole tick." It is the highest-leverage item here **and the riskiest** — it changes when `_integrate_forces` runs relative to script code, and this project puts real logic there (`ship.gd:346-357`) plus an RL training path. *Prototype and measure; do not enable on faith.* +- **`ArenaBoundary.get_surface_pull` has no early-out.** It runs a `to_local()` plus five `_falloff` calls for every dynamic body every tick, including for a ball sitting in the middle of the arena where every term is zero. A single bounds check against `wall_range`/`ceiling_range` skips almost all of it in open play — 7 bodies × 120 Hz once L2 lands. +- **Bot inference is ~6.5k GDScript multiply-accumulates per bot** (`policy_network.gd`). Task 0.8 staggers them; beyond that, the lever is network width, which is a training decision, not a rendering one. + +#### Latency: what is actually left + +After L1–L4 and 120 Hz simulation, at 144 fps, the budget is ≈94 ms — **and 60 of that is RTT.** The remaining 34 ms of local overhead breaks down as input freshness 5.5, tick wait 4.2, jitter 4.2, server 4, extrapolation residual 8, physics interpolation 4.2, present 3.5. Every one of those is at or near a floor set by physics rate or hardware. + +Two code ideas remain, both small and both with a cost: + +- **Forward-extrapolate the local `$Visual`** instead of interpolating between the last two ticks — render the predicted ship at present time rather than up to one tick behind. Worth ~4 ms. Risk: overshoot at the moment of a collision, which is the most visually sensitive moment in the game. +- **Tighten the extrapolation-error smoothing** (§5.6's 8 ms residual). Worth ~4 ms, paid for in more visible correction pops. + +**That is the whole remaining code budget: ~8 ms, both items trading visual stability for it.** Meanwhile: + +- **Regional server siting** takes a 60 ms RTT to 30 for most players: **−30 ms**, four times the remaining code budget, no code at all. +- **Ping-weighted matchmaking and a server browser sorted by measured ping** convert that into something players actually experience rather than something that is true on average. +- **Steam Datagram Relay (Phase 7)** is planned for NAT traversal and DDoS protection, but Valve's backbone frequently routes better than raw BGP paths — for some player pairs SDR is a *latency reduction*, not a tax. Measure it both ways rather than assuming it costs. + +#### Where this stops paying + +Two limits worth writing down before someone spends a month on the last 5 ms: + +1. **Past ~100 ms, you are optimising 3–4 ms at a time against a 60 ms constant.** The ratio of engineering effort to felt improvement collapses. Server siting and matchmaking dominate everything else from that point on. +2. **"Lowest lag" and "best feel" diverge at the end.** Both remaining code levers, and L1 itself, buy milliseconds by predicting further ahead and correcting harder. Past a point that makes the game feel *worse* — twitchier, less stable, more prone to visible snapping — while the latency number keeps improving. The number is a proxy, not the goal. **Task 4.7's tuning pass, with a human in the seat, is the authority; the budget table is not.** + +--- + +## 6. Match lifecycle + +### 6.1 State machine + +``` +LOBBY -> LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP -> ... + -> FULL_TIME -> OVERTIME_WARMUP -> OVERTIME -> ... + -> RESULTS -> LOBBY +``` + +Broadcast as the `match_state` byte in every snapshot, and on transition via `state_change(state, at_tick)`. + +### 6.2 Sequence + +1. **Connect.** Client sends `hello(protocol_version, physics_ticks_per_second, display_name, auth_ticket)`. Server rejects a mismatch on **either** version or tick rate, with a reason string, then `disconnect_peer`. (A client at 30 Hz advances its sequence numbers at half rate and confuses every control loop.) `auth_ticket` is an empty `PackedByteArray` until Phase 7 — reserve the field now. +2. **Welcome.** Server assigns `player_id`, balances teams, replies `welcome(player_id, server_info, roster, match_state, server_tick, score, end_tick)`, broadcasts `player_joined`. +3. **Lobby.** `ready_toggle()`; start when all ready, or `--auto-start` after `--min-players` plus a countdown. +4. **Config.** `match_config(match_id, arena_path, team_size, match_length_ticks, roster[], seed)`. `roster[i] = {slot, team, spawn_index, player_id, name, is_bot}` — **slot order here is the snapshot's body order for the whole match.** The client validates `arena_path` against `ArenaRegistry.ARENAS` before `load()`; a malicious or buggy server must not be able to make a client load an arbitrary `res://` path. +5. **Load.** Both sides load `networked_match.tscn`. Each peer loads the arena and spawns the roster in slot order. Client additionally spawns a camera rig on its own ship and adds `HUD.tscn` **in code** — `networked_match.tscn` must have no HUD child, because `GameMode._ready()` (`game_mode.gd:44-45`) would pick it up server-side. Client sends `scene_ready(match_id)`. +6. **Kickoff.** Server waits for all `scene_ready` (10 s timeout → proceed). Broadcasts `kickoff(reset_transforms[], countdown_start_tick, reset_gen)`. Both sides freeze bodies. HUD counts down from `server_tick`, not a local `Timer`. At `countdown_start_tick + 180` the server unfreezes and broadcasts `state_change(PLAYING)`. +7. **Play.** Inputs up, snapshots down. +8. **Goal.** Server's `Goal` sensor fires → `_handle_goal_scored` debounce → `goal_scored(scoring_team, score, goal_tick, resume_tick)`. Bodies freeze. Clients play the cinematic within `[goal_tick, resume_tick]`. At `resume_tick`: `kickoff(...)`. +9. **Clock.** Tick-derived: `remaining_ticks = end_tick - current_server_tick`. `end_tick` and a `running` flag ship in `match_config` and in `clock_state(running, end_tick, at_tick)`. +10. **Full time / overtime / results.** `RESULTS` holds, then `state_change(LOBBY)` and both sides load `lobby.tscn`. **Clients return to the lobby, not the main menu** — a community server that empties every 2.5 minutes is dead on arrival. + +**Every lifecycle message carries absolute ticks**, never durations. That is what makes reliable-channel latency harmless: on a lossy link ENet's RTO can stretch a `goal_scored` → `kickoff` → `state_change` burst to ~600 ms. **Specify the late-arrival case explicitly**: a `kickoff` that lands after its own `resume_tick` must apply the reset immediately and skip the countdown, not schedule it into the past. + +`NetworkedMatch` must declare all five signals `HUDController` duck-types on (`HUDController.gd:65, 88, 100, 103, 106`) — `timer_updated`, `score_changed`, `match_ended`, `kickoff_countdown`, `overtime_started` — and emit them from RPC handlers instead of from local logic. Otherwise the HUD silently omits rows. + +### 6.3 Late joiners and spectators + +`welcome` carries full state, so a late joiner reconstructs immediately. + +- Free slot and state is `LOBBY`/`WARMUP` → join as a player now. +- Free slot mid-match → **spectate now, take the slot at the next kickoff.** Swapping a controller at a kickoff boundary is free; mid-play it is not. +- No free slot → spectator. A spectator receives identical snapshots (the snapshot is already a broadcast — zero extra server work), spawns no ship, and points a camera rig at a chosen ship or the ball. Cap with `--max-spectators`. + +`HUDController._initialize_hud()` `push_error`s and bails when `ship` is null (`HUDController.gd:41-46`). Spectators need a path through that. + +### 6.4 Disconnects — no ship is ever despawned + +On `peer_disconnected` the server **keeps the ship and swaps its controller**: + +1. `--fill-bots`: replace with an `AIShipController` on the server's configured model. +2. `--no-fill-bots` (default for public servers, see §1.4): swap to the base `ShipController` — inert but simulated, exactly the placeholder `game_mode.gd:216` already uses. + +Set `flags.stalled` so clients can grey out the nameplate. Reserve the slot for 30 s keyed by identity so a reconnect gets its ship back. If the last human leaves, abort to `LOBBY`. + +**Justification is wire-format simplicity, not the bot cache.** Fixed slot order means the snapshot needs no add/remove machinery, no `MultiplayerSpawner`, and no re-indexing. That reason stands on its own. + +`ai_ship_controller.gd` currently caches teammate/opponent lists once with the comment "rosters never change mid-match (no despawn path exists anywhere in this codebase)". **Do not let that be the justification** — protecting a bot's implementation detail is tail-wagging-dog, and taken as an architectural constraint it permanently forecloses 3v3→2v2 shrink, mid-match rebalancing, and join-onto-a-new-slot. Fix the cache anyway (task 0.9): `filter(is_instance_valid)` plus a `roster_changed` signal, ~5 lines of cheap insurance. + +--- + +## 7. Phase and task breakdown + +`[P]` parallelisable within its phase · `[D:x.y]` hard dependency + +### Phase 0 — Non-networked refactors + +Every task lands on `master` independently, is verifiable in single-player today, and cannot break anything. Near-total parallelism. + +| # | Task | Files | Acceptance | +|---|---|---|---| +| 0.1 `[P]` | **DONE.** Added `ShipAction.copy()`. Audit: the only `get_action()` call site (`ship.gd:347`) reassigns `_current_action` fresh each tick rather than buffering it, so no aliasing bug exists yet — `copy()` is a no-op today, ready for Phase 4's prediction ring | `ship_action.gd`, `player_ship_controller.gd` | Free Play unchanged; `copy()` returns a distinct object with equal fields | +| 0.2 `[P]` | **DONE.** Inserted `Visual` (`Node3D`) into `ship.tscn`, reparented `Nose`/`TailFin` under it, redirected all four code-driven `add_child` calls onto `$Visual` (now a public `@onready var visual`), resolved `_apply_team_color`'s lookup to `"Visual/" + mesh_name` | `objects/ship.tscn`, `scripts/ship.gd` | Child-type assertion holds; ship looks identical in Free Play; team colours still apply on both teams | +| 0.3 `[D:0.2]` | **DONE.** `ship_camera.gd`'s three `target.global_transform` reads (ball cam, ship cam ×2) now read `target.visual.global_transform` | `scripts/ship_camera.gd` | Camera behaviour unchanged in Free Play and Match — `visual` has identity transform relative to the body until Phase 4 writes an offset, so this is a no-op today | +| 0.4 `[P]` | **DONE.** `can_sleep = false` on Ship and Ball | `objects/ship.tscn`, `objects/ball.tscn` | No behaviour change | +| 0.5 `[P]` | **DONE.** `continuous_cd = true` on Ship (Ball already had it) | `objects/ship.tscn` | No tunnelling at max speed into the ball or walls | +| 0.6 `[P]` | **DONE.** Spawned ships renamed to `Ship_T%d_S%d` | `game_mode.gd` | Names are `(team, spawn_index)`-derived, not insertion-order | +| 0.7 `[P]` | **DONE.** `_jittered` now uses an owned `RandomNumberGenerator`, self-randomized in `_ready()` unless `kickoff_rng_seed` is set explicitly (a fresh `RandomNumberGenerator` defaults to a fixed internal state, unlike the global `randf_range` Godot auto-randomizes at startup — call this out for whoever reads the diff and expects `.new()` alone to be enough) | `game_mode.gd` | Kickoff jitter unchanged in feel; a fixed seed reproduces kickoffs exactly | +| 0.8 `[P]` | **DONE.** `_ticks_until_decision = randi_range(1, reaction_ticks)` at spawn, after `load_policy()` (which still resets to 0 on later calls, e.g. league opponent swaps — harmless, those land at reset boundaries) | `ai_ship_controller.gd` | Six-bot Spectate shows no periodic frame spike | +| 0.9 `[P]` | **DONE.** Roster validity checked (`Array.any()`) once per decision tick, not every physics tick; `filter(is_instance_valid)` + `roster_changed` signal only fire on an actual stale reference | `ai_ship_controller.gd` | Bots behave identically; freeing a ship mid-match no longer corrupts observations | +| 0.10 `[D:0.12]` `[P]` | ~~Add virtuals `_owns_goal_logic()`, `_allows_time_scale_effects()`, `_goal_pause_seconds()`, `_owns_world_simulation()`~~ **DONE, narrower than drafted.** `_allows_time_scale_effects()` dropped: 0.12 deletes `Engine.time_scale` from the file entirely, so there is nothing left for it to gate. Implemented `_owns_goal_logic()`, `_owns_world_simulation()`, `_goal_pause_seconds()`, all behaviour-preserving (default `true`/`GOAL_CELEBRATION_SECONDS`), gating the goal-signal connection and `_respawn_escaped_bodies()` | `game_mode.gd` | Free Play, Match, Spectate and Training all behave identically — verified no other virtual was load-bearing today; these exist for a future networked-client mode | +| 0.11 `[P]` | **DONE.** `_handle_goal_scored` checks `is_inside_tree()` after each `await` and bails before touching arena/hud state | `game_mode.gd` | A scene change mid-celebration cannot strand the flag | +| 0.12 `[P]` | ~~Replace `Engine.time_scale` hit-stop and goal slow-mo with camera-only effects~~ **DONE.** Added `ShipCameraRig`'s "Impact Punch" group (`punch_fov_kick`/`punch_vignette_kick`/`punch_chroma_kick`/`punch_decay`, applied additively after `_update_speed_feel` each tick, decaying via `move_toward` over real `delta`) triggered from the existing `_on_target_ball_contact`; goal moments now rely on the pre-existing `begin_goal_cut`/`end_goal_cut` cinematic cut alone, no separate slow-mo effect needed. All `Engine.time_scale` fields/methods deleted from `game_mode.gd` (`_hit_stop_*`, `_goal_slowmo_active`, `_restore_hit_stop`, `_run_hit_stop`, `GOAL_SLOWMO_SCALE`) | `game_mode.gd`, `ship_camera.gd` | Goal and impact feel is at least as good; `Engine.time_scale` is never written — confirmed via `grep -rn time_scale scripts/` | +| 0.13 `[P]` | **DONE.** `physics_jitter_fix = 0.0` set. `CLAUDE.md`'s arena dimensions were already correct (18/27/18) — the staleness this task described had already been fixed by the time this task ran | `project.godot`, `CLAUDE.md` | Flight feel unchanged; `CLAUDE.md` matches `arena_boundary.gd:8-14` | +| 0.14 `[D:0.2]` | **DONE.** Added `Ship.set_visual_action(thrust_z, turbo)`, `Ball.set_visual_speed(speed)` (with a `_visual_speed_override` field the trail prefers when ≥0), and `Ship.net_vel_correction`/`net_visual_offset` fields plus the guarded hook at the top of `_integrate_forces` (decays `net_visual_offset` via `_tick_scaled`, writes it to `visual.position`) | `ship.gd`, `ball.gd` | No-op until Phase 4; single-player unchanged — nothing calls any of these yet | +| 0.15 `[P]` | **DONE.** Ship/Ball gained `queue_teleport(to)`; `_integrate_forces` applies it via `state.transform` + zeroed velocities + `reset_physics_interpolation()`. `GameMode._reset_body` now calls `body.call("queue_teleport", to)` (dynamic dispatch — `RigidBody3D` itself has no such method) instead of `set_deferred` | `game_mode.gd`, `ship.gd`, `ball.gd` | Kickoff resets in Match are visually identical, with no interpolation smear | +| **0.15b** | **DONE.** Measured on a live 6-ship Match, 1080p, Apple M4 (dev laptop, not a dedicated gaming reference box — see §5.5.1 caveat). All-on: p50 17.93 ms / p99 20.39 ms (55.8 / 49.0 fps). All-off floor: ~17.2 ms (~58 fps). Per-effect deltas captured but low-confidence (2.9–3.8 ms each, tightly clustered — likely dominated by sampling noise, not real per-effect attribution) | `scenes/arena_base.tscn`, `shaders/post_process.gdshader` | **Measured max frame rate written into §5.5.1.** It is under 144 — confirmed, §5.4's fps list is fiction. **Unplanned finding: it's also under 60 with everything on, and the all-off floor barely moves (~56→58 fps)**, meaning the full-screen-pass toggles are *not* the dominant cost as §5.4/§5.5 assumed. 0.17 can proceed (a preset is still worth building) but 0.26/0.28 need to re-justify their expected win, and a real GPU profiler pass (not `Performance.get_monitor()`) is needed to find the ~17 ms floor's actual cost before further optimization is scoped | +| 0.16 `[D:0.3]` | **DONE.** Camera rig moved `_physics_process` → `_process`; reads `target.visual.get_global_transform_interpolated()` in both ball-cam and ship-cam; rig itself has `physics_interpolation_mode = OFF` (it writes its own transform every rendered frame now, so Godot's built-in interpolation would just fight the manual smoothing). `target` setter primes interpolation (`target.visual.reset_physics_interpolation()`) and calls the new `snap_to_target()` so a freshly-assigned target (or a Spectate switch) doesn't lerp in from wherever the rig was previously. **Shake re-derivation, implemented differently than drafted**: rather than rescale `frequency`, `_apply_shake` now quantizes the noise-domain input to whole 60Hz ticks (`floori(_shake_time * SHAKE_UPDATE_HZ)`) — every render frame within one 1/60s window reuses the identical noise sample, so consecutive *distinct* samples stay exactly `frequency` (2.5) domain-units apart at any render frame rate, reproducing 60fps's original jitter character everywhere instead of smoothing out at high fps. `snap_to_target()` is called from `game_mode.gd`'s `reset_ships()`, not directly from `ship_camera.gd`'s own kickoff-adjacent code — `reset_ships()` is now `async` and awaits one `get_tree().physics_frame` before snapping, because `_reset_body`'s `queue_teleport` (task 0.15) defers the actual transform write to the ship's next `_integrate_forces`; snapping immediately would read the pre-teleport position. Goal-cut shake decay extracted into `_decay_shake()`, called from the `_goal_cut_active` branch. Validated: scripts compile, Free Play renders correctly non-headless, reset produces no camera jump, all three headless scenes exit clean | `scripts/ship_camera.gd`, `scripts/game_mode.gd:reset_ships` | Turbo FOV kick and post-process are smooth at an uncapped frame rate; shake reads the same at 60 and 240 fps; a kickoff cuts the camera rather than lerping it across the arena | +| 0.17 `[D:0.15b]` | **DONE.** `VideoSettings` gains `Preset` (Low/Medium/High/Custom) driving a bundle (`sdfgi_enabled`, `ssil_enabled`, `ssao_enabled`, `shadows_enabled`, `glow_enabled`, `aa_mode`, `resolution_scale`) via `apply_preset()`; a `settings_changed` signal lets an already-loaded arena re-apply live (`arena.gd` connects in `_ready()`) rather than only affecting the next arena load — meets "settings persist and apply without a restart" without needing a scene reload. Shadow gating targets the actual `Light3D` nodes (found once at load via `find_children`, cached, re-applied on every settings change — deliberately *not* re-derived from current state each time, since a light this code just turned off would otherwise become indistinguishable from `FillLight`, which is authored `shadow_enabled = false` on purpose and must never be turned on by the preset ladder). `vsync_mode` (Disabled/Enabled/Adaptive, **Adaptive default**) and `fps_cap_divisor` (0 = uncapped, else divides the live refresh rate at apply time rather than storing a raw fps number, so the same preference re-derives correctly on a different display) added to the settings menu; FPS cap dropdown is `disabled` (greyed) unless VSync is Disabled; refresh-rate query ≤0 falls back to "Uncapped" only. Live fps readout via `_process` reading `Performance.TIME_FPS`. `main_menu.gd`'s `_leave_to_gameplay` now calls `VideoSettings.apply_fps_cap()` instead of hardcoding `Engine.max_fps = 0`, so the player's cap actually reaches gameplay scenes. **Not yet done: the "Low preset ≥2× High" and "flat p99−p50 < 1ms" acceptance numbers** — those need the same real-hardware measurement task 0.15b flagged as unfinished (§5.5.1), not just code review | `scripts/video_settings.gd`, `scripts/settings_menu.gd`, `scenes/settings.tscn`, `scripts/arena.gd`, `scripts/main_menu.gd` | Low preset ≥2× the frame rate of High on the same hardware; settings persist and apply without a restart; every offered cap gives a flat frame-time histogram (p99−p50 < 1 ms) with VSync disabled on a 144 Hz **and** a 165 Hz display; refresh-rate query returning `-1` falls back cleanly | +| 0.17b `[D:0.15b]` `[P]` | **DONE.** `VideoSettings.resolution_scale` (0.5–1.0, default 1.0) drives `Viewport.scaling_3d_mode`/`scaling_3d_scale`/`fsr_sharpness` via `apply_resolution_scale()` — `SCALING_3D_MODE_FSR2` below 1.0 (chosen over bilinear: this project already gave up native resolution at the fixed-1080p blit per 0.17c, so FSR2's sharpening recovers more of that loss than a plain bilinear upscale at the same internal scale), `SCALING_3D_MODE_BILINEAR` with scale pinned to 1.0 at the top of the range (a no-op scaling mode when the scale is 1:1). Low preset defaults to 0.8. Exposed as a slider in the settings menu; **not yet measured against the "0.7 scale gives a large, measurable frame-time drop" bar** — same real-hardware caveat as 0.17 | `scripts/video_settings.gd`, `settings_menu.gd` | 0.7 scale gives a large, measurable frame-time drop with acceptable image quality; setting persists | +| 0.17c `[D:0.17b]` | **DONE — decided, not changed.** Kept `stretch/mode="viewport"` fixed at 1080p rather than moving to `"disabled"`, documented inline in `project.godot [display]` with rationale: 0.17b's `scaling_3d_scale` already covers "render lower than the window" independently of stretch mode (it scales the 3D viewport's internal resolution before this blit, not the window itself), and separately, task 0.15b found an unexplained ~6% non-uniform width scaling on the one machine this was tested on (2036×1080 measured against a 1920×1080 target — see §5.5.1) that needs understanding before stretch mode is touched, not blindly carried into a resolution-dependent change | `project.godot` | The decision and its rationale are written into §5.5; render resolution follows the player's setting | +| 0.17d `[P]` | **INVESTIGATED — no such lever exists in Godot 4.7.** Searched the full `project.godot` schema (`read_project_settings`) for `rendering/rendering_device/vsync/frame_queue_size` and every variant (`frame_queue`, `swapchain`, `present`, `present_queue`) — none exist as a project-settable parameter in this engine version; the RenderingDevice backend may manage its own present queue internally but doesn't expose it. Adaptive vsync (task 0.17, done) is the only half of "L4" actually achievable through project settings. The §5.6 ~17 ms figure for a shallow present queue is therefore **not obtainable as specced** — closing this without a code change is correct here, not a shortfall; reaching it would need engine-level (C++/RenderingDevice) changes out of scope for a project-settings task | +| 0.18 `[P]` | **DONE, with one discovered GDScript constraint.** New `scripts/sim_constants.gd` (`class_name SimConstants`, plain `const TICK_HZ := 60`, not an autoload) is the source of truth for `ship.gd`'s `_tick_scaled` and `training_mode.gd`'s `TICKS_PER_SIM_SECOND` — both reference it via `const SimConstants = preload("res://scripts/sim_constants.gd")` rather than the bare global `class_name` symbol, because a cross-script `const X := f(OtherClass.CONST)` initializer needs the reference resolved before the global class table is guaranteed populated. **`@export_range()` upper bounds cannot take even a preloaded reference** — export hint arguments must be true literals — so `reaction_ticks`/`bot_*_reaction_ticks` (`ai_ship_controller.gd`, `match_mode.gd`, `spectate_mode.gd` ×2) stay at a literal `60`; these are editor-inspector slider bounds, not the timing math itself, so this doesn't reopen the bug the task exists to close, but it means the acceptance criterion below is met for tick-rate math and not for export-hint bounds | `ship.gd`, `training_mode.gd`, new `scripts/sim_constants.gd` | Tick-rate-derived timing math has no bare `60`; changing `TICK_HZ` changes `_tick_scaled` and `TICKS_PER_SIM_SECOND` coherently. `reaction_ticks` export bounds remain literal by GDScript necessity | +| 0.19 `[P]` | **DONE.** `AAMode` gained `MSAA_2X`, appended (not inserted) so existing `user://settings.cfg` ordinals keep their meaning; default `aa_mode` changed to `FXAA`; `settings_menu.gd`'s `AA_OPTIONS` now lists five entries | `video_settings.gd`, `settings_menu.gd` | Five AA options; default is FXAA; existing saved preferences migrate without resetting | +| 0.20 `[P]` | **DONE.** New autoload `scripts/perf_overlay.gd` (`PerfOverlay`), toggled by a new `toggle_perf_overlay` input action (F3 default). Headless-guarded; builds its own `Label` in code rather than touching `HUD.tscn` | new `scripts/perf_overlay.gd`, `project.godot [input]` | `TIME_PROCESS` vs total frame time tells the player whether they are CPU- or GPU-bound | +| 0.21 `[P]` | **DONE.** Shared `HudInstrument._throttled_redraw(delta)` paces `queue_redraw()` to ~60/s; value smoothing itself still runs every `_process` call, only the repaint is throttled | `scripts/hud_instrument.gd`, `scripts/hud_gauge.gd`, `scripts/hud_attitude_indicator.gd`, `scripts/hud_heading_tape.gd` | HUD is visually identical; instrument `_draw` call count is capped at ~60/s regardless of frame rate | +| 0.22 `[P]` | **DONE.** `Engine.max_physics_steps_per_frame = 4` set in `GameMode._ready()`, applies to every mode including headless Training | `scripts/game_mode.gd` | A client throttled to 20 fps degrades smoothly instead of compounding | +| 0.23 `[P]` | **DONE.** New autoload `scripts/background_fps.gd` (`BackgroundFPS`) drops to 30 fps on `NOTIFICATION_APPLICATION_FOCUS_OUT` / restores on focus-in, independent of scene. `main_menu.gd`/`settings_menu.gd` each cap to `DisplayServer.screen_get_refresh_rate()` in `_ready()` (falling back to uncapped on a `-1` query); leaving the main menu for a gameplay scene uncaps again via a new `_leave_to_gameplay()` helper, since gameplay has no cap of its own yet (0.17) | new `scripts/background_fps.gd`, `main_menu.gd`, `settings_menu.gd` | An unfocused window and an idle menu both stop rendering at 900 fps | +| 0.24 `[P]` | **DONE.** Both guarded with `if DisplayServer.get_name() == "headless": return` — `arena.gd:_ready()` skips the whole Environment block, `video_settings.gd:_ready()` skips `apply_aa()` | `scripts/arena.gd`, `scripts/video_settings.gd` | `--headless` allocates no Environment and no AA state | +| 0.25 `[P]` | **DONE.** `_process` still calls `to_local()` every frame (needed for the comparison itself) but skips `set_shader_parameter()` — the actual GPU-facing cost — below a 0.05 m movement threshold | `scripts/arena_boundary.gd` | Field shader behaves identically; the expensive call is skipped on most frames | +| **0.26** `[D:0.15b]` | **Bake the arena GI and retire SDFGI** (§5.7). `arena.gd`/`goal.gd` have no `_process`, no animation — the arena is fully static, and SDFGI is paying continuously to solve a dynamic-world problem this project does not have. Add UV2 to the arena shell, bake `LightmapGI` (or `VoxelGI` if bounce onto ships matters), disable `sdfgi_enabled` and re-evaluate `ssil_enabled` | `scenes/arena_base.tscn`, `scenes/arena_0*.tscn`, `scripts/arena_boundary.gd` | **Largest frame-time reduction of any task here, with equal or better image quality**; High preset keeps its look; bake is reproducible from a documented step | +| **0.27** `[P]` | **Override expensive rendering defaults** (§5.7): `positional_shadow/atlas_size` 4096 → 2048, `directional_shadow/size` 4096 → 2048, `soft_shadow_filter_quality`. `project.godot [rendering]` currently has three keys and everything else is at engine default | `project.godot` | Measurable frame-time reduction; no visible shadow-quality regression at 1080p | +| **0.28** `[D:0.15b]` | **Prototype `physics/3d/run_on_separate_thread`** (§5.7). Decouples the physics step from the render thread and directly attacks §5.4's frame-time variance — **and is the riskiest item in this phase**: it changes when `_integrate_forces` runs relative to script code, and both `ship.gd:346-357` and the RL training path depend on that | `project.godot`, `scripts/ship.gd` | A measured p99 frame-time improvement **and** identical Free Play / Match / headless-Training behaviour, or the change is reverted and the finding recorded here | +| **0.29** `[P]` | **Early-out in `ArenaBoundary.get_surface_pull`**: a bounds check against `wall_range`/`ceiling_range` before the `to_local()` and five `_falloff` calls, which currently run for every dynamic body every tick even mid-arena where every term is zero | `scripts/arena_boundary.gd` | Identical flight feel and identical RL observations; measurable tick-time reduction with 7 bodies | + +> **These tasks exist because of the high-refresh-rate mandate, and their order matters.** **0.15b blocks everything else** — §5.4 and §5.5 are entirely a priori and the first measurement may invalidate the fps list. 0.17/0.17b/0.19 are the actual frame-rate levers (§5.5: the project is bound by full-screen passes no player can disable, not by geometry). 0.16 and 0.20–0.25 are the per-frame hygiene that makes a high frame rate worth having. 0.18 buys nothing today — it is what keeps a future 120 Hz simulation a config change plus a retrain rather than a protocol rewrite. +> +> **0.19–0.29 are all pure single-player wins with no netcode content.** If the multiplayer effort is ever paused, they should still land. Within them, **0.26 (bake the GI) is the largest single frame-time win in the document and costs no image quality** — the arena is fully static, so SDFGI is paying continuously for a problem this project does not have (§5.7). **0.28 is the riskiest**; it is the only Phase 0 task that can plausibly need reverting. + +> **Correction — task 0.2 is wider than an earlier draft claimed.** That draft argued the refactor was "narrow" because `_build_merged_hull` and `_build_movement_vfx` "only `add_child()`". That is exactly the problem: they `add_child()` onto **`self`, the `RigidBody3D`** — `ship.gd:208` (MergedHull: Hull, Canopy, EngineGlowL/R), `:241` (engine cores), `:268` (flames), `:278` (lights). Leave those and §4.4's soft correct offsets only `Nose` and `TailFin` while the hull, canopy, glows, flames and lights stay welded to the corrected collider — **every correction visibly tears the ship in half.** The old acceptance criterion ("looks identical in Free Play") passes either way, which is why the criterion is now a child-type assertion. `ship.gd:218`'s controller `add_child` correctly stays on the body; `CollisionShape3D` stays on the body. +> +> Still true from that draft, and re-verified: `ship.gd:44-47` documents why `Nose`/`TailFin` remain separate `MeshInstance3D`s, and **the RL path is untouched** — `ship_observations.gd` reads only `global_position`, basis, velocities and `PhysicsServer3D` contacts, and `training_mode.gd`'s only `get_node` is `arena.get_node("Boundary")`. + +**Phase gate:** the game plays identically to `master` in Free Play, Match, Spectate, and headless Training, with `Engine.time_scale` never written — **and additionally: §5.5 contains a real measured frame-time table (0.15b), the Low preset roughly doubles the frame rate of High (0.17), and the game looks correct uncapped on a high-refresh display** with no 60 Hz stepping in FOV, shake or post-process. + +### Phase 1 — Transport, connection, lobby + +| # | Task | Acceptance | +|---|---|---| +| 1.0 | **`tests/test_runner.tscn`** — minimal pure-function assertion runner exiting 0/1 | `godot --headless --path Game res://tests/test_runner.tscn` runs and exits 0 | +| 1.1 `[D:1.0]` `[D:0.18]` | `net_codec.gd`: protocol constants, enums, channel ids, quantisers, pack/unpack for both hot packets. **Pure functions**, written against 1.0. Every timing constant derives from `TICK_HZ` — ring sizes, seq windows, `INTERP_DELAY`, timeouts (§5.4) | Round-trip, bounds, and quaternion-error tests pass; setting `TICK_HZ = 120` recomputes every derived constant with no other edit | +| 1.2 `[D:1.1]` | `NetworkManager` autoload: `host`/`join`/`shutdown`, signals, **`server_relay = false`** | Two peers connect and disconnect cleanly | +| 1.3 `[D:1.2]` | Manual multiplayer polling: `get_tree().set_multiplayer_poll(false)`; client flushes at the end of `_physics_process` after the input send **and polls for receive at the top of both `_process` and `_physics_process`, unthrottled** (§5.4c); server polls at tick start (drain) and tick end (flush). Defer any scene-tree mutation in `peer_connected`/`peer_disconnected`, which now fire mid-frame | Measured RTT drops by ~8–17 ms per leg versus default polling; **median age of the newest applied snapshot at render time drops by ≈ half a frame interval** versus polling once per physics tick | +| 1.4 `[D:1.2]` | `MatchNet` autoload skeleton: `hello`/`welcome`/`player_joined`/`player_left`, strict `protocol_version` **and `physics_ticks_per_second`** gating, roster model | A mismatched client is rejected with a readable reason | +| 1.5 `[D:1.4]` | `lobby.tscn` + `lobby.gd`: roster list, team swap, ready toggle, disconnect | Two clients see each other and both ready states | +| 1.6 `[D:1.4]` `[P]` | `server_boot.tscn` + `server_boot.gd`: CLI parsing from `OS.get_cmdline_user_args()`, `Engine.max_fps = 60`, `Engine.max_physics_steps_per_frame` overrun watchdog, host, structured log lines | Headless server starts, logs, accepts connections, and idles at <5% of a core | +| 1.7 `[D:1.5]` `[P]` | `main_menu.gd`: Host / Join-by-IP with a **connecting overlay, cancel, and a failure path** | Connect, cancel, and connection-refused all reach a sane UI state | +| 1.8 `[D:1.2]` `[P]` | Clock: ping/pong, min-RTT-filtered offset, tick estimate (folded into `network_manager.gd`), plus a debug overlay showing RTT and offset | Offset converges within 2 s and stays within ±1 tick on a clean link | + +> `main_menu.gd` gains its **first async flow**. Every existing handler is `GameSettings.x = y; change_scene_to_file(...)` — there is no loading screen, no error state, and no back-navigation state machine to extend. Budget for that. + +**Phase gate:** two clients connect to a headless server, appear in a shared lobby, ready up, and disconnect cleanly. + +### Phase 2 — Server-authoritative simulation, dumb client + +No own-ship prediction yet: the client renders everything, including its own ship, from the interpolation buffer. Unplayable over the internet, fine on LAN, and it proves the whole state pipeline before prediction complicates the picture. + +**This phase is load-bearing, not throwaway** — the codec, slot mapping, snapshot pipeline, interpolator and HUD signal surface all survive into Phase 4. Roughly ten lines get discarded. + +| # | Task | Acceptance | +|---|---|---| +| 2.1 `[D:1.4]` | `networked_match.gd` + `networked_match.tscn` (no HUD child); `match_config` RPC; deterministic slot-order roster spawn; arena-path validation against `ArenaRegistry` | Both peers spawn an identical tree; an invalid arena path is refused | +| 2.2 `[D:2.1]` | Server: reuse **`RLShipController`** as the remote-input controller, naive input application (no jitter buffer yet), snapshot writer, 60 Hz broadcast | Server logs show a stable 60 Hz snapshot cadence | +| 2.3 `[D:2.2]` | Client: snapshot reader and buffer; `net_interpolator.gd` driving frozen-kinematic bodies | Ships and ball move smoothly on the client | +| 2.4 `[D:2.3]` | **Dual-time remote entities** (§4.1): collider at `server_time_est` in `_physics_process`; `$Visual` at `server_time_est - INTERP_DELAY` **in `_process` at true render time**, `physics_interpolation_mode = OFF` (§5.4b) | Collider/visual separation measurable in the debug overlay; contacts resolve against present-time geometry; at 240 fps remote ships show 240 distinct positions/s, not 60 | +| 2.5 `[D:2.3]` `[P]` | Client: forward raw input at 60 Hz (no redundancy, no buffering yet) | Input reaches the server and moves the ship | +| 2.6 `[D:2.3]` `[P]` | Client: `set_visual_action` / `set_visual_speed` wiring for remote engine flames and the ball trail | Remote ships show engine VFX; the ball trail responds to speed | +| 2.7 `[D:2.3]` `[P]` | Client: camera rig on own ship; HUD added in code; `NetworkedMatch` declares all five HUD signals | Full HUD renders, including the score row | +| 2.8 `[D:1.1]` `[P]` | **`net_sim.gd`** — seeded debug-only latency/jitter/loss/duplicate decorator around `MatchNet.send_input` / `send_snapshot`, CLI-driven, asymmetric-capable | `--net-sim-latency 80` measurably raises observed RTT | + +> **`net_sim.gd` belongs in this phase, not Phase 3.** A LAN-only phase gate passes even with §4.1's flaw fully present, because LAN `INTERP_DELAY` sits at the clamp floor and closing-speed error is small. Phases 2 and 3 would both go green and Phase 4 would discover the architecture is wrong. + +**Phase gate — MILESTONE:** a real 1v1 **at `--net-sim-latency 80 --net-sim-jitter 20`**, not just on LAN. Ships fly, the ball moves, goals detect server-side. + +### Phase 3 — Input pipeline hardening + +| # | Task | Acceptance | +|---|---|---| +| 3.1 `[D:2.5]` | Input redundancy (last 4) and sequence numbering in server-tick space | A 3-packet burst loss produces no starvation | +| 3.2 `[D:3.1]` | Server jitter buffer: fixed 32-entry ring, repeat-last on starve, zero after 500 ms, `target_depth = 1`, depth reported in every snapshot | Starvation events logged and visible in the overlay | +| 3.3 `[D:3.2]` `[P]` | Client-owned `input_lead` control loop: fast attack (+3 immediate), slow release (−1 per 60 ticks after 2 s clean) | A simulated 60 ms latency spike is absorbed within ~200 ms | +| 3.4 `[D:3.1]` `[P]` | Rate limiting, malformed-packet counting, `seq > server_tick + 20` rejection, server-side `input_lead` enforcement from arrival times, disconnect policy | A flooding or seq-poisoning client is disconnected; honest clients unaffected | +| 3.5 `[D:3.2]` `[P]` | Unit tests: jitter-buffer policy against scripted arrival traces | Starvation, surplus, and reorder traces all produce the specified actions | +| 3.6 `[D:2.8]` | `--test-bot` client mode driven by the existing `AIShipController`, plus a CI driver launching a headless server and two headless test-bot clients | Exits 0 on a clean run; asserts snapshot count, p95/p99 prediction error, snap count, cross-peer score agreement, and clean stderr | +| 3.7 `[D:2.8]` `[P]` | Debug net overlay: RTT, jitter, loss, buffer depth, snapshot age, bandwidth, prediction error | All values live and plausible | + +> `AIShipController` runs a policy in pure GDScript with no Python or ONNX dependency, so 3.6 gets a competent automated player for free. + +**Phase gate:** the match stays smooth at `--net-sim-latency 80 --net-sim-loss 0.05`; the CI smoke test is green. + +### Phase 4 — Prediction and reconciliation, ship **and ball** + +| # | Task | Acceptance | +|---|---|---| +| 4.1 `[D:3.1]` | `LocalNetShipController`: single sample per tick, `copy()`, input history ring | Exactly one `get_action()` per tick; no aliasing in the history | +| 4.2 `[D:4.1]` | Prediction state ring (128) and snapshot→`predicted[A]` matching | `predicted[A]` resolves for every snapshot on a clean link | +| 4.3 `[D:4.2, 0.14]` | `net_ship_predictor.gd`: snap-vs-blend decision, full-immediate velocity correction, teleport queue, `reset_gen` handling, **ring backfill after a snap** | A snap is never followed by an immediately-forced second snap | +| 4.4 `[D:4.3, 0.2]` | Visual offset with `_tick_scaled(0.88)` decay, `MAX_VISUAL_OFFSET = 0.4`, `reset_physics_interpolation()` on body **and** `$Visual` | No mesh smear on snap; no visible offset beyond 0.4 m | +| 4.5 `[D:4.3]` `[P]` | Catch-up by **replaying stored `ShipAction`s** through the ship's own force formulas | No directional bias under sustained turbo; ship does not feel rubbery | +| 4.6 `[D:4.3]` | **Ball local prediction**: dynamic locally from the tick your predicted ship contacts it for `min(RTT, 250 ms)`; server ball applied to a shadow copy throughout; blend back over 150 ms, hard-snap past 3 m. Triggered by the existing `Ship.ball_contact` (`ship.gd:115`) | Your own touches register visually on contact, not ~RTT later; behind a setting | +| 4.7 `[D:4.4]` `[P]` | Tuning pass with debug-menu sliders: snap thresholds, decay `k`, `MAX_VISUAL_OFFSET`, `INTERP_DELAY` | Values recorded in this document once settled | +| 4.8 `[D:4.4]` `[P]` | Prediction-error telemetry (p50/p95/p99, snap rate) into the overlay and the CI assertions | Snap rate <1/min in normal 1v1 play at 80 ms simulated RTT | +| **4.9** `[D:4.4]` | **L1 — extrapolate remote `$Visual` to present time** (§5.6): render remote ships and the ball at `server_time_est` rather than `server_time_est - INTERP_DELAY`, feeding the residual through 4.4's soft-correct pipeline. **Collapses §4.1's dual clock** — collider and visual share one time, so §5.4b's `_process`/`_physics_process` split for remote bodies is removed. Keep interpolation behind a flag for A/B | **≈30 ms off world response** (174 → ~144 before L4). Measured p99 extrapolation error < 0.3 m and < 5°; correction pops are visible on hard direction changes and nowhere else; A/B against the interpolated path is a deliberate, recorded judgement | +| **4.10** `[D:4.9]` `[P]` | **L3 — adaptive jitter-buffer depth**: target 0 on links with jitter below a threshold, rising to 1+ under jitter, replacing §3.3's fixed `target_depth = 1` | ≈8 ms off world response on clean links with no increase in starve rate; degrades to today's behaviour under `--net-sim-jitter 20` | + +> **Ball prediction is not optional and not Phase 8.** With §4.1 in place the touch registers correctly on the server, but the ball still *renders* a third of a beat late — your ship visibly passes through it before it moves. In a game whose entire point is hitting a ball, that is the difference between "networked" and "broken", and it is the same machinery as own-ship prediction applied to one more body. Do it while the prediction code is warm. Buffering server ball state into a *shadow* copy (rather than discarding it) is what lets you measure disagreement continuously instead of discovering a 3 m error at window end. + +**Phase gate — MILESTONE:** at simulated 100 ms RTT both the ship and the ball feel local; corrections are invisible in free flight and read as bumps on contact. **World response measures ≤130 ms at 60 ms RTT** (§5.6's L1 + L4 target), on whatever graphics settings the machine is running. + +### Phase 5 — Match lifecycle + +| # | Task | Acceptance | +|---|---|---| +| 5.1 `[D:2.1]` | Server state machine, `state_change` broadcast, `match_state` snapshot byte | Clients follow every transition | +| 5.2 `[D:5.1]` | Tick-derived clock replacing the `Timer` + `_process` polling; `clock_state` RPC; goal-time freeze as `end_tick += (resume_tick - goal_tick)` | Clocks agree across peers to within a tick; no float drift across 10 goals | +| 5.3 `[D:5.1]` | `kickoff` RPC with broadcast transforms, freeze/unfreeze, `reset_gen`, countdown derived from `server_tick`, **and the specified late-arrival behaviour** | A `kickoff` delayed past `resume_tick` applies immediately without a negative countdown | +| 5.4 `[D:5.1]` | `goal_scored` RPC; server-side pause window via `_goal_pause_seconds()` and `_set_frozen()` (**never `Engine.time_scale`**); client cinematic split from timing | Server reset no longer fires while clients are mid-celebration | +| 5.5 `[D:5.1]` `[P]` | Full time, overtime, results, return-to-lobby; **remove `get_tree().paused`** | Clients keep sending inputs and processing snapshots throughout the results screen | +| 5.6 `[D:5.1]` `[P]` | Disconnect → controller swap; 30 s identity-keyed slot reservation and reconnect; `--fill-bots` / `--no-fill-bots`; `stalled` flag and nameplate | A disconnect never despawns a ship; reconnect within 30 s restores the slot | +| 5.7 `[D:5.6]` | Null `MatchNet`'s controller reference in the same transaction as the swap, and `is_instance_valid`-guard every use | No freed-object access on repeated disconnect/reconnect | +| 5.8 `[D:5.1]` `[P]` | Spectators and late join; spectator-safe `HUDController` path; camera target cycling | A spectator can watch a live match and cycle targets | +| 5.9 `[D:5.3]` `[P]` | Server-only `_respawn_escaped_bodies()` with a `reset_gen` bump | Clients hard-snap on an escape respawn instead of fighting it | +| 5.10 `[D:5.1]` `[P]` | **Server replay log**: append-only binary `(tick, inputs received, snapshot sent)` | A recorded match replays deterministically enough to reproduce a reported snap | + +> `Ship.set_controller` (`ship.gd:213-218`) calls `queue_free()` on the outgoing controller. Task 5.7 exists because the takeover path in 5.6 otherwise leaves `MatchNet` holding a freed reference — the exact class of bug that surfaces as a random server crash weeks later. + +> Task 5.10 is the highest-value debuggability investment here. The packets are already flat bytes, so it is ~50 lines. 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. + +**Phase gate:** a full 3v3 start-to-finish including a mid-match disconnect and a late joiner. + +### Phase 6 — Dedicated server productionisation + +| # | Task | Acceptance | +|---|---|---| +| 6.1 `[P]` | Export preset (`dedicated_server=true`, `custom_features="dedicated_server"`) and `run/main_scene.dedicated_server`, mirroring the existing `run/main_scene.training` mechanism | Preset builds | +| 6.2 `[D:6.1]` | **Verify the stripped export boots and scores a goal** | Exported binary runs a full match headless | +| 6.3 `[P]` | Full CLI surface plus a config-file fallback | `--help` documents every flag | +| 6.4 `[P]` | Structured logging (join, leave, goal, kick, rate-limit, tick overrun) with `--log-level` | Logs are greppable and rotate sanely | +| 6.5 `[P]` | Arena rotation between matches; `--max-matches N` drain-and-exit | Server cycles arenas and exits cleanly after N | +| 6.6 `[P]` | systemd unit, Dockerfile, `SERVER.md` (ports, firewall, sizing per §1.4, and the SIGTERM caveat) | A third party can host from the docs alone | +| 6.7 `[D:3.6]` `[P]` | CI builds the server export and runs the smoke test against the **exported binary**, not source | Green on a clean checkout | + +> `dedicated_server=true` enables Godot's strip-visuals export mode, which replaces meshes and textures with placeholders per resource. Every relevant site is already headless-guarded — `ship.gd:167`, `ball.gd:25`, `goal.gd`, `arena_boundary.gd` — so the code should be safe. **Verify it against a real stripped build anyway**; this is the kind of thing that fails silently. + +> **Docker/VPS is the primary v1 deployment path.** Raw ENet self-hosting needs port forwarding, and SDR is Phase 7 — so Phases 1–6 ship something that works on LAN or a VPS and nowhere else. That is fine, but say it out loud rather than letting a player discover it. + +> Godot 4 gives GDScript no SIGTERM hook. `SIGTERM`/`Ctrl-C` kills the process immediately and clients see an ENet timeout (~5 s). Acceptable — but document it rather than letting it be discovered. `--max-matches N` under a process supervisor covers planned drains. + +> **Rcon is deferred past v1.** An authenticated remote command channel is a real security surface, and `--max-matches` plus a supervisor covers most of the need with none of it. + +**Phase gate:** `docker run` a server, connect from another machine over the internet, play a full match. + +### Phase 7 — Steam transport, browser, identity + +| # | Task | Acceptance | +|---|---|---| +| 7.1 `[D:1.2]` | GodotSteam integration and custom export templates — **client *and* headless server** | Both templates build and run | +| 7.2 `[D:7.1]` | Extract a `NetTransport` boundary **now**, concretely, from two working implementations; add `steam_transport.gd` (`SteamMultiplayerPeer`, SDR, `advertise()` via `ISteamGameServer`) | Transport swap is one line in `NetworkManager` | +| 7.3 `[D:7.2]` `[P]` | `server_browser.tscn` via `ISteamMatchmakingServers` | Internet, LAN, favourites and history lists all populate | +| 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`; Steam identity in the roster; persistent ban list | Ownership, VAC and ban state verified server-side | +| 7.5 `[D:7.2]` `[P]` | Feature-gate every Steam call behind `OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer")`; verify the ENet path end to end | Non-Steam build is fully functional | + +> **The transport interface is written here, not in Phase 1.** Eight virtual methods (`begin_auth`, `advertise`, `get_identity`, `supports_server_browser`…) designed against an API nobody on the project has used will be wrong. Write `NetworkManager._make_peer()` concretely in Phase 1 and extract the boundary once there are two real implementations. Locked decision 3 guarantees the ENet path is never deleted, so there is no migration risk in waiting. + +> GodotSteam requires custom engine builds and export templates — **including for the headless server**. That is the part people discover three weeks in. Budget for it. + +--- + +## 8. What needs refactoring, not extending + +| # | Location | Why extension is insufficient | +|---|---|---| +| 1 | `objects/ship.tscn`, `ship.gd:175-180, 189-208, 241-278` | No node exists to carry a render-only offset — meshes hang directly off the `RigidBody3D`. Needs `$Visual`. | +| 2 | `ship_camera.gd:115, 149, 150` | Camera reads the body's transform, so it would jump the full correction error while the mesh smoothly lags. | +| 3 | `match_mode.gd:36, 59-64, 76-82, 93-96, 107-109` | The `Timer` + `_process` clock is frame-rate **and** `time_scale` coupled. Must become tick-derived. Five call sites. | +| 4 | `match_mode.gd:162-171` | `get_tree().paused = true` stops the client's own send loop and snapshot processing, and the return-to-lobby RPC lands in a tree that cannot act on it. | +| 5 | `game_mode.gd:95-121, 171-194` | `Engine.time_scale` is fundamentally incompatible with a shared tick clock — sequence numbers ride on `Engine.get_physics_frames()`, so a hit-stop at 0.06 starves the jitter buffer within a few frames. The *effects* must be reimplemented, not merely disabled. | +| 6 | `game_mode.gd:85-92` | `_handle_goal_scored` interleaves timing with presentation. On a headless server `_play_goal_celebration` returns **synchronously**, so the reset fires on the same frame as the goal — while clients are 1.6 s into a cinematic. | +| 7 | `game_mode.gd:248-263` | `_jittered` uses global RNG; `_reset_body` uses `set_deferred`. Both must become authoritative-broadcast plus a Jolt-correct teleport. | +| 8 | `game_mode.gd:54-55, 284-285` | Unconditional goal-signal connection (an interpolated ball entering a client's local `Goal` would score locally) and unconditional escape-respawn both write authoritative state on clients. | +| 9 | `main_menu.gd` (all handlers) | Every mode launch is a synchronous `change_scene_to_file`. Connecting is async and can fail — a genuinely new UI state, not another button. | +| 10 | `HUDController.gd:41-46` | Hard-requires a ship; spectators have none. | +| 11 | `player_ship_controller.gd` | Single reused `ShipAction` instance; buffering aliases every history entry. | +| 12 | `ship_camera.gd:86` (whole rig) | Runs in `_physics_process`, so on a 240 Hz display the FOV kick (`:182`) and `PostFX` parameters (`:186-187`) step at 60 Hz — neither is a transform, so global physics interpolation does not cover them — and the shake noise (`:200-212`) loses its high-frequency character. Must become `_process` + `get_global_transform_interpolated()` (§5.4a, task 0.16). | +| 13 | `video_settings.gd:14-16`, `settings_menu.gd` | Persists AA, glow and brightness only — three values. The three genuinely expensive settings (SDFGI, SSIL, SSAO) and the five shadow-casting lights are unreachable, and neither `vsync_mode` nor `max_fps` is set anywhere. A player chasing 240 fps has exactly one lever: turn glow off. Needs a preset system, not another checkbox (§5.5, tasks 0.17/0.17b). | +| 14 | `scenes/arena_base.tscn:18-50, 61-105` | The Environment every arena inherits enables SDFGI + SSIL + SSAO + a 5-level glow pyramid simultaneously, with four shadow-casting `OmniLight3D`s (24 cubemap faces/frame). Not tunable per-arena around a preset; the preset must gate the shared base (§5.5). | +| 15 | `shaders/post_process.gdshader:4` | `hint_screen_texture` forces a full-screen backbuffer copy **every frame**, not only during turbo — `vignette_strength` never reaches 0 (`ship_camera.gd:187, 243`). Either bake the static vignette into `Environment.adjustment_*` and hide `PostProcess` when `chromatic_aberration` is at rest, or drop the screen read for a plain gradient overlay and keep it only for the turbo chroma. | +| 16 | `project.godot [display]` | `stretch/mode="viewport"` + 1920×1080 base + `aspect="expand"` fixes the 3D render at ~1080p and blits. A 4K player cannot render native; a 1080p player cannot render lower. Blocks any render-scaling setting until decided (task 0.17c). | + +**On `Engine.time_scale`:** replace hit-stop and goal slow-mo with the camera-based effects **in single-player as well** (task 0.12), so there is one code path and one game feel to maintain rather than a networked variant that drifts away from the single-player one. `ShipCameraRig` already has `_shake_strength`, `shake_decay`, `max_shake_offset` and a `PostFX` `ShaderMaterial` to build on. + +**What does not need surgery:** the `ShipController` seam, the Arena/GameMode split, code-driven spawning, group-based discovery, and the dumb `Goal` sensor all extend cleanly. `CLAUDE.md`'s claim about the three load-bearing seams is accurate — they hold. `rl_ship_controller.gd` is *already* the remote-input controller (a public `action` field that something else writes, pulled each tick), so no new class is needed for it. + +--- + +## 9. Godot 4.7 + Jolt gotchas + +1. **`ENetMultiplayerPeer.server_relay` defaults to `true`** — clients can RPC each other through your server. Set it `false`. +2. **`MultiplayerAPI.poll()` runs on the idle frame**, so an `rpc()` from `_physics_process` waits up to a full frame — and `Engine.max_fps = 60` on the server is what creates that delay on the return leg. Take manual control (task 1.3). **~16–33 ms of round-trip, for ~10 lines.** +3. **Jolt sleeps bodies.** A ship corrected to near-zero velocity can sleep and then ignore `state.linear_velocity` writes. `can_sleep = false` on Ship and Ball. +4. **Teleporting a rigid body**: `state.transform` inside `_integrate_forces` is the only path with no frame of lag. `set_deferred("global_transform", …)` lands between frames and interacts badly with Jolt's sleep/wake ordering. +5. **`reset_physics_interpolation()` is not automatic for `state.transform` writes** (it is when you set `global_transform` directly). Call it explicitly, on the body **and** on `$Visual`. +6. **`physics_jitter_fix = 0.0` does not give you "a flat 60 Hz."** You still get occasional 0-tick and 2-tick frames, because frame time is never exactly 16.667 ms. The real reason to set it to 0 is that you never want a tick's input *delayed* by the accumulator smoother. **The send path must therefore transmit both ticks' actions on a 2-tick frame** — redundancy-4 covers this, but only if you actually send both. +7. **`_integrate_forces` is not called on frozen bodies**, so remote ships never pull `get_action()` — hence `set_visual_action`. Use `FREEZE_MODE_KINEMATIC`, **not `STATIC`**, or contact velocity transfer breaks. +8. **Never write `linear_velocity` to a frozen body** — Godot/Jolt zeroes and holds it. +9. **`Engine.max_physics_steps_per_frame` defaults to 8.** If a server tick overruns 16.7 ms the accumulator backs up and the next frame runs multiple ticks, spiking CPU further. Log overruns (task 1.6). +10. **ENet channel indices** are offset by Godot's reserved system channels — verify the mapping empirically. +11. **ENet peer timeout** defaults to ~5 s. Tune via `ENetPacketPeer.set_timeout()` for faster drop detection. +12. **Jolt is not bit-deterministic** across platforms or across differing contact orderings. Never rely on it anywhere, including in "obviously safe" places like a client-side goal check. +13. **`dedicated_server=true` exports strip visual resources.** Verify against a real stripped build (task 6.2). +14. **MTU**: ENet fragments above ~1400 B. At 219 B/snapshot there is ~6× headroom; recheck if per-body cosmetic state is ever added. +15. **RPC NodePath caching**: the first `rpc()` to a node sends the full path, later calls send a cached int. Routing hot paths through autoloads warms the cache once at connect and never invalidates it on scene change. +16. **Physics tick rate is 60 for v1 — and must never be a literal.** Every policy in `Game/bots/` is tick-coupled through `ship.gd:450`'s `_tick_scaled` (defined at a 60 Hz reference) and `ai_ship_controller.gd`'s `reaction_ticks`, so raising it toward Rocket League's 120 invalidates every trained model and halves server density. But it is the largest single latency term left (§5.4), so it *will* be revisited: derive everything from `TICK_HZ` (tasks 0.18, 1.1) so that day is a config change plus a retrain. +17. **`Node3D.get_global_transform_interpolated()` is the only correct way to track a physics-interpolated body from `_process`.** `global_transform` returns the last physics tick's pose, so a per-frame camera reading it chases a 60 Hz staircase. Per the engine docs the method "creates an interpolation pump… the first time it is called" — **call it once before any `reset_physics_interpolation()` on that node**, or the first hard snap streaks (§4.5). +18. **Physics interpolation covers transforms only.** `camera.fov`, shader parameters, light energy and anything else written from `_physics_process` steps at 60 Hz on a 240 Hz display. Either write them from `_process` or accept the stepping deliberately. +19. **`display/window/vsync_mode` defaults to enabled (FIFO) and `max_fps` to uncapped.** Neither is set in `project.godot`. FIFO present latency is **1.5–3 refresh intervals** depending on swapchain image count (2 vs 3) and whether the present queue is full — §5's tables use the optimistic 1.5, which assumes the renderer is *not* GPU-bound. **The model does not hold below refresh**, where a missed vblank under strict FIFO halves the effective rate and roughly doubles present latency. Prefer **Adaptive** as the default, not Mailbox (§5.4). *(Swapchain image count per platform needs empirical verification.)* +20. **`Engine.max_fps` is a throttle, not a frame pacer.** It pads each frame with a post-frame sleep; it has no vblank phase lock. Caps that are not integer divisors of the refresh rate beat against scanout, and combining a cap with an active vsync paces *worse* than either alone (§5.4). Derive the offered caps from `DisplayServer.screen_get_refresh_rate()`. +21. **`DisplayServer.window_get_vsync_mode()` echoes your request, not the driver's grant.** There is no GDScript API for the negotiated `VkPresentModeKHR`, so a UI cannot honestly report what was applied. Show a live fps readout instead and let the player infer it. +22. **`Engine.max_physics_steps_per_frame = 8` is a client problem too**, not just a server one (gotcha 9). A client hitching to 20 fps runs 3 ticks per frame, and each of those frames also runs the per-frame camera rig and remote-visual sampling. Set it to 4 client-side (task 0.22). On a multi-tick frame the send path must transmit **every** tick's action (gotcha 6) — §4.3's `_physics_process` sampling does this naturally, but nothing else guarantees it. +23. **`hint_screen_texture` forces a full-screen backbuffer copy on every frame the node is drawn**, regardless of what the shader then does with it. Branching inside the shader saves taps, not the copy. Hide the node when the effect is at rest. +24. **`physics_jitter_fix` matters less the higher the frame rate.** Its purpose is smoothing when frame rate ≈ tick rate; at 240 fps against 60 Hz physics most frames run zero ticks and the accumulator is never near an edge. Gotcha 6's reasoning for setting it to `0.0` still holds, but do not expect a visible difference on a high-refresh machine — test that change at 60 fps. + +--- + +## 10. Testing + +**Editor.** Debug → Run Multiple Instances, 2–3 instances with per-instance args (`-- --server`, `-- --connect 127.0.0.1:27015`) and `--position` so windows don't stack. + +**CLI.** +```bash +godot --headless --path Game res://scenes/server_boot.tscn -- --port 27015 --team-size 1 --auto-start +godot --path Game -- --connect 127.0.0.1:27015 --name Alice +``` + +**CI smoke test (task 3.6).** Headless server plus two headless `--test-bot` clients, driven by the existing `AIShipController`. Asserts: +- snapshots received ≥ `N * snapshot_hz * 0.9` +- own-ship prediction error p95 < 0.5 m, p99 < 2.0 m, hard-snap count < 3 +- final score identical on the server and both clients +- no `push_error` emitted (scrape stderr) + +**Network conditions.** `net_sim.gd` (task 2.8) is first-class: seeded so failures reproduce, works in CI, needs no display, and can be applied *asymmetrically* — which OS tools make painful. `tc netem` / Network Link Conditioner / `clumsy` for a pre-release realism pass only. A real remote host once per phase from Phase 4 onward is the only true test of the jitter buffer's adaptivity. + +**Unit tests (task 1.0).** No test framework exists today, so keep it minimal — a scene that runs pure-function assertions and exits with a code. High-value targets, all zero-engine-state: codec quantise/dequantise round-trip and bounds; quaternion max error; snapshot pack→unpack identity; input packet framing; jitter-buffer policy against scripted arrival traces; `ShipAction.copy()` non-aliasing. These are exactly where a bug is invisible in play and catastrophic in aggregate. + +--- + +## 11. Flagged, not solved + +**Low-latency present and graphics presets** — *now specified*, see §5.4, §5.5 and tasks 0.17/0.17b. Left here as a pointer because they are the largest wins in the document per line of code changed, and they are video settings rather than netcode. + +**120 Hz simulation** — deliberately deferred, not dismissed. §5.4 and §5.6 record what it would buy (≈21 ms of world response once L1 has taken the interpolation buffer out, plus ≈8 ms of own-ship feel — the difference between ≈127 ms and ≈107 ms), what it costs (a full bot retrain, half the server density, double the bandwidth), and the one rule that keeps the door open: `TICK_HZ`, never `60`. + +**The latency gap to the reference has a plan but not yet a measurement.** §5.2 lands at ≈174 ms as designed; §5.6 routes that to ≈127 ms (tasks 0.17d, 4.9) and ≈103 ms (tasks 4.10 plus 120 Hz simulation), against ~90–110 ms for the reference class at the same RTT. Every figure in §5.6 is arithmetic on the budget, not a measurement — task 4.9's acceptance criterion exists to make it one. Beyond that the residual is RTT, which is a server-siting problem (§6) rather than a code one and is worth more than every remaining code lever combined. + +**Audio.** `TODO.md` records that there is none. `set_visual_action` / `set_visual_speed` (task 0.14) is precisely where remote-ship engine audio will hang, and "ball feel" (task 4.6) is half auditory. Design those hooks with that in mind rather than retrofitting. + +**Split-screen.** Tracked separately in `TODO.md`; unrelated to this effort, though the camera-outside-the-ship structure that enables it is the same structure this plan relies on.