From 0e42182ccefd9c55f8e6ecc72e482110c1b1b698 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:01:21 +0100 Subject: [PATCH] fix(*): jitter kickoff resets to break deterministic same-model mirror matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameMode.reset_ball()/reset_ships() teleported to exact, identical spawn transforms every kickoff. Combined with deterministic bot inference (action_noise = 0 by default), two ships running the same policy from a mirror-symmetric state produced mirrored, non-diverging play instead of a real contest — most visible when both sides use the same exported model. Adds a small position/yaw jitter (well under anything a player would notice as "not a real kickoff") so kickoff-style resets stop being bit-for-bit identical. --- Game/scripts/game_mode.gd | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Game/scripts/game_mode.gd b/Game/scripts/game_mode.gd index b880f7e6..103617d9 100644 --- a/Game/scripts/game_mode.gd +++ b/Game/scripts/game_mode.gd @@ -85,15 +85,36 @@ func spawn_camera_rig(target: Ship) -> ShipCameraRig: return rig +# Tiny per-reset randomization, well below anything a player would notice as +# "not a real kickoff" — just enough that two ships running the identical +# deterministic AI policy (action_noise = 0) don't start every kickoff from a +# bit-for-bit mirror-symmetric state. A perfectly symmetric state feeds both +# controllers identical (canonicalized) observations, so they emit mirrored +# actions and can lock into a repetitive, non-scoring stalemate — much more +# visible bot-vs-bot (same model both sides) than bot-vs-human, since a human +# never satisfies "identical policy" in the first place. See training_mode.gd's +# _end_eval_episode, which randomizes eval episode states for the same reason. +const KICKOFF_POSITION_JITTER := 0.3 +const KICKOFF_YAW_JITTER := deg_to_rad(15.0) + + func reset_ball() -> void: if is_instance_valid(ball): - _reset_body(ball, arena.get_ball_spawn()) + _reset_body(ball, _jittered(arena.get_ball_spawn(), KICKOFF_POSITION_JITTER, 0.0)) func reset_ships() -> void: for ship in ships: if is_instance_valid(ship): - _reset_body(ship, _ship_spawn_transforms[ship]) + _reset_body(ship, _jittered(_ship_spawn_transforms[ship], KICKOFF_POSITION_JITTER, KICKOFF_YAW_JITTER)) + + +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 basis := to.basis + if yaw_jitter > 0.0: + basis = basis.rotated(Vector3.UP, randf_range(-yaw_jitter, yaw_jitter)) + return Transform3D(basis, to.origin + offset) func _reset_body(body: RigidBody3D, to: Transform3D) -> void: