class_name ShipAIController extends AIController3D # Training-side bridge between godot_rl_agents and a ship. This is the only # class that touches plugin types (AIController3D / the Sync node protocol) — # everything else stays behind the ShipController seam: actions received from # the trainer are written into an RLShipController, which the ship pulls like # any other controller. # # Action space/layout is owned by ShipActionCodec (get_action_space/ # set_action just delegate to it) — see that file for the per-axis # MultiDiscrete design and why. ShipAction axes are ship-local (body frame), # so they need no team mirroring — only observations do (see # ShipObservations.canon). # Reward shaping weights. Dense terms accrue per physics tick (60 sim-ticks # per sim-second); event terms fire once. Exported so tuning needs no code # edits. Goal rewards are added by TrainingMode, which owns goal events. @export var ball_touch_reward := 0.4 # Ball touches pay out at most once per this many physics ticks (1 sim- # second at 60). Run07 lesson: body_entered re-fires on every micro- # separation, so pinning the ball against a surface farmed ~2 touches/s — # outearning every other term while the goal rate fell. The cooldown keeps # touches a stepping-stone signal instead of the objective. Halved again # after run01-vs-run02 eval (training/eval_history.json) came back 87.5% # draws: even at 1 touch/s, a full episode's worth of touches could still # outweigh TrainingMode's goal_reward, so scoring and ending the episode # early was never worth it. See goal_reward's comment for the other half of # this fix. @export var ball_touch_cooldown_ticks := 60 # A touch pays out scaled by how goal-directed it was — full ball_touch_reward # when the post-touch ball velocity points straight at the attack goal, down # to this floor when it doesn't (0 = only goal-directed touches pay at all). # Without this, any contact paid the same regardless of direction, so batting # the ball anywhere counted the same as an actual shot on goal — reinforcing # possession, not scoring. The floor keeps a purely defensive touch (e.g. # clearing a shot away from your own goal) worth something as a stepping # stone, matching ball_touch_cooldown_ticks's existing "stepping-stone, not # the objective" framing. @export_range(0.0, 1.0) var ball_touch_direction_floor := 0.3 @export var velocity_to_ball_weight := 0.02 # Dense reward for approaching the ball *nose first* near the floor. Unlike # velocity_to_ball_weight, sideways/reverse closing velocity earns nothing: # the planar ship-forward vector must face the ball and planar velocity must # have a positive component along it. Default off so existing curricula and # frozen checkpoints keep their original objective; generation 5 handling # turns it on while reducing the orientation-agnostic term. @export var forward_velocity_to_ball_weight := 0.0 # Aerial mirror of forward_velocity_to_ball_weight: nose-first closing speed # on the ball, active above GROUND_HANDLING_HEIGHT instead of below it (the # two are mutually exclusive by altitude, never both active on the same # tick). Generation 5's intercepts stage added air_intercept_chance without # an airborne equivalent of the term that actually solved ground handling; # above 3m the only remaining approach incentive was the generic, orientation # -agnostic velocity_to_ball_weight (0.02-0.04), which three consecutive # 60M-step attempts (180M cumulative, resuming each time) showed produces # zero learnable gradient toward touching an aerial ball at all — # productive_air_touch_fraction stayed exactly 0.0 the whole time while every # other metric kept improving on the same budget. Uses the full 3D nose # vector rather than the planar-only one, since a real aerial requires # pitching away from level. @export var air_approach_weight := 0.0 # Event bonus, conjunctive with the same goal-direction alignment already # gating ball_touch_reward: extra payout for a touch that is BOTH genuinely # aerial (ball.y > AIR_TOUCH_HEIGHT, matching the productive_air_touch_ # fraction telemetry definition exactly) AND goal-directed. air_approach_ # weight alone did not move productive_air_touch_fraction (still 0.0 after # a further 180M steps, 360M cumulative) because nothing in the reward ever # made touching the ball while still airborne worth more than the # alternative every policy already had available for free: let gravity pull # an unredirected air-intercept ball back down (it falls well short of the # goal's ~0-1.5m height band over the required flight distance, so it does # not auto-score) and then collect the same goal_reward/ball_touch_reward # via the already-dominant, already-solved ground game once it lands. This # is deliberately NOT the standalone height-only bonus generation 4 ruled # out (see TRAINING.md's "why no air-touch reward" note, added to close the # RLGym wall-bounce exploit): it only pays scaled by the same alignment # dot-product as the base term, so batting the ball in a non-productive # direction earns nothing extra, same anti-farming shape as ball_touch_ # reward itself. Also safe from that specific exploit on distributional # grounds: air-intercept spawns are central (x in [-8,8], z in [-10,10], # arena half-extents 18/27) and air-drill spawns keep AIR_DRILL_BALL_WALL_ # CLEARANCE from every wall, so neither state can be solved by bouncing off # one. @export var air_touch_bonus_weight := 0.0 @export var ball_velocity_to_goal_weight := 0.004 # Per-tick penalty scaled by distance to the ball (full value at the arena's # far diagonal, 0 on top of the ball). Run04 lesson: with idling worth a flat # 0, camping in a corner strictly dominated risking the wall/tilt penalties # to chase the ball — this makes "do nothing far from the ball" the worst # option instead of the safest. A penalty, not a proximity bonus, so orbiting # the ball farms nothing. @export var ball_distance_penalty := 0.002 # Per-tick penalty while pressed against a side wall, end wall, or the # ceiling — NOT the floor (run03 lesson: taxing floor contact punishes the # ship's natural low flight and drowns every other signal). At 60 ticks per # sim-second this is -0.15/s. Halved for run05: the ball lives near walls, # and the old -0.3/s made the productive region of the pitch aversive # relative to the (then far weaker) ball-seeking shaping. @export var wall_contact_penalty := 0.0025 # Per-tick penalty for not being upright, scaled by tilt: 0 when flat, full # value when inverted. A penalty rather than an upright bonus so a flat, idle # ship farms nothing. Lowered 4x for curriculum generation 4 (was 0.002, # -0.12/s): a genuine aerial approach to a high ball requires pitching, and # the old value quietly opposed the exact behaviour generation 4 is trying # to teach. Not removed outright — an always-inverted bot still looks bad in # a shipped game. @export var tilt_penalty := 0.0005 # Additional tilt cost that fades to zero over the first few metres above the # floor. This can teach readable, upright ground handling without opposing # pitch/roll during a real aerial. Generation 5 uses this instead of raising # the global tilt_penalty back to its pre-flight value. @export var ground_tilt_penalty := 0.0 # Per-tick penalty on the planar-velocity component not pointed along the # nose (sideways or reverse), independent of the ball — the mirror image of # forward_velocity_to_ball_weight's ball-conditioned bonus. Same # GROUND_HANDLING_HEIGHT altitude fade as ground_tilt_penalty. @export var non_forward_penalty := 0.0 # Per-tick bonus for genuinely resting on the floor (ShipObservations. # is_floor_contact, real contact — not just being below # GROUND_HANDLING_HEIGHT) while upright. The positive counterpart to # ground_tilt_penalty/non_forward_penalty: without it, staying above # GROUND_HANDLING_HEIGHT is reward-neutral relative to grounding, so a # policy that's still bad at ground handling could "solve" those penalties # by just avoiding the floor rather than by getting better at handling on # it — worsening Stage 3's already-airborne-heavy baseline instead of # fixing it. An initial 0.015 overshot this: it's a *guaranteed* per-tick # reward, so it needs to stay below ball_distance_penalty's worst case # (idling at the arena's far corner), not just "comparable" to it — at # 0.015 (above ball_distance_penalty's 0.01 ceiling) a Stage-4 run # converged on sitting pinned upright and farming this instead of chasing # the ball, cratering goal_rate. Keep this term's episode-long ceiling # (value * ~1800 ticks) below ball_distance_penalty's worst-case episode # cost, not just below ball_touch_reward/goal_reward. # # SUPERSEDED (2026-08-12), kept at 0 for older curricula that set it: the # magnitude was never the real problem. Retuning it 0.015 -> 0.004 only # moved along a tradeoff — at 0.015 upright_fraction climbed while # goal_rate sagged, at 0.004 goal_rate climbed while upright_fraction went # flat — because an *additive* uprightness reward is an alternative to # playing well, so the policy just picks whichever is cheaper. Uprightness # is now a multiplier inside the forward-approach term below instead, which # makes it conjunctive with (not competing against) moving forward at the # ball. Prefer that pattern for any future posture shaping; only reach for # a standalone additive posture bonus if there is genuinely nothing to # condition it on. @export var grounded_upright_reward := 0.0 # Per-tick bonus for own speed: 0 stationary, full value (+0.24/s) at # max_speed. Run07 lesson: after the kickoff flurry both ships parked next to # a cornered ball — with every other dense term near zero there, standing # still was a rest state. Sized well below velocity_to_ball_weight so flying # fast toward the ball still beats flying fast anywhere else. @export var speed_reward_weight := 0.004 # Flat per-tick cost (-0.06/s, -1.8 over a full 30s episode) applied # regardless of position or behaviour. Every other dense term can be farmed # indefinitely by an episode that never ends in a goal; this one can't — it # only stops accruing once the episode does, via a goal or the timeout. That # makes running the clock out strictly worse than scoring as soon as a # chance appears, instead of a free way to keep collecting dense reward. @export var time_penalty := 0.001 # Per-tick penalty scaled by height above the floor (0 on the floor, full # value at the arena's ceiling) — distinct from the locomotion mask, which # only discards *thrust*-driven vertical/pitch-roll input; a masked ship can # still be launched airborne by collisions (ball impacts, ship-vs-ship # knockback, the wall/ceiling surface-pull field), and nothing previously # penalized time spent up there. Default 0 (off) so ordinary runs are # unaffected; the floor-lock curriculum stage turns it on. @export var airborne_penalty := 0.0 # Height above which a touch counts as aerial — for air_touch_fraction / # productive_air_touch_* telemetry (see get_info) and, conjunctively, for # air_touch_bonus_weight. Not a standalone reward term; see set_action/ # get_info on why generation 4 deliberately does not add one. # # Lowered 5.0 -> 3.0 on 2026-08-24, and this reverses Round 9's explicit # "AIR_TOUCH_HEIGHT stays 5.0" decision, so the reasoning matters. 5.0 was # never derived from anything: every aerial mechanism in generation 5 — the # drill geometry, the touch bonus, all three air-touch metrics — was built on # top of it, but nobody measured where the ball actually goes. Instrumenting # it (ball_mean_altitude / ball_peak_altitude / ball_above_air_touch_fraction, # added alongside this change) over normal match play found the ball averages # ~1.6m, the average episode's PEAK ball height is only ~2.4m, and the ball is # above 5m for ~5% of ticks. So 5.0 sat at roughly twice the typical episode # peak, and the drill had to spawn the ball at 8-14m — far above anything the # game produces — purely to give it hang time above that bar. # # 3.0 is not a softened bar chosen to let a run pass; it is this project's # existing definition of airborne, matching AIRBORNE_ALTITUDE_THRESHOLD and # GROUND_HANDLING_HEIGHT below, and it sits just above the measured mean # episode peak so it still denotes a genuine aerial rather than ordinary # bouncing. Simulating the drill against real physics (ball gravity_scale 0.8 # + linear_damp 0.1, ship thrust 120/mass 5, drag 0.98/tick) at the two # thresholds shows it strictly dominates: with the band retuned to 6-10m an # ideal interceptor reaches the ball 67.8% of the time (was 53.2%) and touches # it above the bar 57.3% of the time (was 41.2%), needing 5.2m of climb rather # than 8.2m. # # Round 9's comparability objection is real but has nothing left to protect: # productive_air_touch_fraction read exactly 0.0 for all nine attempts, so # there is no history this preserves. Pre-2026-08-24 air-touch numbers are # measured against 5.0 and are NOT comparable with anything after it. const AIR_TOUCH_HEIGHT := 3.0 # Generation-5 ground-handling telemetry/reward thresholds. Fixed constants # keep the logged metrics comparable across stages; changing one starts a new # metric definition and therefore requires a fresh baseline. const GROUND_HANDLING_HEIGHT := 3.0 const UPRIGHT_DOT_THRESHOLD := 0.7 const FORWARD_MOTION_DOT_THRESHOLD := 0.7 const MIN_HANDLING_SPEED := 1.0 const PRODUCTIVE_AIR_TOUCH_ALIGNMENT := 0.5 # Contact normals with y above this are floor contact (exempt from the wall # penalty); below it they read as wall (sideways) or ceiling (downward). # Mirrors ShipObservations.FLOOR_NORMAL_MIN_Y (see that file's comment). const FLOOR_NORMAL_MIN_Y := 0.7 # Longest possible ship-to-ball separation: the enclosure's interior diagonal. # Normalizes ball_distance_penalty so its export is the worst-case per-tick cost. const MAX_BALL_DISTANCE := sqrt( (2.0 * ArenaBoundary.INNER_HALF_X) ** 2 + (2.0 * ArenaBoundary.INNER_HALF_Z) ** 2 + ArenaBoundary.INNER_HEIGHT ** 2 ) var ship: Ship var rl_controller: RLShipController var ball: RigidBody3D var teammates: Array[Ship] = [] var opponents: Array[Ship] = [] var attack_goal_position: Vector3 # Set directly by TrainingMode (_on_goal_scored / the timeout branch in # _physics_process) at the same time as `done = true`. Deliberately NOT # cleared in reset(): TrainingMode's _reset_episode() (which calls reset()) # runs synchronously, immediately after done is set, before the Sync node # ever reads get_info()/get_done() for that terminal tick — clearing it here # would wipe the value that read needs. Both call sites always overwrite # (true on goal, false on timeout) rather than toggle, so no reset is needed. var goal_scored_this_episode := false # Set only in the timeout branch (TrainingMode._physics_process), never on a # goal — a goal is a genuine terminal (V(s)=0 is correct there); a timeout # is an artificial episode boundary the value function should be bootstrapped # through instead (see get_info). Same "always overwritten by both call # sites, never cleared in reset()" pattern as goal_scored_this_episode above, # for the same reason. var truncated_this_episode := false var terminal_obs: Array = [] var _ticks_since_ball_touch := 1 << 30 # large so the first touch always pays # Flight telemetry (see get_info) — leading indicators for whether the # policy is actually using its vertical/pitch-roll authority, visible from # the first rollout instead of only in a win-rate number measured a full # training run later. Accumulated per-tick, reset() zeroes them each episode; # get_info() reports the running fraction/mean so the *final* tick of an # episode (the one VecMonitor's info_keywords captures) holds the whole # episode's aggregate. const AIRBORNE_ALTITUDE_THRESHOLD := 3.0 var _telemetry_ticks := 0 var _airborne_ticks := 0 var _altitude_sum := 0.0 var _thrust_y_sum := 0.0 var _touches := 0 var _air_touches := 0 var _productive_air_touches := 0 var _ball_above_air_touch_ticks := 0 var _ball_peak_altitude := 0.0 var _ball_altitude_sum := 0.0 var _ground_ticks := 0 var _upright_ground_ticks := 0 var _moving_ground_ticks := 0 var _forward_moving_ground_ticks := 0 # Diagnostic (non-gating) counterpart to _ground_ticks/_upright_ground_ticks. # Those use altitude (< GROUND_HANDLING_HEIGHT) as a proxy for "on the # ground", but with airborne_fraction ~0.45 and mean_altitude ~4.4m a large # share of sub-3m ticks are really ballistic transit — climbing, descending, # or tumbling after contact — where attitude is neither controllable nor # meaningful, so upright_fraction systematically understates how upright the # ship is when it is actually driving. These count only ticks with genuine # floor contact, which is the thing "keep the belly on the floor" actually # means. Kept separate from (not a replacement for) upright_fraction so the # gated metric's definition stays comparable across every past stage. var _floor_contact_ticks := 0 var _upright_floor_contact_ticks := 0 # Wire up references after the ship is spawned. `attack_goal` is the goal # this ship scores into (goal.team == the opposing team's team). func setup( p_ship: Ship, p_rl_controller: RLShipController, p_ball: RigidBody3D, p_teammates: Array[Ship], p_opponents: Array[Ship], p_attack_goal_position: Vector3 ) -> void: ship = p_ship rl_controller = p_rl_controller ball = p_ball teammates = p_teammates opponents = p_opponents attack_goal_position = p_attack_goal_position init(ship) # ship.contact_monitor is on unconditionally (see ship.gd) since # ShipObservations now reads it for every ship, not just training agents. ship.body_entered.connect(_on_ship_body_entered) func get_obs() -> Dictionary: return {"obs": ShipObservations.build(ship, teammates, opponents, ball, attack_goal_position)} func get_reward() -> float: return reward # Symmetric across both self-play agents. "goal_scored": whether this # episode ended in a goal at all (not which team) — a clean "goal rate" # signal distinct from rollout/ep_rew_mean, which mixes this with dense # shaping (ball chasing/touching); see train.py's GoalRateCallback. # "truncated"/"terminal_obs": only present on a timeout tick — remapped by # cosmic_env.py into SB3's expected "TimeLimit.truncated"/ # "terminal_observation" keys so PPO bootstraps V(s) through episode # timeouts instead of treating every 30s draw as a true terminal state (a # real, previously-unnoticed bug independent of the action-space work — see # TRAINING.md). Flight telemetry fields are leading indicators for # generation 4's core hypothesis (see train.py's FlightTelemetryCallback). func get_info() -> Dictionary: # The four telemetry keys must ALWAYS be present (not just when their # denominator is nonzero) — VecMonitor's info_keywords does a bare # info[key] lookup on whatever info dict is attached to a completed # episode's terminal step (see train.py's VecMonitor(..., # info_keywords=(...))) and raises KeyError, crashing the whole training # run, if a key is ever missing. 0.0 is a reasonable default for "no # touches/no ticks yet" (in practice _telemetry_ticks is >0 by the time # any episode ends; _touches often legitimately is 0). var info := {"goal_scored": goal_scored_this_episode} if truncated_this_episode: info["truncated"] = true info["terminal_obs"] = terminal_obs info["airborne_fraction"] = float(_airborne_ticks) / _telemetry_ticks if _telemetry_ticks > 0 else 0.0 info["mean_altitude"] = _altitude_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0 info["vertical_thrust_mean"] = _thrust_y_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0 info["air_touch_fraction"] = float(_air_touches) / _touches if _touches > 0 else 0.0 info["productive_air_touch_fraction"] = float(_productive_air_touches) / _touches if _touches > 0 else 0.0 # The gate metric for Stage 5/6. The _fraction pair above divide by TOTAL # touches, which makes them unusable as a bar: a policy with a strong ground # game accumulates many ground touches, and those dilute the ratio for # identical aerial behaviour. Stage 4 exists to improve exactly that ground # game — it took forward_motion_fraction from ~0.24 to ~0.48 — so Stage 4's # success actively pushed Stage 5's gate toward zero, and the two stages were # working against each other. It is also why the only non-zero values ever # logged across nine attempts came from degenerate episodes whose single # touch happened to be a productive aerial (per-episode value 1.0, so exactly # 0.01 once meaned over SB3's 100-episode ep_info_buffer — the 0.0100 that # was every run's maximum). # # This one asks the question the floor actually means: did this episode # contain a productive aerial at all? Meaned over the buffer it reads # directly as "what share of episodes contained one", is bounded 0-1, and # cannot be diluted by ground play. Deliberately insensitive to magnitude: # three aerials in an episode score the same as one, which is the right # trade for a gate (see TRAINING.md for the diagnostic alternative). info["productive_air_touch_episode_fraction"] = 1.0 if _productive_air_touches > 0 else 0.0 info["ball_above_air_touch_fraction"] = \ float(_ball_above_air_touch_ticks) / _telemetry_ticks if _telemetry_ticks > 0 else 0.0 info["ball_mean_altitude"] = _ball_altitude_sum / _telemetry_ticks if _telemetry_ticks > 0 else 0.0 # Highest the ball reached this episode. Meaned over the buffer this says # where the aerial band actually IS, without picking a threshold first — # the number _place_air_intercept's spawn band should be derived from # rather than guessed at. info["ball_peak_altitude"] = _ball_peak_altitude info["upright_fraction"] = float(_upright_ground_ticks) / _ground_ticks if _ground_ticks > 0 else 0.0 info["forward_motion_fraction"] = float(_forward_moving_ground_ticks) / _moving_ground_ticks if _moving_ground_ticks > 0 else 0.0 # Diagnostic only — deliberately NOT in any stage's telemetry_floors (see # generation5.py). Unlike the counters above, _floor_contact_ticks can # legitimately be 0 for a whole episode (a policy that never touches down), # so the 0.0 default here is load-bearing, not just defensive. info["grounded_upright_fraction"] = \ float(_upright_floor_contact_ticks) / _floor_contact_ticks if _floor_contact_ticks > 0 else 0.0 return info func get_action_space() -> Dictionary: return ShipActionCodec.action_space_dict() func set_action(action) -> void: rl_controller.action = ShipActionCodec.apply_team_frame( ShipActionCodec.from_indices(action), ship.team ) func reset(): super() _ticks_since_ball_touch = 1 << 30 _telemetry_ticks = 0 _airborne_ticks = 0 _altitude_sum = 0.0 _thrust_y_sum = 0.0 _touches = 0 _air_touches = 0 _productive_air_touches = 0 _ball_above_air_touch_ticks = 0 _ball_altitude_sum = 0.0 _ball_peak_altitude = 0.0 _ground_ticks = 0 _upright_ground_ticks = 0 _moving_ground_ticks = 0 _forward_moving_ground_ticks = 0 _floor_contact_ticks = 0 _upright_floor_contact_ticks = 0 func _physics_process(delta): super(delta) if not is_instance_valid(ship) or not is_instance_valid(ball): return _ticks_since_ball_touch += 1 # Flat time cost — see time_penalty. reward -= time_penalty # Dense shaping: own velocity toward the ball var to_ball := ball.global_position - ship.global_position if to_ball.length_squared() > 0.0001: var closing_speed := ship.linear_velocity.dot(to_ball.normalized()) reward += velocity_to_ball_weight * closing_speed / ship.max_speed # Ground-handling shaping: upright, forward planar motion while the nose # faces the ball. It fades out with altitude so an aerial remains free to # approach a ball using whatever body attitude is effective. # # Uprightness is a *multiplier* here rather than a separate additive term, # and that is the whole point. Stage 4's earlier rounds paid uprightness # additively (grounded_upright_reward): because additive terms let a # policy collect whichever one is cheapest, it could either play well # (tilted, scoring) or sit parked upright (still, not scoring) — and it # picked one or the other depending purely on that term's magnitude, so # upright_fraction and goal_rate moved in opposite directions at every # value tried. As a multiplier, uprightness pays only while the ship is # also moving forward and nose-on to the ball, so no subset of the three # behaviours can be farmed in isolation: parked pays zero (forward_speed # is zero), on-its-side pays zero (uprightness is zero), and only doing # all three at once pays full. if forward_velocity_to_ball_weight > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT: var planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z) var planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z) var planar_to_ball := Vector3(to_ball.x, 0.0, to_ball.z) if planar_forward.length_squared() > 0.0001 and planar_to_ball.length_squared() > 0.0001: planar_forward = planar_forward.normalized() var facing_ball: float = maxf(planar_forward.dot(planar_to_ball.normalized()), 0.0) var forward_speed: float = maxf(planar_velocity.dot(planar_forward), 0.0) / ship.max_speed var approach_uprightness: float = maxf(ship.global_transform.basis.y.dot(Vector3.UP), 0.0) var handling_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0) reward += forward_velocity_to_ball_weight * forward_speed * facing_ball \ * approach_uprightness * handling_ground_factor # Aerial shaping: nose-first 3D closing speed on the ball (see # air_approach_weight). Mirrors the ground block above but with the full # nose vector instead of the planar one, and no uprightness multiplier — # a genuine aerial approach requires pitching away from level, so paying # only while upright would oppose the exact behaviour this rewards. if air_approach_weight > 0.0 and ship.global_position.y >= GROUND_HANDLING_HEIGHT \ and to_ball.length_squared() > 0.0001: var nose_forward := -ship.global_transform.basis.z if nose_forward.length_squared() > 0.0001: nose_forward = nose_forward.normalized() var to_ball_dir := to_ball.normalized() var air_facing_ball: float = maxf(nose_forward.dot(to_ball_dir), 0.0) var air_closing_speed: float = maxf(ship.linear_velocity.dot(to_ball_dir), 0.0) / ship.max_speed reward += air_approach_weight * air_closing_speed * air_facing_ball # Dense penalty: distance to the ball, so idling far away bleeds reward # instead of scoring a safe zero (see ball_distance_penalty). if ball_distance_penalty > 0.0: reward -= ball_distance_penalty * to_ball.length() / MAX_BALL_DISTANCE # Dense bonus: own speed, so hovering in place is never a rest state # (see speed_reward_weight). if speed_reward_weight > 0.0: reward += speed_reward_weight * ship.linear_velocity.length() / ship.max_speed # Dense shaping: ball velocity toward the goal we attack var ball_to_goal := attack_goal_position - ball.global_position if ball_to_goal.length_squared() > 0.0001: var ball_progress := ball.linear_velocity.dot(ball_to_goal.normalized()) reward += ball_velocity_to_goal_weight * ball_progress / ShipObservations.BALL_SPEED_SCALE # Dense penalty: every tick spent pressed against a wall or the ceiling # (contact monitoring is already on for the ball-touch reward). Ships # bumping each other, the ball, or the floor is fine. The boundary is one # body, so the contact normal tells us which surface: floor contact # pushes the ship up (+Y), walls push sideways, the ceiling down. if wall_contact_penalty > 0.0 and _wall_or_ceiling_contact(): reward -= wall_contact_penalty # Dense penalty: tilt away from upright (0 flat, max when inverted) — # discourages ending up on a side or roof without rewarding idleness. if tilt_penalty > 0.0: var uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP) reward -= tilt_penalty * (1.0 - uprightness) * 0.5 # Low-altitude-only posture pressure (see ground_tilt_penalty). if ground_tilt_penalty > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT: var ground_uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP) var tilt_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0) reward -= ground_tilt_penalty * (1.0 - ground_uprightness) * 0.5 * tilt_ground_factor # Dense penalty: any planar velocity component not pointed along the nose # (sideways or reverse), independent of the ball — the mirror image of # forward_velocity_to_ball_weight's ball-conditioned bonus. Fades out with # altitude via the same GROUND_HANDLING_HEIGHT ramp as ground_tilt_penalty. # non_forward_speed is the true lateral magnitude (Pythagorean, not the # cruder planar_speed - forward_component, which under-charges diagonal # motion — e.g. at 45 degrees off the nose that gave ~29% of full-speed # penalty instead of the correct ~71%) for any forward-facing component; # a backward-facing component (dot product below zero) is fully # penalized regardless of angle, same as pure sideways motion. if non_forward_penalty > 0.0 and ship.global_position.y < GROUND_HANDLING_HEIGHT: var non_forward_planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z) var non_forward_planar_speed := non_forward_planar_velocity.length() var non_forward_planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z) if non_forward_planar_speed > 0.0001 and non_forward_planar_forward.length_squared() > 0.0001: var forward_component: float = non_forward_planar_velocity.dot(non_forward_planar_forward.normalized()) var non_forward_speed: float if forward_component >= 0.0: non_forward_speed = sqrt(maxf( non_forward_planar_speed * non_forward_planar_speed - forward_component * forward_component, 0.0 )) else: non_forward_speed = non_forward_planar_speed var non_forward_ground_factor: float = 1.0 - clampf(ship.global_position.y / GROUND_HANDLING_HEIGHT, 0.0, 1.0) reward -= non_forward_penalty * (non_forward_speed / ship.max_speed) * non_forward_ground_factor # Dense bonus: genuinely resting on the floor while upright (see # grounded_upright_reward) — the positive counterpart to # ground_tilt_penalty/non_forward_penalty, so grounding is worth # pursuing, not just less punished than staying airborne. if grounded_upright_reward > 0.0 and ShipObservations.is_floor_contact(ship): var grounded_uprightness: float = ship.global_transform.basis.y.dot(Vector3.UP) reward += grounded_upright_reward * maxf(grounded_uprightness, 0.0) # Dense penalty: height above the floor (see airborne_penalty). The # floor sits at world y = 0 (see training_mode.gd's FIELD_MIN_Y/ # _escaped bounds); normalized so the worst case is pinned at the # ceiling. if airborne_penalty > 0.0: var height := maxf(ship.global_position.y, 0.0) reward -= airborne_penalty * height / ArenaBoundary.INNER_HEIGHT # Flight telemetry accumulation (see get_info) — not reward, just # observation of what the policy is actually doing this tick. _telemetry_ticks += 1 _altitude_sum += ship.global_position.y if ship.global_position.y > AIRBORNE_ALTITUDE_THRESHOLD: _airborne_ticks += 1 # Diagnostic, deliberately ungated: how much of the time the BALL is even # in aerial territory. Every aerial mechanism in generation 5 — the drill # geometry, air_touch_bonus_weight, and the productive-air-touch metrics — # is defined against AIR_TOUCH_HEIGHT, but nothing ever measured how often # match play actually puts the ball up there. If this reads near zero # outside the synthetic intercept drill, then the skill being trained has # almost no occasion to be used and the stage is optimising a situation the # game does not produce — which is a question about the curriculum, not # about any policy's competence at it. if is_instance_valid(ball): _ball_altitude_sum += ball.global_position.y _ball_peak_altitude = maxf(_ball_peak_altitude, ball.global_position.y) if ball.global_position.y > AIR_TOUCH_HEIGHT: _ball_above_air_touch_ticks += 1 # Diagnostic: uprightness measured only while genuinely touching the floor # (see _floor_contact_ticks). Same UPRIGHT_DOT_THRESHOLD as the altitude- # based metric so the two are directly comparable. if ShipObservations.is_floor_contact(ship): _floor_contact_ticks += 1 if ship.global_transform.basis.y.dot(Vector3.UP) >= UPRIGHT_DOT_THRESHOLD: _upright_floor_contact_ticks += 1 _thrust_y_sum += rl_controller.action.thrust.y if ship.global_position.y < GROUND_HANDLING_HEIGHT: _ground_ticks += 1 if ship.global_transform.basis.y.dot(Vector3.UP) >= UPRIGHT_DOT_THRESHOLD: _upright_ground_ticks += 1 var planar_velocity := Vector3(ship.linear_velocity.x, 0.0, ship.linear_velocity.z) if planar_velocity.length() >= MIN_HANDLING_SPEED: _moving_ground_ticks += 1 var planar_forward := Vector3(-ship.global_transform.basis.z.x, 0.0, -ship.global_transform.basis.z.z) if planar_forward.length_squared() > 0.0001 \ and planar_velocity.normalized().dot(planar_forward.normalized()) >= FORWARD_MOTION_DOT_THRESHOLD: _forward_moving_ground_ticks += 1 func _wall_or_ceiling_contact() -> bool: return ShipObservations.contact_normal(ship) != Vector3.ZERO func _on_ship_body_entered(body: Node) -> void: if not body.is_in_group("ball") or _ticks_since_ball_touch < ball_touch_cooldown_ticks: return # Contact-signal ordering means ball.linear_velocity here already reflects # the collision impulse from this touch, not the pre-touch velocity. var alignment := 0.0 var to_goal := attack_goal_position - ball.global_position if to_goal.length_squared() > 0.0001 and ball.linear_velocity.length_squared() > 0.0001: alignment = clampf(ball.linear_velocity.normalized().dot(to_goal.normalized()), 0.0, 1.0) var touch_payout := ball_touch_reward * lerpf(ball_touch_direction_floor, 1.0, alignment) if air_touch_bonus_weight > 0.0 and ball.global_position.y > AIR_TOUCH_HEIGHT: touch_payout += air_touch_bonus_weight * alignment reward += touch_payout _ticks_since_ball_touch = 0 # air_touch_fraction/productive_air_touch_fraction (see get_info) share # their AIR_TOUCH_HEIGHT/alignment definitions 1:1 with air_touch_bonus_ # weight above by design — the reward now targets exactly the behaviour # the telemetry measures. _touches += 1 if ball.global_position.y > AIR_TOUCH_HEIGHT: _air_touches += 1 if alignment >= PRODUCTIVE_AIR_TOUCH_ALIGNMENT: _productive_air_touches += 1