12 KiB
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, 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.
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.
-
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 momentcanSendFleetrefused 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:fleetSpeedskips immobile hulls;sendFleetsplits them into a garrison that stays behind;consolidateFleetskeys on mobility so the garrison is never re-merged. -
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.
bombardPopKilland the Stellar Converter'splanetCrackerwere declared in the rules but never implemented. Implementing orbital bombardment (MOO1's actual answer) made conquest work immediately. -
The invasion window never opened. Two versions of this failed the same way. Requiring
defenseHp === 0never 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. -
Transports never left home.
manageFleetshad an earlyif (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. -
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%. -
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). -
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.
-
…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.
-
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.
-
…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.
-
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.
-
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_SHAREcaps missiles at 40% of tonnage. This is a design rule, not an optimisation. -
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).
-
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. -
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.
-
The Council never convened.
colonizedFractionmeasured 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. -
Fleets proliferated to 281. Ships completing while the local fleet is in transit each spawn a new fleet.
consolidateFleetsmerges idle fleets each turn. -
Dead empires left ghost fleets on the map forever. Caught by the soak's invariant checker, not by playing.
-
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.
guaranteeNearbyWorldsnow seeds new worlds when it must. -
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, andclampPanallowed another 160px of slack on every side on top of that. The ladder is now built per galaxy inVegaZoom.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).
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 # ~1004 checks, ~60s
node tools/verifyMasterOfVega.js --quick # 1003 checks, ~15s
node tools/verifyMasterOfVega.js --games=50 # a deeper soak
Twelve sections; section 2 runs the real procedural painters against a Proxy fake canvas, section 4b is the fleet-order engine behind the command panel, section 10 is the self-play soak with invariants and a turn-time budget.
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.