feat(multiplayer): Phase 2 server-authoritative simulation, dumb client

Implements tasks 2.1-2.7: NetworkedMatch spawns a deterministic slot
layout from the lobby roster, the server drives each connected peer's
ship via RLShipController fed by decoded client input and broadcasts
60Hz snapshots, and the client renders everything (including its own
ship) from a per-body NetInterpolator with no local prediction yet.
Dual-time remote entities split collider updates (present-time, for
correct contacts) from $Visual updates (interp-delayed, for smoothness).
Camera/HUD wiring and remote engine-flame VFX fell out of the existing
Ship API for free once snapshots were flowing.

Three real bugs found and fixed while getting a two-process test
green: an RPC method named _input collided with Node's built-in
_input virtual and broke the whole MatchSim autoload from loading;
networked_match.gd never called NetworkManager.poll(), so nothing
sent via RPC in this scene reached the wire despite Phase 1's manual
polling being wired up everywhere else; and a match_config
request/response fallback (added to close a startup race) could
double-deliver once polling was fixed, requiring an idempotency guard.

Verified with tests/networked_match_smoke: a real headless two-process
host+client run shows the client rendering 31m of server-authoritative
movement from a held forward-thrust input, with thrust_z=1.0 confirmed
on the interpolated snapshot mid-drive and camera/HUD both wired.
Full Phase 1 regression suite re-run clean alongside it.

Task 2.8 (net_sim.gd latency/jitter/loss decorator) is not yet done;
Phase 2's own gate needs it before it's fully met.
This commit is contained in:
Josh Creek
2026-08-20 08:42:13 +01:00
parent 4533da34e0
commit 39a41c016c
9 changed files with 780 additions and 8 deletions
+95
View File
@@ -0,0 +1,95 @@
extends Node
# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs:
# match_config (server assigns arena + deterministic slot order from
# MatchNet.roster), input (client -> server, per-tick action), snapshot
# (server -> client, NetCodec-packed body state), and a small score_update
# for the HUD. Lives on an autoload per §1.3's derived decision ("All
# hot-path RPCs live on autoloads") even though these are scoped to
# whichever match happens to be running — a scene-node RPC target would
# need matching NodePaths across peers, which an autoload sidesteps
# entirely, and it's what lets NetworkedMatch itself stay a plain scene
# node with no networking-identity concerns of its own.
#
# Channel intent per §2.1: 0 reliable (match_config, score_update), 1
# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet
# verified against ENet's own reserved system channel offset (§2.1's own
# "verify empirically" hedge); if that turns out to matter these indices
# will need adjusting, not the RPC design itself.
const NetCodec = preload("res://scripts/net_codec.gd")
signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array)
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
signal score_update_received(score: Dictionary)
# Server only: the last match_config actually sent, so a client whose own
# scene load (and therefore its match_config_received listener) finishes
# AFTER the server already broadcast can still get it — a one-shot
# broadcast alone is racy against however long the client takes to reach
# the point where it's listening, and Godot signals never buffer for a
# late connection. request_match_config() closes that race by turning
# delivery into "ask until you get it" instead of "hope you were already
# listening." Also covers a late joiner mid-match (Phase 5 will still need
# to add live match *state*, not just this static config, for that case).
var _last_match_config: Dictionary = {}
func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
_last_match_config = {
"arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices,
}
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
func request_match_config() -> void:
_request_match_config.rpc_id(1)
func send_input(bytes: PackedByteArray) -> void:
_recv_input.rpc_id(1, bytes)
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
_snapshot.rpc_id(peer_id, bytes)
func send_score_update(score: Dictionary) -> void:
_score_update.rpc(score)
@rpc("authority", "call_remote", "reliable", 0)
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
@rpc("any_peer", "call_remote", "reliable", 0)
func _request_match_config() -> void:
if not multiplayer.is_server() or _last_match_config.is_empty():
return
var peer_id := multiplayer.get_remote_sender_id()
_match_config.rpc_id(
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
_last_match_config["teams"], _last_match_config["spawn_indices"]
)
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func _recv_input(bytes: PackedByteArray) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
var decoded := NetCodec.unpack_input(bytes)
input_received.emit(peer_id, decoded)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func _snapshot(bytes: PackedByteArray) -> void:
var decoded := NetCodec.unpack_snapshot(bytes)
snapshot_received.emit(decoded)
@rpc("authority", "call_remote", "reliable", 0)
func _score_update(score: Dictionary) -> void:
score_update_received.emit(score)
+114
View File
@@ -0,0 +1,114 @@
class_name NetInterpolator
extends RefCounted
# Buffers recent snapshot samples for ONE remote body and produces
# interpolated states at any requested (possibly fractional) server tick —
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
# entities"): once at the present-time estimate for the collider, once
# further back at present-minus-INTERP_DELAY for $Visual.
#
# server_tick (Engine.get_physics_frames() at send time) maps to an
# estimated server wall-clock time via TICK_HZ without any extra
# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec()
# count from the same process-start epoch, and physics has been running at
# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ)
# is a valid estimate of "what Time.get_ticks_msec() read on the server when
# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms()
# reading into the same tick-space with to_tick(ms) before calling sample_at().
const NetBodyState = preload("res://scripts/net_body_state.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
const MAX_SAMPLES := 16
# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one
# flying through a wall."
const MAX_EXTRAPOLATION_MS := 150.0
const TICK_MS := 1000.0 / SimConstants.TICK_HZ
var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first
var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset
static func to_tick(server_time_ms: float) -> float:
return server_time_ms / TICK_MS
# Returns true if this sample's reset_gen differs from the last one seen —
# the caller's cue to hard-snap instead of interpolating across a
# server-authoritative teleport (kickoff, goal reset) rather than sliding
# across the arena. Clears buffered history on a reset so a stale
# pre-reset sample can never bracket a post-reset one.
func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool:
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
if is_reset:
_samples.clear()
reset_gen = sample_reset_gen
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
return is_reset # stale/duplicate (unreliable_ordered should already prevent this, but don't trust it blindly)
_samples.append({"tick": server_tick, "state": state})
if _samples.size() > MAX_SAMPLES:
_samples.pop_front()
return is_reset
func has_samples() -> bool:
return not _samples.is_empty()
func latest() -> NetBodyState:
return _samples.back()["state"] if not _samples.is_empty() else null
# target_tick may be fractional (a point in time between two integer ticks).
func sample_at(target_tick: float) -> NetBodyState:
if _samples.is_empty():
return null
if _samples.size() == 1:
return _samples[0]["state"]
if target_tick <= _samples[0]["tick"]:
return _samples[0]["state"]
var newest: Dictionary = _samples.back()
if target_tick >= newest["tick"]:
return _extrapolate(newest, target_tick)
for i in range(_samples.size() - 1):
var a: Dictionary = _samples[i]
var b: Dictionary = _samples[i + 1]
if a["tick"] <= target_tick and target_tick <= b["tick"]:
var a_tick: float = a["tick"]
var b_tick: float = b["tick"]
var span := b_tick - a_tick
var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0
return _lerp_state(a["state"], b["state"], t)
return newest["state"]
func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState:
var out := NetBodyState.new()
out.position = a.position.lerp(b.position, t)
out.rotation = a.rotation.slerp(b.rotation, t)
out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t)
out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t)
out.frozen = b.frozen
out.turbo = b.turbo
out.thrust_z = b.thrust_z
out.stalled = b.stalled
out.avel_range = b.avel_range
return out
func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
var state: NetBodyState = newest["state"]
var ticks_ahead: float = target_tick - float(newest["tick"])
var ms_ahead := ticks_ahead * TICK_MS
var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS)
var out := NetBodyState.new()
out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0)
out.rotation = state.rotation
out.linear_velocity = state.linear_velocity
out.angular_velocity = state.angular_velocity
out.frozen = state.frozen
out.turbo = state.turbo
out.thrust_z = state.thrust_z
out.stalled = state.stalled
out.avel_range = state.avel_range
return out
+360
View File
@@ -0,0 +1,360 @@
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())