Skip to content

Bullet Heaven — Combat Depth, New Content & Balance

Bullet Heaven — Combat Depth, New Content & Balance

Section titled “Bullet Heaven — Combat Depth, New Content & Balance”

Date: 2026-06-25 Status: Design (approved in brainstorming; awaiting spec review) Author: Chris Allen + Claude

A multi-feature pass adding combat depth, content, and a balance sweep to the Godot game. Everything is a shared sim/render system used by both Story and Survival unless stated otherwise. Driven by Chris’s request:

new boss with 5 random attacks (incl. a charging beam + arcing rocket-shrapnel); separate the tutorial from the main game and drop the dialogue pauses there; per-enemy attribute variation; tanks never arrive alone; a balance pass on player dmg / enemy dmg / upgrade cost; new enemies that each mirror a player weapon archetype with varied speed; a new relentless enemy dash plus a player thruster/dodge to counter it; visually distinct enemy archetypes; and damage numbers that actually show.

  • More legible, more varied combat that reads at a glance.
  • A reason to invest in mobility (player thruster vs a no-pause enemy dash).
  • A headline boss with five distinct, randomly-selected attacks.
  • Sane, costed balance numbers for damage, armor, XP, gold and meta cost.
  • Multiplayer / Phase 3 (the determinism discipline below keeps the door open).
  • Reworking the level-up picker into a non-blocking UI (the picker pause stays — see §2).
  • Re-exporting bible.json from seed.js (it has drifted ahead; hand-edit data/bible.json per the project rule).
  • A full telemetry-driven tune (telemetry is effectively empty: 2 story runs, and its avg_dps metric is broken ~2,000,000). Numbers here are reasoned + will be finalized by playtest; the broken DPS metric is noted as optional cleanup.

The start menu gains a third card. Mode is a render/flow concern in main.gd + ui/start_menu.gd; the sim only knows story == null (survival) vs non-null.

Card enable_story flags Dialogue freeze Layout/spawns Level-up pause
TUTORIAL suppress_tutorial=false, randomize=false yes (read the lines) fixed yes
STORY (main game) suppress_tutorial=true, randomize=true no randomized yes
SURVIVAL (story stays null) n/a procedural yes
  • TUTORIAL is today’s taught first run. Completing it (reach region 2 or finish) sets meta.tutorial_done = true exactly as now.
  • STORY is the main campaign: always suppress tutorials + randomize, and dialogue never freezes the sim — lines float by non-blocking via the existing DialogueBox (time-revealed, auto-advancing; no input gate).
  • Level-up picker pause is KEPT in all modes (genre-core; Chris confirmed). Only the story-dialogue freeze is removed from the main game.

Implementation:

  • StartMenu: add a TUTORIAL card; mode_chosen carries an enum/string (tutorial / story / survival) instead of the current bool story.
  • main.gd: a _mode field. The dialogue-freeze branch (if sim.story != null and dialogue_box.is_showing()) is gated to _mode == TUTORIAL only. Story start uses the table above instead of done = meta.tutorial_done.
  • Determinism-neutral (render/flow only).

3. Player thruster + the new enemy dash (a designed pair)

Section titled “3. Player thruster + the new enemy dash (a designed pair)”
  • New InputState.dash: bool (default false → baseline-safe).
  • Input wiring (InputRouter / ensure_actions): Space or RB on web/pad; a Siri-remote button on tvOS (pick one not used by decoy on LB/Q).
  • Mechanic on Sim + PlayerState: on a rising edge, if off cooldown, the player gets a short committed burst in the current move direction (fallback: last facing) over ~0.16s, plus an i-frame window (~0.18s) during which _hurt_player / contact / projectile hits are ignored.
  • Tunables (constants, playtest-final): DASH_COOLDOWN=1.5, DASH_SPEED≈900, DASH_TIME=0.16, DASH_IFRAMES=0.18.
  • Upgrades:
    • In-run (level-up picker): a stat-mod (thrusters) that improves cooldown
      • i-frames, routed through StatEffects.
    • Meta: a new Thrusters meta upgrade in bible.json meta_upgrades.
  • PlayerState gains dash_cooldown_mult, dash_iframe_bonus, plus transient dash_timer, dash_cd, dash_dir, iframe_timer.
  • HUD: a small thruster-ready indicator (render-only).
  • Determinism: no dash input + no i-frame hits in the blade-only baseline → byte-identical. The hit-skip only branches when iframe_timer > 0, never true in the baseline.

3b. New enemy dash — “Rusher” (EnemyPool.BEHAVIOR_RUSH)

Section titled “3b. New enemy dash — “Rusher” (EnemyPool.BEHAVIOR_RUSH)”
  • Fast, no charge telegraph and no recharge pause: commits to short straight bursts back-to-back, re-aiming only briefly (~0.12s) between bursts. Relentless but dodgeable by moving perpendicular (it overshoots and must re-aim).
  • Reuses dash_timer/dash_phase (RUSH_AIM vs RUSH_BURST) — no new columns.
  • Per-type tunables from the bible (rush_speed, rush_burst_s, rush_aim_s).
  • Carried by a new enemy archetype (see §4c) and/or applied to an existing fast type; spawn-gated past the 10s baseline window → determinism-safe.

4. Enemies — variation, escorts, new archetypes, visuals

Section titled “4. Enemies — variation, escorts, new archetypes, visuals”

4a. Per-enemy attribute variation (re-pins baseline)

Section titled “4a. Per-enemy attribute variation (re-pins baseline)”
  • In _spawn_enemies (survival) and StoryDirector random-replay spawns (NOT tutorial-authored encounters), each spawned enemy gets correlated jitter drawn from rng:
    • one “size roll” s ∈ [0.8, 1.25] scales radius; bigger → +HP, −speed, +contact, smaller → −HP, +speed, −contact (a believable size/mass link),
    • plus small independent noise on HP and speed,
    • an occasional stronger “variant” roll (~8%) pushes one stat further (a glass-cannon sprinter or a mini-tank).
  • Helper EnemyPool.add(...) already takes explicit radius/hp/speed/contact, so variation is applied at the call site — no pool changes.
  • Determinism: draws from rng inside the baseline window and mutates enemies.data/posmoves the checksum; re-pin.

4b. Tank-escort rule (survival) (re-pins baseline)

Section titled “4b. Tank-escort rule (survival) (re-pins baseline)”
  • In _spawn_enemies, when pick_type returns TYPE_TANK or TYPE_BRUTE, also spawn a small escort cluster (e.g. 3–5 swarmers/spiders) near the heavy so it never arrives alone. (Story already does this in Warden rooms.)
  • Determinism: extra rng draws + spawns → re-pin (batched with §4a).

4c. New weapon-mirroring archetypes (spawn-gated → determinism-safe)

Section titled “4c. New weapon-mirroring archetypes (spawn-gated → determinism-safe)”

Five new enemies, each echoing a player weapon, with distinct speed. They attack via the enemy_proj pool, which gains a per-projectile damage column (currently every enemy shot deals the uniform SHOOTER_PROJ_DAMAGE); plus two new mechanics (a telegraphed beam line, a delayed AoE).

Enemy Mirrors Speed Attack
Zapper pulse (lightning) fast quick single bolts at the player
Bomber nova (fire) slow lobs a delayed AoE blast (telegraph ring → burst)
Orbiter orbit (cold) medium cold shards orbiting it (contact hazard)
Lancer beam (light) slow/ranged telegraphed straight beam-line (line-vs-player)
Scatterer scatter (blood) medium fan of pellets
  • New EnemyPool.TYPE_* ids (continue from TYPE_BRUTE=8), _build_enemy_types resize, bible entries (element + stats + behavior), SpawnDirector.pick_type bands (gated ≥ ~30–60s so the <10s baseline is untouched), main.gd render LUT resized to the new max id, and per-archetype shapes (§4d).
  • enemy_proj damage column swap-removes in lockstep; _check_player_hit uses per-shot damage instead of the constant. Determinism: enemy_proj is empty in the baseline (no shooters in <10s), so adding the column + per-shot damage is byte-identical.

4d. Visual archetype differentiation (render-only, determinism-neutral)

Section titled “4d. Visual archetype differentiation (render-only, determinism-neutral)”
  • Replace the single-mesh swarm with per-archetype silhouette meshes: build a small set of MultiMeshInstance2D partitions, one per shape, and route each enemy into its shape bucket each frame by a type_id → shape LUT. Keeps the one-draw-call-per-shape property (~10 shapes = cheap) and preserves the additive halo layer.
  • Shapes: triangle (swarmer), heavy hexagon (tank/brute), diamond (shooter), chevron (skirmisher), spiky star (elite/dasher), asterisk (spider), + distinct shapes for the five new archetypes (e.g. zapper = jagged bolt, bomber = round with fuse, orbiter = ringed dot, lancer = long sliver, scatterer = burst).
  • Headless tests assert: total instance count across buckets == enemies.count, and a given type_id lands in its shape bucket. Pixel shapes verified by playtest (the MultiMesh headless-readback caveat).
  • Update the public site legend (~/Claude/bullet-heaven-site/index.html Enemies section) to match the new silhouettes/colours (project rule).

5. New boss — 5 attacks, random each cycle (time-gated → determinism-safe)

Section titled “5. New boss — 5 attacks, random each cycle (time-gated → determinism-safe)”

A second boss entity alongside the existing 4-attack Warden. Lives in the EnemyPool (weapons damage it for free). A new BossState variant (or a boss_kind flag) selects one of five attacks at random each cycle (rng), with longer telegraphs on the two heaviest. Survival alternates the two bosses; story Warden rooms may assign either.

  1. Cutter (beam) — locks a long-range beam toward the player; the sweep rotates slow, then accelerates right before it cuts (the damaging beam fires along the line for a brief window). Telegraphed rotating line; reuses the FxManager beam node + a line-vs-player hit check.
  2. Artillery (rockets + shrapnel) — fast rockets that arc up and away (render: rising + shrinking to fake altitude, off the top of the view), then land at semi-random points around the player after a short hang, each with a ground target ring (telegraph) → impact → radial shrapnel burst. Dedicated boss_rockets structure with phases ascend → hang → land → shrapnel (shrapnel via enemy_proj). The “3D” look is a pure render scale trick; the sim only tracks a 2D landing point + timers.
  3. Shockwave rings — concentric expanding rings of shots with weave-gaps.
  4. Charge slam (dash) — telegraphs a line, then dashes hard across the arena dealing heavy path damage (ties into the dodge/thruster theme).
  5. Summon swarm — spawns a cluster of adds to pressure the player.
  • New render needs: rocket ascend/descend + ground-target ring + shrapnel; the accelerating beam telegraph. Both reuse existing FX building blocks where possible. Every new entity pool/fx_events kind must have a renderer/match arm (the “invisible entity” rule).
  • HUD boss bar reuses boss_render_info() (already reads boss.max_hp).

6a. Damage numbers (determinism-neutral — fx_events excluded from checksum)

Section titled “6a. Damage numbers (determinism-neutral — fx_events excluded from checksum)”
  • DMGNUM_MIN 7.0 → 2.0 (ordinary hits now show), DMGNUM_BIG 18 → 14, DMGNUM_CAP 18 → 28/tick. Big/reaction hits still flash gold.

6b. Armor model (determinism-safe — armor-0 enemies unaffected)

Section titled “6b. Armor model (determinism-safe — armor-0 enemies unaffected)”
  • _damage_enemy floor amount * 0.1amount * 0.25: chip/ranged weapons do at least 25% of their hit to armored foes (today beam 0.4 vs armor 8 ≈ 0.04 — effectively useless). Baseline swarmers are armor-0, so the floor branch never changes for them → byte-identical.

6c. Weapon base damage (non-blade = determinism-safe; blade re-pins)

Section titled “6c. Weapon base damage (non-blade = determinism-safe; blade re-pins)”
  • Baseline is blade-only, so tuning every other weapon is free. Proposed (playtest-final): pulse 2→3, orbit 0.8→1.2, beam 0.4→0.8, nova 3→4, turret 0.7→1.0, scatter 1.0→1.4. Blade stays 3.0 to avoid churning the baseline (revisit only if play demands it, accepting a re-pin).
  • XP curve (xp_to_next *= 1.35): if changed, re-pins (xp/level are in the checksum). Proposal: keep 1.35 unless play shows level-ups dry up; treat any change as a batched re-pin.
  • Gold (determinism-neutral — run_gold not in checksum): BOSS_GOLD 25→40; heavy/ranged kills (tank/brute/elite/skirmisher + new archetypes) drop a small bonus (+2–3) so gold tracks effort. GOLD_PER_KILL stays 1.
  • Meta cost (render-side MetaState, determinism-neutral): cap geometric growth at 1.6 (haste/bulwark 1.7 and swiftness 1.8 are steep); keep base costs.
  • Telemetry note (optional): the dashboard avg_dps is ~2,000,000 — almost certainly total-damage-not-per-second or a bad divisor in gameplay_telemetry. Worth a fix so future balance is data-driven, but out of this spec’s core scope.

Baseline (survival, seed 1234, 600 ticks, blade-only): snapshot_string().hash() = 4152236597, state_checksum() = 1267954985 (pinned in tests/test_determinism_checksum.gd).

Change Baseline impact
Modes/tutorial, damage-number thresholds, visual shapes neutral
Player thruster, rusher behavior neutral (no dash input / gated spawns)
New archetypes + enemy_proj damage column neutral (gated; pool empty in baseline)
New boss (all 5 attacks) neutral (time-gated)
Armor floor 0.1→0.25 neutral (baseline enemies armor-0)
Non-blade weapon damage, gold, meta cost neutral
Per-enemy variation (§4a) re-pin
Tank escorts (§4b) re-pin
Blade dmg / XP curve (if changed) re-pin

The re-pinning changes are batched and the baseline re-pinned once at the end of that group, with the new checksums recorded in tests/test_determinism_checksum.gd and CLAUDE.md.

Each chunk follows the bh-dev-chunk ritual (TDD → import → boot-check → test-count → determinism → commit → tvOS sync) and bumps Sim_Const.BUILD + the site changelog when deployed. Determinism-neutral chunks first; the baseline-movers grouped last with a single re-pin.

  1. Damage numbers (§6a) — quick win.
  2. Modes & tutorial separation (§2).
  3. Visual archetype differentiation (§4d) + site legend.
  4. Player thruster + rusher dash (§3) — designed together.
  5. New weapon-mirroring archetypes (§4c) — may split: (a) enemy_proj damage column + Zapper/Scatterer/Bomber, (b) Orbiter + Lancer (beam-line/orbit shards).
  6. New 5-attack boss (§5) — may split: (a) state machine + Cutter + Charge + Summon, (b) Artillery rockets/shrapnel + Shockwave rings + render.
  7. Armor + non-blade weapon + gold + meta-cost balance (§6b–6d, neutral parts).
  8. Per-enemy variation + tank escorts (§4a/4b) + any blade/XP balance — the batched re-pin chunk (last).
  • Render perf (pillar = thousands @ 60fps): per-shape MultiMesh adds ~a few draw calls; profile if shape count climbs. New boss/enemy FX gated to events.
  • Invisible-entity trap: every new pool (boss_rockets, orbit shards) and every new fx_events kind needs a renderer / match arm — audited per chunk.
  • tvOS input for the thruster: limited Siri-remote buttons; pick one free of the decoy/confirm bindings; verify on device via bh-deploy.
  • Balance is reasoned, not measured: ship behind playtest; once the gameplay telemetry accumulates (and the DPS metric is fixed) re-tune from real data.
  • Re-pin honesty: never silence the determinism test — re-pin with recorded new checksums and a note on what moved them.