28 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, 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.jsinspects a world. The right column shows the planet at the full 192px theplanetssheet 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.jsruns 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.
moveQueueRunis 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+ kfor 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.
-
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). -
slider()only works inside a container at the origin. Its drag maths isapply(p.x - c.x)— a screen coordinate minus a local one. That is only correct while the parent container sits at (0, 0), which everymodalShelllayer 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 whyVegaColonyView'spanelLayeris at the origin with every child in absolute coordinates, and why the flyout — the one layer that moves — deliberately contains no sliders. -
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 aGeometryMask's Graphics has its own world transform and does not follow a moving parent — the flyout tween drags it along by hand inonUpdate. -
A Phaser
Videodoes not report a width of zero before it decodes. Every video in this game is sized by scaling from its source width rather than callingsetDisplaySize, guarded asobj.width || SRC. That guard never fires: a freshly createdVideocarries a placeholder size until its first frame decodes, at which pointupdateTexture()builds the real texture and re-sizes the object to it. So the scale is set from the placeholder and stays wrong byplaceholder / realWidthonce 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 onvideoTexture, 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.
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
<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 audio — setVolume(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().