WorldEnvironment's Environment sub-resource was shared across every
instantiate() of a cached arena PackedScene, so glow/brightness/sky
tweaks in Arena._ready() compounded further each time a player
re-entered an arena instead of applying fresh.
Adds a [layer_names] section to project.godot (Ships/Ball/Arena/
GoalSensor) and sets collision_layer/collision_mask on the 4 physics
body roots (Ship, Ball, Goal Area3D, ArenaBoundary StaticBody3D),
which previously all sat on the default layer 1 / mask 1 so every
body broadphase-tested against every other.
Ships collide with ships/ball/arena but no longer test against the
goal sensor; the goal's mask (Ball only) is the physics-level fix for
what goal.gd's is_in_group("ball") check was doing defensively in
code (left in place, now a no-op guard). ArenaBoundary's runtime-
generated CollisionShape3D children inherit layer/mask from the
StaticBody3D root automatically.
Verified live via godot-mcp under Jolt Physics: ship-vs-ship and
ship-vs-ball collisions still transfer momentum, the ball still
bounces off arena walls, a ball entering a goal still fires
goal_scored (score updates / HUD reset), and a ship teleported into a
goal recess produces zero overlapping bodies on the goal Area3D (was
reachable before, physics-level fix confirmed, not just the code
guard). Headless smoke test (free_play.tscn) is clean.
Hull/Canopy/EngineGlowL/EngineGlowR are runtime-baked into one ArrayMesh
in _ready via SurfaceTool.append_from, dropping 4 MeshInstance3D children
to 1 (Nose/TailFin stay separate, they're retinted per-team). Skipped in
headless mode like the goal/arena_boundary visual builds, since physics
only cares about CollisionShape3D.
All 6 source surfaces (hull, canopy, engine_l x2, engine_r x2) carry
distinct materials, so this doesn't literally cut draw calls 6 to 3 as
TODO.md assumed — Godot still issues one draw call per surface regardless
of node count. The real win is scene-tree/transform overhead, not batching.
_apply_team_color() allocated a fresh StandardMaterial3D on every call, and
ran at least twice per ship (once from _ready at the default team, once from
the team setter when the game mode assigns the real team). Cache one
StandardMaterial3D per team in a static dict on Ship and reuse it across
every ship on that team.
Goal._build_visuals() built 10 MeshInstance3D nodes across 4 materials
(1 pocket + 1 net + 4 bezel-ring + 4 rim-ring boxes) per goal. Replaced
with a single MeshInstance3D wrapping one ArrayMesh with 4 SurfaceTool-
committed surfaces (pocket, net, bezel, rim), one material per surface
via surface_set_material — 10 nodes down to 1, same 4 materials.
Kept 4 surfaces rather than collapsing further: the rim is a tuned
team-tinted emitter, the net carries its own discard shader, and the
pocket/bezel differ in albedo/metallic/roughness. Merging those into a
shared material would be a visible regression, not a free win.
Added a local box-to-SurfaceTool helper (6 quads via arena_boundary.gd's
_add_quad/_add_tri winding-correction trick, copied in rather than
shared since that file's geometry is collision-adjacent). The pocket's
old cull_mode = CULL_FRONT trick is replaced by emitting its geometry
with inverted winding; the net's cull_front stays material-driven since
goal_net.gdshader's own render_mode depends on that winding convention.
Verified in the editor: both team-tinted goals render an intact pocket,
net, bezel and glowing rim with no backface/winding artifacts, and the
headless free_play smoke test still runs clean.
_process() called get_viewport().get_camera_3d() every frame to drive
the containment field's camera-side fade. Cache it the same way
ship_camera.gd caches the ball, revalidating with is_instance_valid
since exactly one camera rig is spawned per game-mode run today.
hud_gauge, hud_attitude_indicator, and hud_heading_tape now skip
queue_redraw() when the newly-lerped value hasn't moved past a small
epsilon, instead of redrawing every frame forever. Angle-wrapping
values (heading, attitude roll) use a new shared angle_delta_deg
helper on HudInstrument so the wrap boundary doesn't read as a false
jump.
HUD.tscn's Instruments node now sets process_mode = 1 (PAUSABLE),
overriding the inherited ALWAYS mode from the HUD root so instruments
stop processing during the post-match pause freeze, while sibling
ResultOverlay keeps running its win-screen tween.
Ship._emit_telemetry_data() ran get_euler()+trig every physics tick
for every ship regardless of whether a HUD was watching, wasting work
on AI ships and every headless training instance. Disable
_physics_process outright when headless, and skip emission the rest
of the time unless a signal actually has a listener.
Arenas inherit from a new arena_base.tscn instead of restating ~40 shared
lines each; only sky/ambient/glow/tint/decoration vary, exposed via new
Arena exports since nested Environment properties aren't overridable
through scene inheritance. Bot construction and score-keeping move onto
GameMode, shared by match and spectate modes while preserving their
differing GameSettings-override behavior and the HUD's score-row
duck-typing. HUD instruments share a HudInstrument base for the
smoothing-weight calc and angle-lerp helper. Also dedupes
MAIN_MENU_SCENE_PATH into ScenePaths and documents why DIFFICULTIES
tiers share one checkpoint.
Replace NebulaDust's flat unshaded glow material with a custom
ShaderMaterial: a fake per-pixel puff normal on the billboarded quad
feeds a light() override so motes catch the directional light and a
nebula-core color bias, alpha gets a depth-texture soft-particle fade
so motes no longer hard-clip through boundary/decoration geometry,
and a per-particle hash adds subtle sparkle.
Extend gen_nebula_sky.py with two more dust-lane layers at different
scales and subtle hue variation within the bright core. Add
gen_planet_surface.py (no prior generator existed) producing latitude
bands, storm vortices, and a lit/unlit terminator baked from the
planet mesh's actual UV convention against arena_02's directional
light, verified in-engine via screenshots.
Add tools/blender/gen_nebula.py (previously nebula_decoration.blend had
no generator script, unlike ship/ball) to rebuild the station with
inset/extrude panel-line greeble and three separate emissive window
strips, and give debris its own rockier, damage-scarred materials
instead of cloning the station's hull material.
Ship gets a greebled hull, tapered nose, swept canopy, twin engine nacelles,
and tail fin (built via the vendored Blender MCP, generator committed at
tools/blender/gen_ship.py) in place of the 5 flat primitives. The ball is
fully remodeled as a smooth round sphere with a crossed emissive accent
pattern (gen_ball.py), replacing the old flat-shaded gold_ball rather than
just tweaking its material.
Node names (Nose/TailFin) are preserved for Ship._apply_team_color(), and
the RigidBody3D/CollisionShape3D physics on both ship.tscn and ball.tscn are
untouched so RL-trained bots and flight feel stay valid. Each part's mesh is
extracted to a standalone .res (tools/blender/extract_meshes.gd) rather than
referenced via glb::ArrayMesh_xxx sub-paths, which don't reliably resolve
across scene files and were silently rendering both models invisible.
Adds sdfgi_enabled and a ReflectionProbe to all three arenas' Environment
setup so surfaces get real bounce lighting/reflections instead of flat
ambient. Converts arena_boundary.tscn's shared glass field material from
unshaded to a shaded, rim-lit material so it reads as glass (dim face-on,
highlighted at grazing angles) rather than a flat tinted overlay.
Every bright star in sky_nebula.png reused the exact same diffraction-
spike stamp, just relocated. Commit the generator (recovered from an
ephemeral scratchpad) to tools/textures/gen_nebula_sky.py, randomize
each hero star's rotation, arm count, spike length, and brightness,
and regenerate the texture.
Introduce arena_02 (Nebula) and arena_03 (Asteroid Field) alongside
arena_01, listed in a new ArenaRegistry (scripts/arena_registry.gd) as
the single source of truth for available arenas. Free Play lets the
player pick an arena from the main menu; Match/Spectate each pick one
at random per session; Training keeps its own fixed arena_01.
Nebula gets an RL-style visual treatment: a near-invisible glass
boundary material shared by all arenas, a baked equirect nebula sky
texture, a drifting-dust particle system, and a Blender-modeled
station/debris/planet decoration set. GameMode gains a
_get_arena_scene_path() hook so modes can instantiate their arena in
code instead of hardcoding it in the scene.
Replace the raw checkpoint dropdown with curated Easy/Medium/Hard presets
that drive GameSettings' bot model/reaction_ticks/action_noise overrides.
Move raw-checkpoint testing and Spectate mode into a dev-only section
hidden via OS.is_debug_build() so they disappear from release exports.
Generation 2's single "unmask" stage (flip vertical/pitch-roll locomotion
from grounded-only to full 3D in one step) failed 3 independent 240M-step
attempts, landing at a stable 32% / 28% / 31% win rate vs curric-s5-aggression
each time -- not noise, and not fixable by more training time (attempts 2-3
each continued the same checkpoint lineage for another full 240M steps with
zero improvement). Every attempt shows train/std collapsing from ~0.30 to
~0.13-0.15 within the first ~10% of steps and never recovering: the policy
locks the newly-opened axes back down before ever meaningfully exploring
them.
Replaces the boolean allow_vertical/allow_pitch_roll mask on ShipAIController
with float vertical_ramp/pitch_roll_ramp multipliers (0.0-1.0), scaling axis
effect in set_action() instead of gating it outright -- the action space
never changes shape, so checkpoints stay resumable across ramp values. The
single unmask stage in curriculum.py becomes 4: three ungated warmup stages
(25%/50%/75% authority, airborne_penalty ramping in step) that train,
checkpoint, and always advance with no eval gate, then the measured stage at
full authority -- same reference, opponent mode, and 240M budget as the 3
failed attempts, for a direct comparison. Adds a "gated" flag/branch to
main()'s loop for the ungated stages.
This is generation 3 of the curriculum; generation 2's state is archived to
curriculum_state_gen2.json (mirroring the earlier gen1 -> gen2 archival) and
curriculum_state.json resets fresh, since its stage 0 no longer means what it
used to. See TRAINING.md's "Generation 3" section for the full postmortem,
stage table, and the open question about whether scaling action effect in
Godot (which PPO's own entropy/exploration math never sees) actually
addresses the collapse.