class_name NetworkedMatch extends GameMode # Phase 2: server-authoritative simulation, dumb client (multiplayer-todo.md # §7 Phase 2). The server runs the real physics for every ship — via # RLShipController, fed by each connected player's forwarded input — and # the ball, and broadcasts NetCodec snapshots at 60Hz. The client renders # everything, including its own ship, from the interpolation buffer; there # is no local prediction yet (that's Phase 4), so every body on the client # is FREEZE_MODE_KINEMATIC and driven entirely by incoming snapshots. # # No HUD/Arena child in networked_match.tscn — both are built in code, once # the arena is actually known (the server picks one; the client learns it # from match_config), which is why this overrides _ready() completely # rather than relying on GameMode's default (arena-required-synchronously) # flow. signal timer_updated(minutes: int, seconds: int) signal score_changed(score: Dictionary) signal match_ended(winning_team: int, score: Dictionary) signal kickoff_countdown(count: int) signal overtime_started const NetCodec = preload("res://scripts/net_codec.gd") const NetBodyState = preload("res://scripts/net_body_state.gd") const NetInterpolator = preload("res://scripts/net_interpolator.gd") const HUD_SCENE = preload("res://scenes/HUD.tscn") # Minimum plausible interpolation delay even on a same-machine/LAN link — # §4.6's INTERP_DELAY clamp floor. The full formula (one_way + snapshot # interval*1.5 + 2.5*jitter_ewma) is simplified here to one_way + interval*1.5 # with no jitter term yet (no jitter EWMA is tracked before Phase 3) — close # enough for Phase 2's "smooth, not exactly latency-optimal" bar. const INTERP_DELAY_MIN_MS := 25.0 const INTERP_DELAY_MAX_MS := 200.0 const SNAPSHOT_INTERVAL_MS := 1000.0 / 60.0 class SlotInfo: var peer_id: int var team: int var spawn_index: int var ship: Ship var controller: RLShipController # server only var interpolator := NetInterpolator.new() # client only var _slots: Array[SlotInfo] = [] var _my_slot: SlotInfo = null # client only var _ball_interpolator := NetInterpolator.new() # client only var _local_input_sampler := PlayerShipController.new() # client only: reads local input each tick to forward; never added to a Ship, never in the tree — get_action() only touches the global Input singleton var _input_seq := 0 # client only var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport func _ready() -> void: add_to_group("game") Engine.max_physics_steps_per_frame = 4 if kickoff_rng_seed == 0: _kickoff_rng.randomize() if multiplayer.is_server(): _start_server() else: MatchSim.match_config_received.connect(_on_match_config_received) MatchSim.snapshot_received.connect(_on_snapshot_received) MatchSim.score_update_received.connect(_on_score_update_received) _request_match_config_until_received() # The one-shot server broadcast in _start_server() is racy against however # long this client's own scene load took to reach this line — it may have # already fired into a MatchSim with no listener connected yet, or the # server may not have even started the match yet. Keep asking until # _on_match_config_received actually populates _slots. func _request_match_config_until_received() -> void: while _slots.is_empty() and is_inside_tree(): MatchSim.request_match_config() await get_tree().create_timer(0.5).timeout func _owns_goal_logic() -> bool: return multiplayer.is_server() func _owns_world_simulation() -> bool: return multiplayer.is_server() # ============================================================ # Server # ============================================================ func _start_server() -> void: var arena_path := ArenaRegistry.random_path() arena = (load(arena_path) as PackedScene).instantiate() add_child(arena) for goal in arena.get_goals(): goal.goal_scored.connect(_handle_goal_scored) spawn_ball() var peer_ids := PackedInt32Array() var teams := PackedInt32Array() var spawn_indices := PackedInt32Array() var team_counts := {0: 0, 1: 0} var sorted_peer_ids: Array = MatchNet.roster.keys() sorted_peer_ids.sort() for peer_id in sorted_peer_ids: var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id] var spawn_index: int = team_counts.get(info.team, 0) team_counts[info.team] = spawn_index + 1 var slot := SlotInfo.new() slot.peer_id = peer_id slot.team = info.team slot.spawn_index = spawn_index slot.controller = RLShipController.new() slot.ship = spawn_ship(info.team, spawn_index, slot.controller) _slots.append(slot) peer_ids.append(peer_id) teams.append(info.team) spawn_indices.append(spawn_index) MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices) MatchSim.input_received.connect(_on_input_received) func _on_input_received(peer_id: int, decoded: Dictionary) -> void: for slot in _slots: if slot.peer_id == peer_id: var actions: Array = decoded["actions"] # Newest-first; no redundancy handling yet (task 3.x) — just take # the newest one every time a packet arrives. if not actions.is_empty(): slot.controller.action = actions[0] return func _on_goal_registered(conceding_team: int) -> void: _record_goal(1 - conceding_team) MatchSim.send_score_update(score.duplicate()) func _on_goal_scored(_conceding_team: int) -> void: _reset_gen = (_reset_gen + 1) % 256 reset_ball() reset_ships() func _broadcast_snapshot() -> void: var server_tick := Engine.get_physics_frames() var bodies: Array[NetBodyState] = [] for slot in _slots: if is_instance_valid(slot.ship): bodies.append(_ship_to_net_body_state(slot.ship)) if is_instance_valid(ball): bodies.append(_ball_to_net_body_state(ball)) var segment := NetCodec.pack_snapshot_body_segment(server_tick, 0, _reset_gen, bodies) # Per-client header fields (last_input_seq/input_buffer_depth/echo) aren't # tracked yet — that's the jitter-buffer work in Phase 3 (tasks 3.1-3.2). # Building the shared body segment once and reusing it per peer (rather # than re-encoding per client) is the whole reason §2.4 splits the wire # format into a per-client header + a shared body segment in the first # place — see pack_snapshot_body_segment's own doc comment. # "No ship is ever despawned" (§6.4) means _slots outlives a disconnect — # a real one will be handled by Phase 5's reconnect/controller-swap # logic, but sending an RPC to a peer_id ENet no longer knows about # (found via the smoke test: a client that exits mid-match spammed # "Attempt to call RPC with unknown peer ID" every tick for the rest of # the host's run) throws instead of silently no-op'ing. Guard against it. var connected_peers := multiplayer.get_peers() for slot in _slots: if connected_peers.has(slot.peer_id): MatchSim.send_snapshot(slot.peer_id, NetCodec.pack_snapshot(0, 0, 0, segment)) func _ship_to_net_body_state(ship: Ship) -> NetBodyState: var s := NetBodyState.new() s.position = ship.global_position s.rotation = ship.global_transform.basis.get_rotation_quaternion() s.linear_velocity = ship.linear_velocity s.angular_velocity = ship.angular_velocity s.frozen = false s.turbo = ship.is_turbo_active() # Matches Ship._update_movement_vfx's own read of thrust.z: only positive # forward thrust drives the visible flame (see task 2.6). s.thrust_z = clampf(maxf(ship.controller.get_action().thrust.z if ship.controller else 0.0, 0.0), 0.0, 1.0) s.avel_range = NetCodec.SHIP_AVEL_RANGE return s func _ball_to_net_body_state(b: RigidBody3D) -> NetBodyState: var s := NetBodyState.new() s.position = b.global_position s.rotation = b.global_transform.basis.get_rotation_quaternion() s.linear_velocity = b.linear_velocity s.angular_velocity = b.angular_velocity s.avel_range = NetCodec.BALL_AVEL_RANGE return s # ============================================================ # Client # ============================================================ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: if not _slots.is_empty(): # Not idempotent by accident: the original broadcast from # _start_server() and a reply to this client's own # request_match_config() (see _request_match_config_until_received) # can both legitimately arrive — the retry loop exists specifically # because either one alone isn't reliably delivered, so seeing both # is expected, not a protocol error. Processing this twice would # double-spawn the whole match (found via the two-process smoke # test: two arenas, two ships, two HUDs, _slots.size() == 2 instead # of 1). Once is enough. return var known := false for a in ArenaRegistry.ARENAS: if a["path"] == arena_path: known = true break if not known: push_error("NetworkedMatch: server sent unknown arena path '%s', refusing match_config" % arena_path) return arena = (load(arena_path) as PackedScene).instantiate() add_child(arena) # _owns_goal_logic() is false here, so GameMode's usual goal-signal wiring # never happens — a client's local (interpolated, laggy) Goal sensor must # never be allowed to decide a score, only the server's real one can. spawn_ball() ball.freeze = true ball.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC var my_id := multiplayer.get_unique_id() for i in peer_ids.size(): var slot := SlotInfo.new() slot.peer_id = peer_ids[i] slot.team = teams[i] slot.spawn_index = spawn_indices[i] slot.ship = spawn_ship(slot.team, slot.spawn_index, null) slot.ship.freeze = true slot.ship.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC # §4.6: manual, per-render-frame $Visual updates must not fight # Godot's own built-in physics interpolation. if is_instance_valid(slot.ship.visual): slot.ship.visual.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF _slots.append(slot) if slot.peer_id == my_id: _my_slot = slot _spawn_hud() if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship): spawn_camera_rig(_my_slot.ship) func _spawn_hud() -> void: hud = HUD_SCENE.instantiate() add_child(hud) func _send_local_input() -> void: if _slots.is_empty(): return # match_config hasn't arrived yet var action := _local_input_sampler.get_action().copy() _input_seq += 1 var bytes := NetCodec.pack_input(_input_seq, 0, Time.get_ticks_msec(), [action]) MatchSim.send_input(bytes) func _on_snapshot_received(decoded: Dictionary) -> void: var server_tick: int = decoded["server_tick"] var reset_gen: int = decoded["reset_gen"] var bodies: Array = decoded["bodies"] for i in _slots.size(): if i < bodies.size(): _slots[i].interpolator.add_sample(server_tick, bodies[i], reset_gen) if bodies.size() > _slots.size(): _ball_interpolator.add_sample(server_tick, bodies[_slots.size()], reset_gen) func _current_interp_delay_ms() -> float: var rtt := NetworkManager.rtt_ms var one_way := (rtt / 2.0) if rtt >= 0.0 else INTERP_DELAY_MIN_MS return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS) # Collider time: present-time estimate, applied once per physics tick. func _physics_process(_delta: float) -> void: # Automatic multiplayer polling is disabled project-wide (task 1.3) — # every scene that sends/receives RPCs has to poll manually, and this # one is no exception. Missing this meant NOTHING sent after entering # this scene ever actually reached the wire in either direction # (queued but never flushed) — found via the two-process smoke test, # not by inspection. NetworkManager.poll() if _owns_world_simulation(): _respawn_escaped_bodies() if multiplayer.is_server(): _broadcast_snapshot() return _send_local_input() var server_time_est := NetworkManager.get_server_time_estimate_ms() var collider_tick := NetInterpolator.to_tick(server_time_est) for slot in _slots: if is_instance_valid(slot.ship) and slot.interpolator.has_samples(): _apply_collider_state(slot.ship, slot.interpolator.sample_at(collider_tick)) if is_instance_valid(ball) and _ball_interpolator.has_samples(): _apply_collider_state(ball, _ball_interpolator.sample_at(collider_tick)) # Visual time: present-minus-INTERP_DELAY, applied once per rendered frame — # separate from the collider update above so a high-refresh client samples # remote motion at true render rate instead of repeating the same 60Hz value # several times in a row (§2.4's "240 distinct positions/s, not 60"). # # Ball only gets the VFX half of this (trail speed), not a transform write: # unlike Ship, Ball has no separate $Visual child to offset from its # collider (task 0.2's Visual-node split was scoped to Ship only) — giving # it one is a bigger structural change than Phase 2's remit, so for now the # ball's rendered position is whatever _physics_process's present-time # collider update leaves it at, one tick behind true dual-time smoothness. func _process(_delta: float) -> void: # §7 task 1.3: poll for receive unconditionally at the top of both # _process and _physics_process, not just physics — a snapshot that # lands between ticks can be rendered immediately at high refresh rates # instead of waiting for the next physics step. NetworkManager.poll() if multiplayer.is_server() or _slots.is_empty(): return var server_time_est := NetworkManager.get_server_time_estimate_ms() var visual_tick := NetInterpolator.to_tick(server_time_est - _current_interp_delay_ms()) for slot in _slots: if is_instance_valid(slot.ship) and slot.interpolator.has_samples(): _apply_ship_visual_state(slot.ship, slot.interpolator.sample_at(visual_tick)) if is_instance_valid(ball) and _ball_interpolator.has_samples(): var state := _ball_interpolator.sample_at(visual_tick) if state != null: (ball as Ball).set_visual_speed(state.linear_velocity.length()) func _apply_collider_state(body: RigidBody3D, state: NetBodyState) -> void: if state == null: return body.global_transform = Transform3D(Basis(state.rotation), state.position) func _apply_ship_visual_state(ship: Ship, state: NetBodyState) -> void: if state == null: return if is_instance_valid(ship.visual): ship.visual.global_transform = Transform3D(Basis(state.rotation), state.position) ship.set_visual_action(state.thrust_z, state.turbo) func _on_score_update_received(new_score: Dictionary) -> void: score = new_score.duplicate() score_changed.emit(score.duplicate())