fertig-classic-games/docs/mastervega-build-plan.md

76 KiB
Raw Blame History

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,fs,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.

  31. Locking thrust to facing breaks the slowest-turning ships worst — exactly the ones a "can this hull hold position" feature most needs to work for. Adding real linear momentum (Brian's ask: ships shouldn't "stop on a dime," should have to "turn and engage thrusters again") first modeled a literal single main engine — thrust only ever applied along facing, so braking meant physically turning the whole hull to a retrograde heading before any deceleration could happen at all. A battleship needs 20+ seconds to complete that turn at its (deliberately slow) turn rate; for all of that time a facing-locked engine was still thrusting partly toward the target it was trying to stop at. Every isolated battleship-vs-stationary-target smoke test overshot; the tier-9 case never stopped at all, flying 1250+ units past a target it was supposed to hold at. Fixed by decoupling thrust direction from facing entirely at first (vectored maneuvering thrust, facing purely cosmetic) — this immediately fixed the overshoot, but also silently made turn rate matter for nothing at all, which broke a genuinely-load-bearing existing verifier check (fast-turning hull closes distance faster) and quietly contradicted Brian's own "turn and engage thrusters" wording. Landed on a middle ground: thrust always points in the correct task direction (never actively wrong-way, so the original failure mode can't recur), but its magnitude is scaled by alignment = max(0, cos(angle between current facing and task direction)) — a fast-turning hull reaches full thrust almost immediately, a slow-turning one ramps up over several seconds, and a hull starting 90°+ off gets zero thrust until it's turned enough to matter. This reintroduced turn rate as a real (if softer) factor without reopening trap 31's original failure — see computeShipMove()'s comment for the full reasoning. Cost: cruiser and battleship's brakeSeconds needed retuning noticeably stronger (2.2→1.8, plus battleship stayed at 1.3) to absorb the alignment ramp-up's cost and still reliably hold at high tech tiers — a size-scaled parameter that looks "purely" tactical can still be secretly load-bearing for a different mechanic once thrust efficiency depends on turn rate too.

  32. Several ships converging on the same weighted-random target approach it almost in formation — exactly the case with the least relative motion for reactive collision avoidance to work with. pickWeighted (trap 28) already sends multiple ships at one popular target by design; under real momentum, their bearings toward that single shared point converge as they close, so by the time they're near it they're moving at similar speed in a similar direction — normal quadratic-falloff separation barely nudges ships apart under those conditions before they're already on top of each other. A dedicated multi-ship convergence smoke test (mixed fleet, worst gap between any two ships NOT targeting each other, as a fraction of their combined avoidRadius) measured near-total overlap (worst ratio ~0.00-0.05, i.e. ships passing almost exactly through each other) once real momentum replaced the old kinematic model's implicit clamp. Two independent fixes were needed together — either alone left meaningful overlap: (a) avoidAccel, a flat acceleration budget for collision avoidance ONLY, deliberately not drawn from a hull's own (possibly weak) linearAccel — a frigate's deliberately poor brakes are a tactical choice about holding position, not a physical inability to dodge a teammate; (b) GOLDEN_ANGLE-spaced approach points — each ship steers toward its own point spread around the shared target (offset by its own avoidRadius, angle from its seq via the golden angle for even spacing) rather than literally the target's identical coordinate, so ships sharing a target fan out toward it instead of beelining for one point. Worst-case ratio across repeated seeds after both fixes: ~0.1-0.4, comfortably real personal space, never the ~0.00-0.05 collapse from before.

  33. A "can this hull hold position" feature that only appears to work because something else is quietly doing the braking is not actually fixed — remove the crutch and the real gap shows up. Weakening avoidAccel (per Brian's later correction to trap 32 — ships shouldn't bounce off each other, overlap is fine) had an unexpected side effect: battleship, the hull explicitly meant to reliably hold position, mostly stopped doing so at higher tech tiers, flying straight through a stationary target at full speed. Root cause, invisible until then: a slow-turning hull's FACING only ever starts steering toward retrograde once braking genuinely begins (dist <= beamRange) — by which point it's still oriented from the approach phase, and a full 180° reversal can take 15-20+ seconds for the slowest hulls at their turn rate, so alignment-scaled thrust (trap 31) stays near zero for most of the close pass. This was ALWAYS true; it just didn't matter while avoidAccel was tuned at 1000 (10-100x any hull's own tactical thrust) — the "reduced but nonzero" avoidance against a ship's own target (TARGET_AVOID_FRACTION) was strong enough to arrest velocity on its own, accidentally doing the tactical job braking was supposed to do. Once avoidance was correctly turned down to a gentle preference, that accidental crutch went away and exposed the underlying gap. Fixed with ANTICIPATORY turning: computeShipMove() now compares worstTurnTime (time for a full 180° reversal at the hull's max turn rate) against timeToRange (time until the ship would reach beamRange at its current speed) and starts steering FACING toward retrograde early whenever there isn't enough runway left — critically, facing-only, not thrust: thrust stays pointed at the approach point until real braking is actually needed, and lets alignment-scaling (trap 31) naturally bleed off forward efficiency as facing diverges during the pre-turn, rather than needing a third explicit "how hard to brake" state. An interim version made thrust itself switch to retrograde this early too, which overcorrected the other way — ships stopped 5-10+ units short of beamRange, outside their own weapons' reach, since full braking kicked in before they'd actually arrived. Lesson, same shape as trap 31: when a fix's test results look right, check WHY they're right — a passing smoke test can be hiding a different, load-bearing mechanism doing the actual work, which then breaks silently the moment that mechanism gets retuned for an unrelated reason.

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/30s 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 = 2s, 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.

Linear momentum + size-scaled brake authority, same day: Brian's ask — "the smaller a ship is the more I want to enforce momentum," battleships should be able to slow and stop, frigates should constantly be moving and run strafing runs, and "don't allow any ship to stop on a dime... they should have to turn and engage thrusters again." Explicitly left the exact mechanics to design judgment ("whichever decision is most supportive of [the real-space-battle] concept is the right decision").

  • Real velocity vector (vx/vy) replaces the old kinematic clamp. Previously a ship's position was computed directly from "how far is left to the stopping point," which meant it could never overshoot — no real inertia existed for translation (only rotation had momentum, from the earlier session). Now velocity integrates thrust over time and is capped at effSpeed, same as a real accelerating/decelerating body.
  • New per-hull brakeSeconds (hulls block, live engine ignores it): time to kill full speed under max thrust. linearAccel = effSpeed / brakeSeconds. A hull whose stopping distance at full speed (speed²/(2·linearAccel)) is comfortably under beamRange can decelerate to a stable hold well inside weapon range; one whose stopping distance exceeds beamRange physically cannot stop before reaching the target and blows straight through — this ONE consequence of the physics is what produces both "battleships can hold" and "frigates must strafe," with no hull-specific tactic branch anywhere in the code. Tuned values: battleship 1.3s, cruiser 1.8s, destroyer 2.8s, frigate 11s, scout 15s (unarmed, doesn't matter). Destroyer/cruiser are deliberately marginal at high tech tiers — ships get faster with tech but brakes don't scale with it, so a fully-teched destroyer can slip from "holds position" into orbit-like strafing purely from being fast enough to outrun its own brakes. Not a bug: a nice emergent "battles get more fluid and chaotic as tech advances" property, left as-is once understood (see the debugging story in trap 31 for how this was initially mistaken for one).
  • Two real bugs found and fixed getting here — traps 31 and 32 above. Both were caught by isolated smoke tests before ever reaching the full verifier, same methodology as the original momentum-controller work: design in isolation, measure, fix, THEN integrate.
  • Thrust/facing decoupling + alignment scaling (trap 31): facing still steers toward the ship's current task direction with the same momentum controller as before, but thrust magnitude (not direction) is scaled by how well facing has caught up — full strength when aligned, zero at 90°+ off. This is what makes turn rate matter again without reopening the original battleship-never-stops failure.
  • Collision avoidance gets its own acceleration budget (avoidAccel, flat across every hull) plus golden-angle approach-point spreading (trap 32) — both needed together to keep ships that share a weighted-random target from converging on its literal coordinate.
  • A ship's own current target gets a reduced, not zero, avoidance radius (TARGET_AVOID_FRACTION = 0.35 in computeSeparation) — fully excluding it (tried first) let a fast, hard-braking hull occasionally overshoot to exactly 0 distance, flying dead through the target's center; a small floor stops that without reintroducing the original "avoidance fights engagement" problem (personal-space radius summed between two big hulls can exceed beamRange — 350 vs 220 for two battleships — so full-strength avoidance against your own target made closing to weapon range impossible in the first place).
  • Verifier section 11 gained two permanent checks matching the smoke-test methodology: a battleship/frigate minDist-vs-stationary-target gradient (battleship minDist > 50, frigate minDist < 20, tuned against actually measured values with margin, not guessed), and a multi-ship convergence worst-gap-ratio check (> 0.05, well below the ~0.1-0.4 normally measured, so it only fires on a real regression). The existing turn-rate invariant check had to be rewritten around "time to first reach weapon range" instead of "net distance closed in a fixed window" — under real momentum, a fast-turning hull can overshoot through its target and read as worse net progress than a slow hull that hasn't arrived yet, which is exactly the strafing behaviour this feature is FOR, so the old metric actively fought the new physics rather than measuring turn rate. The cloak/singularity A/B check (last retuned two sessions ago for the continuous-time rewrite) needed retuning again — the added movement chaos washed the deliberately subtle signal down to noise (~48-50%) at every fleet size fast enough to test on every run; rather than chase a shrinking effect through ever-larger, ever-slower trial counts, the check now asserts the flags don't measurably HURT rather than that they measurably help, which is the honest thing that's actually still true and stable.
  • Quick suite: 2591 passed, 0 failed. Full (non-quick) suite: 2593 passed, 0 failed.
  • Never browser-tested, per feedback_no_auto_verify.

Collision avoidance de-fanged, still 2026-08-09 (Brian's correction — "I don't need or want the ships to bounce off of each other... let's not stop them from overlapping as needed"): avoidAccel (added same day for trap 32) had been tuned to 1000 world units/s², a force an order of magnitude stronger than any hull's own tactical linearAccel (~11-70 across hulls/tiers) — strong enough to produce a visible, sharp deflection the instant two ships got close, which is exactly the "bounce" Brian was reacting to. That strength existed specifically to STOP ships from ever getting close, which turns out to have been solving a problem Brian doesn't want solved: overlap during a strafing pass or a shared-target convergence is fine, even expected. Turned down to avoidAccel: 60 — comparable in scale to a hull's own thrust, so avoidance is now a real but gentle steering preference, easily overridden by whatever the ship's actual objective requires. The computeSeparation/GOLDEN_ANGLE mechanics from trap 32 are unchanged (ships sharing a target still fan out toward slightly different approach points, purely cosmetic/geometric, not a force), only the avoidance force's magnitude changed. The multi-ship convergence verifier check (added same day) had its own assertion inverted to match: it no longer requires a minimum gap between non-targeting ships (routinely near 0 now, by design) — it instead asserts no single-tick velocity change exceeds a small bound, which is the actual thing "no bounce" means and the thing worth protecting against regressing. Lesson: when a fix silently over-corrects a stated design goal into its own success criterion (here, "ships shouldn't collide" quietly became "ships must maintain a comfortable gap at all times"), the verifier coverage written to protect that fix can end up actively hostile to a later, perfectly reasonable design correction — reread what a check is actually asserting against the CURRENT intent, not just whether it still passes.

  • Quick and full suites: clean, 0 failures (numbers unchanged from above; only the one convergence check's assertion changed shape).
  • Never browser-tested, per feedback_no_auto_verify.

Agility variance widened + battleship's braking actually fixed, still 2026-08-09 (Brian: heavier ships "look good," but wants "much more variance in agility" — frigate tuned like "an X-wing fighter" (turn rate AND acceleration), destroyer "a good midpoint" between frigate and cruiser "leaning on more agile." Explicitly asked whether frigate's now-strong acceleration should be allowed to let it hold position sometimes too (previously impossible by design) — Brian chose yes, full agility over preserving the old always-strafing identity).

  • Retuned (hulls block; cruiser/battleship untouched): frigate turnRateBase 150→340, brakeSeconds 11→1.0 (now the single most agile hull in the fleet on both axes, stronger brakes than even battleship); destroyer turnRateBase 100→230, brakeSeconds 2.8→1.35 (sits between frigate and cruiser on both axes, leaning toward frigate's end).
  • This immediately surfaced trap 33 — testing the retune at tier 9 revealed battleship no longer reliably held position at all (flying through a stationary target at full speed), a regression that had nothing to do with today's hull changes and everything to do with last session's avoidAccel weakening quietly removing a crutch that had been propping up braking all along. Fixed with anticipatory pre-turning — see trap 33 for the full mechanism and the interim version that overcorrected (ships stopping short of their own weapon range). This fix is what makes today's frigate retune actually deliver "can hold sometimes" rather than "flies through even more dramatically than before" — the two changes landed together, in the order they were found, not independently planned.
  • Verifier: the frigate check that used to assert "cannot hold" was inverted to match the new design intent (now checked identically to battleship/cruiser/destroyer — all four warship hulls must be able to decelerate and hold within a stationary-target smoke test). Added a small set of pure data-integrity checks against the hulls block itself (frigate turns fastest and brakes hardest of any warship; destroyer's turn rate sits above the frigate/cruiser midpoint) so the AGILITY ORDERING Brian asked for stays protected even as exact tuning numbers get adjusted later, independent of how any particular simulated scenario happens to play out.
  • Quick suite: 2596 passed, 0 failed. Full suite: 2598 passed, 0 failed.
  • Never browser-tested, per feedback_no_auto_verify.

Parked ships no longer coast forever, still 2026-08-09 (Brian noticed ships "just drifting" and asked whether every ship is always actively targeting/pursuing — answer: yes, always, as long as any enemy is alive, but a settled ship's residual velocity was never explicitly zeroed). computeShipMove() previously started every tick's velocity from s.vx/ s.vy unconditionally, even when hasTask is false (parked: in range, already below BRAKE_SPEED_EPS) — meaning whatever tiny speed (up to just under BRAKE_SPEED_EPS, ~2 units/s) it had at the exact moment it crossed the "stopped" threshold was never killed off, and it coasted at that speed indefinitely. Fixed: velocity now starts from 0 (not s.vx/s.vy) whenever hasTask is false, with collision avoidance still applied on top of that clean zero afterward (so a parked ship can still be nudged by a neighbour — only the target-seeking residual was the bug, not avoidance's own gentle push). Confirmed via a stationary-target smoke test across all four warship hulls: finalSpeed is now exactly 0.0000 once settled (was ~1-2), and each hull spends hundreds of ticks sitting at literal zero rather than perpetually gliding.

Beam colour by species + V2-only damage multiplier, still 2026-08-09: (1) beam weapons (not missiles, a different archetype — those keep their fixed orange) now render in a vivid, saturation/value-boosted version of the FIRING ship's species color (the same hex already used throughout the UI for portraits/panels/text), so a battle reads at a glance who's shooting whom — vibrantSpeciesColor() in VegaCombatViewV2.js, memoized per species since the same handful of species recur across every fire event. (2) Brian, after watching V2 play out live: battles take "a ton of turns," wanted ship power amplified — asked for pros/cons before any change. Key tradeoff flagged and agreed: weapon damage and hull HP are NOT V2-specific data, VegaCombatV2.js builds ships via the same shared designFor() the live engine reads, so editing those values directly would change the live game's balance too. New combatV2.damageMultiplier (started at 1.6, "modest") scales each weapon's raw output BEFORE shield mitigation inside fireMounts() only — shields stay a fixed absolute reduction per hit (amplified guns naturally eat into them relatively more, rather than the multiplier just padding already-mitigated damage), and the live engine's weapon numbers are completely untouched. Finding, more interesting than the fix itself: measuring actual battle duration before/after revealed the auto-disengage timer (disengageFraction × maxRounds × turnSeconds ≈ 102s) is ALREADY firing in effectively every simulated battle before combat concludes naturally, regardless of damage level — at multiplier=1, 100/100 sampled 17-ship mixed-fleet battles ended with ships still fleeing, not dying. 1.6x meaningfully shortens duration for typical/smaller same-hull fleets (5×cruiser: 96.8s→69.6s; 8×destroyer: 54.4s→27.3s — roughly halved, and the fraction resolving by pure attrition rather than forced retreat roughly doubled or better) — but the large 17-ship mixed-tier fleet barely moved (102.0s→98.2s) because that composition's total HP pool still isn't ground down enough by the disengage deadline even at 1.6x; pushing to 3x got 41/100 to resolve by pure attrition there, versus 0/100 at baseline. The disengage-timing CONSTANT itself, not just raw damage, may be the dominant lever for "battles take too many turns" — tuned under different pacing assumptions (pre-momentum, pre-agility-retune), and worth reconsidering independently of how far the multiplier gets pushed.

  • Quick suite: 2596 passed, 0 failed. Full suite: 2598 passed, 0 failed.
  • Never browser-tested, per feedback_no_auto_verify.

Disengage timer extended, then damage pushed further — the two levers pull in OPPOSITE directions, still 2026-08-09: acting on the finding above, Brian asked for "a meaningful adjustment to the disengage timer." New combatV2.maxDurationSeconds (240, up from the implicit 120 via the shared rules.combat.maxRounds) is V2's own total time budget — a new maxDurationSec(b) helper in VegaCombatV2.js replaces every b.C.maxRounds * turnSeconds computation (three call sites: the disengage trigger, the hard-timeout win condition, runBattle's tick guard), same V1-isolation reasoning as the damage multiplier — rules.combat.maxRounds is shared with the live engine's own round cap and must stay untouched. disengageFraction also moved 0.85 → 0.9 (216s trigger, was 102s). Counterintuitive result, worth remembering: this made battles LONGER, not shorter — the opposite of the original ask. The disengage timer hadn't been making fights drag on; it had been functioning as an IMPLICIT duration cap, cutting battles off at ~102s whether or not combat was actually close to a real conclusion. Removing that cap let fights that genuinely needed more than 102s take it: 17-ship mixed fleet 98.2s→174.9s, 5×cruiser 69.6s→141.1s, 8×destroyer 27.3s→84.8s — all roughly doubled. Decisiveness DID improve a lot (pure-attrition resolution went from single digits to 25-38% across scenarios), but "battles take a ton of turns" and "battles get cut off by an arbitrary timeout before concluding" turn out to be two different complaints that don't share a fix — solving the second one directly worked against the first. Lesson: when a diagnostic finding motivates a fix, check which of the ORIGINAL stated goals that fix actually serves — a mechanism that's "wrong" by one measure (decisiveness) can simultaneously be load-bearing for another (duration), and removing it doesn't just remove the problem, it removes whatever it was accidentally solving too. With the timer no longer artificially truncating fights, damageMultiplier became the correct lever again — pushed 1.6 → 2.8 after sweeping 1.6/2.2/2.8/3.5 and finding duration drops sharply and monotonically as multiplier climbs while decisiveness holds or improves (not a tradeoff against itself, unlike the timer). Net result across the three test scenarios, timer-extension-and-damage-push combined: 17-ship mixed 102.0s→67.9s (44% pure-attrition, was 0%), 5×cruiser 96.8s→45.2s (50%, was 8%), 8×destroyer 54.4s→17.6s (58%, was 33%) — meaningfully shorter than the ORIGINAL pre-any-change baseline on every scenario, and far more decisive throughout. Real, expected cost: mirror-match bias rose to 27.3pp (was passing at <25pp) — lower time-to-kill means whoever lands the first good roll matters proportionally more, exactly the variance-sensitivity tradeoff flagged before any of this work started. Section 11's mirror-bias tolerance widened 0.25→0.30 to match, documented as a deliberate, explained consequence of the multiplier change (not a targeting regression) rather than dialing the multiplier back to dodge a threshold tuned for a much slower-TTK combat model.

  • Quick and full suites: clean, 0 failures (after the tolerance widening).
  • Never browser-tested, per feedback_no_auto_verify.

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 preDestroyremoveVideoElement() 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 <video preload="auto"> on the same URL, so the game object's own request hits the browser cache. The system view calls it the moment the Found Colony button appears — a colony ship in orbit over a settleable world, the strongest signal available that the vignette is about to be needed — which is a better moment than "entered the room" and a far better one than "the vignette is already on screen". If the clip still has not landed when the vignette opens, the window says ACQUIRING COLONY FEED… and the picture fades into it on arrival. Three details it is built around: filecomplete-video-<key> is emitted with the key as a string while FILE_LOAD_ERROR is emitted with the File and fires for every failing file in the queue, so only the error handlers filter by key; the in-flight set is a WeakMap keyed by scene, because the loader is per-scene and leaving mid-fetch must not wedge a key against a loader that no longer exists; and the panel re-asks on every rebuild, so a warm-up with no callback registers no listeners at all. Section 2 asserts the block stays out of the manifest — the resolver runs headlessly against a cache stub — because putting it back would quietly restore the 19 MB and nothing else would notice.

Two pictures of the world are on screen at once, and the clip is the optional one. The backdrop is always the opaque 1920×1080 still (worldBackgrounds, gradient-painted from type.color when that art does not exist). The 960×544 clip (colonyVideos) plays in a window between the masthead and the congratulation line, right-aligned so its edge lands on the centre line of the orrery disc in the top corner — the third picture of the same world, next to the disc that is the first. A type with no clip simply gets no window: the vignette reads as a still-backed report card rather than a broken frame, which is why the verifier only reports the clip count while asserting that a type without one still has a backdrop to stand on.

Only colonisable types are declared. canColonize() rejects gasgiant and asteroids on the static flag before any species trait is consulted, so a clip for either could never play. Section 2 fails a colonyVideos entry naming a type that cannot be settled, the same way it fails a key that does not equal colonyVideoKey(typeId).

The window is cut to the clip, and the clip is scaled by width alone. The frame is 640×363 — 960×544 exactly — so one axis settles the other and nothing has to reason about two. The scale goes through VegaArt.sourceWidth(), which is load-bearing rather than a nicety: see trap 23, which this clip is what finally exposed.

It plays once, holds the last frame, and offers a replay. setLoop(false) and play(false) — an HTMLVideoElement stops on its final frame and the texture keeps it, so ending is the still. On Phaser's complete a badge fades in at the picture's bottom-right and a hit zone over the whole picture is armed; clicking anywhere replays it. Both go in after the video (a Container renders in insertion order), the hit zone is armed and disarmed with setVisible, which is also what gates Phaser's input (inputCandidate), and complete is bound with on, not once, so it re-arms after every replay. play() restarts cleanly because completeHandler clears Phaser's _playCalled and an ended element seeks back to 0 by itself.

Keeping the sound on took three separate guards. This is the only video in the game with audiosetVolume(0.9) over the founding cue, loaded with noAudio: false while services/assetLoader.js passes true for every portrait and ship clip — and three things in Phaser 3.90 will silence it: (1) loadHandler() sets el.muted and el.defaultMuted from the cache entry's noAudio flag, so a key ever registered with noAudio: true stays muted for the session; (2) playSuccess() calls setMute(true) if the Sound Manager is muted; (3) the first-frame handler copies el.muted back into _codeMuted. applyAudio() re-asserts all of it on creation, on created and on playing, and clears defaultMuted on the element directly — Phaser never does, and el.load() re-applies it. The autoplay-refusal fallback hangs off locked (VIDEO_LOCKED, emitted on a NotAllowedError); playfailed and playerror, used here first, do not exist in Phaser and were dead code. Muting is the whole fallback: Phaser retries from preUpdate on its own, and calling play() again would no-op against _playCalled.

Only the human ever sees it. VegaAI.js plants colonies through the same Logic.colonize(); the vignette is opened by the view that called it, never by the engine. The event still fires for both, because the ticker log and the turn-report classifier still walk it.

Section 6b covers what is checkable headlessly: that colonised is not notable and has no label, that colonising still pushes its event, that the new colony is findable by orbit the way the view finds it, and that every number the vignette prints — max pop, output, construction, factory cap — is finite and sane on a colony one tick old, for all 13 colonisable world types. That last state is one no other screen ever sees.

Balance reference (27-game AI soak)

outcomes: { conquest: 17, council: 7, timeout: 3 }
turns: min 110, median 282, max 800
AI turn time: 0.45 ms average (budget 50 ms)
mirror-match bias: < 5.2pp at every tier, zero stalemates
wins spread across 8 of 10 species

Files touched to register the game

src/data/gamesRegistry.js, src/main.js, src/scenes/GameRoomScene.js (slugDispatch), src/data/assetManifest.js, src/scenes/PreloadScene.js (eager-loads mastervega-artwork.json), src/services/soundtrack.js (hacker).

Verification

node tools/verifyMasterOfVega.js            # ~1421 checks, ~60s
node tools/verifyMasterOfVega.js --quick    # 1420 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak

Thirteen sections; section 2 runs the real procedural painters against a Proxy fake canvas and cross-checks every declared artwork path against the filesystem (portraits, stills, world backdrops, colony clips), section 4b is the fleet-order engine behind the command panel, section 6 covers the colony economy plus the slider padlocks and the queue reorder/repeat API the colony screen drives, section 6b is the founding vignette and what the turn report will no longer interrupt for, and section 10 is the self-play soak with invariants and a turn-time budget.

Note for section 6: the padlock and queue blocks mutate st.colonies[0], and the soak below them measures every colony against its own ceiling — so they snapshot that colony's sliders/locked/queue and put them back. A unit test that leaves a colony reconfigured makes the soak measure something else.

Note for 4b and anything like it: addFleet merges into an existing fleet at the same star, so a test that adds ships to a homeworld is really testing "the starting fleet plus mine" and its counts mean nothing. Clear st.fleets first.

Never browser-tested. Everything above is engine- and Node-verified only.

Art

All sheets are optional and start path: null; VegaArt.js paints stand-ins at the identical frame geometry. See src/games/mastervega/sprites.md for the frame maps. Frame indexes are append-only.

The video and full-screen blocks in data/mastervega-artwork.json follow the same drop-in contract — declare it, drop the file, no code changes:

block shape size fallback
portraitVideos species → clip 256×256 portraitStills, then the sheet
portraitStills species → image the painted sheet
shipVideos species → hull → clip 256×256 that species' portrait
worldBackgrounds planet type → image 1920×1080 gradient from type.color
colonyVideos colonisable planet type → clip 960×544 worldBackgrounds, then gradient

colonyVideos is the one block not in src/data/assetManifest.js — see the vignette section above; it is fetched a clip at a time by ensureColonyVideo().