Branch: combat
Fourth and last gameplay milestone of the ftl-shape vertical slice. Adds a single combat encounter against a placeholder enemy ship; power allocation drives the loop (Weapons charge faster with more power, Shields absorb hits and regenerate, Engines provide an evasion %); auto-fire on ready; win/lose detection with R-to-restart.
Purpose. Make the systems-power data layer pay off. The player and a placeholder enemy ship trade fire under power-driven mechanics; the encounter is short and deterministic-given-RNG, with a clean win/lose state and a one-key restart. Combat is the climactic milestone before polish.
In scope.
WeaponsPower charge-units / sec, threshold WeaponChargeMax = 8. On full charge, fires automatically, deals 1 to enemy, resets.EnemyFireInterval = 6 s for 1 damage at the player.floor(ShieldsPower / 2) (so 0/1=none, 2/3=1 layer, 4=2 layers). Each layer absorbs one hit. Layers regenerate continuously, one per ShieldRegen = 3 s of elapsed time, capped at the current max.EnginesPower * 5. Each incoming hit rolls; on success the shot misses (no shield consumed, no hull damage).PlayerHullMax = 20, EnemyHullMax = 15. Damage clamps at 0.R resets the encounter (combat state, systems, crew).Out of scope. Per-room targeting; system damage; crew taking damage / fleeing rooms; repair; multiple weapons; multiple enemy types or AI variation; missiles / drones / beams / hacking; FTL drive / map / events; sound / music; Pilot and MedBay gameplay effects (their HUD columns still allocate power but produce no effect — flagged for polish).
Chosen approach.
Data — new file combat.go (pure, no Ebitengine):
type GameResult int with GameOngoing, GameVictory, GameDefeat.type CombatSide struct { Hull, MaxHull int; ShieldLayers int; ShieldRegenT float64; HitFlashT float64 }.type Combat struct { Player, Enemy CombatSide; PlayerWeaponT float64; EnemyFireT float64; Result GameResult }.func NewCombat() Combat — initial state: player 20/20, enemy 15/15, all timers 0, Result = GameOngoing.func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) — full tick. If Result != GameOngoing, no-op. Otherwise:
HitFlashT down on both sides (clamp ≥ 0).playerShieldMax(sys); clamp Player.ShieldLayers ≤ max.ShieldRegenT += dt; while ShieldRegenT >= ShieldRegen && ShieldLayers < max: ++ShieldLayers; ShieldRegenT -= ShieldRegen.dt * float64(sys[int(RoleWeapons)].PowerLevel). On >= WeaponChargeMax: call resolveIncoming(&c.Enemy, 0, rng), reset PlayerWeaponT = 0.dt. On >= EnemyFireInterval: call resolveIncoming(&c.Player, 5*sys[int(RoleEngines)].PowerLevel, rng), reset EnemyFireT = 0.Enemy.Hull == 0 && Player.Hull > 0: Result = GameVictory; if Player.Hull == 0: Result = GameDefeat.func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand) — if rng.Intn(100) < evasionPct: return (dodged). Else: if ShieldLayers > 0: --ShieldLayers; else Hull = max(0, Hull-1); HitFlashT = HitFlashDuration.func playerShieldMax(sys []System) int — sys[int(RoleShields)].PowerLevel / 2.const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 ).Enemy ship layout — modify ship.go:
func NewEnemyShip() Ship — same 5-room layout shifted by +24 tiles; enemy occupies tiles [27, 38] × [6, 15], pixels [432, 608] × [96, 240].Rendering — modify render.go:
func drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64) — every room rendered with full roleColor (no power dimming). When hitFlashT > 0: translucent red overlay across all rooms.drawShip to take an extra hitFlashT float64 arg → same red overlay when player takes a hull hit.func drawHullBars(screen *ebiten.Image, c Combat) — top-left "PLAYER 20/20" + filled bar; top-right "ENEMY 15/15" mirrored.func drawWeaponCharge(screen *ebiten.Image, c Combat) — horizontal fill bar near the Weapons HUD column, fraction = PlayerWeaponT / WeaponChargeMax.func drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System) — N dots above the player Shields room, lit = active layer, dim = max-but-regenerating slot.func drawEvasionReadout(screen *ebiten.Image, sys []System) — text "Evade: NN%" above the Engines room.func drawResultOverlay(screen *ebiten.Image, result GameResult) — semi-transparent dark fill + centered "VICTORY (R to restart)" / "DEFEAT (R to restart)".Input + integration — modify main.go:
Game gains combat Combat, enemy Ship, rng *rand.Rand.main() initialises combat := NewCombat(), enemy := NewEnemyShip(), rng := rand.New(rand.NewSource(time.Now().UnixNano())).Update:
combat.Result == GameOngoing: call updateCombat(&g.combat, g.systems, dt, g.rng) after the existing crew tick.inpututil.IsKeyJustPressed(ebiten.KeyR) and combat.Result != GameOngoing: full reset — g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1.Result != GameOngoing to keep the post-game state stable until restart.Draw: existing drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT); new drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT); new HUD overlays (hull bars, weapon charge, shield dots, evasion %); existing drawHud; drawCrew for each crew; drawResultOverlay last.Tradeoffs that settled the decisions.
floor(power/2) — mirrors FTL's "2 power per layer", keeps the existing System data unchanged, makes each shield power level visibly different.*rand.Rand makes the tick deterministic in tests.R to restart vs auto-restart — explicit input prevents accidental loops; matches FTL's "main menu after defeat" condensed to one keypress.polish-tier. Marking them inert keeps scope honest.Backwards compat. Greenfield additions; no public API; only behavior change is Game.Update's ordering (combat tick interleaved with crew tick). HUD click handling, crew movement, power allocation semantics unchanged. R is a new key binding — not previously used anywhere in the project.
TDD: yes — updateCombat, resolveIncoming, playerShieldMax, NewCombat, and the restart-reset block are pure functions with meaningful branching (idle vs ongoing, weapon-charge threshold, shield regen tick, hull underflow, evasion roll on/off, win/lose state transitions). Rendering and key input stay manual smoke. Seeded *rand.Rand makes evasion deterministic.
combat.go and ship.go must not import Ebitengine. Combat logic and ship layouts are pure.updateCombat and resolveIncoming are the only mutators of Combat and CombatSide fields. Renderer reads only.0 ≤ Hull ≤ MaxHull for both sides at all times.0 ≤ ShieldLayers ≤ playerShieldMax(sys) after every updateCombat call. Layers clamp down when power is removed.Combat.Result only transitions out of GameOngoing once per fight; once GameVictory or GameDefeat, stays until reset by R.updateCombat(c, sys, dt, rng) is deterministic for a given input state and *rand.Rand state; the only randomness source is the passed *rand.Rand.GOOS=js GOARCH=wasm and native; no //go:build, no platform-suffixed files.hud.go (carried over from systems-power's IV6).combat.go doesn't know pixel layout; render.go reads Combat only.inpututil.IsKeyJustPressed(ebiten.KeyR) works under GOOS=js GOARCH=wasm. Same package as the mouse path that already works in WASM.math/rand's *rand.Rand is portable to WASM. Verified by Go's WASM port matrix.Approach: TDD-first on the pure pieces (combat tick, then enemy ship layout), then one integration phase that wires rendering, input, and the restart key. Greenfield additions; the only signature change is drawShip (one call site).
combat_test.go (create) — tests first, must fail before 1.2.
TestNewCombat_initialState — Player.Hull=20, MaxHull=20, ShieldLayers=0, Enemy.Hull=15, MaxHull=15, all timers 0, Result==GameOngoing. Covers IV3.TestPlayerShieldMax_table — shields power 0..4 → 0,0,1,1,2.TestResolveIncoming_shieldsAbsorb — ShieldLayers>0 → --ShieldLayers, hull unchanged, HitFlashT==0.TestResolveIncoming_hullDamage — ShieldLayers==0 → Hull--, HitFlashT==HitFlashDuration.TestResolveIncoming_hullClampsAtZero — Hull==0 → stays 0 (IV3).TestResolveIncoming_evasionMiss — seeded rng rolling under threshold → no change to side state.TestResolveIncoming_evasionHit — seeded rng rolling over threshold → damage applied.TestUpdateCombat_postResultNoOp — Result=GameVictory → state frozen across a tick (IV5).TestUpdateCombat_hitFlashTicksDown — both sides' HitFlashT decremented and clamped ≥0.TestUpdateCombat_shieldRegen — power=4, layers=0, dt > ShieldRegen → layers increments toward playerShieldMax.TestUpdateCombat_shieldsClampOnPowerDrop — layers=2, shields power dropped to 0 → next tick clamps to 0 (IV4).TestUpdateCombat_weaponChargeFires — weapons=1, PlayerWeaponT just below WeaponChargeMax → next tick fires, Enemy.Hull--, PlayerWeaponT=0.TestUpdateCombat_weaponZeroPowerNoCharge — weapons=0 → PlayerWeaponT unchanged.TestUpdateCombat_enemyFiresOnTimer — EnemyFireT crosses EnemyFireInterval, engines=0, no shields → Player.Hull--, EnemyFireT=0.TestUpdateCombat_victoryTransition — enemy hull=1, weapon ready → Result==GameVictory after fire.TestUpdateCombat_defeatTransition — player hull=1, enemy fires, no shields, no evasion → Result==GameDefeat.TestUpdateCombat_determinism — same seed + same input sequence → identical state (IV6, AS2).combat.go (create)
package main; imports math/rand only. No Ebitengine. Respects: IV1.const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 ). Respects: PC4.type GameResult int with GameOngoing, GameVictory, GameDefeat.type CombatSide struct { Hull, MaxHull int; ShieldLayers int; ShieldRegenT float64; HitFlashT float64 }.type Combat struct { Player, Enemy CombatSide; PlayerWeaponT float64; EnemyFireT float64; Result GameResult }.func NewCombat() Combat — returns initial state per design step list.func playerShieldMax(sys []System) int — sys[int(RoleShields)].PowerLevel / 2. Pure query (GPC3).func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand) — design step list. Sole mutator pair on CombatSide. Respects: IV2.func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) — design step list 1–6. Sole mutator on Combat. Respects: IV2, IV3, IV4, IV5, IV6.combat: Combat data + updateCombat with testsship_test.go (create) — tests first, must fail before 2.2.
TestNewEnemyShip_roomCount — 5 rooms.TestNewEnemyShip_shifted — for each room, enemy.Rooms[i].GridX == player.Rooms[i].GridX + 24; GridY/GridW/GridH/Role/Name unchanged.ship.go (modify, append)
func NewEnemyShip() Ship — same 5 rooms as NewPlayerShip, every GridX shifted by +24. Pure data; no Ebitengine. Respects: IV1.combat: NewEnemyShip layoutrender.go (modify)
drawShip(screen *ebiten.Image, s Ship, sys []System, hitFlashT float64) — signature change: extra hitFlashT. After existing room loop, when hitFlashT > 0, overlay translucent red across all rooms (alpha scaled by hitFlashT / HitFlashDuration). Respects: PC3.drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64) — every room rendered with full roleColor (no power dimming, no label-only treatment beyond border + name). Same red overlay rule on hitFlashT > 0.drawHullBars(screen *ebiten.Image, c Combat) — top-left "PLAYER %d/%d" + filled bar; top-right "ENEMY %d/%d" mirrored.drawWeaponCharge(screen *ebiten.Image, c Combat) — horizontal fill bar above the Weapons HUD column at (HudX + int(RoleWeapons)*HudColW, HudY-…). Reads HudX, HudColW, HudY from hud.go. Respects: IV8.drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System) — playerShieldMax(sys) dots above the player Shields room (room read from layout, not hard-coded — call sites use s.Rooms indexing by role). Lit dot for active layer; dim dot for max-but-regenerating slot.drawEvasionReadout(screen *ebiten.Image, sys []System) — "Evade: NN%" text above the Engines room; value sys[int(RoleEngines)].PowerLevel * 5.drawResultOverlay(screen *ebiten.Image, result GameResult) — semi-transparent dark fill + centered "VICTORY (R to restart)" / "DEFEAT (R to restart)". No-op when result == GameOngoing.main.go (modify)
Game gains combat Combat, enemy Ship, rng *rand.Rand. Init in main() via NewCombat(), NewEnemyShip(), rand.New(rand.NewSource(time.Now().UnixNano())).Update:
g.combat.Result == GameOngoing: updateCombat(&g.combat, g.systems, dt, g.rng).addPower call. Add early-return guard at the start of HUD branch — if g.combat.Result != GameOngoing, no-op. Same for right-click HUD gate. Respects: PC1.inpututil.IsKeyJustPressed(ebiten.KeyR) and g.combat.Result != GameOngoing: g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1. Respects: AS1.Draw: replace drawShip(screen, g.ship, g.systems) with drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT); insert drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT); after existing drawHud add drawHullBars, drawWeaponCharge, drawShieldsIndicator, drawEvasionReadout; finally drawResultOverlay(screen, g.combat.Result) last so it sits on top.combat: render, input, restartPer Design TDD: yes. PH1 + PH2 tests written first and must fail on the pre-implementation tree. PH3 has no automated tests — rendering + key input need real display. Manual smoke via make play-web:
Player.Hull == 0 → DEFEAT overlay; HUD clicks become no-ops; press R → encounter resets, ship + crew + systems all back to start.Enemy.Hull == 0 → VICTORY overlay; same restart flow.Combat, NewCombat, updateCombat, playerShieldMax, HitFlashDuration, WeaponChargeMax, GameResult, NewEnemyShip.const in one file (PC4), so retuning is one diff.drawShip signature change ripples to one call site in main.go:Draw. Mitigation: PH3 commits both edits together; native + WASM build run before commit.func NewCombat() Combat — defined in combat.go, consumed by main.go init + R-restart.func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) — defined in combat.go, consumed by main.go:Update.func playerShieldMax(sys []System) int — defined in combat.go, consumed by render.go:drawShieldsIndicator.type Combat, type CombatSide, type GameResult (and constants GameOngoing/Victory/Defeat, HitFlashDuration, WeaponChargeMax) — defined in combat.go, consumed by render.go (read-only) and main.go.func NewEnemyShip() Ship — defined in ship.go, consumed by main.go init.func drawShip(screen, Ship, []System, float64) — signature change in render.go, consumed by main.go:Draw (one call site).Result: passed in-session; unit tests + browser smoke deferred to user (see Notes).
Invariants / assumptions:
grep 'hajimehoshi/ebiten' combat.go ship.go combat_test.go ship_test.go → empty.grep -E '\.(Hull|MaxHull|ShieldLayers|...)\s*[+\-*/]?=' outside combat.go → empty (the only hit is a == comparison in main.go:42, a read).Result = writes only in combat.go:123, 125; main.go reset path is whole-struct replacement via NewCombat(), gated on Result != GameOngoing.GOOS=js GOARCH=wasm go build . → succeeds.HudY/HudX/HudColW/HudColCount defined only in hud.go:8-11; all other refs are reads.math/rand ships in WASM build (covered by CK4).Interfaces:
NewCombat() callers: main.go:48, 128 (init + R-restart).updateCombat(&c, sys, dt, rng) called from main.go:43; signature matches.playerShieldMax(sys) called from render.go:160 (drawShieldsIndicator) + combat.go:94 (internal).GameOngoing/Victory/Defeat, HitFlashDuration, WeaponChargeMax referenced read-only across render.go and main.go.NewEnemyShip() called from main.go:129.drawShip(screen, ship, sys, hitFlashT) 4-arg signature: 1 call site at main.go:105, matches definition render.go:54.Build: go build . && GOOS=js GOARCH=wasm go build . && go vet ./... → all clean.
Notes:
combat_test.go tests + PH2's 2 ship_test.go tests + 32 prior tests) cannot execute in this Claude Code session: Ebitengine v2.9.9 internal/ui package's init() calls currentMouseLocation() which dereferences nil when no Window Server is attached (/Users/blikh/go/pkg/mod/github.com/hajimehoshi/ebiten/v2@v2.9.9/internal/ui/ui_darwin.go:286). Compile + go vet ./... are clean. User: run go test ./... in a regular terminal to confirm green.make play-web and a real browser session — also deferred to the user.Outcome: combat encounter implemented and integrated; reviewer cleared the diff with no Critical or Important findings (HEAD d77efb6).
Invariants:
combat.go and ship.go import only math/rand; no Ebitengine references.updateCombat and resolveIncoming mutate Combat / CombatSide fields; main.go reset uses whole-struct replacement via NewCombat().resolveIncoming guards Hull > 0 before decrement; hull cannot underflow.Result != GameOngoing early-return freezes state; tie-tick resolves to GameDefeat per if/else if ordering.updateCombat takes *rand.Rand as the only randomness source; TestUpdateCombat_determinism covers it.GOOS=js GOARCH=wasm go build . clean.HudY/HudX/HudColW/HudColCount defined only in hud.go; render and main read them.inpututil.IsKeyJustPressed(ebiten.KeyR) is the same package as the mouse path that already works in WASM; user-side browser smoke confirms.math/rand builds and vets clean for GOOS=js GOARCH=wasm.combat.go.const in combat.go (PC4) — retuning is one diff.drawShieldsIndicator and drawEvasionReadout each gained an s Ship parameter (plan signatures took only c Combat, sys []System and sys []System). Why: the first implementation called NewPlayerShip() per frame inside the renderer to locate the Shields/Engines room, allocating a 5-room slice each draw and hiding the coupling between layout and renderer. Fix threads g.ship through (commit 9eb78ec). Aligns with GPC1 and the plan bullet's intent ("call sites use s.Rooms indexing by role").Verified by:
go test ./... deferred — Ebitengine GLFW init() panics on currentMouseLocation() in this session (no Window Server). User: run in a regular terminal to confirm 51/51 (32 prior + 17 combat + 2 ship).make play-web and play through several encounters at different power splits to settle UK1 + UK2 and confirm AS1 + AS3.