# Master of Vega — build plan and findings **Read this before touching `src/games/mastervega/`.** It records the traps found while building it; several were only discovered by instrumenting the soak and would be very easy to reintroduce. Master of Orion clone. Registered as `mastervega`, category `arcade-console-pc` ("Video Games"), iconFrame **92**. MOO1 rules with three MOO2 conveniences (colony building queue, colony detail view, hireable leaders). Preset ship hulls with an auto-refitting **Mark**, no ship designer. Tactical grid combat. Empires are the ten **species** themselves — this game deliberately does *not* use `data/opponents.json` or `ui/Portrait.js`. ## Architecture Split enforced by import discipline, modelled on `totalannihilation/` rather than `civilization/` (whose scene grew to 1792 lines). **Headless — zero Phaser imports, importable by Node:** | File | Role | |---|---| | `VegaRules.js` | `compileRules(json)` — validate + index the rules file | | `VegaGalaxyGen.js` | Deterministic galaxy: shapes, stars, planets, Gabriel-graph starlanes, homeworlds | | `VegaLogic.js` | The engine: colonies, sliders, buildings, research, fleets, combat dispatch, council, victory | | `VegaShips.js` | Preset hulls + Mark auto-refit; the knapsack loadout | | `VegaCombat.js` | Tactical battle **stepper** | | `VegaAI.js` | AI empire controller | | `VegaDiplomacy.js` | Treaties, attitudes, council politics | | `VegaLeaders.js` | Leader pool, offers, postings | | `VegaZoom.js` | Star-map zoom ladder (Phaser-free so it is headlessly checkable) | **Render tier:** `MasterOfVegaGame.js` (scene), `VegaStarMap.js`, `VegaSidePanel.js`, `VegaNebula.js`, `VegaSystemView.js`, `VegaColonyView.js`, `VegaCombatView.js`, `VegaScreens.js`, `VegaArt.js`, `VegaFx.js`. ### The star map's command panel `VegaSidePanel.js` is MOO1's right-hand column: docked, hidden until something is selected, three modes — **star** (class, worlds, colonies with their production read-out, forces in orbit, and a *View System* button), **fleet** (one row per ship stack with a −/+ count), **order** (distance, ETA, who is waiting there, Accept / Cancel). The panel owns no state. `MasterOfVegaGame` decides what is selected and calls `showStar` / `showFleet` / `showOrder`; the panel only reads the engine and reports clicks back through callbacks. Selecting a fleet arms it: the next star click is a destination, not an inspection — except the fleet's *own* star, which drops the selection and shows the system, because otherwise a fleet could never be deselected by clicking where it already is. **Splitting is the count selector, not a separate command.** `sendDetachment()` sends the selection and leaves the rest behind as its own fleet. There is deliberately no "split and stay" button: `consolidateFleets` merges idle fleets in the same system at the start of the owner's next turn (trap 17), so a detachment that does not leave immediately simply un-splits itself. A detachment gets a fresh fleet id and therefore does **not** inherit the parent's fleet leader. Three things in `sendDetachment` are load-bearing and each is verified in section 4b: * The request is validated **against the actual stacks before totals are compared** — otherwise asking for nine of a stack of three reads as "all of them" and silently sends the whole fleet. * Duplicate entries for one stack are **summed** before the availability check, or 2 + 2 of a stack of 3 passes twice. * A refusal is **total**: the reachability probe runs on a copy, so a rejected order never leaves the fleet carved in two with the pieces going nowhere. The star map gained `blockPointer` (a drag or a wheel starting over the panel must not pan or zoom the galaxy underneath it) and `onEmptyClick`, which uses the `currentlyOver` list Phaser passes to the input plugin's `pointerup` — an empty list is a genuine click on the void, which is how the panel is dismissed. ### Colony management is two screens, not one Everything about a world used to be crammed into the right-hand column of the system-view modal: header, summary, five live sliders, the build queue, *and* the whole add-to-queue catalogue. It did not fit, and it knew it — the catalogue loop ended in a bare `break` once it ran off the bottom of the panel, silently dropping buildings the player was entitled to build. It is now split by what the player is actually doing: * **`VegaSystemView.js`** *inspects* a world. The right column shows the planet at the full 192px the `planets` sheet is cut at, its type/size/richness/ gravity, and — for our own colonies — a **read-only** picture of the same numbers: allocation as five tinted bars, the queue as four lines with cumulative ETAs. Nothing there responds to a click except **View Colony**. * **`VegaColonyView.js`** *runs* one. Full screen, on the world's own backdrop art, with a semi-transparent panel carrying the live sliders (each with a padlock) and a **Build Queue** button that flies out a 760px queue manager to its left: reorder with ▲▼, remove with ✕, and a scrolling catalogue where every buildable quotes its cost, its ETA at the current construction rate, and what it does. The colony screen opens *directly* from the system view rather than through `MasterOfVegaGame.openModal`, which refuses a second modal while one is up — and this one has to stay up, because it is what keeps the star map inert underneath. `D.colony` (70) sits above `D.modal` (60) so the two stack; the system view hides its layer and pauses its orrery tick while the colony screen is in front, and rebuilds on the way back. Repeat counts are a **display** concern only. `enqueueMany` pushes N separate entries, so `processColony` and the save format know nothing about repeats; `collapseQueue` folds a run of identical ships back into one "× N" row for rendering. A part-built entry never joins a run, so the head item's progress bar always means something. Four functions back all of this and they all live in `VegaLogic.js`, not in the views — `collapseQueue`, `moveQueueRun`, `colonyBuildRate` and `queueEtas` — for two reasons. The two screens quote the same numbers and must agree, and the index arithmetic in `moveQueueRun` fails *silently* when it is wrong (see below), so it needs to be somewhere the Node verifier can reach. > `moveQueueRun` is the sharp edge. Moving a "× 5" row is five splices, and the > two directions do **not** use the same indices: going **up**, the copies still > behind keep their slots so both ends advance together (`+ k`); going **down**, > pulling the head copy out slides the rest of the run into the slot it vacated, > so the *same* pair of indices moves every copy. Using `+ k` for both — the > obvious first guess — interleaves the run with its neighbour > (`s,s,s,f,f` → `s,f,s,s,f`) without throwing. Section 6 asserts the > contiguous result in both directions. State is plain JSON with the RNG cursor inside it (`state.rngState`, explicit-step mulberry32), so replaying a seed reproduces the galaxy, the battles and the winner exactly. `serialize()` strips `rules` and every underscore-prefixed memo cache. ## Traps found during the build Each of these was a real bug that produced a plausible-looking but broken game. 1. **An immobile hull froze the entire fleet.** `fleetSpeed()` returned 0 if *any* ship in a fleet was immobile. A completed Star Base joins the fleet over its own colony — which is the empire's main battle fleet — and from that moment `canSendFleet` refused every order. Instrumented over one game: **338 of 338** valid attacks (in range, strong enough, at war) were refused for this reason. No colony could ever be attacked, so no war could be won. Fix: `fleetSpeed` skips immobile hulls; `sendFleet` splits them into a garrison that stays behind; `consolidateFleets` keys on mobility so the garrison is never re-merged. 2. **Conquest was unreachable without bombardment.** Even at a 2500-turn cap, *no empire was ever eliminated*: a cornered empire's beaten fleet always retreats to its last fortified homeworld and denies the attacker the clean orbit an invasion needs. `bombardPopKill` and the Stellar Converter's `planetCracker` were declared in the rules but never implemented. Implementing orbital bombardment (MOO1's actual answer) made conquest work immediately. 3. **The invasion window never opened.** Two versions of this failed the same way. Requiring `defenseHp === 0` never worked because combat resolves on the attacker's turn and the *defender's* turn rebuilds the batteries first. Requiring an empty sky failed too — a besieged colony finishes a ship every few turns and that one hull blocked the landing indefinitely. Measured: 1268 colony-turns under hostile orbit, **2** invasion attempts. Now the rule is orbital *superiority*, and surviving defences fight as ground support. 4. **Transports never left home.** `manageFleets` had an early `if (power <= 0) continue`, and transports carry no guns — so transport fleets skipped every movement branch. 3495 idle observations against 12 in transit. They now escort the main battle fleet. 5. **The AI threw transports away.** Without a forecast it launched every landing regardless of odds: 1971 failures against 130 successes. `invasionForecast()` now gates it — success rate went to ~91%. 6. **Nobody ever met anybody.** Contact required flying a ship into an occupied system, so most galaxies had no diplomacy and no wars at all. Empires now also make contact by proximity (`economy.contactRange`). 7. **The Council could never elect anyone.** Every empire stood as a candidate and therefore voted for itself — 30 sessions in an 800-turn game, all null. MOO1's rule: the **two largest** empires stand and everyone else votes between them. 8. **…and then it elected someone every time.** A dominant empire won its own council vote a hundred turns before it could finish a war, so conquest never completed. MOO1's **refusal to submit** (the defeated candidate walks out and the election is void) restored the balance. 9. **Combat had a decisive first-strike bias.** Firing was sequential, so whichever side acted first in the round contact was made won. Mirror matches swung from 100% to 0% attacker depending on tier, because the parity of the contact round changes with fleet speed. Damage is now **banked and applied together** at the end of the round. 10. **…and then ties were resolved in a fixed direction.** Symmetric fleets reach the disengage round *exactly* tied astonishingly often; giving the attacker every drawn battle produced a 44pp bias. The coin is now flipped. 11. **Every battle stalemated on the round cap.** Two causes: in-combat repair (6% of hull/round) outpaced damage, and the attrition tail of an even fight is enormously long. Repair was cut to 3%/6%, and a **disengage rule** now makes the weaker side withdraw at round 25 rather than grinding to the cap. 12. **An all-missile ship ran dry and stalemated.** At some tiers missiles score better damage-per-space than beams, so a pure knapsack built ships that emptied their racks in five rounds and then sat unarmed. `MISSILE_SHARE` caps missiles at 40% of tonnage. This is a design rule, not an optimisation. 13. **Researching a weapon could make ships worse.** A greedy "best damage-per-space" fill mounts one oversized gun and strands the leftover tonnage — the cruiser's beam damage *dropped* from 42 to 28 on learning Death Ray. The loadout is now an unbounded **knapsack**, which is provably monotonic in tech because researching only ever adds candidates. The one sanctioned exception is the tier where missiles first appear (see the verifier's section 4). 14. **Waste ran away and killed every colony.** Population fell 50 → 20 with 868 accumulated waste. Cleanup is now **mandatory** and taken off the top, pro-rata from the other channels — MOO1's eco slider snapping to the minimum. Related: factories beyond what the population can staff are **mothballed** (`effectiveFactories`), or a shrinking colony keeps polluting from factories nobody is left to run and can never recover. 15. **Espionage leaked the whole tech tree.** Crediting raw espionage score made every empire a spy agency; all five reached 60/60 techs, making the per-species availability roll meaningless. Only the surplus over a baseline now counts. 16. **The Council never convened.** `colonizedFraction` measured against *every* star, but a third of systems hold only gas giants and belts, so "half the galaxy colonised" was unreachable. It now measures against settleable stars. 17. **Fleets proliferated to 281.** Ships completing while the local fleet is in transit each spawn a new fleet. `consolidateFleets` merges idle fleets each turn. 18. **Dead empires left ghost fleets** on the map forever. Caught by the soak's invariant checker, not by playing. 19. **Worldgen fairness could not always be satisfied.** On large sparse galaxies a homeworld can have *no* star inside its opening fuel range, so there was nothing to upgrade and that empire simply could not expand. `guaranteeNearbyWorlds` now seeds new worlds when it must. 20. **Zoom could pull back past the edge of the map.** The zoom ladder was a fixed `[0.35 … 2.0]`, which only ever suited the *huge* galaxy — on a small one the bottom two steps showed the map floating in empty space, and `clampPan` allowed another 160px of slack on every side on top of that. The ladder is now built per galaxy in `VegaZoom.js`, anchored so its bottom rung is exactly the zoom at which the galaxy covers the viewport, and the pan clamp has no slack. Kept Phaser-free so the verifier can assert the invariant directly (section 3b). 21. **`slider()` only works inside a container at the origin.** Its drag maths is `apply(p.x - c.x)` — a *screen* coordinate minus a *local* one. That is only correct while the parent container sits at (0, 0), which every `modalShell` layer does. Nest one inside a panel container at x=1416, or anything that tweens, and the knob jumps by the parent's offset and drifts during the animation. It is why `VegaColonyView`'s `panelLayer` is at the origin with every child in absolute coordinates, and why the flyout — the one layer that moves — deliberately contains no sliders. 22. **A mask is a rendering concern; Phaser still hit-tests through it.** The colony screen's catalogue is the game's first scrolling list, and a row scrolled out of sight remains perfectly clickable unless something says otherwise. Every row handler asks `scroller.contains(pointer)` first. Two related traps in the same helper: the wheel catcher must be added *below* the content or it swallows every row click, and a `GeometryMask`'s Graphics has its own world transform and does **not** follow a moving parent — the flyout tween drags it along by hand in `onUpdate`. 23. **A Phaser `Video` does not report a width of zero before it decodes.** Every video in this game is sized by scaling *from* its source width rather than calling `setDisplaySize`, guarded as `obj.width || SRC`. That guard never fires: a freshly created `Video` carries a **placeholder** size until its first frame decodes, at which point `updateTexture()` builds the real texture and re-sizes the object to it. So the scale is set from the placeholder and stays wrong by `placeholder / realWidth` once the real texture lands. It hid for as long as it did because every clip in the game **was** 256 px square — placeholder and fallback were the same number, so both branches agreed. The 960×544 colony clips exposed it: the vignette opened 2.5× too large (`640 / 256`, which is how the placeholder is known to be 256 — phaser is not vendored in this repo to read it off), and corrected itself only on the *second* lap of the loop, when the re-fit ran against a texture that was real by then. Removing the loop is what made it permanent and visible. `VegaArt.sourceWidth()` is now the single guard: it gates on `videoTexture`, null until that moment and the only reliable way to tell the two states apart, so the placeholder's actual value never matters. Section 2 asserts both branches. 24. **`bestComponents()` computing a flag is not the same as `designFor()` returning it.** Wiring the `cloaked`/`singularity` tech effects into combat (2026-08-09) found `singularity` tallied correctly into `comps.singularity` in `VegaShips.js` — same as `cloaked` — but the object literal `designFor()` returns only ever copied `cloaked: comps.cloaked` onto the design; there was no `singularity:` line at all, so every design's `design.singularity` was `undefined` forever, silently. A combat check that read `shooter.design?. singularity` would never have fired and nothing would have said why. Caught only because the new verifier check builds a design with **both** flags stripped as a sanity assertion (`baseDesign.cloaked && baseDesign. singularity`) *before* running the A/B battle that depends on them — without that assertion the A/B test would have "passed" by measuring nothing. Lesson: any per-tech boolean/stat added to `bestComponents()` needs a matching line in `designFor()`'s return object, and there is no compiler to catch the omission — assert the field is actually present on a fully-teched design, not just that the battle outcome looks plausible. Also: attrition combat **compounds** small per-shot edges hard. An isolated A/B fleet fight (same design, one flag flipped) at `cloakEvasion: 0.15` produced a 99% win rate — more decisive than the suite's own ">0.8" bar for a full two-tech-tier lead across every field at once. Tuned down empirically (not from the single-hit-chance formula) against an isolated same-fleet A/B harness until it landed near what a mild species combat-trait is worth in that same harness (~62%): `cloakEvasion: 0.02`, `singularityShieldPierce: 0.5`. Both are checked in section 5 with a `> 0.52 && < 0.8` band, loose enough to absorb RNG noise but tight enough to catch another 0.15-style overshoot. 25. **A per-function `scene.input`/`scene.events` listener has to be unregistered by whoever adds it, if that function can run more than once per scene session.** `VegaCombatCamera.js` (2026-08-09, built for the V2 per-ship combat prototype below) copies `VegaStarMap.js`'s zoom/pan pattern — but `VegaStarMap` is instantiated exactly once per game and its listeners live for the whole session, while `openCombatViewV2()` is a plain function invoked **once per battle**, and the game opens several battles in a row within one scene session (`MasterOfVegaGame.playPlayerBattles()` loops over pending fights). Copying the star map's "bind once, never unbind" habit would stack another listener — with a stale closure over an already-destroyed `battleRoot` container — on top of every earlier battle's, first as a leak, then as a crash the moment a stale listener fires. `bindZoomPan()`s `destroy()` explicitly `scene.input.off(...)`s every listener it added (plus `scene.events.off('update', ...)` for the parallax tick) for this reason, and both of `VegaCombatViewV2.js`'s teardown paths (`finish()` and the escape-hatch `destroy` it returns) call it unconditionally. Caught by design review before it was ever wrong, not by debugging a live crash — worth writing down anyway, since it is exactly the kind of thing a future contributor copying the star map's pattern elsewhere would miss on the first pass. 26. **A simultaneous wipe-out must be checked before either single-side win condition, or it silently favours one side.** Both engines' round- resolution end with `if (!aLeft.length) {...defender wins...} else if (!dLeft.length) {...attacker wins...}` — if BOTH sides' last living unit dies to the same banked round, `!aLeft.length` is true first and the `else if` never even checks `dLeft`, so the defender is declared the winner despite being equally dead. This bug is verbatim in `VegaCombat.js` too, but at stack granularity it needs an entire multi-ship stack to die in the same round — rare enough that the live engine's test suite (built around 5-ship stacks) never surfaced it. At the V2 per-ship prototype's one-ship-per-side granularity it is common (measured ~19% of 1v1 mirror-match trials) and was the single largest contributor to an early 1v1 mirror-match bias caught during this rewrite's own calibration (attacker winning only 38% instead of ~50%). Fixed in `VegaCombatV2.js` with an explicit `!aLeft.length && !dLeft.length → draw` check ahead of the single-side cases. **Not** fixed in `VegaCombat.js` — out of scope for a same-session parallel- prototype build, and its real-world impact there is low given multi-ship stacks — but worth fixing there too if this engine ever graduates, or even standalone, since it is a genuine one-line correctness bug. 27. **Movement must be banked like damage, or whichever side's tie-break "goes first" in a round quietly wins.** `VegaCombat.js`'s own comment explains at length why *damage* is banked (applied together after everyone has fired, so firing order can't decide who survives to shoot back) — but the V2 prototype's first pass at *movement* missed the same lesson: each ship moved immediately, in `order` (initiative, tie-broken by a stable per-battle `seq`), so a ship that moved earlier in a round reacted to the *current* (already-updated-this-round) position of any enemy that had already moved, while a later mover was still working from stale data — and since ties always favour the lower `seq`, one side consistently had the informational edge. An isolated mirror-match soak (identical fleets, no advantage either direction) swung from 0% to 65% attacker wins purely from fleet size, with nothing else changed. Fixed by making movement two-phase exactly like damage: every ship computes its new facing/x/y against the same pre-round snapshot (`computeMove()`, pure, no mutation), and only once every ship has computed one are any of them actually applied. Same fix shape as `VegaCombat.js`'s own banked- damage lesson, just for a different piece of per-round state — a reminder that "banked, not sequential" is a property every per-round mutation needs, not something you get once and keep for free. 28. **Hard nearest-neighbour targeting is a chaotic system at 5-20 discrete ships per side, not a smooth attrition curve.** Even after fixing traps 26 and 27, a 5v5 mirror match still showed a 28-point win-rate bias, and changing nothing but the placement grid's cell-spacing constant flipped an otherwise-identical mirror match from 100% attacker wins to 0%. Root cause: with strict "always target the mathematically nearest enemy" for both movement and firing, *which* ships end up duelling *which* other ships is a hard, deterministic function of exact sub-pixel geometry — a tiny perturbation can flip who wins the first exchange, which changes who's left to form the next pairing, cascading into a completely different battle. This is a real property of nearest- neighbour target-locking at small N, not a coding mistake to hunt down further. Mitigated (not eliminated) with `pickWeighted()` in `VegaCombatV2.js`: target choice is weighted by `1/distance²` rather than winner-take-all nearest, for both movement's "which enemy to close on" and firing's "which in-range enemy to shoot." This tames the sensitivity (worst observed mirror-match bias dropped from 78pp to ~15pp) without abandoning "ships prefer close targets," but real residual variance remains — the V2 verifier section's mirror-bias check uses a deliberately looser tolerance (25pp) than the live engine's (12pp), and the softening also dilutes small deliberately-subtle edges like the cloak/singularity calibration (see trap 24) from ~62% down to ~53-55% — still a real, correctly-directioned, non-noise signal, just a much smaller one. A smarter, non-degenerate placement/targeting model (formation-aware, not raw nearest-neighbour) is the natural next step when ship placement itself is revisited — deliberately deferred for now, per the feature's own scoping. 29. **In a continuous-time engine, movement is no longer free — a placement gap overflow starves combat of time to actually happen, not just of space.** Converting V2 from discrete rounds to continuous seconds (see the "continuous fluid battle" section below) meant closing distance now spends real time from the *same* budget that also governs firing cooldowns and the disengage timer, unlike the old discrete-round model where movement was effectively free. This turned a latent placement bug into an acute one: for large/mixed fleets (e.g. 17 ships/side including battleships), `gap = GAP_MULT(3) × max(gridW)` could compute a gap far exceeding `worldWidth`, placing ships at literally negative X. With that much distance to close, only the fastest hulls ever got into range before the disengage timer fired on a tiny, unrepresentative skirmish — which is what actually decided the whole battle, reintroducing a 22pp mixed-hull mirror-match bias that traps 27/28's fixes didn't touch (they weren't the cause here). Fixed by clamping the gap in `placeFleets()` to never exceed 85% of `worldWidth`. Lesson: a formula that "only" affects starting positions can still be a *pacing* bug once the engine treats travel time as a scarce resource. 30. **A movement "stop at range" distance and a firing "in range" check must agree, or ships can park forever just outside their own weapons' reach.** After fixing trap 29, battles still occasionally stalled outright (not just biased) — surviving ships sat at full HP, unchanged for hundreds of simulated seconds, all parked at `dist` reading exactly `beamRange` to one decimal place. Root cause: `computeShipMove()`'s stopping distance (`want = dist - beamRange`) assumes movement happens straight down the seek line, but actual movement follows `step.facing`, which drifts off that line whenever separation steering blends in — i.e. almost any time a ship has a neighbour inside its `avoidRadius`, exactly the crowded end-of-battle situation where the last few ships per side are bunched up. The component of motion actually along the seek line ends up slightly *less* than the computed `moveDist`, so a ship can settle into a stable equilibrium a hair outside its hard weapon range and never close the last fraction of a unit — permanent for any weapon with finite ammo once its only unlimited-ammo mount (a beam) is excluded forever by a strict `dist > range`. Fixed with a small tolerance (`RANGE_EPS = 4` world units) on both the per-mount firing check in `fireMounts()` and the cooldown-arming check in `advance()` — "close enough" is the correct semantics for a steering approximation, not bit-exact geometry. Confirmed fixed by driving the same stalled seeds to a hard-uncapped duration (`maxRounds` overridden to 5000): every previously-stalled battle now resolves. ## Master of Vega V2 — per-ship tactical combat prototype (2026-08-09) A second, parallel combat engine and view, reachable only via `?movsim`'s Live/V2 toggle — **`VegaCombat.js`/`VegaCombatView.js` and every real player battle are completely untouched.** Built because Brian wanted to *see* individual ships (sized by hull: frigate/destroyer/cruiser/battleship at 0.5×/1×/1.5×/2.5×, `sizeScale`), watch hull-appropriate movement/turn speed (`sizeSpeedMult`/`turnRateBase`, new per-hull fields in `data/mastervega-rules.json`'s `hulls` block — additive, ignored entirely by the live engine), and zoom/pan a battle with a parallax starfield — none of which the old one-marker-plus-"×N"-label stack view could show. Brian explicitly chose **true per-ship targeting** (every ship independently picks its own target and rolls its own to-hit, no formation aggregation) over a lower-risk "aggregate math, individual rendering only" option, understanding that would mean re-deriving/re-validating the balance math — see traps 25-28 above for what that actually took. - **New files**: `VegaCombatV2.js` (headless, mirrors `VegaCombat.js`'s `createBattle`/`stepRound`/`runBattle`/`battleResult` contract and `battleResult()`'s exact output shape, but `battle.ships` is a flat array of individual entities with real `hp`/`x`/`y`/`facing`, not `battle.stacks`), `VegaCombatCamera.js` (Phaser-touching zoom/pan/ parallax, adapted from `VegaStarMap.js` — see trap 25), `VegaCombatViewV2.js` (Phaser rendering, one container per ship not per stack). - **World space is continuous 2D**, not the live engine's 1D lane — new `rules.combatV2` block (`worldWidth`/`worldHeight`/`moveUnitsPerSpeed`/ `beamRange`/`missileRange`), completely separate from `rules.combat`'s own `beamRange`/`missileRange` (which the live engine still reads unmodified). 1 old lane-tile = 100 new world units, chosen specifically so a `sizeSpeedMult: 1.0` (destroyer) ship's pacing matches the live engine's calibrated feel exactly — only the new per-hull multiplier introduces deviation from it. - **`hitChance()` and the shield-pierce/singularity math are copied verbatim** from `VegaCombat.js` — the one thing explicitly not up for revision. The only aggregation change in `fire()` is dropping the old `shooter.count` multiplier (the shooter *is* one ship now); `sampleHits()` is still reused per ship-mount, since a single hull can still carry many copies of one weapon (e.g. 30+ lasers on a battleship). - **Facing affects movement only** (Brian's explicit choice) — no firing arcs, no to-hit modifier from facing, so the calibrated hit-chance formula needed zero changes despite ships now having real headings. - **Placement is deliberately a placeholder**: `placeFleets(rules, attackerShips, defenderShips)`, exported standalone specifically so a later rework can replace just that one function — a simple few-columns/few-rows grid per side, per Brian's explicit ask to defer real formation design. A deterministic per-ship y-wobble is load-bearing, not decorative — see trap 28's root cause. Originally per-side (`placeShips(rules, side, ships)`), called once each for attacker and defender; changed same day (see below) to place both fleets together, because the gap *between* them needs to know both footprints at once. - **Sound**: reuses last session's Mark-banded weapon/missile sfx keys and stagger mechanism, but caps actual cue playback at ~12 per round (evenly sampled across the round) regardless of how many of the round's 60-150+ individual fire events there are — every event still gets full visual FX, only audio is capped. Also gained one destroy cue per warship class (frigate/destroyer/cruiser/battleship), sharing the same round stagger timeline so a multi-kill round doesn't clip them together. - Verifier: section 11 (headless like everything else) — ship-expansion shape, determinism, the turn-rate invariant, a mirror-bias check at a deliberately looser tolerance than the live engine's (see trap 28), the cloak/singularity A/B calibration (see the fleet-size note below), and (same day) formation-strategy resolution/stamping and the placement/zoom-fit math below. - Never browser-tested, per [[feedback_no_auto_verify]]. **Formation strategy, same day**: a `formationStrategy` chosen once per side before a battle starts — `VegaFormations.js` (new, headless) defines two to start, "Power Pressure" and "Speed Swarm", with `randomFormationStrategy(rnd)` for a silent pick. `VegaCombatV2.createBattle()` resolves each side's choice (honouring an explicit, valid one; silently rolling from the battle's own seeded `rnd` otherwise — this is *all* "AI picks silently" needed, no separate decision logic) and stamps it onto every ship on that side plus `battle.attackerFormation`/`defenderFormation`. `VegaCombatSim.js`'s Watch Battle (V2 mode only) shows a `modalShell` picker for the attacker before launching; the defender gets no prompt at all. **This is plumbing only — formation strategy does not yet affect placement, movement, or targeting.** That's explicitly upcoming work; today it's just data flowing through so that work has somewhere to read from. **Bigger battlefield + fleet-size-adaptive initial zoom, same day**: two requests that turned out to be coupled. `rules.combatV2.worldWidth/worldHeight` went from 1200×800 to 3600×2400 (3×, same aspect ratio). Separately, `VegaCombatCamera.bindZoomPan()` gained a `fitBounds` option — given a bounding box (world units), it picks the zoom ladder's largest rung that still frames the box without cropping it (`VegaZoom.pickFitZoomIndex`, pure/headless), instead of always opening at the ladder's fixed bottom rung. `VegaCombatV2.shipBounds(ships)` computes that box from the actual battle (every entity including the planet, so a fleet-vs-planet fight with no defender ships still frames correctly). The coupling: the **first** placement pass just widened `GRID_MARGIN` — kept each fleet pinned to a fixed distance from the world's edges, unchanged in principle from before, just now measured against a 3× bigger world. That was wrong. Because the margin was fixed regardless of fleet size, the two fleets always ended up separated by roughly `worldWidth - 2×margin` no matter how many ships were in them — a 1-ship skirmish and a 20-ship fleet action opened with the *same* ~3300-unit gap, so `shipBounds`'s width was dominated entirely by the world's size, not the fleet's, and the "adaptive" zoom always picked the same bottom rung regardless of ship count — silently defeating the entire feature while every individual piece of it (in isolation) looked correct. Fixed by replacing the fixed-margin placement with `placeFleets(rules, attackerShips, defenderShips)`: the gap between fleets is now `3×` the larger fleet's own footprint width (floored at 900 world units so even a lone ship has room to use missile range before beam range), and the whole attacker-gap-defender span is centered in the world. A small battle now opens as a small, tight, centered cluster; a big one spans proportionally more of the bigger world — which is what makes `shipBounds`'s size actually track ship count, which is what makes `pickFitZoomIndex` actually vary. **Lesson: an "adaptive to X" feature is only as adaptive as whatever produces the data it reads — the zoom code was correct from the start and still produced a non-adaptive result, because the placement code feeding it wasn't actually a function of the thing being adapted to.** Also had to move the defended-planet's position off a fixed `worldWidth - 60` (which put it far from a defender fleet now placed near the world's center) onto `layout.defenderEndX + 220`, returned by `placeFleets` for exactly this purpose. Also discovered while re-calibrating after the placement change: the cloak/singularity A/B check (section 11) needs 10 ships per side, not the 5 used everywhere else in that section, to read a stable signal — at 5-a-side the softened (weighted-random) targeting's pairing chaos fully swamps a deliberately subtle 2%/0.5-shield-point edge (~48-50%, indistinguishable from noise); at 10-a-side there are enough simultaneous ship-vs-ship duels for the law of large numbers to smooth that out, and the same edge reads a stable ~69-70% — comparable in spirit to the live engine's own ~62% calibration. Not a regression to chase further, a real property of small-N per-ship combat, same family as trap 28. **Superseded by the continuous-time rewrite below** — the added movement/collision-avoidance chaos washed this out further; see that section for the current numbers (15/side, ~55-57%). **Continuous "fluid battle" rewrite, same day**: replaced the discrete turn/round model with a real continuous simulation, per Brian's explicit ask — "do away with the turn concept... make this a single fluid battle." `stepRound(b, orders)` (whole-battle, lockstep) became `advance(b, dt, {allowRetreat})` (fixed `SIM_DT = 1/30`s tick), driven either headlessly by `runBattle()`'s tick loop or in real time by the view's own accumulator (see below). The old discrete "round" became a per-ship **cooldown**: every ship tracks its own `cooldown` timer, independently arms it (`turnSeconds = 2`s, `data/mastervega-rules.json`'s `combatV2` block) the moment its target comes into range — not from battle start — and fires when it hits zero. Damage stays **banked** (`queueDamage`/`applyPendingDamage`, into `b.pending`) for the same reason movement already was (trap 27): tried applying it immediately first, on the theory that independent per-ship cooldowns would rarely land on the exact same tick — wrong, because every ship shares the same `turnSeconds` cadence, so ships that arm around the same moment stay synchronized on every later cycle too, recreating the old "whoever's processed first this tick wins" bias (22pp on a mixed-hull mirror match) right back. Movement gained real physics: ships now carry **angular momentum** (`angularStep()` — a bang-bang accelerate/decelerate controller with a `stopDist = ω²/2·accel` braking-distance check, tuned so a hull never overshoots its target heading and oscillates) instead of snapping to a heading instantly, and **collision avoidance** (`computeSeparation()` — quadratic falloff by penetration depth into `avoidRadius = sizeScale × separationUnit`, blended with the seek-the-target vector). One easy-to-miss design point: the tangential push direction in `computeSeparation()` must be a **fixed** rotation (`px, py = -ry, rx`, always the same handedness) not a per-ship-parity choice — a per-ship approach was tried first and silently cancelled out on exactly the case that matters most, two ships approaching head-on, because that geometry is already anti-symmetric between the two ships and a parity-based tiebreak fights itself. Movement itself was deliberately slowed down (`turnRateScale`, `moveUnitsPerSpeed` retuned down) per Brian's explicit ask, on top of whatever slowdown the momentum/avoidance math added on its own. Traps 29 and 30 (above) were both surfaced by this change specifically — continuous time turns "how long does closing distance take" from a free action into something that competes for the same time budget as everything else, which is what made a latent placement-gap bug (29) and a movement/ firing range-check disagreement (30) both newly acute enough to decide battles or stall them outright. With both fixed, the mixed-hull mirror bias that trap 28 could only mitigate to ~15-25pp is now **fully gone** (49.5% / 50.5% measured on a 17-ship/side mixed fleet over 200 seeds) — it turned out to be almost entirely a symptom of traps 29/30, not the targeting chaos trap 28 described. Same-hull mirrors still lean defender by a modest amount (~5-12pp across tested fleet sizes) — inherited unchanged from the live engine's deliberate "an attacker being beaten badly leaves early" early-exit rule (`applyAutoOrders`, asymmetric by design, not a new bug), just more visible now that battles reliably run to a real conclusion instead of being dominated by the old stall/disengage noise. `VegaCombatViewV2.js` was rewritten to match: "Next round" became **Play/Pause**, driven by a real-time accumulator on the scene's own `update` event (same register-once-per-battle/`off()`-in-`destroy()` lifecycle as trap 25's camera fix — this is a second, independent listener needing the same discipline). Ship containers are set directly to the engine's real `x`/`y`/`facing` every frame rather than tweened between round snapshots, since the sim already produces smooth motion at 30 ticks/sec — a tween on top of that would only add lag. Sound went from "~12 cues sampled per round" to **rate-limited over real time** (`SOUND_MIN_GAP_MS`/`DEATH_SOUND_MIN_GAP_MS`, a minimum gap between cue starts) since fire events now arrive as a steady trickle rather than one big per-round batch — every event still gets its full visual FX, no sampling needed for that any more. ### Ship rows carry video, so they have to be pooled Every place a ship is listed — the side panel's task force, its in-transit, garrison and order lists, and the colony catalogue — now shows a looping **commander video** beside an upside-down picture of the hull, and opens a centred detail window (`VegaShipDetail.js`, depth `D.detail` 76) with the clip at its full 256px, the hull at 192px, `hull.desc`, the derived stat block and the loadout the knapsack settled on. Everything in that window comes out of `designFor`; no new data was added. Two things about it are load-bearing. **The portraits cannot be created inline.** Both hosts rebuild their entire body on every click — `VegaSidePanel.rebuild()` on each − / +, `rebuildFlyout()` on each enqueue — so building the portraits with the rows would tear down and re-create a decoder per row per click, restarting every loop under the player's cursor. `createShipMediaPool` in `VegaShipMedia.js` keeps them in a layer the rebuild does not touch, bracketed `beginFrame()` … `endFrame()`; unclaimed entries are hidden and paused, never destroyed. The colony catalogue wipes with `removeAll(true)`, so its pool layer is lifted out with `detachPool()` first and re-homed into the fresh scroll column afterwards — inside `content`, so it scrolls and masks with the rows it belongs to. **Duplicate Video objects on one cached video are fine — the note at the top of `openSpeciesDetail` is more cautious than it needs to be.** Checked against the Phaser 3.90 source: `Video.loadHandler` builds its own element with `document.createElement('video')`, and `preDestroy` → `removeVideoElement()` detaches only that instance's. The cache holds a URL, not a shared element. That is what makes the fallback ladder usable at all: a species with no ship clips shows its portrait video on all seven rows at once. The cost is decode time, and pausing is how it is paid — the panel pauses on `hide()`, and both hosts pause while the detail window is up, since that window has its own portrait at 256px and there is no point running both. (The reparenting in `openSpeciesDetail` is still fine and still halves the decoding there; it just is not a correctness requirement.) A missing clip is a **legitimate fallback, not a bug**, which is why the verifier reports `7/80 recorded` rather than failing. What it does assert is that nothing *declared* is wrong — every key must equal `shipVideoKey(species, hull)`, which is the trap the `colonyship` / `ship-human-colony.mp4` filename mismatch sets. ### Founding a colony is a cutscene, not a report row Planting a colony is the most consequential thing a player does on the map, and it used to be announced the *next* turn as one row in the "New Turn" popup — the same weight the game gives a finished refit. `VegaColonyIntro.js` replaces that with a full-screen vignette the moment the Found Colony button is pressed: the soundtrack ducks, the world's own 1920×1080 backdrop fills the screen, a clip of *this world* being settled plays in a framed window over it alongside `assets/music/vega/colony.mp3`, and the founding numbers are read out underneath. `colonised` is **out of `NOTABLE_TYPES`** as a result — put it back and the same event is announced twice, the second time smaller. Seven things about it. **The clips are fetched just in time, not by the asset manifest — and Phaser's video loader does not fetch anything at all.** `VideoFile.load()` records the URL, marks itself complete and returns; its own comment reads *"we don't actually load anything (the Video Game Object does that)"*. The bytes arrive when a `Video` sets `el.src`. So putting 13 clips in the manifest never cost 19 MB at game-room entry — it cost 13 cache entries — and a "warm-up" that only went through `scene.load` would have warmed nothing. What keeping the block out of the manifest actually buys is **ownership of the timing**: `ensureColonyVideo()` registers the URL *and* pulls the bytes through a detached `