Void Breaker — Phase 2a (Builds & Weapons) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn Void Breaker’s single weapon + 15-card pool into 5 pick-at-start weapons × a synergistic upgrade pool with build-defining transformers — the roguelite replay engine.
Architecture: A new pure-data weapons.ts (mirroring characters.ts) defines weapons with a data-driven fire descriptor. The engine’s firePlayerProj becomes a dispatcher over fire.mode; existing PlayerStats multipliers stack on every weapon. Transformer upgrades set new PlayerStats fields that firePlayerProj stamps onto new (default-inert) Projectile flags, with behaviors handled in the projectile/hit loops.
Tech Stack: TypeScript, Vitest (node_modules/.bin/vitest), tsx sim harness, React components, procedural WebAudio. Worktree at /Users/kai/.superset/worktrees/5a044df8-4237-42e1-a19d-f4922526040c/voidbreaker-gameplay-over; run binaries via node_modules/.bin/* (pnpm wrappers blocked). Branch: feat/voidbreaker-builds-phase2a.
Global Constraints¶
No gameplay regression for the default weapon. With weapon = Pulse Blaster and no transformer upgrades, combat must be byte-for-behavior identical to today. Prove via balance-sim parity.
No new enemies / bosses / biomes (those are 2b / 2c). Pick-at-start weapons only — no mid-run swaps. No charge-hold input. No sustained beam (Arc Coil chains via discrete projectiles).
New fields default to inert so all existing code paths are unaffected until an upgrade/weapon opts in.
Headless-safe: the balance-sim sets
g.headless = true(Phase 1 contract). New behaviors must not depend on render/audio.Honor Reduced Effects in any renderer/UI work (Phase 1 conventions:
reducedFx, prefers-reduced-motion).Run commands from
node_modules/.bin/: e.g.node_modules/.bin/vitest run lib/__tests__/void-breaker-weapons.test.ts. There is no DOM test env — engine/data tests only; renderer/UI verified by manual playtest.Existing 54 Void Breaker tests stay green. The 8 pre-existing i18n-catalog failures (feed/library/kowloon) are unrelated and out of scope.
Existing infrastructure (read before starting)¶
lib/void-breaker/upgrades.ts:PlayerStats,makePlayerStats(),UpgradeId,UpgradeDef,UPGRADE_DEFS,rollUpgradeChoices(stacks, count, bossReward).lib/void-breaker/characters.ts: the pure-data pattern to mirror (CharacterDef,CHARACTERS,getCharacter,isCharacterId).lib/void-breaker/game.ts:firePlayerProj()(~654), the fire gate inupdatePlayer(p.fireTimer = p.fireRate * this.stats.fireRateMult;~623,p.fireRate = PLAYER_FIRE_RATEset instartGame~226),updateProjectiles(~1431, wherefusebombs callexplodeBomb),checkPlayerHits(~1497, where player bullets hit enemies; pierce handled ~1524),killEnemy(~1573),explodeBomb(p)(~1169, enemy AoE — mirror for player),Projectilepool init in the constructor (~176).lib/void-breaker/types.ts:Projectileinterface (~91) — haspierce,lastHitId,fuse,blastRadius.lib/void-breaker/constants.ts:PLAYER_FIRE_RATE = 0.20,PROJ_SPEED = 550,PROJ_RADIUS = 4,PROJ_DAMAGE = 1.lib/void-breaker/metaProgression.ts: persistedunlockedset + helpers for character unlocks (the pattern weapon unlocks reuse).lib/__tests__/void-breaker-engine.test.ts: shared test helpersmakeInput,advanceToPlaying,isolate,placeEnemy,placePlayerProj(copy/import these patterns).scripts/void-breaker-balance-sim.ts: buildsnew VoidBreakerEngine(), setsg.headless = true, loopsg.update(1/60, input); bot logic +UPGRADE_PRIORITY.
MILESTONE 1 — Weapon system foundation¶
Task 1: weapons.ts data module + tests¶
Files:
Create:
lib/void-breaker/weapons.tsCreate:
lib/__tests__/void-breaker-weapons.test.ts
Interfaces:
Produces:
type WeaponId = 'pulse' | 'scatter' | 'railgun' | 'grenade' | 'arc'type FireMode = 'single' | 'spread' | 'railgun' | 'lob' | 'arc'interface WeaponFire { mode: FireMode; pellets?: number; spread?: number; pierce?: number; fuse?: number; blastRadius?: number; chains?: number }interface WeaponDef { id: WeaponId; name: string; title: string; description: string; icon: string; color: string; unlockCost: number; baseFireInterval: number; baseDamage: number; baseProjSpeed: number; baseProjRadius: number; baseProjLife: number; fire: WeaponFire }const WEAPONS: WeaponDef[],getWeapon(id: WeaponId): WeaponDef(falls back toWEAPONS[0]),isWeaponId(v: unknown): v is WeaponId.The default weapon is
pulsewithunlockCost: 0and base stats equal to today’s constants (baseFireInterval: 0.20,baseDamage: 1,baseProjSpeed: 550,baseProjRadius: 4,baseProjLife: 2.5,fire: { mode: 'single', pellets: 1, spread: 0.12 }).
Step 1: Write the failing test —
lib/__tests__/void-breaker-weapons.test.ts:
import { describe, it, expect } from 'vitest';
import { WEAPONS, getWeapon, isWeaponId } from '@/lib/void-breaker/weapons';
describe('weapons data', () => {
it('has 5 weapons with unique ids', () => {
expect(WEAPONS).toHaveLength(5);
expect(new Set(WEAPONS.map(w => w.id)).size).toBe(5);
});
it('pulse is the free default and matches current blaster stats', () => {
const p = getWeapon('pulse');
expect(p.unlockCost).toBe(0);
expect(p.baseFireInterval).toBeCloseTo(0.20, 5);
expect(p.baseDamage).toBe(1);
expect(p.baseProjSpeed).toBe(550);
expect(p.fire.mode).toBe('single');
});
it('every non-default weapon has a positive unlock cost and sane base stats', () => {
for (const w of WEAPONS.filter(w => w.id !== 'pulse')) {
expect(w.unlockCost).toBeGreaterThan(0);
expect(w.baseFireInterval).toBeGreaterThan(0);
expect(w.baseDamage).toBeGreaterThan(0);
expect(w.baseProjSpeed).toBeGreaterThan(0);
}
});
it('getWeapon falls back to pulse for an unknown id, isWeaponId validates', () => {
// @ts-expect-error intentionally invalid
expect(getWeapon('nope').id).toBe('pulse');
expect(isWeaponId('railgun')).toBe(true);
expect(isWeaponId('nope')).toBe(false);
expect(isWeaponId(42)).toBe(false);
});
it('each fire mode is represented exactly once across the roster', () => {
const modes = WEAPONS.map(w => w.fire.mode).sort();
expect(modes).toEqual(['arc', 'lob', 'railgun', 'single', 'spread']);
});
});
Step 2: Run, verify it fails
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-weapons.test.ts
Expected: FAIL — module weapons.ts does not exist.
Step 3: Implement
lib/void-breaker/weapons.ts
// ── Void Breaker — weapons (pick one at run start) ───────────────────────────
// Pure data + helpers, mirroring characters.ts. Each weapon has base combat
// stats plus a data-driven `fire` descriptor the engine's firePlayerProj reads.
// No engine import (keeps it unit-testable and avoids a circular dependency).
export type WeaponId = 'pulse' | 'scatter' | 'railgun' | 'grenade' | 'arc';
export type FireMode = 'single' | 'spread' | 'railgun' | 'lob' | 'arc';
export interface WeaponFire {
mode: FireMode;
/** Base pellets per shot (extra multishot from PlayerStats stacks on top). */
pellets?: number;
/** Angular gap between pellets, radians. */
spread?: number;
/** Base pierce baked into the weapon (PlayerStats.pierce adds on top). */
pierce?: number;
/** Lob fuse seconds (mode 'lob'). */
fuse?: number;
/** Lob blast radius (mode 'lob'). */
blastRadius?: number;
/** Base chain hops (mode 'arc'); Chain Lightning upgrade adds more. */
chains?: number;
}
export interface WeaponDef {
id: WeaponId;
name: string;
title: string; // atmospheric subtitle
description: string;
icon: string;
color: string;
unlockCost: number; // Void Cores (0 = free)
baseFireInterval: number; // seconds between shots
baseDamage: number; // per projectile, before PlayerStats.damageBonus
baseProjSpeed: number;
baseProjRadius: number;
baseProjLife: number;
fire: WeaponFire;
}
export const WEAPONS: WeaponDef[] = [
{
id: 'pulse', name: 'Pulse Blaster', title: '脉冲', icon: '⟫', color: '#00f5ff',
description: 'Balanced rapid-fire single shot. Reliable, no surprises.',
unlockCost: 0,
baseFireInterval: 0.20, baseDamage: 1, baseProjSpeed: 550, baseProjRadius: 4, baseProjLife: 2.5,
fire: { mode: 'single', pellets: 1, spread: 0.12 },
},
{
id: 'scatter', name: 'Scattergun', title: '散射', icon: '⁂', color: '#ff8844',
description: 'A short-range cone of pellets. Devastating up close, slow to cycle.',
unlockCost: 60,
baseFireInterval: 0.55, baseDamage: 1, baseProjSpeed: 600, baseProjRadius: 4, baseProjLife: 0.45,
fire: { mode: 'spread', pellets: 5, spread: 0.16 },
},
{
id: 'railgun', name: 'Railgun', title: '轨道炮', icon: '➳', color: '#88ddff',
description: 'Slow, heavy slug that punches through everything in its path.',
unlockCost: 90,
baseFireInterval: 0.85, baseDamage: 4, baseProjSpeed: 1100, baseProjRadius: 6, baseProjLife: 1.2,
fire: { mode: 'railgun', pellets: 1, spread: 0, pierce: 999 },
},
{
id: 'grenade', name: 'Grenade Launcher', title: '榴弹', icon: '✸', color: '#ffaa00',
description: 'Lobs fused charges that blast on a timer. Area denial.',
unlockCost: 90,
baseFireInterval: 0.9, baseDamage: 3, baseProjSpeed: 360, baseProjRadius: 6, baseProjLife: 5,
fire: { mode: 'lob', pellets: 1, spread: 0, fuse: 1.1, blastRadius: 95 },
},
{
id: 'arc', name: 'Arc Coil', title: '电弧', icon: '⚡', color: '#cc66ff',
description: 'Bolts that leap to nearby enemies. Born for crowds.',
unlockCost: 120,
baseFireInterval: 0.35, baseDamage: 1, baseProjSpeed: 700, baseProjRadius: 4, baseProjLife: 1.6,
fire: { mode: 'arc', pellets: 1, spread: 0, chains: 2 },
},
];
const BY_ID = new Map(WEAPONS.map(w => [w.id, w]));
export function getWeapon(id: WeaponId): WeaponDef { return BY_ID.get(id) ?? WEAPONS[0]; }
export function isWeaponId(v: unknown): v is WeaponId {
return typeof v === 'string' && BY_ID.has(v as WeaponId);
}
Step 4: Run, verify it passes
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-weapons.test.ts
Expected: PASS (5 tests).
Step 5: Commit
git add lib/void-breaker/weapons.ts lib/__tests__/void-breaker-weapons.test.ts
git commit -m "Void Breaker (builds): weapons.ts data module — 5 weapons"
Task 2: firePlayerProj dispatcher + Projectile transformer fields + Arc chain + player bomb¶
Files:
Modify:
lib/void-breaker/types.ts(Projectile~91)Modify:
lib/void-breaker/game.ts(constructor pool init ~176,startGame~211,firePlayerProj~654,updateProjectiles~1431, addexplodePlayerBomb, addchainBolt)Test:
lib/__tests__/void-breaker-weapons.test.ts(extend)
Interfaces:
Consumes:
WeaponDef/getWeaponfrom Task 1.Produces:
Projectilegains (all default inert):bounces: number,chains: number,explodeOnHit: boolean,explodeRadius: number,homing: number.VoidBreakerEngine.weapon: WeaponDef(public, defaultgetWeapon('pulse'), settable beforestartGame, reset instartGame).firePlayerProjspawns per the weapon’sfire.mode; Pulse Blaster behavior is identical to today.private explodePlayerBomb(p: Projectile): void— AoE damage to enemies (mirror ofexplodeBomb).private chainBolt(fromX: number, fromY: number, hitId: number, damage: number, hops: number): void— spawn a short bolt to the nearest other live enemy and damage it, decrementing hops.
Step 1: Write the failing tests — append to
void-breaker-weapons.test.ts:
import { VoidBreakerEngine } from '@/lib/void-breaker/game';
import { getWeapon } from '@/lib/void-breaker/weapons';
import type { InputState } from '@/lib/void-breaker/types';
function mk(over: Partial<InputState> = {}): InputState {
return { up:false,down:false,left:false,right:false,mouseX:1200,mouseY:500,
detonate:false,dash:false,focus:false,pause:false,voidPulse:false,phaseShift:false,
reflectShield:false,allySynergy:false, ...over };
}
function toPlaying(g: VoidBreakerEngine) { for (let i=0;i<200 && g.state!=='playing';i++) g.update(0.05, mk()); }
function fireOnce(g: VoidBreakerEngine) {
g.enemies.forEach(e => e.active = false);
g.projectiles.forEach(p => p.active = false);
g.waveEnemiesAlive = 99; g.player.fireTimer = 0; g.player.x = 800; g.player.y = 500;
g.update(0.016, mk());
}
describe('firePlayerProj dispatcher', () => {
it('pulse fires a single player projectile', () => {
const g = new VoidBreakerEngine(); g.startGame(); toPlaying(g);
fireOnce(g);
expect(g.projectiles.filter(p => p.active && p.isPlayer).length).toBe(1);
});
it('scatter fires multiple pellets', () => {
const g = new VoidBreakerEngine(); g.weapon = getWeapon('scatter'); g.startGame(); toPlaying(g);
fireOnce(g);
expect(g.projectiles.filter(p => p.active && p.isPlayer).length).toBeGreaterThanOrEqual(5);
});
it('railgun rounds pierce everything', () => {
const g = new VoidBreakerEngine(); g.weapon = getWeapon('railgun'); g.startGame(); toPlaying(g);
fireOnce(g);
const round = g.projectiles.find(p => p.active && p.isPlayer)!;
expect(round.pierce).toBeGreaterThan(50);
});
it('grenade lobs a fused player bomb', () => {
const g = new VoidBreakerEngine(); g.weapon = getWeapon('grenade'); g.startGame(); toPlaying(g);
fireOnce(g);
const bomb = g.projectiles.find(p => p.active && p.isPlayer && p.fuse > 0);
expect(bomb).toBeTruthy();
expect(bomb!.blastRadius).toBeGreaterThan(0);
});
it('arc bolts carry chain hops', () => {
const g = new VoidBreakerEngine(); g.weapon = getWeapon('arc'); g.startGame(); toPlaying(g);
fireOnce(g);
const bolt = g.projectiles.find(p => p.active && p.isPlayer)!;
expect(bolt.chains).toBeGreaterThanOrEqual(2);
});
});
Step 2: Run, verify it fails
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-weapons.test.ts
Expected: FAIL — g.weapon / new fields / dispatcher not present.
Step 3: Add Projectile fields in
types.ts:
/** Explosion radius for a bomb (only read when fuse > 0). */
blastRadius: number;
/** Transformer: ricochet hops remaining (0 = none). */
bounces: number;
/** Transformer: chain-lightning hops remaining (0 = none). */
chains: number;
/** Transformer: explode on first hit. */
explodeOnHit: boolean;
/** Explosion radius when explodeOnHit. */
explodeRadius: number;
/** Transformer: homing turn rate (rad/s, 0 = none). */
homing: number;
Step 4: Init the new fields in the constructor pool (
game.ts~176, thethis.projectiles = Array.from(...)literal): addbounces: 0, chains: 0, explodeOnHit: false, explodeRadius: 0, homing: 0,to the projectile object literal.Step 5: Add the
weaponfield + reset. Nearcharacter(~128):weapon: WeaponDef = getWeapon('pulse');and import{ WeaponDef, getWeapon }from./weapons. InstartGame, setthis.player.fireRate = this.weapon.baseFireInterval;(replace thePLAYER_FIRE_RATEliteral use forfireRate), and there is no per-run reset needed forweaponitself (it persists as the selection) — but ensurestartGamereads it.Step 6: Rewrite
firePlayerProj(game.ts~654):
private firePlayerProj(): void {
const p = this.player;
const s = this.stats;
const w = this.weapon;
const f = w.fire;
const count = Math.max(1, (f.pellets ?? 1) + (s.projectileCount - 1));
const speed = w.baseProjSpeed * s.projSpeedMult;
const damage = w.baseDamage + s.damageBonus;
const spreadStep = f.spread ?? 0.12;
const baseAngle = p.aimAngle - (count - 1) * spreadStep * 0.5;
for (let i = 0; i < count; i++) {
const slot = this.projectiles.find(pr => !pr.active);
if (!slot) break;
const a = baseAngle + i * spreadStep;
const cos = Math.cos(a), sin = Math.sin(a);
slot.active = true;
slot.x = p.x + cos * (p.radius + 4);
slot.y = p.y + sin * (p.radius + 4);
slot.vx = cos * speed;
slot.vy = sin * speed;
slot.radius = w.baseProjRadius;
slot.damage = damage;
slot.isPlayer = true;
slot.life = w.baseProjLife;
slot.pierce = (f.pierce ?? 0) + s.pierce;
slot.lastHitId = -1;
// Lob (grenade): fused AoE round.
slot.fuse = f.mode === 'lob' ? (f.fuse ?? 1.1) : 0;
slot.blastRadius = f.mode === 'lob' ? (f.blastRadius ?? 90) : 0;
// Transformer flags (weapon base + PlayerStats stack; M2 sets the stats).
slot.bounces = s.bounceCount;
slot.chains = (f.chains ?? 0) + s.chainCount;
slot.explodeOnHit = s.explodeOnHit;
slot.explodeRadius = s.explodeRadius;
slot.homing = s.homingTurn;
}
this.player.recoil = 1;
}
NOTE: s.bounceCount, s.chainCount, s.explodeOnHit, s.explodeRadius, s.homingTurn are added to PlayerStats in Task 4 (M2). To keep Task 2 self-contained and compiling, add these fields to PlayerStats + makePlayerStats() NOW with inert defaults (Task 4 only adds the upgrades that set them):
In upgrades.ts PlayerStats add: bounceCount: number; chainCount: number; explodeOnHit: boolean; explodeRadius: number; homingTurn: number; orbitalCount: number; overchargeEvery: number; and in makePlayerStats() add bounceCount: 0, chainCount: 0, explodeOnHit: false, explodeRadius: 0, homingTurn: 0, orbitalCount: 0, overchargeEvery: 0,.
Step 7: Add
explodePlayerBomband bomb routing. InupdateProjectiles(~1431), find where afuse > 0projectile detonates and currently callsthis.explodeBomb(p). Route player bombs to a new method:if (p.fuse > 0) { p.fuse -= dt; if (p.fuse <= 0) { p.isPlayer ? this.explodePlayerBomb(p) : this.explodeBomb(p); p.active = false; continue; } }(match the existing fuse decrement; only add theisPlayerbranch). Then add:
/** Detonate a player-lobbed bomb: AoE damage to enemies + spectacle. */
private explodePlayerBomb(p: Projectile): void {
this.spawnShockwave(p.x, p.y, p.blastRadius, '#ffaa44', 5);
this.spawnParticles(p.x, p.y, '#ffaa44', 14, 180);
this.emitSfx('detonate', { gain: 0.5 });
this.addTrauma(0.35);
for (const e of this.enemies) {
if (!e.active || e.anim !== 'alive') continue;
if (e.isBoss && e.bossSpecialActive) continue;
if (dist(e.x, e.y, p.x, p.y) < p.blastRadius + e.radius) {
e.hp -= p.damage;
e.hitFlashUntil = this.elapsedMs + 80;
if (e.hp <= 0) this.killEnemy(e);
}
}
}
Step 8: Add
chainBoltand call it on Arc hits. IncheckPlayerHits(~1522), right afterif (e.hp <= 0) this.killEnemy(e);and before the pierce handling, add:
if (p.chains > 0) { this.chainBolt(e.x, e.y, e.id, p.damage, p.chains); p.chains = 0; }
(Set to 0 so a single bullet chains once per hit, not infinitely.) Then add the method:
/** Chain lightning: hop to the nearest other live enemy, damaging it; recurse. */
private chainBolt(fromX: number, fromY: number, hitId: number, damage: number, hops: number): void {
let best: Enemy | null = null;
let bestD = 260; // max chain range
for (const e of this.enemies) {
if (!e.active || e.anim !== 'alive' || e.id === hitId) continue;
if (e.isBoss && e.bossSpecialActive) continue;
const d = dist(e.x, e.y, fromX, fromY);
if (d < bestD) { bestD = d; best = e; }
}
if (!best) return;
best.hp -= damage;
best.hitFlashUntil = this.elapsedMs + 80;
this.spawnParticles(best.x, best.y, '#cc88ff', 4, 90);
this.emitSfx('hit', { pitch: 1.3 });
if (best.hp <= 0) this.killEnemy(best);
else if (hops > 1) this.chainBolt(best.x, best.y, best.id, damage, hops - 1);
}
Step 9: Run the weapon tests + full engine suite
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-weapons.test.ts lib/__tests__/void-breaker-engine.test.ts lib/__tests__/void-breaker-feel.test.ts
Expected: PASS. If placePlayerProj/placeEnemy helpers fail to compile because the Projectile/PlayerStats literals they build lack the new fields, update those helpers to include the inert defaults (test-harness upkeep, not behavior).
Step 10: Prove Pulse Blaster parity in the sim
Run: node_modules/.bin/tsx scripts/void-breaker-balance-sim.ts 30
Expected: median wave ≈ the Phase-1 baseline (median 10). The default weapon path is unchanged. Record the number.
Step 11: Commit
git add lib/void-breaker/game.ts lib/void-breaker/types.ts lib/void-breaker/upgrades.ts lib/__tests__/
git commit -m "Void Breaker (builds): data-driven firePlayerProj dispatcher + 5-weapon fire modes"
Task 3: Weapon selection plumbing + Void-Core unlock + picker wiring¶
Files:
Modify:
lib/void-breaker/metaProgression.ts(generalize/extend theunlockedset + helpers to cover weapons),lib/void-breaker/saveSystem.ts(persist selected weapon id),components/void-breaker/VoidBreakerGame.tsx(setengine.weaponbeforestartGame; pass weapon state/handlers to UI)Test:
lib/__tests__/void-breaker-meta.test.ts(extend for weapon unlock helpers)
Interfaces:
Consumes:
WEAPONS/getWeapon/isWeaponId, the metaunlockedpattern.Produces: persisted
selectedWeapon: WeaponId+ weapon-unlock helpers mirroring characters (isWeaponUnlocked(state, id),unlockWeapon(state, id, cost)or a generalized unlock keyed by a namespaced id). The component setsengine.weapon = getWeapon(selectedWeapon)before eachstartGame.Step 1: Write the failing test — extend
void-breaker-meta.test.tswith weapon-unlock coverage mirroring the existing character-unlock tests (defaultpulsealways unlocked; a locked weapon becomes unlocked after spending cores; cores can’t go negative). Use the same assertions style as the character-unlock tests already in that file.Step 2: Run, verify it fails
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-meta.test.ts
Expected: FAIL — weapon-unlock helpers don’t exist.
Step 3: Implement the weapon-unlock helpers in
metaProgression.ts(mirror the character logic; if the existingunlockedset is keyed by raw ids, namespace weapon ids as e.g.weapon:railgunto avoid collisions, or add a parallelunlockedWeaponsset — match whatever is cleanest given the current shape). PersistselectedWeaponinsaveSystem.tsalongside the selected character (default'pulse', validate withisWeaponId).Step 4: Wire the engine in
VoidBreakerGame.tsx: read the persisted/selected weapon and setengine.weapon = getWeapon(selectedWeaponId)immediately beforeengine.startGame()(next to whereengine.characteris set today). Keep a React state for the selected weapon + an unlock handler, mirroring the character picker wiring (the UI itself is Task 6).Step 5: Run tests + typecheck
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-meta.test.ts && node_modules/.bin/tsc --noEmit -p tsconfig.json 2>&1 | grep -i void-breaker; echo "(no void-breaker errors = clean)"
Expected: meta tests PASS; no void-breaker typecheck errors.
Step 6: Commit
git add lib/void-breaker/metaProgression.ts lib/void-breaker/saveSystem.ts components/void-breaker/VoidBreakerGame.tsx lib/__tests__/void-breaker-meta.test.ts
git commit -m "Void Breaker (builds): weapon selection + Void-Core unlock plumbing"
MILESTONE 2 — Transformer upgrades¶
Task 4: Transformer behaviors (bounce, explosive-on-hit, homing) + stamping¶
Files:
Modify:
lib/void-breaker/game.ts(checkPlayerHits~1497,updateProjectiles~1431)Test:
lib/__tests__/void-breaker-weapons.test.ts(extend)
Interfaces:
Consumes: the inert
Projectilefields +PlayerStatsfields added in Task 2,explodePlayerBomb,chainBolt.Produces: behaviors when a player bullet hits an enemy in
checkPlayerHits:explodeOnHit → call
explodePlayerBomb-style AoE at the hit point (factor a sharedprivate aoeDamage(x, y, radius, damage, exclId)used by bothexplodePlayerBomband explosive rounds to stay DRY).bounces → retarget the projectile toward the nearest other live enemy (redirect
vx/vy, resetlastHitId), decrementbounces, instead of deactivating.homing (in
updateProjectiles) → each frame, rotate a player bullet’s velocity toward the nearest live enemy by up tohoming * dt.
Step 1: Write failing tests — append to
void-breaker-weapons.test.ts. Use the engine helpers (isolate/placeEnemy/placePlayerProjpatterns) to assert: (a) a bullet withexplodeOnHit=true+explodeRadiusdamages a second nearby enemy on hit; (b) a bullet withbounces=1stays active and redirects after a kill (itsvx/vychange toward a second enemy); (c) a bullet withhoming>0curves — after several frames its velocity angle moves toward an off-axis enemy. Provide concrete arrays-and-assertions like the existing engine tests (place two enemies, fire one bullet, step, assert second enemy hp drops / bullet still active / angle delta).
import { factory helpers as in engine test } // copy makeInput/isolate/placeEnemy/placePlayerProj
// (a) explosive
it('explosive rounds damage a nearby second enemy', () => {
const g = new VoidBreakerEngine(); g.startGame(); toPlaying(g);
g.enemies.forEach(e=>e.active=false); g.projectiles.forEach(p=>p.active=false);
g.obstacles=[]; g.waveEnemiesAlive=99;
const a = g.enemies.find(e=>!e.active)!; Object.assign(a,{active:true,isBoss:false,isElite:false,type:'drifter',x:1200,y:500,radius:10,hp:1,maxHp:1,shardCount:0,value:10,color:'#fff',anim:'alive',animTimer:0,speed:0,vx:0,vy:0,dashState:'idle',bossSpecialActive:false});
const b = g.enemies.find(e=>!e.active)!; Object.assign(b,{active:true,isBoss:false,isElite:false,type:'drifter',x:1230,y:500,radius:10,hp:5,maxHp:5,shardCount:0,value:10,color:'#fff',anim:'alive',animTimer:0,speed:0,vx:0,vy:0,dashState:'idle',bossSpecialActive:false});
const proj = g.projectiles.find(p=>!p.active)!; Object.assign(proj,{active:true,isPlayer:true,x:1200,y:500,vx:0,vy:0,radius:4,damage:1,life:2,pierce:0,lastHitId:-1,fuse:0,blastRadius:0,bounces:0,chains:0,explodeOnHit:true,explodeRadius:60,homing:0});
g.update(0.05, mk());
expect(b.hp).toBeLessThan(5);
});
(Write analogous tests for bounce + homing.)
Step 2: Run, verify fail
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-weapons.test.ts
Expected: FAIL — behaviors not implemented.
Step 3: Implement. Add a shared
private aoeDamage(x: number, y: number, radius: number, damage: number, exclId: number): void(loop live enemies, skipexclId, damage + maybe-kill) and refactorexplodePlayerBombto use it. IncheckPlayerHits, after computing the hit and the existing kill/pierce logic, insert (before the pierceif):
if (p.explodeOnHit) {
this.spawnShockwave(p.x, p.y, p.explodeRadius, '#ffaa44', 4);
this.aoeDamage(p.x, p.y, p.explodeRadius, p.damage, e.id);
this.addTrauma(0.15);
}
if (p.bounces > 0) {
const next = this.nearestLiveEnemy(p.x, p.y, e.id);
if (next) {
const ang = Math.atan2(next.y - p.y, next.x - p.x);
const spd = Math.hypot(p.vx, p.vy) || 400;
p.vx = Math.cos(ang) * spd; p.vy = Math.sin(ang) * spd;
p.lastHitId = -1; p.bounces--;
// skip the deactivation below by continuing the projectile loop
}
}
Then adjust the existing pierce/deactivate tail so a projectile with a successful bounce is NOT deactivated (e.g. gate the else p.active = false; on p.bounces having been spent and no pierce). Add helper private nearestLiveEnemy(x, y, exclId): Enemy | null (the chainBolt search, extracted/shared). In updateProjectiles, for each active player projectile with p.homing > 0, steer:
if (p.isPlayer && p.homing > 0) {
const t = this.nearestLiveEnemy(p.x, p.y, -1);
if (t) {
const desired = Math.atan2(t.y - p.y, t.x - p.x);
const cur = Math.atan2(p.vy, p.vx);
const spd = Math.hypot(p.vx, p.vy);
const next = cur + Math.max(-p.homing * dt, Math.min(p.homing * dt, angleDiff(desired, cur)));
p.vx = Math.cos(next) * spd; p.vy = Math.sin(next) * spd;
}
}
(angleDiff already exists and is imported/used in game.ts.)
Step 4: Run tests + full suite + sim parity
Run: node_modules/.bin/vitest run lib/__tests__/ 2>&1 | grep -E "Tests +[0-9]" then node_modules/.bin/tsx scripts/void-breaker-balance-sim.ts 30 | grep runs
Expected: all Void Breaker tests PASS (i18n failures unrelated); sim median ≈ baseline (transformer fields are inert with no upgrades taken).
Step 5: Commit
git add lib/void-breaker/game.ts lib/__tests__/void-breaker-weapons.test.ts
git commit -m "Void Breaker (builds): transformer projectile behaviors — bounce, explosive, homing"
Task 5: Orbitals + Overcharge + the new upgrade cards¶
Files:
Modify:
lib/void-breaker/upgrades.ts(UpgradeId,UPGRADE_DEFS),lib/void-breaker/game.ts(orbital state + update; overcharge counter infirePlayerProj/checkPlayerHits)Modify:
lib/void-breaker/types.ts(anOrbitalshape if needed)Test:
lib/__tests__/void-breaker-upgrades.test.ts+void-breaker-weapons.test.ts(extend)
Interfaces:
Consumes:
PlayerStatstransformer fields (bounceCount,chainCount,explodeOnHit,explodeRadius,homingTurn,orbitalCount,overchargeEvery).Produces:
~13 new
UpgradeDefs appended toUPGRADE_DEFS(newUpgradeIdliterals): the transformers —ricochet(bounceCount += 1),chain_lightning(chainCount += 2),explosive_rounds(explodeOnHit = true; explodeRadius = max(explodeRadius, 70)),homing_rounds(homingTurn += 4),orbitals(orbitalCount += 1),overcharge(overchargeEvery = overchargeEvery === 0 ? 5 : Math.max(3, overchargeEvery - 1)) — mostmaxStacks: 1(rare); plus ~7 stat fillers rounding out crit/AoE/lifesteal/speed/fire-rate archetypes (each mutating an existingPlayerStatsfield,common/rare).Orbitals: a small owned set of orbiting blades updated each frame (orbit the player, damage live enemies on contact). State on the engine (e.g.
orbitals: {angle:number}[]sized tostats.orbitalCount), updated inupdateand damaging viaaoeDamage/direct contact; rendered in Task 6.Overcharge: a shot counter in
firePlayerProj; everyoverchargeEvery-th shot flags the spawned projectile(s) as empowered (guaranteed crit + bonus damage). Simplest: trackprivate shotCountand whenoverchargeEvery>0 && shotCount % overchargeEvery === 0multiplydamageand force a crit-style popup.
Step 1: Write failing tests — in
void-breaker-upgrades.test.tsassert each newUpgradeDef.applymutates the expectedPlayerStatsfield frommakePlayerStats()(e.g.ricochetsetsbounceCountto 1;explosive_roundssetsexplodeOnHittrue &explodeRadius>=70;overchargesetsoverchargeEveryto 5 then lowers it on the 2nd stack ifmaxStacks>1). Invoid-breaker-weapons.test.tsassert orbitals: withg.stats.orbitalCount = 1and an enemy adjacent to the player, stepping the engine damages it; and overcharge: withoverchargeEvery = 1, a fired projectile is empowered (damage above base).Step 2: Run, verify fail
Run: node_modules/.bin/vitest run lib/__tests__/void-breaker-upgrades.test.ts lib/__tests__/void-breaker-weapons.test.ts
Expected: FAIL.
Step 3: Implement the upgrade defs (full
UpgradeDefobjects with name/description/rarity/maxStacks/color/icon/apply — no placeholders, write each out), the orbital state + update loop + contact damage, and the overcharge counter. Keep magnitudes conservative (transformers are powerful; rely on Task 7 tuning).Step 4: Run tests + full suite + per-weapon sanity
Run: node_modules/.bin/vitest run lib/__tests__/ 2>&1 | grep -E "Tests +[0-9]"
Expected: all Void Breaker tests PASS.
Step 5: Commit
git add lib/void-breaker/upgrades.ts lib/void-breaker/game.ts lib/void-breaker/types.ts lib/__tests__/
git commit -m "Void Breaker (builds): transformer + synergy upgrades (orbitals, overcharge, ricochet, chain, explosive, homing + fillers)"
MILESTONE 3 — UI & balance¶
Task 6: Weapon picker UI + projectile/orbital rendering¶
Files:
Modify:
components/void-breaker/VoidBreakerUI.tsx(weapon picker on the menu, mirroring the character picker),lib/void-breaker/renderer.ts+lib/void-breaker/renderer3d.ts(render orbitals; light styling for grenade/arc/railgun projectiles via existing Phase-1 build-distinct logic)Verify: manual browser playtest (no unit test — visual)
Step 1: Weapon picker UI. Add a weapon selector beside the character picker in
VoidBreakerUI.tsx, mirroring that component: each weapon shows icon/name/title/description, lock state + Void-Core cost; clicking an affordable locked weapon unlocks + selects it (calls the Task-3 handler). Reduced-motion friendly; match existing menu styling/animation conventions.Step 2: Render orbitals + projectile flair. In both renderers, draw the orbiting blades from the engine’s orbital state, and give grenade (lobbed arc), railgun (long heavy slug), and arc (jagged bolt) projectiles light distinct visuals (reuse Phase-1 build-distinct projectile styling; honor
reducedFx). Reset any pooled visual state for reused projectiles.Step 3: Manual verify. Dev server,
/void-breaker: each weapon is selectable (and unlockable), plays distinctly, and orbitals/transformers render in both 3D + 2D, with and without Reduced Effects.Step 4: Commit
git add components/void-breaker/VoidBreakerUI.tsx lib/void-breaker/renderer.ts lib/void-breaker/renderer3d.ts
git commit -m "Void Breaker (builds): weapon picker UI + orbital/projectile rendering"
Task 7: Balance — per-weapon sim + cross-weapon tuning¶
Files:
Modify:
scripts/void-breaker-balance-sim.ts(accept a weapon arg),lib/void-breaker/weapons.ts+lib/void-breaker/upgrades.ts(tuning only)Verify: sim output + manual playtest
Interfaces:
Consumes: all of M1/M2.
Produces:
scripts/void-breaker-balance-sim.tsaccepts--weapon=<id>(or a positional weapon id) and setsg.weapon = getWeapon(id)beforestartGame; the bot’sUPGRADE_PRIORITYis extended to value the new upgrades so builds actually form.Step 1: Add the
--weaponoption to the sim: parse the arg, defaultpulse, validate withisWeaponId, setg.weaponinrunSim. Print the weapon in the summary line.Step 2: Run each weapon and record results:
for w in pulse scatter railgun grenade arc; do node_modules/.bin/tsx scripts/void-breaker-balance-sim.ts 40 --weapon=$w | grep runs; done
Expected: each weapon completes; capture median waves.
Step 3: Tune. Acceptance band: no weapon’s median wave is an outlier (rough target: every weapon within ~±40% of the Pulse baseline median; none ~0, none runaway). Adjust weapon base stats in
weapons.tsand/or transformer magnitudes inupgrades.tsand re-run until all weapons sit in band. Document the final medians in a comment block at the top of the sim file or in the commit message.Step 4: Full suite + final sim sweep
Run: node_modules/.bin/vitest run lib/__tests__/ 2>&1 | grep -E "Tests +[0-9]" and the per-weapon loop from Step 2.
Expected: all Void Breaker tests PASS; all weapons in band.
Step 5: Manual playtest checkpoint with the user (each weapon + a couple of transformer builds, 3D + 2D, Reduced Effects on/off).
Step 6: Commit
git add scripts/void-breaker-balance-sim.ts lib/void-breaker/weapons.ts lib/void-breaker/upgrades.ts
git commit -m "Void Breaker (builds): per-weapon balance sim + cross-weapon tuning"
Final phase verification¶
Full test suite:
node_modules/.bin/vitest run lib/__tests__/— all Void Breaker tests green (i18n pre-existing failures excepted).Per-weapon sim sweep — every weapon in the acceptance band; Pulse Blaster median ≈ pre-2a baseline (parity).
Typecheck clean for void-breaker files:
node_modules/.bin/tsc --noEmit -p tsconfig.json 2>&1 | grep -i void-breaker(empty).Manual playtest: all 5 weapons selectable/unlockable and distinct; transformer builds feel build-defining; 3D + 2D; Reduced Effects sane.
Self-review notes¶
Spec coverage: weapons.ts (T1) ✓; data-driven firePlayerProj + 5 fire modes (T2) ✓; transformers — bounce/chain/explosive/homing/orbital/overcharge (T2 fields/chain, T4 bounce/explosive/homing, T5 orbital/overcharge) ✓; ~13 upgrades (T5) ✓; meta unlock + selection (T3) ✓; weapon picker UI (T6) ✓; per-weapon balance sim + tuning (T7) ✓; tests (T1–T5) + manual playtest (T6–T7) ✓.
Type consistency:
PlayerStatstransformer fields (bounceCount,chainCount,explodeOnHit,explodeRadius,homingTurn,orbitalCount,overchargeEvery) added in T2, set by upgrades in T5, read by behaviors in T2/T4/T5 — names consistent.Projectileflags (bounces,chains,explodeOnHit,explodeRadius,homing) added T2, stamped infirePlayerProj(T2), consumed T4.chainBolt/nearestLiveEnemy/aoeDamage/explodePlayerBombdefined where first used.weaponfield +getWeapon/isWeaponId/WeaponDefconsistent across T1–T3, T7.Parity guard (default-weapon, no-upgrade behavior unchanged) is enforced by the sim run in T2/T4 and the final sweep.
Headless/Reduced-FX constraints carried into every relevant task.