127 KiB
Orbit — Project Notes
Working agreements and conventions for building this game. Read this before adding new systems — these are the rules that keep the project scalable.
Commits (important)
- Brian makes the commits, manually. After finishing a change, leave the
work staged/unstaged in the working tree with a suggested commit message
(in the chat reply, or a note here), and do NOT run
git commityourself. This gives Brian the chance to review the code before it enters history. - Amends/reverts are fine if asked explicitly.
What we're building
A procedurally generated space RPG in the spirit of Privateer, but in top-down 2D: stars/sectors you can jump between, ships to fly, crew, trading/economy, stations, quests — to be scoped as we go.
Hard technical constraints:
- Plain ES6 modules, no transpilation, no build step.
- No package managers required to run — the game must work from any static HTTP server (the browser fetches everything).
- Third-party libraries are vendored into
lib/(right now:lib/phaser.min.js, Phaser 4.2.1 UMD build, seelib/PHASER_LICENSE.md). - Must be served over http(s), not
file://(ES modules +fetchof JSON).
Config lives in JSON (important)
- Tunable data lives in
data/*.json: dimensions, colors, text, physics, balance, spawn tables, and anything a non-programmer might want to tweak. data/manifest.jsonlists which files to load. Add a config file = drop it indata/+ one line in the manifest. It is then available as a section named after the file (ship.json→config.get('ship.thrust')).- Code reads config through the
configsingleton (js/config/Config.js):
Always supply a sensible fallback so code never depends on a missing key.import { config } from '../config/Config.js'; const thrust = config.get('ship.thrust', 900); const menu = config.section('menu', {}); - Rule of thumb: if a value might ever change (balance, layout, copy, colors), it belongs in JSON, not code. Code owns behavior, JSON owns parameters.
- Split config by concern as the game grows: world generation already has
data/galaxy.json(shape/scale),data/systems.json(archetypes), anddata/naming.json(syllable pools + name banks); next up might bedata/economy.json,data/ships.json,data/crew.json, etc. One file per system beats one giant file.
Code is modular & class-based (important)
- One class per file, ES module exports, no globals (except the deliberate
singletons:
config). - Layering:
js/scenes/— Phaser scenes: state + orchestration only (thin classes)js/entities/— in-world objects that own their behavior (Ship, NPC, …)js/ui/— reusable UI components (MenuButton, panels, HUD)js/visuals/— decorative, non-interactive visuals (Starfield, …)js/galaxy/— the world model: seeded Galaxy, system archetypes, lazy content generation (Galaxy, SystemGenerator)js/utils/— small pure helpers (Color, Rng, NameGenerator)js/config/— config loading + Phaser game configjs/vendor/— shims to pinned third-party libraries
- Scenes stay thin: they compose entities/UI and wire input. They don't hold balance numbers or game rules.
- Every class should be standalone-constructible: it takes what it needs
(
scene, config values) in its constructor rather than reaching for globals — that keeps things testable and reusable. - Entities drive themselves from their own
update(time, delta)method, called by the owning scene (e.g.GameScene.update). If an entity ever needs the engine's auto-update instead, definepreUpdate(the Phaser v4 hook — v4 does not auto-callupdate). - Import Phaser only through
js/vendor/phaser.js— the single place that changes if we swap framework versions.
World model — the galaxy (important)
The galaxy is seeded and two-level. The seed is chosen on the main menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
- Roster —
Galaxy.create(seed)builds every system's identity (id, name, type, x, y, d, zone) up front. 90 systems is ~10 ms, so the whole galaxy is always known: the player can never "discover" a layout that wasn't already implied by the seed. - Contents — planets/moons/belts/settlements/hazards are
generated lazily on first arrival (
galaxy.ensureContent(id)), then cached. Each system's draw stream isRng.derive(seed, 'system', id)— independent of generation order — so lazy and eager (galaxy.generateAll()) results are identical. Pick whichever is cheaper at runtime; correctness never depends on it.
Determinism rules (keep them!):
- All generation draws go through
Rng(js/utils/Rng.js). NeverMath.random()in a generation path. - Anything that must be order-independent (names, contents) draws from a
Rng.derive(seed, <purpose>, id)stream — never from the galaxy-level sequence. The roster loop is allowed to use its own sequence because the loop order itself is part of the seed contract. - Seeds are trimmed; seed → hash → stream is one-way.
- Dev tools that assert determinism:
dev/galaxy.test.mjs.
System archetypes live in data/systems.json. Each type is:
- themable —
theme.coloretc. for UI; - attribute-driven —
attributessteer the SystemGenerator (star classes, binary chance, planet class weights, moon/belt chances, habitability, hazard, free-space settlement odds). New attribute key = JSON + a few lines inSystemGenerator.js; - object count is global, not per-type —
data/systems.json → objectCount:barren(10%) of all systems are gate-only DEAD-END LEAVES of the jump network (no planets, no stations —barren⇒0; exactly one gate — JumpNetwork keeps them at the tree's leaves), the rest hold a weighted whole number of OBJECTS (planets + free-space stations) from theobjectstable (2/3/4/5). Stations roll first (0–2, per-type odds × the core→rim density), then planets fill the remaining budget (N − stations); if the station roll would consume the whole budget it is demoted to a planet, so every non-barren system holds ≥ 1 planet — the gate's tether anchor and the place the player can build out from. The starting system is the exception: it always holds exactly two generated planets — a gas giant and a rocky world — which with the home world (the origin, the player's homestead, not a generated planet) makes its three planets, always. - planet frames are spread galaxy-wide —
data/planets.json → framesmaps each class to its spritesheet face pool. A random per-system pick would let neighboring stars wear the same face, soGalaxy._generate()runs one galaxy-wide pass (js/galaxy/PlanetFrames.js → assignPlanetFrames): systems in a FIXED spatial order (x, y — a pure function of the seeded roster, so it never depends on visit order) each pick the least-used (class, frame) among their already-assigned nearest stars, tie-broken by a derived seeded Rng. The stamps land on each planet record (planet.frame) and the home world's face oncontent.homeFrame, read byGameScene. Verified:dev/frames.test.mjs. - distributed — a seeded WIDE (2:1) field of stars
(
galaxy.layout.field:width,height,minSpacing) placed by Bridson Poisson-disk sampling — even, organic spacing (no clumps, no voids, not a grid). The 2:1 shape matches the map plate (830×414 at the 1280×720 design size), and the plate fit is a true rectangle fit with a plate-px margin on all sides (map.galaxy.platePadding) — the fully-zoomed-out galaxy fills the plate and no star sits on its edge. The player's home sits in the star NEAREST the configured corner (startingSystem: policy "corner",corner: SE= lower right;center/randomstill work), and the HOME→FAR diagonal is the progression axis: every record carriesd(0 = home corner, 1 = far corner) and itszone(galaxy.distribution.zones, equal-area thirds: near/middle/far). Types mix PER ZONE —galaxy.distribution.zoneMixmultiplies each type's globaldistribution.weightper zone (the old per-typeradiusBandis gone:void/nebulafavor the deep corner,habitablethe home corner). Richer galaxy-level rules (faction territories, trade hubs, combat difficulty by zone) will readrecord.zone/record.d— the seams are in place now.
The player's current system starts at galaxy.currentSystem()
(startingSystem.policy: corner (default — the home corner, SE),
center, or random). Its type is fixed: the starting system is
always a Main Sequence system (startingSystem.type, default 'main'
— data/systems.json → types.main), so the player's home is an ordinary
star. Jumping between systems
now has its NETWORK (see "Jump gates" below — data/gates.json +
js/galaxy/JumpNetwork.js, built on galaxy.neighborsOf(id) and the
spatial hash); the in-flight jump drive is the next mechanic on top.
The galaxy is already lived in. It was settled long before the
player arrives. Every planet is settled (for now): each world hosts
the one settlement kind that fits its class (data/settlements.json → allPlanetsSettled + settledKindByClass) — colony on a habitable
rocky world, miningStation over every other rocky/ice/lava world,
cloudBase riding every gas giant. Plus the free-space kind:
deepSpaceStation (adrift in open space — up to two per system), still
rolled per archetype and thinned core→rim. Barren systems (the objectCount → 0 stops) are
the one exception to "lived in": they hold no planets and no stations —
just the jump gate — and are reachable only by jumping in (strong
connectivity keeps them on the network). Their "charted · unclaimed" report
branch is now the normal case, not a defensive fallback. Model & seams:
- Kinds vocabulary —
data/settlements.json(label, description, theme color, population range, anchor type). Add a kind = JSON + naming pool; the generator picks it up by name. - The settled rule —
data/settlements.json → allPlanetsSettled+settledKindByClass(class → kind, with ahabitableoverride for rocky worlds). FlipallPlanetsSettledoff to return to the old probabilistic layer (per-typechance+needs— the code path is kept behind the flag). - Per-type free-space rates —
types.<id>.attributes.settlementsindata/systems.json:deepSpaceStation.chanceof a station (+secondof a second one). Same attribute-driven pattern as everything else. - Home→far gradient —
galaxy.settlements.gradientindata/galaxy.json: the settled heart (the home corner,d = 0) is denser (factor 1.0), the deep corner (d = 1) is thinner (clamped tofloor). Each record carriesd(0 = home corner, 1 = far corner) and itszoneso density — and later faction strength, hazard, and trade value — is per-system, not global. - Faction ownership (LIVE) — the roster's faction pass
(
js/galaxy/Galaxy.js → _assignFactions, data/factions.json) assigns each system afaction(or null = neutral wild space) and designates the four capitals; every settlement'sowneris populated from the system's territory (a capital world's settlement is owned by its faction; neutral/wild space stays owner-less — pirates are still the future layer). The dossier (js/galaxy/SystemReport.js) reads the faction's label + color. - Pure report formatter —
js/galaxy/SystemReport.js(formatSystemReport(content)→ title/subtitle/faction/population/ status). Compact by design: the HUD shows the system name, identity ("Main Sequence system · star G"), and standing ("Faction: Neutral · Pop ~1.2M" — faction is Neutral for now; unclaimed systems read "Unclaimed"). Per-settlement / gate detail stays in the content + star chart. The GameScene HUD renders it; a future star map / terminal UI reuses it. - Landing/exploration (a future feature) will treat settlements as points of interest: the data already says what's there and where (anchor = planet ordinal or open space).
Naming — planets and stations get their names from curated banks in
data/naming.json → banks (distinct from the star/galaxy syllable pools):
banks.planet— a deep list mixing colonial names ("New Denver", "Port Tucson") and alien names ("Klaxoria", "Vexithunhal").banks.station— a deep list mixing official/procedural designations ("Deep Space SC-145", "Nav Relay V-77") and smuggler / outlaw hangouts ("Hell's Hideout", "The Rusty Anchor").- Assignment —
NameGenerator.planetDeck(rng)/stationDeck(rng)build one seeded shuffle of the whole bank;SystemGeneratordeals names out of it in order. So within a system there are no repeats (the first N draws are N distinct names), and a system only reuses a name if it has more bodies than the bank holds (impossible at these sizes). The decks are dealt from dedicated derived streams (Rng.derive(seed,'system',id,'names', 'planets'|'stations')) so they stay order-independent (lazy === eager). The home world (the player's planet, in the starting system only) takes the first name off that system's planet deck — so it gets a real bank name too, never clashing with one of the planets (content.homeName; the oldplanets.homeNameis now just a fallback). Stars and the galaxy are still synthesised from syllable pools (unbounded).
The current system is a place, not just a dossier (discovery + compass)
The player starts in a solar system, and the other worlds exist in the world — solid, rendered, flyable-to. Rules and seams:
- World layout —
SystemGenerator.layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles)(pure,js/galaxy/SystemGenerator.js) places each system's planets and free-space stations (the home world at the origin in the starting system; every other system's center is EMPTY — its star is invisible flavor, never rendered) on a COMPACT ARC at one orbital radius, built around TETHER DISCOVERY (data/tether.json): CONSECUTIVE objects sit a level-1 tether apart (≤ 5120 − edgeMargin — a level-1 tether from any one of them always discovers a neighbor, so a chain of level-1 tethers walks the whole system), every other pair sits clearly inside or outside level 2 (the level-2 graph is connected — level 2 DEFINITELY discovers while undiscovered objects remain), and NO anchorable pair (planets, stations, home world) sits in the rim band around a tether boundary (± edgeMargin — data/tether.json →edgeMargin, 512 px — objects are never at the edge of a tether region, where the scan misses them). Level 3 is never required for discovery. The orbital radius (center-to-object) sits in[minSpacing, maxSpacing](6400–15360 px; home additionally ≤ level-2 − edgeMargin = 9728 — the home world IS an anchor), and the consecutive gap in[neighborMin, level-1 − edgeMargin](2048–4608 px). The arc's step angle is a deterministic scan that serves the system's gate target bearings. (The old regular N-gon ring is gone — its chords put 2- and 3-object systems past level 2 and spread 4- and 5-object systems too thin.) Planet-bound settlements (colonies, mining stations, cloud bases) are features of their planet, not layout objects. Draws come from the dedicated forkRng.derive(seed, 'system', id, 'layout')— layout results are seed-deterministic and don't perturb the content stream (lazy === eager is preserved). Class sizes/tints inclassScale/classTint. - Discovery —
js/galaxy/Discovery.js(pure, no Phaser — Node- testable, save-ready:toJSON()/fromJSON()). Rule: the ship withingame.discovery.distance(data/game.json, default 540 px) of an object's edge (center distance ≤ radius + distance) discovers it, once, per system. The home world is discovered at spawn in the starting system (the ship starts beside it) — "Home World" exists in exactly one system and is that system's central NAV point (id 'home'); every other system's center is EMPTY (the star is invisible dossier flavor — name/class, never rendered) and carries no central NAV point. Feedback: rim ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery). - Compass —
js/ui/DiscoveryCompass.js(screen-space Container, scrollFactor 0). Every frame it refreshes the set of discovered objects that are off-screen, drawing a themed chevron arrow on the screen edge with a type/name chip, separated by angle when rays crowd. Edge-anchor geometry (edgeAnchor,circleInView,lerpAngle) is exported pure for tests. Arrow texture is generated procedurally on first use (__compass_arrow). - World solids — the ship collides with every solid in the system
(
GameScene.solids): the home world (starting system only — it is the only central body in the galaxy, and its 'home' NAV point), all system planets, asteroid clusters, stations and jump gates: you can approach a rim, never pass through. - Verified:
dev/discovery.test.mjs(rule, boundaries, per-system state, JSON round-trip, layout band + determinism, compass geometry).
Jump gates — the galaxy's highway layer
Every system has 1–3 jump gates (data/gates.json), each one the
exit toward a NEARBY star on the 2-D map. Two layers:
- The network —
js/galaxy/JumpNetwork.js(pure, Node-tested; built once per galaxy inGalaxy._generate(), exposed asgalaxy.jumpNetwork/galaxy.jumpGatesFor(id)). It is a degree-limited spanning tree of the nearest-star graph (each system'sneighborPool= 8 closest stars,galaxy.neighborsOf), with tree edges run BOTH ways;shortcuts(one-way extra edges bought with the spare gate budget) is a dormant code path, OFF in the current config (data/gates.json → shortcuts: false) — so the network is a PURE SPANNING TREE: exactly one route between any two systems, no closed loops. The galaxy reads as a MAZE of dead ends and long hauls; strong connectivity still holds by construction (a bidirected tree — from any system you can reach any other — no closed systems, no trapped sets), every jump is local, no system holds more thanmaxGatesgates, and EVERY jump has a return gate (the destination's gate pointing back). A BARREN system is a dead-end LEAF of the tree — exactly one gate, in and out the same way (the function takes the barren set and never lets a barren node adopt children; the repair pass prefers non-barren attach targets). A 3-tier repair (local attach → swap → last-resort attach, logged) covers pathological pools, preferring a working network over the cap. - The in-system placement —
SystemGenerator.layoutGates(...)(pure) places each gate on one of the radius steps spanninganchorMinDistance–anchorMaxDistance(default 3500–4608 px — the distance VARIES per gate, always inside the level-1 tether (5120 px,data/tether.json) and strictly < 5000 px) around one of the system's ANCHORS (a PLANET — free-space stations are deliberately not anchors, since the build console lives on a world — or the home world in the starting system) — so the tether rule is hard by construction. Candidates are tried in this order: same side of the anchor as the target FIRST, then OUTSIDE the rim bands (± edgeMargin — a gate must never sit at the edge of ANOTHER object's tether either), then the facing tier — the ray-circle intersection (gate exactly on the system→star ray), the circle point aimed at the star, then a ±75° scan — so the direction rule is soft (same side of the anchor as the target, deviation < 90°; in practice ~98% are within 75°). Gates stayminRadius..maxRadius(2048–20480 px) from the system center, keepsize+clearancefrom solid discs (planets, free-space stations, the home world) and 2·size+gateGapfrom each other, and get unique names ("Avidy Gate", "Avidy Gate II" — star names are syllable-generated and collide). Barren systems (no anchors — theobjectCount→ 0 stops) get their single gate on the center→destination ray atbarrenDistance(8192 px) instead; their payload is 1–2 asteroid clusters drifting inside that gate's level-1 tether (data/asteroids.json → barren— the dead end's only thing to mine). Every gate record carriesactive: false— gates are DORMANT until activated (the entity renders dim, field still); activation is the SYSTEM research category's jumpgate tech completing (the Research section below): the system's gates go live plus the return gates in the systems they connect to, and each activated gate anchors a level-1 tether so the player can leave. A gate'srotationis the bearing from the gate to its destination star — the art (twin-pylon portal,js/entities/JumpGate.js— procedural, Station.js-style) faces where it jumps; the entity'sactivate()lifts the dormant dimming (the anchor tether comes from the scene). - In the scene — GameScene builds the gates as solid world objects
(the ship keeps
gates.shipClearancefrom them, autopilot flies to their rim), discoverable (compass arrows + toast, in the gate cyan#5fd4ff), and the HUD dossier lists them ("… Gate · jump to "). They are NOT comms targets (worldObjectAtexcludes them;gateAt()is their own hit test) — a click on ANY gate opens the GATE COMM WINDOW (the shared comms panel, js/ui/CommsPanel.js, in its gate variant — the LINK line reads ACTIVE / DORMANT, REQUEST JUMP initiates the jump and is a grayed ghost while dormant, CANCEL closes it) — never the ordinary fly-here. - The JUMP (
data/gates.json→jump; the pure geometry isjs/galaxy/JumpTravel.js, Node-tested bydev/jump-travel.test.mjs):GameScene.jumpThroughGatere-stages the run for the connected system through the save pipeline in miniature —captureState()snapshots the whole run (discovery, reputation, research, builds, minerals, playtime,activatedGates), the destination system + arrival position are swapped in,prepareLoad()rebuilds the galaxy from the seed (deterministic — same names/placements) with the new current system, andscene.restart()rebuilds this scene onto it: the destination's native tethers re-form increate(), and_onEnterSystem()grants its map tech. ARRIVAL: the ship materialises just past the destination's RETURN gate's keepout (the gate pointing back — activated with its twin per ACTIVITY, its tether anchoring the landing), offset back along its facing with nose along the travel direction; with shortcuts OFF (the current config) the network is a pure spanning tree, so every jump has a return gate — JumpTravel's null path (land on the destination's origin, inside its home-tether zone) is defensive only. THE CLIP: the jump plays a full-screen one-shot between the two systems (jump.video, cover-scaled over an opaque theme backdrop — the source system must not show through;jump.videoVolume0..1, 0 = silent). The clip + backdrop are SCREEN-pinned (scrollFactor(0)) — this scene's camera follows the ship and scrolls, so a world-space clip (the defaultscrollFactor 1) renders off-screen and you'd hear it without seeing it (the same reason every screen UI here pins itself). The destination is ALREADY staged behind it (prepareLoadran first), so the scene restarts the moment the clip ends — or errors, or stalls (a STALL guard advances after a grace period with no playback progress; a CAP does so at duration + margin), whichever fires first (_playJumpClip/_finishJump, both idempotent). A DOUBLE-CLICK (two quick presses, the 350 ms window shared with the landing/takeoff skip) skips the rest of the clip; a single press is swallowed. Ifvideois empty/missing the jump falls back to the shortjumpDelayMscut. A mid-jump guard (_jumping) swallows input for the whole clip; mining blocks the jump (console nudge). - Determinism — same seed ⇒ same network, same gates, same
placements, same
activeflags, same jump arrivals. Verified:dev/jumps.test.mjs(network invariants, placement invariants, barren gates on-ray,active: falseeverywhere, composition buckets, determinism),dev/jumpgate.test.mjs(the entity's solid contract + dormant/active rendering),dev/jump-travel.test.mjs(the jump's config + pure arrival geometry + a real galaxy's return-gate coverage + determinism), and the layout band indev/discovery.test.mjs.
Galaxy map — the MAP console's GALAXY tab
The deck's MAP button opens the cartography console
(js/ui/MapWindow.js, data/map.json, depth 80). It has three tabs on
the plate:
- CURRENT SYSTEM — the system chart (canvas-painted: discovered objects, the tether union, the fog of war), hover + ENGAGE AUTOPILOT, wheel zoom / drag pan. Unchanged by the galaxy work.
- GALAXY (live —
tabs.galaxy.standbyis the killswitch;truereturns it to the old "OFFLINE" toast) — the whole-galaxy chart,js/ui/GalaxyView.js, fed byGameScene.galaxySnapshot()(js/galaxy/GalaxyChart.js, pure + Node-tested):- A glowing star per system, colored by its archetype
(
data/systems.json → types, 6 kinds) and pulsing on that archetype's heartbeat —galaxy.pulse.<type>indata/map.json(speedHz +amp, the per-star-type animation; binary beats fast with a seeded twin core, nebulae shimmer, voids breathe slow). - The jump-lane web — the JumpNetwork's spanning tree as thin
lanes, three reads: TRAVELED (the run jumped it — bright glow pass +
a flow packet drifting the route), FRONTIER (exactly one end charted
— the "next step"), unexplored (faint threads). Lane state comes from
the run's footprint (below) + the SYSTEM category's activated gates
(
activatedGates, the LIVE lanes a jump can be confirmed on). - The charted region — the convex hull of the visited systems,
inflated (
galaxy.hull): a soft fill + subtle double outline (the discovered-area shading). - HOME + SHIP markers, ambient dust blobs + the core glow, and a
reveal ripple OUT OF HOME on tab switch / window open
(stars bloom by distance —
galaxy.reveal). - Interaction (the plate idiom): wheel zoom about the cursor,
drag pan, double-tap 1×; hover = ring + tooltip (name, type, gate
count, charted state, link state to the current system); click a
star: current → "YOU ARE HERE"; LIVE lane → CONFIRM JUMP (the
same transport as the world's gate click —
GameScene.jumpFromMap→jumpThroughGate); dormant lane / no lane → the readouts. The window is a pure view: the galaxy tab pollsgetGalaxy()on the same 500 ms cycle the system chart is.
- A glowing star per system, colored by its archetype
(
- SYSTEM — the same painter applied to a CHARTED star's system
(no ship/tether): pick a charted star on the GALAXY tab to arm it, then
click an object on its chart to SET DESTINATION (see the Route
section below). Locked ("NO SYSTEM TARGET") until a charted star is
picked. The shared
ConfirmOverlayis label-sized to fit the dialog (baseWidth+ measured width, per-showwidth/heightoverrides).
The run's footprint (save-ready): GameScene keeps two
registry-backed sets (the activatedGates pattern) — visitedSystems
(the systems the run has ENTERED; the current one is marked at
create()) and usedGates (the undirected lanes the run has TRAVELED —
GalaxyChart.edgeKey(a,b) = min<max, so a lane is the same key from
either end). jumpThroughGate() records the lane + destination right
before captureState(), so the save carries the footprint; SaveData
captures/restores/resets both, and a save that predates the tab
defaults to "current system charted, nothing traveled" (old saves load).
FACTIONS (the worldgen layer is LIVE — data/factions.json): the
roster's faction pass (js/galaxy/Galaxy.js → _assignFactions) assigns
territories — each top-level BRANCH of the jump tree (a subtree
rooted at one of home's gate neighbors) is one faction's territory: a
branch's middle systems (depth 2 … maxDepth−1) are ELIGIBLE, and the
branch's home faction keeps its min(eligible, quota) closest to the
middle band (|d − 0.5|). BALANCE (data/factions.json → balance) keeps
the galaxy fair without making it equal — two rules: the CAP,
quota = the MEDIAN of the branches' eligible sizes (branch sizes are
inherently skewed and a branch IS its faction's territory, so without
the cap one faction would dominate the start; the big branch's overflow
stays neutral wild space), and the FLOOR, balance.minTerritory — a
faction whose own branch is too small is lifted out of the neutral wild
space (first the branches' eligible overflow, nearest the recipient so
the territory stays contiguous, then the richest faction's least-core
systems; a faction with no branch is built entirely from borrowed
space), so no faction starts with a one-system sliver. A tiny galaxy
with too few eligible systems is best-effort (a working territory
beats an empty one). The ring right outside home + the branch tips
always stay neutral — and designates the four capitals: the NEUTRAL
capital (always a direct gate neighbor of home, its system stays
neutral) + one faction capital per faction (apex / mindweave / zenith,
middle band, seeded, inside the faction's final territory). The
branch↔faction order is a SEEDED SHUFFLE — a
new galaxy reshuffles who sits where; same seed ⇒ identical. Each
capital's system grows its capital planet (class capital*,
planets.png frames 18..21 — data/planets.json → capitals; the
capital settlement — data/settlements.json; owner = the faction —
the reputation seam). The plate's FACTION LAYER (js/ui/GalaxyView.js →
_redrawFactions, data/map.json → galaxy.factions) rings every
territory system in its faction's color and marks the capitals with the
double-ring seat. The GALAXY tab's legend row (data/map.json →
galaxy.legend) keys those colors — a star dot in a faction ring = its
territory, double ring = the capital seat, drawn live from
data/factions.json so a new faction keys itself — and the star tooltip
names the hovered system's standing ("APEX TERRITORY" / "WILD SPACE").
The 'Leaving the Nest' quest rides it: Trestle's last
brief (data/comms.json: trestle-brief-3 — "this star cluster's capital")
sets the player's destination to the neutral capital's seat on the
acknowledgment (js/comms/CommsHub.js → onEnd, js/scenes/GameScene.js →
_commEnded, js/galaxy/Galaxy.js → neutralCapitalTarget()). The
cross-branch BRIDGES (data/gates.json → bridges; the bridge pass in
js/galaxy/JumpNetwork.js) link the territories through the middle belt
so the factions can reach — and fight — each other without routing
through the trunk.
The next layer: the
per-faction territory-region FILL (this file's hull pass, colored per
faction) + the relation tiers (Reputation.factionStanding() —
the standing data) + reachability / combat / trade off the territories.
Reputation.factionStanding() is the data-side counterpart.
- Tuning — all of it in
data/map.json → galaxy(stars, pulse per archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy). - Tests —
dev/galaxy-map.test.mjs(edgeKey symmetry, the snapshot's systems/lanes/flags (incl. the faction data + bridge lanes), hull/padding geometry incl. the parallel-polygon corners, the pulse's bounds + determinism, the archetype colors, and the save round-trip incl. the legacy default).
Route — SET DESTINATION (the MAP console's SYSTEM tab)
The SYSTEM tab charts a CHARTED star's system (the same painter as the
CURRENT SYSTEM tab, minus the ship/tether). Clicking an object on that
chart asks SET DESTINATION (MapWindow._askObject → the shared
ConfirmOverlay, widened to fit the label). Confirming plots the route
and closes the map window.
The route is between SYSTEMS, and it is DERIVED (js/galaxy/Route.js,
pure + Node-tested). The run persists ONLY the destination —
GameScene.destination = { systemId, objectId } (registry-backed, like
visitedSystems). The path from wherever the ship is NOW to that system
is computed on demand by planRoute() (Dijkstra by travel distance over
the gate graph — the jump network is a spanning tree,
data/gates.json → shortcuts:false, so this is the unique route;
strong connectivity guarantees one always exists). Deriving it (instead
of storing a progress counter) makes the follow/detour rules automatic:
- Set (
setDestination) — store the destination, toast the hop count (data/gates.json → route.setToast), close the map. The compass then points at the route's NEXT STOP. - Next stop — the route's NEXT STOP is marked on the compass in
ORANGE (
route.compassColor), shown even while still UNDISCOVERED (it is the thing to find), and its ordinary arrow is suppressed so the two don't stack; on screen there is no arrow (the player can see it). It is the CURRENT SYSTEM's jump gate toward the destination (routeNextGate,route:id,route.typeLabel) in an INTERMEDIATE system, or the DESTINATION OBJECT itself (routeDestinationObject,dest:id,route.destTypeLabel) once the player is in the destination system — the last hop is across the system to the world. Both arealwaysFull(never fold to the text-less far chip) and resolvable byautopilotTo(which strips theroute:/dest:prefix). - Jump (
jumpThroughGate→_routeForJump, BEFOREcaptureStateso the cleared state is what saves): entered the DESTINATION with a specific OBJECT → the trip is NOT over (the last leg is across the system), so the destination stays active and the compass now points at that object; entered the DESTINATION with no object → route done, cleardestination(the compass orange drops) + a REACHED notice; entered the route's NEXT → on course, silent (the derived route just shortens); entered anything else → a DETOUR, the route re-plots from the detour + a RE-PLOTTED notice. Because the route is derived, the re-plot is automatic — this only announces it. - Reached (
_checkDestinationReached, each frame) — when the player is in the destination system and the ship is within the destination object's keep-out rim, the trip is done: cleardestination(the compass orange drops, the SYSTEM tab unlocks) + a REACHED toast (fired directly — the player is already in this scene, no jump cut to ride out). - Notices — a jump fires its own toast + full-screen clip, which would
swallow an immediate REACHED/RE-PLOTTED toast, so those are QUEUED in
the registry (
routeNotice) and surfaced by the DESTINATION scene'screate()a beat after spawn.resetRunStateclears it (a New Game inherits no pending notice). - Save —
destinationis captured/restored/reset injs/save/SaveData.js(a save predating it has no field → no route); the route itself is re-derived at load from the saved current system.
Tuning — data/gates.json → route (the orange compassColor, the
typeLabel / destTypeLabel, the three toasts with their
{dest}/{hops} placeholders).
Visualization — the active route is drawn on the MAP console:
- GALAXY tab — the route's lanes are drawn in ORANGE
(
data/map.json → galaxy.edges.route, color =route.compassColor) above the used/frontier/unexplored reads, and the DESTINATION STAR is circled in orange (data/map.json → galaxy.destination) — the "where am I going" path + target across the whole map. The route's edge keys are computed byGameScene.routeEdgeKeys()(the consecutive system pairs ofplanRoute's path) and passed tobuildGalaxySnapshot(which marksroute: trueon those edges andisDestination: trueon the destination star).GalaxyView._redrawEdgesdraws the route lanes (three-pass glow, like traveled) andGalaxyView._drawMarkersdraws the destination ring (a breathing orange circle, shown only while a destination is set). - CURRENT SYSTEM tab — two cases:
- Destination is IN this system — the destination object is circled
in orange (the route's final stop).
GameScene.mapChartSnapshotsetsdestinationIdon the snapshot;MapWindow.paintChartdraws the ring (step 8b) over the object (shown only when the object is discovered — a ring over a ghost would be noise). - Destination is OUTSIDE this system — the ROUTE EXIT GATE (the jump
gate the player should path out of the system from,
routeNextGate) is circled in orange + a dashed orange line is drawn from the ship to it ("path out of the system").GameScene.mapChartSnapshotsetsrouteExitGateon the snapshot (the gate's id/x/y/name);MapWindow. paintChartdraws the ring + line (step 8c). The gate may be undiscovered (the player hasn't seen it yet) — the ring + line still show WHERE to go, which is the point of the route.
- Destination is IN this system — the destination object is circled
in orange (the route's final stop).
- SYSTEM tab — the destination object is circled in orange when
viewing the destination system's chart.
GameScene.systemChartSnapshotForpassesdestinationIdtosystemChartSnapshot(which carries it on the snapshot);MapWindow.paintChartdraws the ring (same step 8b).
Tests — dev/route.test.mjs (already-there short-circuit, path
validity over real gate edges, next is a gate neighbour, determinism +
tree symmetry (a→b reversed === b→a), strong connectivity across sampled
ordered pairs, guard rails). The compass orange, the map close, and the
notice queue are Phaser glue — verified in a headless browser
(dev/shot-firefox.mjs / geckodriver execute/sync):
set → orange route:<gate> compass entry (#ff8c1a); arrive → route
clears + REACHED notice queued; detour → destination kept + RE-PLOTTED
notice; clear → the orange entry drops on the next compass reconcile.
The tether — the player's range (important)
The ship starts with one level-1 tether anchored on the home world
(system origin): a circular zone, 5120 px from the anchor's center
(data/tether.json → level1Radius). That rim is the player's boundary:
- The union is the space. The ship may be anywhere inside ANY of its
tethers' zones. Where two zones overlap there is no wall and no line
— the boundary of the union is where the barrier lives, and the line is
drawn only on each tether's visible arcs (
Tether.visibleArcs— the rim arcs not covered by another zone). - Outside the union is a hard barrier. The ship is clamped to the
closest boundary point with the outward normal; outward velocity AND
acceleration are stripped (same static-resolve pattern as
Planet.constrainShip, run after the planets inGameScene.update). Click-to-fly and autopilot targets are clamped too, so an out-of-range destination becomes "fly to the rim and rest on the line". - Feedback on contact: the line shudders locally at the hit point
(TetherField pulse + radial zap), a small camera kick, and a throttled
magenta console toast (
tether.contactin the config).
Layering (same rules as everything else):
js/tether/Tether.js— PURE (no Phaser): the record (id/anchor/level/ radius),radiusForLevel, union membership,clampPoint(nearest union boundary point + normal), andvisibleArcs(circle-arc interval math on the unit circle). Node-tested bydev/tether.test.mjs— including the two-direction boundary property (nothing visible is inside another zone; nothing exposed is missing from the visible arcs).js/tether/TetherField.js— the scene-facing field: owns the tether list, exposes the seam the build system will use (add(id, x, y, level),remove(id),setLevel(id, level),onChange→ HUD refresh), applies the constraint (constrainShip), and renders the barrier: dash dots of constant world size along the visible arcs, on-screen culling only, marching offset, additive glow + RGB ghost fringe under the main pass, ambient glitch bursts (dash flicker, radial displacement, data drops, sparks), contact pulses. All tuning indata/tether.json → line/contact.data/tether.json— level radii (level1Radius,radiusGrowth,maxLevel), the starting tether (homeId/homeLevel), and every visual/feel parameter.
Upgrade economics (planned): radius(level) = 5120 × 2^(level−1) — level 2 = 10240 px, which reaches every object of the current 6-object maximum (all within 10240 px of the home world); the defensive two-orbit fallback (11 objects, up to ~13380 px out) would fit inside level 3 (20480 px). Extra tethers anchored on planets/stations will be the "expand your reach" verb; the seams are in place.
Phaser v4 note: the barrier is two Graphics layers redrawn per frame
(clear() → one strokePath per pass) — no textures, no physics bodies,
no camera math beyond culling. The scene drives tick()/draw() from
update() alongside the TimeClock/tween stepping.
Research — the progression gate (time-based, one at a time)
The RESEARCH deck slot opens the Research console (js/ui/ResearchWindow.js):
left pane loops the muted assets/videos/research-computer.mp4 archive feed
(scanlines, sweep band, REC pulse, ambient glitch bursts, decode-in reveals),
right pane holds the category tabs + the branching tech tree for the
selected category (starts at the top, unlocks downward), and the selected
tech's dossier (icon, description, duration) with a RESEARCH button that is
only present when the tech is researchable and nothing is in progress.
Data (one file per category):
data/research.json— global rules:enabled,timeUnit: "seconds",maxConcurrent: 1,defaultCategory, thecategoriesregistry (id, label, icon, accent), thevideo(file + aspect), andfxtiming.data/research/<category>.json— the tree: a flatnodesmap where each node haslabel,description,duration(intimeUnit),requires(parent ids — a DAG),unlocks(what the tech OPENS, below), and optionaleffects.startinglists the pre-unlocked roots. Section name = file basename (research/exploration.json→config.section('exploration')). **Add a category = one file + one line inresearch.json → categories- one line in
data/manifest.json.**
- one line in
The SYSTEM category (dynamic — per solar system): one category is
flagged dynamic: true in research.json → categories — it has no
data file; its tree is built at runtime for the system the player is in
(js/research/SystemCategory.js → buildSystemTree), so its two techs
are NAMED after that system. Node ids embed the system id
(S000012_map, S000012_gates) so a run can chart many systems in the
one shared category without collisions:
-
{System} Map—duration: 0, instarting: granted the moment the player is in the system (GameScene._onEnterSystem()unlocks it — create() runs it on every entry, which a jump is: the jump restarts the scene onto the destination, see the Jump gates section). Copy: Added the Solar System of {system} to the onboard NAV System. Discover all NAV points to unlock the system Jumpgates. -
Unlock Jumpgates— the button label is FIXED (no system name — the tree node has no room for it; the map tech carries the name). Requires the map; researchable once every NAV point of the system is discovered (the home world in the starting system, and otherwise all planets, all space stations, all jump gates — the scene's discoverable set minus the asteroid clusters; the center of a non-home system is empty, so it carries no central NAV point); 45 s (data/gates.json → activation.researchDuration). Completing it activates the system's gates and the return gates in the systems they connect to (pure, from the jump network alone —activationKeys), and per the gate ACTIVITY rule each activated gate anchors a level-1 tether at its own position.Diagnosing "I charted everything but it stays locked": the console command
orbitNav()(DevTools → Console, space view —js/dev/NavDiag.js, installed bymain.js) prints this system's live NAV chart: every NAV point with a ✓/✗, the discovered/total count, and exactly which objects are still undiscovered.orbitNavBrief()gives the one-line summary. It builds on the purenavChart()core above.Activation wiring (a real bug lived here): completing the tech runs
GameScene._applyResearchEffects, whoseactivateGatesbranch recovers the system id from the node id it's passed — via the puresystemIdOfGatesNode(nodeId)(S000137_gates→S000137). A node object carries noidfield (its id is the key in the tree'snodesmap), so readingnode.idyieldsundefinedand the activation silently never ran ("researched the jumpgates but they stay dark" — it was not a name/case mismatch; the id lookup is by system id, never by name).GameScene._onEnterSystem()now reconciles on every entry: if the system's gates tech is unlocked it re-runs_activateSystemJumpgates(idempotent + silent when already online), so a missed activation self-heals on the next entry/jump/reload without a re-research.
The gates node's chart gate is an availability HOOK, not a requires
edge (its requires are all met at grant — the gate is live-world state):
the tree object carries optional available(state, id) / lockNote(state, id)
functions, consulted by ResearchModel.isAvailable and the console's
LOCKED readout. Static trees have no hooks. The label/description copy
and the duration live in data/gates.json → activation (templated —
{system} is replaced with the system name); the tests exempt
dynamic: true categories from the one-file-per-category rule.
The UNLOCKS space (research → everything else): a tech unlocks more
than follow-on tech. Each node's unlocks is the declaration side:
unlocks.research— the readable mirror of the children'srequiresedges (the DAG itself stays authoritative —requiresonly).unlocks.builds— ids indata/builds.jsonthe tech makes available. The authoritative gate is the build's ownrequires(a list of"<category>/<node id>"research ids — e.g.tether-l2.requires: ["exploration/tether_l2"]): a build is available once every id it names is researched.dev/research-builds.test.mjskeeps both sides in lock-step (the test fails if one names the other and the other doesn't name back, or an id dangles).- Builds also carry
category(planet / cargo — the console's tabs), optionaltargets(the surfaces it can be placed on — defaults to[category]),cost(minerals today; credits when they land),repeatable,starting(pre-installed on fresh runs — e.g. the home world's level-1 tether), optionalplanetRequires(world-state gates, e.g.tetherLevel),effects,theme. tether-l2(the level-2 tether ring, anchored on a world that holds a level-1 tether) is the first build — 200 minerals, 20 s, effecttether: {level: 2}on the target world. The build console is live (the deck's BUILD slot,js/ui/BuildWindow.js— see the section below); tune cost/duration freely indata/builds.json.- The console's dossier shows the line (
UNLOCKS: TETHER LEVEL 3 · TETHER ANCHORING · BUILD · TETHER - LEVEL 2), read throughResearchModel.unlocksOf(tree, id)+buildDefs()— the one read point the build UI uses too. - The second static category is Mining (
data/research/mining.json):improved_arm(Tier 1, root) →advanced_arm+improved_storage(Tier 2, both require the Tier-1). All three are BLUEPRINTS — 60 s each,effects: {}— and each declares its matching SHIP-UPGRADE build indata/builds.json(mining-arm-improved/mining-arm-advanced/mining-storage-improved, the console's Mining tab) — the build'srequires: ["mining/<node id>"]is the authoritative gate, the node'sunlocks.buildsthe declaration (the test keeps both in lock-step). New glyph set for the tab:mining,mining2,storage(js/research/ResearchIcons.js) — the build rows reuse the same glyphs.
Code layering (same rules as the tether):
js/research/ResearchModel.js— PURE (no Phaser):roots,issues(DAG validation),levels(longest-path level),layoutTree(deterministic column/row layout: DFS leaf-slot assignment, parent = mean of children),isAvailable,missingRequires, and the unlocks contract:unlocksOf(normalize{builds, research}),buildDefs(builds.json → map,_-keys excluded),unlockIssues(tree)(mirror + build wiring),buildIssues()(every buildrequiresid resolves).isAvailablealso consults a tree's optionalavailable(state, id)hook (the dynamic SYSTEM category's chart gate). Node-tested.js/research/SystemCategory.js— PURE (no Phaser): the dynamic SYSTEM category.buildSystemTree({systemId, systemName, accent, isComplete})(the per-system tree + theavailable/lockNotehooks),systemIdOfGatesNode(nodeId)(recover the system id a gates node id encodes — the node object has noidfield, so the scene passes the id in; the pure core of the activation wiring),navPoints(content)(the chart's NAV points + their kind),navPointIds(content)(the chart's NAV points, ids only),navChart(discovery, systemId, content)(the live discovered/missing split that gates the jumpgate tech — the pure core of theorbitNav()console diagnostic injs/dev/NavDiag.js),isNavComplete(discovery, systemId, content)(every NAV point discovered),activationKeys(galaxy, systemId)(the system's gates + the linked systems' return gates, from the jump network alone),applyActivation(content, systemId, keys)(flip the records idempotently). Node-tested bydev/system-category.test.mjs.js/galaxy/JumpTravel.js— PURE (no Phaser): the jump's arrival geometry.returnGateFor(content, fromId)(the destination's gate pointing back at the system left — with shortcuts OFF it always exists; the null path is defensive),arrivalPoint(gate, cfg)(spawn just past the keepout, on the far side of the gate's facing, nose along the travel direction),jumpArrival(content, fromId, cfg)(both; null ⇒ the scene lands on the destination's origin — defensive only). Driven byGameScene.jumpThroughGate(the transport — save pipeline + scene restart); Node-tested bydev/jump-travel.test.mjs(contract, geometry, a real galaxy, determinism).js/research/ResearchState.js— PURE:unlock,isUnlocked,getActive,start,progress(time),tick(time)(→ array of completions),restoreActive,toJSON(now)/fromJSON. Node-tested.js/research/ResearchIcons.js— procedural 128 px icon textures (tether rings / anchor / signal waves / diamond fallback), tinted.js/ui/ResearchWindow.js— the scene-facing window (depth 80, above the save panel). A passive view: it asksGameSceneto start a run viaonResearch(catId, id); the scene owns the rules, the effects, the toasts, and the save data. The window re-renders fromResearchState+ResearchModelonly.GameScene—beginResearch,_completeResearch,_applyResearchEffects,_deckResearchBar. The effects seam readsnode.effects:{ tether: { level: N } }→TetherField.setLevel(homeId, N)- toast;
{ capability: "flag" }→scene.researchCapabilities.add(flag);{ activateGates: true }(SYSTEM category) → the system's gates + the linked systems' return gates go ACTIVE (_activateSystemJumpgates— the activation keys join the run'sactivatedGatesset, the current system's gate entities wake, and each activated gate anchors its level-1 tether); unknown shapes log and no-op. New effect kinds plug in there without touching tree data.
- toast;
- The SYSTEM category's world state is the run's
activatedGatesset (activation keys"<sysId>><destId>") — registry-backed like discovery (GameScene.createreads it;resetRunStateclears it on New Game,prepareLoadrestores it), and the anchored gate tethers save with the run's tether list. On entry the scene flips the current system's gate records BEFORE the gate entities build (the dormant look is baked at construction), and anchors each activated gate's tether after the field exists.GameScene._onEnterSystem()grants the map tech on arrival (new run, load, and — when it lands — every jump landing). The ResearchWindow takes the live tree viasystemTreeand re-paints nodes live when a state flips under the open console (the chart completes, a run starts).
Save: record.research = { unlocked: ["cat::id", …], active: {category, id, durationMs, remainingMs} | null }. remainingMs is captured
at save time; restoreActive(spec, now) rebuilds startedAt from the fresh
now. A save from before research exists loads as fresh (no unlocks, no
active run) — old saves keep working. The SYSTEM category's activation keys
ride record.activatedGates (an array of "<sysId>><destId>"; saves from
before the category load as an empty set — gates stay dormant). No
auto-save: research state persists on the next explicit player save
(SavePanel), consistent with the rest of the game.
SFX: begin → construct, complete → discovery (both existing
data/sfx.json keys — there are no research_begin/research_complete
keys). Open/close → ui_window/ui_close.
Verified: dev/research-builds.test.mjs (manifest registration,
research.json globals — incl. the dynamic SYSTEM category's registration,
the exploration tree's raw-JSON contract — DAG, node
fields, effects — the real code path: ResearchModel layout/levels/determinism
- the unlocks contract (unlocksOf/unlockIssues/buildIssues) +
ResearchState start/tick/complete/restore round-trip, builds.json (the
tether-l2 build, both sides of the gate, the template), actionbar.json).
dev/system-category.test.mjs(the gates.json activation contract, the per-system tree through the real code path — hooks, one-at-a-time, save/restore round-trip — the NAV-point rule, the activation keys + record flips).dev/research-shot.html+dev/cdp-shot.mjsopen the window and start a run through CDP for a screenshot.
Builds — the surface install (cost-based, one at a time)
The BUILD deck slot (on a planet surface — the deck re-deals itself there:
Shop, Build, Ship, ·, Take Off, Menu) opens the Build console
(js/ui/BuildWindow.js, same visual language as the Research console —
left pane loops the muted 2:3 assets/videos/build.mp4 feed, right pane
holds the category tabs + a list of the category's buildable items —
not a tree: builds are one-off installs on the planet — + the selected
build's dossier). Locked items are grayed with their missing gates
(research / tether level); installed items read BUILT ✓; the BUILD
button appears only when the build is available AND affordable.
Division of labor (the Research split, exactly):
js/build/BuildModel.js— PURE (no Phaser):categories,loadBuilds(category → its builds),defById,startingPairs(pre-installed on fresh runs —tether-l1.starting: ["home"]),missingRequirements(research +planetRequires.tetherLevel),isAvailable,rowState(built / active / available / locked),costLines,canAfford,isShipScoped(targets: ["ship"]— the ship upgrades). Node-tested bydev/builds.test.mjs.js/build/BuildState.js— PURE: the run's build records (built: planet → set of buildIds) + the single in-progress build (active,maxConcurrent: 1);start/progress/tick/restoreActive,toJSON(now)/fromJSON, andisBuiltAnywhere(build)(ship-scoped builds count as built if installed on ANY planet). Node-tested.js/ui/BuildWindow.js— the scene-facing window (depth 80, above the save panel). A passive view: it asks viaonBuild(buildId);SurfaceScene.beginSurfaceBuild→GameScene.beginBuildenforces the rules (one at a time, not installed, research gate, planet gate, mineral cost — the full amount paid up front), thenBuildState.startruns the clock. The window re-renders fromBuildState+BuildModelonly.GameScene—beginBuild(the ship-scoped gate:isBuiltAnywherefortargets: ["ship"]builds),completeBuild,_applyBuildEffects(the effects seam:tether {level: N}→ the world's tether strengthens to at least N viaTetherField.setLevel— never a downgrade — + toast;capability "flag"→scene.researchCapabilities;mining {rate, capacity}→_applyMiningUpgrade— the ship's stats, MAX wins; unknown shapes log and no-op)._restoreMiningUpgrades()re-derives the ship's mining stats from the build records increate()(the save carries the records, not the stats — same seam as_rematerializeBuiltTethers; idempotent, a fresh run has none). The state lives on the GameScene (buildState), so the records + the in-progress build outlive the surface stay AND save with the run (record.builds).
The clock (important — different from research): the build's time
base is the game-loop clock (game.loop.now — the engine's global
monotonic ms), NOT a scene's time.now. A scene's clock freezes while
the scene sleeps, and the GameScene SLEEPS while the surface is active —
so the SurfaceScene ticks buildState.tick(game.loop.now) in update()
while on the surface, and the GameScene ticks it in space (a build
started on the surface keeps running if the player takes off mid-build —
then completes in space, effect and all). Research keeps its scene-clock
base (GameScene.time.now — the scene is awake while it runs). Saves
capture the build's remainingMs on the loop clock; restoreActive
rebuilds startedAt so the build finishes at the same wall time after
load. (Note: for an AWAKE scene, scene.time.now IS the loop timestamp
in this build — TimePlugin.update sets now = t — the two only diverge
while a scene sleeps.)
Deck lock: while a build runs, every deck action except BUILD is
refused (SurfaceScene.deckAction) — one build at a time. The BUILD slot
stays open: the window shows the in-progress build + its progress.
Save: record.builds = { built: { planet: [buildId, …] }, active: { planet, build, durationMs, remainingMs } | null }. A save from before
builds exist loads as fresh — old saves keep working (dev/saves.test.mjs
covers the staging).
The starting installs are a rule, not save data: a fresh run is
seeded with tether-l1 installed on the home world (the player starts
with their home tether — "every world starts with its tether already in
place"), and GameScene.applyRestore RE-ASSERTS the starting pairs
AFTER a load (_seedStartingBuilds, idempotent) — a save captured before
the seed existed (or by an older iteration) replaces the seeded state and
must not un-install the home world's level-1 tether. A player resuming a
run always has Tether - Level 1 built on home (pinned in
dev/builds.test.mjs).
First-landing auto-install (tether-l1): GameScene.ensureLandingTether
runs in startLanding — BEFORE the SurfaceScene launch, so the surface
HUD's tetherLevel snapshot already reads Tether Level 1: the world
(planet or deep-space station) hosts its level-1 tether the moment the
player first lands, with no build-console step (the build is free +
instant — cost: {}, duration: 0). It applies the tether-l1
effects (anchor + toast) and marks the build installed (the console
reads BUILT; the L2 planetRequires.tetherLevel: 1 gate is met). It is
idempotent — the home seed, an installed record, or an existing tether
all short-circuit; a world that already hosts a tether without a record
is adopted, never anchored twice. The world lookups it feeds
(_applyBuildEffects, tetherLevelFor, _rematerializeBuiltTethers)
include systemStations, so station tethers anchor, level up, and
re-anchor across jump cuts like planets' do, and applyRestore's
re-key list knows station names too.
Ship-scoped builds (the Mining tab) — one-off installs, ship-wide
effect: targets: ["ship"] (the mining arms + storage, data/builds.json
→ the Mining category) are installed from any planet's console but the
effect is the SHIP's: effects.mining.rate → ship.stats.miningSpeed
(minerals/s — Mining._extract multiplies the base economy rate by it,
read live per tick) and effects.mining.capacity →
ship.stats.mineralStorage (the hold cap — Ship.storageRoom /
addMinerals read it live, so the corner HUD's n / CAP follows). Both
are MAX-guarded (the faster arm / bigger hold always wins — building the
advanced arm then the improved keeps 2.0/s). "Built" is SHIP-SCOPED:
BuildState.isBuiltAnywhere (installed on any planet ⇒ built wherever
the ship lands — BuildWindow.ctxFor + _detailStatus + the
GameScene.beginBuild gate all read it for these builds, so no re-buy on
the next world). The records still key the planet they were built from
(the save format is unchanged).
Name casing (a real bug this caught): world state is keyed by the
world's CANONICAL name (its discovery name — build records, tether
labels). The comms panel DISPLAYS names uppercased — and used to carry
that display casing back into the landing handoff (lastTarget.name),
so a surface reached through the panel read its own home world as
"nothing installed, no tether" (L1 available, L2's tether gate failing).
Fixed at the source: CommsPanel keeps the canonical spelling in the
data payload (uppercase is display-only), and GameScene.startLanding
normalizes the name against the known worlds before launch
(js/utils/WorldNames.js → canonicalPlanetName, Node-tested in
dev/world-names.test.mjs) — so tetherLevelFor and the surface's
planetName always match the world-state keys.
SFX: begin → construct, complete → discovery, refused action →
ui_close, window open/close → ui_window/ui_close (all existing
data/sfx.json keys).
Diagnostics (in the real game): js/dev/BuildDiag.js (installed by
js/main.js) is a read-only observer — it never mutates state.
- Opening the Build console logs a one-line
[orbit-diag v…]summary: planet, home,isHome, thebuiltmap, the planet's tether level, the L1/L2/L3 row states (with missing gates), the ship's mining stats (mining={rate, hold, cap}), a stale-JS probe (jsSeed=true|false), and the loadedtether-l1.startingfield. orbitDiag()in DevTools → Console prints the full report: env, JS probes, thedata/builds.jsoncontract as loaded, home/current planet, built records, tether objects, research state, the save bank'sbuildsper slot, pending restore, scene wiring, errors since boot.- If
orbitDiagis undefined, the browser served a cached olderjs/main.js→ serve withnode dev/server.mjs(sendsCache-Control: no-storeon every response) or hard-reload with the cache disabled. (A plainpython3 -m http.serverlets browsers heuristically cache the ES modules — the classic "new data + old JS" trap that makes an oldGameScene.jskeep running after edits.) dev/build-check.htmlplays the real Build flow (menu → New Game → home world → build console) and prints the L1/L2 row states before and after thetether_l2research; its failure path also reports the stale-JS probe, so even a failed run is diagnostic.
Verified: dev/builds.test.mjs (data contract from builds.json —
categories/resources/builds entries — the pure model: requirements,
availability, row states, cost/afford, starting pairs — the state
machine: start/guards/progress/tick/completion — save/restore round-trip
incl. remaining-time preservation), dev/research-builds.test.mjs
(research↔build unlock lock-step, incl. tether_l2 carrying NO effect —
the build carries it), dev/saves.test.mjs (the builds field rides the
pending restore; legacy records load clean).
Reputation — standing on planets & space stations (data layer)
The player holds a REPUTATION (standing) on each planet and space station:
- Scale —
data/reputation.json:min…max(−20…+20, max = best), integer steps,neutral= the standing with a place the player has no standing with (0),home= the home world's standing (+20). - The home-world exception — the player's home world is ALWAYS +20: pinned. Nothing sets, changes, or clears it (it's not even storable — the key is derived from the scale itself).
- The faction check —
Reputation.standingFor(place)resolves: home → stored standing → the place'sowner→ faction standing → neutral.owneris now POPULATED by the worldgen layer (data/factions.json — the roster's faction pass sets a settlement's owner to its system's faction; neutral/wild space stays owner-less) so faction-controlled places resolve through the check; the per-faction standing itself is still a markedTODO(factions)stub —Reputation.factionStanding(id)returns null (no standing with any faction yet — the player's real relations land there; the resolution order is final), so today a faction-controlled place still reads neutral (home: +20). - The mutation seams —
set(key, value)/change(key, delta), both clamped to the scale; "ways to influence reputation" land on these. - Place identity — every planet and settlement now carries a stable
idfrom the generator (seed-deterministic: planets<systemId>-p<ordinal>, settlements<systemId>-s<n>, n = draw order); the home world is the fixed key'home'(Reputation.HOME_KEY, same id discovery uses). Same seed ⇒ same ids ⇒ saved standing lines up with the regenerated galaxy. Lazy === eager holds (id is content, from the per-system stream's own record/ordinal). - Module & save —
js/reputation/Reputation.js(pure, no Phaser — the Discovery pattern):toJSON()/fromJSON(); the save record carriesreputation(scale snapshot + stored standings); the scene keeps one instance in the shared registry (New Game resets it viaresetRunState). A save from before reputation exists loads as fresh all-neutral — old saves keep working. The comms panel shows it (a 41-mark bar, −20 → +20, red→green, lit up to the standing —Reputation.marksFor()) and gates REQUEST LANDING at standing ≤ −4; nothing CHANGES standing yet — the influence mechanics land on the mutation seams above. - Tests —
dev/reputation.test.mjs(scale, home pinning, clamping, faction-check order incl. a monkey-patched "factions exist" pass, save round-trips + legacy/corrupt records, capture/prepare/reset integration, generator place-id shape/uniqueness/stability).
Phaser version
- Pinned: Phaser 4.2.1 ("Giedi"), vendored in
lib/phaser.min.js. - App code is v4-specific where v4 changed things (e.g.
Phaser.Math.Angle.RotateTo,banner: false,Clamp(value, min, max)). - v4 quirks that bite (verified against this build, Sept 2026):
- The engine calls a scene's
update(time, delta)directly but does not step the scene'sTimeClockorTweenManager(the v3 scene-events PRE_UPDATE/UPDATE plumbing is not fired in the v4 loop). Each scene must callthis.time.update(time, delta)andthis.tweens.update()at the top ofupdate(), ordelayedCall/addEvent/tweens silently never run. (Done inMenuScene.update/GameScene.update.) - A GameObject constructed with
new(e.g. ourContainersubclasses injs/ui/) is not added to the scene display list — thescene.add.*factories do that. Callscene.add.existing(this)in the constructor (Ship/Planet already do; GlitchText/MenuButton now do too). Container.add(child, index)— the v3 varargs form is gone.cont.add(a, b, c)adds ONLYa(the second arg is an insert index, the rest are silently dropped); the dropped children stay at scene level and paint under the window's opaque backplate, so they simply vanish. Multi-add is the ARRAY form:cont.add([a, b, c])(ActionBar/CommsPanel use it; BuildWindow rows/button and ResearchWindow's action button regressed on the varargs form until fixed, Sept 2026).- Text colors go straight to the canvas: v4 writes
fillStyle = style.color, so a numeric color is an invalid fillStyle and the text silently renders black. Text styles andsetColor()must get CSS strings — usetoCss()fromjs/utils/Color.js(keeptoColor()for Graphics/shape APIs). - An
Imagecreated before its texture key exists can stay bound to the__MISSINGtexture forever — even after the key is generated later in the same session. Generate the texture first (seeDiscoveryCompass.ensureArrowTexture: texture, thenscene.add.image). - Input on screen-fixed containers is per-child. Hit-testing uses
each object's own scrollFactor (
InputManager:g = worldX + scrollX*sf - scrollX), while a child of ascrollFactor(0)container renders screen-fixed. So a child whose scrollFactor is left at the default (1) is drawn pinned but hit-tested in world space — clicks miss it whenever the camera has scrolled. (MenuButton never bit by this: the menu camera doesn't move.) SetscrollFactor(0)on every child of a screen-fixed UI container — done inActionBar.buildSlots.
- The engine calls a scene's
sound.play()throws on a missing key — andcache.hasAudio()doesn't exist in this build. Playing a key that never loaded is a hardError: Audio key "…" not found in cache, not a no-op, and thecache.hasAudio()guard the old playSfx relied on is absent here. The shared voice (js/utils/Sfx.js → playSfxOn) therefore checksscene.cache.audio.has(key)directly (the real v4 API) before playing, so a not-yet-loaded asset is a silent no-op — verified in the browser (aplaySfx('ui_hover')against an unloaded key threw through the click handler until the guard was fixed).- The sound manager stops BY KEY — there is no
sound.stop(). In this build the manager exposesstopByKey(key)/stopAll()(andisPlaying(key)/getAll(key)/getAllPlaying()), but NOT a keylessstop(). So stopping a looping SFX isscene.sound.stopByKey('sfx_<name>')— the shared voice wraps it asjs/utils/Sfx.js → stopSfxOn. (sound.play(key, { loop: true })DOES loop — verified: a 20 s clip kept playing past its natural end, and stopped cleanly onstopByKey.) - A scene transition does NOT call
scene.shutdown(). The manager stops a scene viasys.shutdown(), which flips the status and emits theshutdownEVENT on the scene's emitter — the scene's ownshutdown()method is never invoked (verified: a wrappedMenuScene.shutdowndid not run on menu → game, while the'shutdown'event fired). Scene-level cleanup that must run on transition (stopping a music loop, …) therefore hooks the event:this.events.once('shutdown', …)fromcreate()(done for the menu hum and the surface hum); theshutdown()methods stay as the game-destroy path.- The surface is a LAUNCH + SLEEP, not a transition. Landing does
scene.launch('SurfaceScene')and thenscene.sleep()— a sleeping scene never emitsshutdown(verified: the game soundtrack and the sound manager kept running through the surface until the scene's'sleep'event was hooked). Take Off wakes the scene (scene.wake), which emits'wake'. So GameScene's music + mining hum stop on'sleep'and resume on'wake'(repeatable —events.on, notonce), while'shutdown'covers return-to-menu and destroy (js/scenes/GameScene.js → create).
- The surface is a LAUNCH + SLEEP, not a transition. Landing does
- Ship art — the starter ship is now a spritesheet frame:
data/ship.json → texture(assets/images/ships-player.png, 256×256 frames,frame0), mirroring the planets pattern. The frame art facesartFacing("north" — the nose is the frame's top edge; the glass cockpit section is aft, per the art's author);Shipapplies a constant render offset (artOffset) so heading math is identical and the ship rotates about the frame center. Sprite scale = (size×scale)/frameWidth keeps the 46 px world size (and 23 px collision radius) regardless of frame size. Missing/failed sheet → built-in procedural dart (facing east), with a console note. - Jump gate art — the gate is now a spritesheet frame pair:
data/gates.json → texture(assets/images/jumpgate.png, 256×256 frames). Frame 0 = the gate body (ring + pylons), drawn static at full alpha in every state (the swirl's absence — not a dim — is the dormant tell); frame 1 = the ACTIVE swirl — a swirling energy disc that fills the mouth, shown only when the gate is active (activation perdata/gates.json → activation), spinning slowly clockwise and breathing its alpha between 0.6 and 0.9 (tunables ingates.swirl: spinSpeed rad/s, alphaMin/Max, breathRate). Sprite scale = (size×2)/frameWidth keeps the ring's outer edge on the 96 px keepout disc (the ship hoversshipClearancepx outside it) regardless of frame size. Missing/failed sheet → built-in procedural gate, with a console note. - To upgrade: replace the vendored file + note the version here (and re-check the quirks above — they may go away).
System effects — the star's character (data-driven)
- A system's visual character is a bundle of sub-effects configured per
archetype in
data/systems.json → types.<type>.effect. Each sub-effect is an OPTIONAL block; a type wears exactly what it lists (an empty{}= no effect). Four archetypes are live today;mainandvoidrender untouched on purpose (plain is the control group — the universe must not all look special):- nebula — the RIPPLE (1 center, screen anchor): concentric rings, the light unsettled — plus the OVERT far-sky LIGHTNING (a jagged edge-to-edge bolt with branches, in the gas's base/accent colors, strobed, soft distant thunder).
- redDwarf — a SUPER-subtle RIPPLE (1 center, star anchor; the base shimmer is barely there) that SURGES on each FLARE — the flare is a seeded rhythm that spikes the ripple's amplitude AND flashes the grade warm, together — plus a warm GRADE (tint + a SHADOW LIFT that turns the darkness red), a gritty RED HAZE over the void (content, behind the stars), and ember dust (content).
- binary — a two-color SPLIT grade (the two light sources washing the
view from opposite sides) + a two-light SHADOW LIFT that paints the
VOID itself amber on one side and azure on the other (across the seeded
axis) + a slow BREATH (the two lights take their turn in the sky) + a
wandering companion star (content). NO ripple — two stars, one steady
light. The two-color void needs a neutral content base to paint on (the
clear color bypasses the filter) — see
voidbelow. - habitable — an ELECTRIFIED STORM in the upper sky (2–3 purple
cloud clusters, each a back/flash/front trio; per-cluster lightning
strikes on a seeded cycle — charge rises, the core ignites, a rebound
flicker, a decaying afterglow; the strike's 3D is pure sprite compositing
— see the storm section below) + a FOREST-NIGHT void base (deep
green-black, so the darkness feels vegetative) + twinkling FIREFLIES
(slow green motes with a
twinklepulse — the ember field's optional twinkle) + a near-neutral GREEN grade (the storm owns the purple; the green lives in the fireflies and the void, not a tint over everything). The star itself is invisible flavor; the shimmer / glow / wander is its character.
- Two kinds of sub-effect:
- Camera filters (WebGL; the HUD is excluded by the UI-camera split):
ripple(1–2 centers,center: screen|star,strength/amplitude/speed/padding,centers,orbit.radius/period) andgrade(tint/amount/saturation/brightness,lift: {amount, color — or a+b}— a SHADOW LIFT that pushes only the dark pixels toward a hue (the red void) or toward TWO directional hues across the seeded axis (the binary's two lights:aon one side,bon the other),grain: {amount,parallax}— fine grain in the dark, offset by the camera scroll so it sits at a depth (it shifts slightly as you fly, not stuck to the lens), orsplit: {a,b,mix,axis}— a two-color directional wash).breath(period/depth) slowly trades the two lift hues' brightness (the binary's stars taking their turn). Theflare(interval/duration/rippleBoost/flash/flashColor) is a shared signal both filters read, so the wave and the light flash as one. - Content (world-space sprites, Canvas-safe, behind the art):
haze(redDwarf red haze over the void →js/visuals/RedHaze.js, depth −1, BEHIND the stars),void(binary's NEUTRAL dark+grit void base / habitable's FOREST-NIGHT green-black base →js/visuals/VoidBase.js, depth −1 — the surface the lift/haze paint on, since the clear color bypasses the filter),particles(redDwarf ember dust / habitable twinkling fireflies →js/visuals/EmberField.js; the optionaltwinkle: {rate,depth}gives each mote a slow sine pulse, and no twinkle config = steady embers),wanderer(binary companion star →js/visuals/WandererStar.js), andstorm(habitable's purple storm →js/visuals/StormLayer.js— a SKY DOME: the clusters are anchored to the camera view with near-zero parallax (0.05–0.10) so they stay overhead while the world flies past, wrapping into a visible sky band), andlightning(the nebula's OVERT far-sky bolts →js/visuals/NebulaLightning.js— a jagged edge-to-edge trunk + random branches in the gas's base/accent colors (alternating per strike), strobed like real lightning, one soft distant thunder clap per strike; world Graphics at depth 2.9 — the SKY itself, behind the back gas, so the ripple displaces the bolt and the front gas veils it).
- Camera filters (WebGL; the HUD is excluded by the UI-camera split):
- Files:
js/visuals/SystemEffects.js(ripple + grade filters + facade),js/visuals/SystemEffectsMath.js(pure UV / flare / orbit / grade / lift / storm-envelope / lightning-bolt math),js/visuals/UiCameras.js(the UI camera split),js/visuals/RedHaze.js(the gritty red haze),js/visuals/VoidBase.js(the neutral void base),js/visuals/EmberField.js(ember dust + twinkle),js/visuals/WandererStar.js(companion star),js/visuals/StormLayer.js(the habitable storm),js/visuals/NebulaLightning.js(the nebula's far-sky bolts),js/galaxy/FxSystems.js(demo system picker),dev/system-effects.test.mjs(77 checks, bare Node). - How the filters draw (Phaser 4.2.1): each filter is a
Phaser.Filters.Controlleroncameras.main'sfilters.internallist — when any internal filter is active the camera pass auto-composites (FBO + blit), so the WORLD (starfield included) is displaced/graded and the HUD is not. Multiple filters chain on the SAME pass (ripple then grade). The UI pass is a second camera (fx-ui,forceComposite) that draws the screen-pinned objects on top WITHOUT clearing the world. Both directions are partitioned withcamera.ignore()(set-only — no un-ignore): screen-pinned roots ignored by MAIN, world roots (220+) by the UI pass, so nothing draws twice and input still hits the UI first (hit-test walks cameras in reverse array order). Verified in the browser: a scrollFactor-0 test ruler on the UI pass is pixel-stable across frames while the world behind it shimmers. - WebGL-only for the filters, Canvas-safe for content: the filters need
renderer.gl; on the Canvas fallback they are skipped (no crash). The CONTENT sub-effects are plain sprites and render regardless. The node constructors are registered once per renderer (ensureRippleNode/ensureGradeNode,hasNode-guarded — the registry is global and throws on duplicates). - The ripple math: up to two centers, each contributing a traveling
radial wave; the total displacement is the sum. Per center:
dir * sin(dist*strength − phase) * amplitude * fade, sampled through the injectedboundedSampler(out-of-bounds reads return transparent — a too-big amplitude punches holes; the padding is the guard). A center with amplitude 0 is inert, so the 1-center systems are the degenerate case.paddingmust cover the worst case:amplitude × (1+flare.rippleBoost) × centers × max(width,height)(the test enforces it). - The grade math (pure in
gradeColor, mirrored in the shader): brightness → saturation (pull to luma) → tint (a hue multiplier normalized to mean 1, blended byamount) → SHADOW LIFT (push the dark pixels toward a hue — or toward TWO directional hues across the seeded axis,aon one side /bon the other, modulated by thebreath's slow trade — bylift.amount, luminance-gated so the bright stars stay put —shadowLift, pure + tested) → split (a two-color wash across the view along a seeded axis) → flash (an additive warm spike during a flare). Thegrainis a per-pixel spatial effect and lives in the shader only. All values are scalars (no vec packing) for portability. - The red haze / void base (content, not a filter): the darkness of
space is the renderer's BACKGROUND (config
backgroundColor), which a camera filter does NOT reach — the clear color bypasses the composite. So any colored void is a CONTENT layer: one large, canvas-generated texture drawn at depth −1, BEHIND the starfield (0–2), recentered on the camera each frame so it always fills the view. The white stars and all art sit in front and stay bright — only the void is tinted. Canvas-safe, never touches the HUD. Two flavors:RedHaze(redDwarf) bakes in a dusty RED base;VoidBase(binary) bakes in a NEUTRAL dark+grit base so the grade's two-light lift + breath can paint the amber/azure split onto it (a neutral base keeps the axis + animation fully in the shader). - Per-system variation:
SystemGeneratorstampscontent.fx = {phase, angle, drift}(a dedicated fork(seed,'system',id,'fx'), so lazy === eager) — the flare rhythm, the split axis, and the companion's wander are all seeded per system, so each system is stable but distinct. - Tuning is a JSON edit —
data/systems.json → types.<type>.effect. - Demo:
?fx=<type>(e.g.dev/test-game.html?fx=redDwarf) retargets the run to the richest system of that archetype (pickFxSystem, case-insensitive), force-discovers everything (Discovery.markAllDiscovered) and activates the gates — a clean minute of flying to judge the star. No param → ordinary play, and an empty-bundle system never splits the cameras (the single-camera pipeline renders exactly as before).
Nebula atmosphere — the gas you fly THROUGH
- A nebula is not only the ripple: it fills with soft coloured GAS, in
TWO layers of large, low-alpha cloud sprites (content, world-space —
not a filter: they parallax-scroll with the camera, wrap like the
starfield, and work in the Canvas fallback too; no WebGL pass). Only
NEBULA-type systems wear it.
- BACK (depth 3 — above the starfield's 0–2, below the planets at 5 and the ship at 10), tinted with the system's base atmosphere color: distant atmosphere, art floats in front of it, never tinted by it.
- FRONT (depth 11 — ABOVE the ship), tinted with the derived accent
color: big, soft veils with parallax > 1 that drift PAST you slightly
faster than the world — the moment you read FLYING THROUGH the
nebula, not looking at one. The ship stays crisp (the veils are
low-alpha; if it ever reads as fog over the controls, lower
front.alphafirst).front.enabled: falsereverts to the single back layer (the pre-2-layer look). - LIGHTNING (depth 2.9/2.91 — the SKY itself, above the starfield,
BEHIND the back gas so the gas tints/veils it, and the ripple
displaces it as it flies): OVERT bolts that start on one end of the
view and end on the other — a jagged trunk (seeded midpoint
displacement) with 1–4 shorter branches — in the system's GAS colors,
alternating base/accent per strike (the two-tone sky), a wide
additive glow (the gas hue) over a thin near-white hot core. The
cadence is seeded per system (content.fx): one strike every
intervalseconds (8–15s), a rapid STROBE (~0.45s) — real lightning flickers, it doesn't fade — and a soft distant thunder clap (the habitable's claps at 0.35 volume — this lightning is FAR). Stateless: nothing to pool; each frame the envelope decides dark vs. striking and the shape is derived from (strike ordinal, system seed) — stable during the strobe, different every strike, identical every run.effect.lightning.enabled: false(or deleting the block) reverts to the lightning-less nebula.
- Per-system colour (two-tone): every system gets a stable base
atmosphere color — one of the 8-shade palette, picked at random at
galaxy generation (SystemGenerator, dedicated fork
(seed,'system',id, 'atmosphere'), so lazy === eager). Stored ascontent.atmosphere.color; the BACK layer tints to it. The FRONT tint is DERIVED from it —shiftHue(js/utils/Color.js) rotates the hue byfront.accent.hueShift(42°) and lifts lightness (lightScale, 1.12) — two gas species of one nebula, always stable per system (the 8 base shades are well-spread around the hue wheel, so the 8 two-tone pairs are all different-feeling places). - The cloud: a small set of distinct procedural puffs (value-noise
fbm for the internal structure, multiplied by a WIDE RADIAL falloff so
each has no perceptible silhouette — fully transparent by the inscribed
circle, no hard border or corner). Generated once via the shared
canvasTexturehelper; every nebula reuses the set and differs only in tint. NORMAL blending (the default) so overlaps layer softly instead of ADD-ing into bright seams, and each puff swirls slowly on the scene clock (speed scaled bydrift). - Files:
js/visuals/NebulaAtmosphere.js(both gas layers +drawCloud),js/visuals/NebulaLightning.js(the far-sky bolts),js/visuals/SystemEffectsMath.js(boltStrobe/strikeCycle/boltShape— the seeded lightning math, pure + Node-tested),js/utils/Color.js(shiftHue— the accent derivation),data/game.json → nebula(palette + back-layercount/alpha/size/ parallax/drift+frontblock +texturetunables),data/systems.json → types.nebula.effect.lightning(the bolt's cadence/shape/look/thunder),js/galaxy/SystemGenerator.js(per-system base color),js/scenes/GameScene.js(create/update/destroy wiring),dev/nebula.test.mjs(41 checks, bare Node: palette contract, per-system color determinism, cloud texture, accent math, two-layer structure, lightning structure),dev/system-effects.test.mjs(the lightning bolt math). - Tuning is a JSON edit (
data/game.json → nebula): back layer viaalpha(opacity — keep it a wash, not a wall),count(cloud sprites),size(cloud px),parallax(keep it slower than the starfield's 0.15–0.85 so it sits far behind); front layer viafront.*—enabled(the revert switch),count,alpha(it veils the ship — keep it lower than the back's),size(bigger = closer),parallax(> 1 = closer than the art; 1.05–1.4 is the sweet spot),drift(swirl speed), andaccent(hueShiftdegrees,satScale,lightScale). Shared:shapes(distinct puff varieties — more = less repetition),texture(noiseoctaves/persistence),drift(back-layer swirl). - See it:
dev/test-game.html?fx=nebula&seed=<seed>drops you in a nebula (a different seed → a different two-tone gas) — fly around and judge the look; the sky bolts in on its own seeded cadence (8–15s). Headless:dev/smoke-game.mjs+?lightning=1&forceStrike=1verifies the live scene owns a NebulaLightning and actually strokes a bolt (window.__nlProbe).
Habitable storm — lightning in the upper sky (content, Canvas-safe)
- The habitable's signature: a few big PURPLE CLOUD CLUSTERS overhead that periodically LIGHT UP — charge builds (the clouds brighten over the last ~45% of the cycle), the core ignites in a white-lavender FLASH with a quick rebound flicker, then the afterglow decays. Different clusters strike on different seeds, so the sky is never a synchronized strobe — it's a weather system. The narrative is Miller–Urey: life started in lightning.
- The 3D is sprite compositing, not shaders. Each cluster is three
sprites at depths 3.0/3.1/3.2 (above the starfield, below planets/ship):
- a BACK cloud (violet, soft — the light source seen through),
- a FLASH (additive, cloud-shaped with a HOT RADIAL CORE —
drawFlash= the sharedcloudAlphafbm shape ∪ a bright core, so a strike reads as the cloud igniting from within), - a FRONT cloud (dark indigo, fbm holes) that silhouettes over the flash — the light finds the gaps and wraps the cloud's edge (the additive bleed past the dark rim). The offsets are loose enough that the core survives the overlap; the density does the rest.
- The storm is a SKY DOME, not a world-space object. The clusters are anchored to the camera view (view-space home + tiny drift + wrap into a visible sky band, the same model as the wanderer star and starfield) with near-zero parallax (0.05–0.10): they stay overhead while the world flies past. (A first cut used wide world-space margins like the starfield and the clouds drifted off-screen with 3 big sprites — the sky-anchor is the fix.)
- The envelope is pure + tested:
stormEnvelope(now, interval, strike, phase, chargeWindow)inSystemEffectsMath.js→{charge, flash}(both 0..1). Charge = a smoothstep ramp in the lastchargeWindowfraction of the cycle (resets at the strike); flash = a Gaussian primary burst + rebound + third crack + an exponential afterglow, clipped to three strike-durations so the sky is fully quiet between storms. - Alpha model: back = base × (0.7 + 0.5·charge + 0.3·flash) (the build is visible); flash = env.flash × flashStrength (ADD); front = base × (0.8 + 0.3·charge) (it darkens as the charge builds).
- Files:
js/visuals/StormLayer.js(clusters, sky anchor, per-frame envelope → alphas),js/visuals/SystemEffectsMath.js(stormEnvelope,sstep01),js/visuals/NebulaAtmosphere.js(cloudAlpha+drawCloud— the shared cloud shape;drawFlashin StormLayer adds the hot core),data/systems.json → types.habitable.effect(the whole package),js/scenes/GameScene.js(create/update/destroy wiring). - Tuning is a JSON edit (
types.habitable.effect.storm):clusters,back/front/flashcolors,size,parallax(keep it sky-slow),alpha(keep the clouds a presence, not a ceiling),cycle.interval(calm weather = 10–16 s),cycle.charge(how long the build lasts),cycle.flicker(strike + rebound length),flashStrength. - See it:
dev/test-game.html?fx=habitable&seed=<seed>— fly for a minute; strikes land every ~10–16 s per cluster, staggered.
Bounty Hunt — the job board's wanted contracts (data-driven)
The job board's old offline bounty slot is now a live contract: a level-2+
BOUNTY HUNT posted only in Neutral systems. The ship is sent on a
strictly sequential 3-waypoint patrol of a named pirate leader's known travel
lanes. Each leg may stand up a raiders-only skirmish (the lane is hot); one
leg — rolled at accept and never shown to the player — holds the mark itself,
which deploys with its escort in the engagement. The mark goes down → the
contract is COMPLETE, every leg is checked, the reward claims once.
- Data owns the numbers and the stories (
data/sidequests.json):categories.bounty—enabled,minLevel: 2,contexts: ["neutral"],waypoints: [3, 3],encounter.bountyChance: 0.2(per remaining leg, so 20% / 16% / 64% across the three),encounter.skirmishChance: 0.5(each pre-bounty leg, independent),encounter.skirmishWave(rolling 6 raiders, 3 concurrent),encounter.bountyWave(rolling 9 total: 8 raiders + the elite, 3 concurrent),encounter.elite(healthScale/damageScale×2, the elite's frames 7),bands(level bands, each a roster of dossiers; the level-6+ band is a documented stub,fallbackBand: 'hunter'),compass/marker(hunt accent — deliberately no contact color), and theui.*copy (bountyLockBadge,dossierWanted/Epithet/Crime/ Craft, the leg/sighting/skirmish/complete toasts,bountyActivate/bountyActivedoor labels). Every key has a code-side fallback — missing JSON never throws.descriptions.neutral.bounty— 20 written leader dossiers (unique title/issuer/name/epithet/crime +craft+craftLabel), spread across all seven enemy frames, so the elite always flies its story's craft.descriptions.faction.<factionId>.<category>— the nine faction voice pools (Apex / MindWeave / Zenith × patrol / bounty / escort, 20 variants each = 180 listings). Every body carries{enemy}, so a hunt written by an Apex board fights the rival that Apex board was actually seeded against. A faction dossier never declarescraft: a declared id overridesderiveBountyPlan's frame pick, which would fly a hard-coded airframe at a differently-seeded opponent — unset, the mark flies its own faction's roster (data/combat.json → factions), so copy and combat always agree.js/quests/SideQuests.js → descriptionPool()resolves the chain: the faction's own pool → the shareddescriptions.faction.<category>war voice →descriptions.neutral.<category>(a neutral board starts at its own pool; it never reads war copy). Bodies are written to the readout's real estate: a hunt leaves two body lines under its dossier block (~294 characters), patrol/escort get up to five — longer copy ships with fitText's ellipsis.
- The model is pure + tested (
js/quests/SideQuests.js):sideQuestCategoryMeta— an omittedminLevelmeans no gate (0), so a fresh run's level-0 board stays open; a declared gate stands (bounty = 2).sideQuestAcceptable(posting, level)— the single accept gate (the board's row state, the window's door and the scene all ask it the same way); an unknown level stands open.bountyRules(level)→ the band's odds/waves;deriveBountyPlan(seed, jobId, targetSystemId, level)→ the hidden plan (dossier pick,bountyAtleg, the per-legpicketsrolls) on one labelledRng.derivefork, rolled at accept and fixed on the route record — a signed contract is a signed contract.nextBountyWaypoint(route)(the sequential law),bountySkirmishScript/bountyEngagementScript(the rolling waves),bountyHuntXp(paid out, never pre-shown),bountyRouteComplete(all legs visited +bounty.down), andSideQuestState'sbountySkirmishCleared/bountyDown(the down leg auto-checks every remaining waypoint).
- The hidden-info rule (the hunt's whole feel): the posting carries no
leg/picket/elite field of any kind — the board's readout shows only the
dossier + payout + leg progress; the beacons read as lanes (no red
contact, no count); the compass tracks the next waypoint only; and the XP
total appears on the contract only after accept (pre-accept totals would
leak the skirmish count). The scene's dispatcher (
_sideRouteKind) keeps the patrol and bounty paths apart so patrol stays byte-for-byte identical. - The combat seam (minimal):
Waves.normalize/rostertake one optional elite entry (deployed at the head of the opening group, named);EnemyShip.applyEliteBuffscales hull/shields/damage;CombatSystemspawns elite entries, andhaltWaves()stops deployments/replacements while on-screen fighters stay hostile — the mark kill halts, the escort must be finished, then the contract reads COMPLETE (GameScene.handleCombatKillrecognises the elite bymarkName). - Board + scene wiring:
JobBoardWindowrenders the greyed LOCKED row + badge below the gate (the door stays pressable so the press speaks the rule), the dossier block, the hunt progress line, and thebountyActivate/bountyActivedoor labels on hunt rows;SurfaceScene.acceptSurfaceJobrefuses a locked accept with the data's own copy.GameSceneadds the hunt dispatchers (updateBountyHunt,_fireBountyEngagements,_bountyFieldClear,_drawBountyMarkers) beside the untouched patrol bodies. - Evidence:
dev/bounty-hunts.test.mjs(gate, plan distribution, the chain's odds, the wave scripts, the down/claim rules, the hidden-info rule, patrol regression) — plus the in-browser probedev/bounty-hunt-shot.htmldev/bounty-hunt-shot-run.mjs(locked row → accept → hunt legs → mark kill → claim; JSON + PNGs land indev/shots/).
The class layer — who you are, chosen at level 5
At combat level 5 the game stops and asks a question: Trestle opens the class console, the world holds its breath, and the player picks the build the run will be built around (Vanguard or Wraith). Nothing about it is hardcoded: the roster, the copy, the colours, the icon frames, the confirm wording and each perk's mechanics numbers are data; the code only knows how to ask.
- The data —
data/classes.json(in the manifest) is the whole feature in one file:_enabled, the two classes (id,name,accent,insigniaframe,tagline,doctrine[],summary,bonus{label,note,perks[]},skillPoints), the sharediconSheet(texture path + 128×128 frame size), thewindowchrome copy (title,prompt,tabHint,pickLabel,pickedLabel,deferredToast, …), the Trestleintro(video,speech,text— the speech text lives here, never in a component), theconfirmPicktemplate, andtoastPicked. The door's LEVEL is not restated here: it readsdata/shipScreen.json → level.classAt(5), so one number owns the rule.data/classes.json → perkscarries the mechanics (dualCannon.barrels,dualCannon.barrelOffset,targetingComputer.maxLead) beside their labels. - The code — three small pieces, each with one job:
js/player/ClassModel.js(pure reads): the roster, a class/perk record, the icon sheet, the window/intro/confirm specs,classAt(). Malformed entries drop rather than render a dead tab; unknown ids read as null.js/player/ClassState.js(the run ledger):{ id, perks:Set, skillPoints, granted }.choose()is idempotent — the same class again grants nothing twice (alreadyGranted), a DIFFERENT class is refused (taken: reclassing is a later layer, priced), andawardSkillPoints/spendSkillPointskeep the bank honest.toJSON/restorevalidate against the CURRENT roster, so a hand-edited save can never hand out a perk from a class that no longer exists (an unknown saved id degrades to "no class on file").js/ui/ClassWindow.js(the view only): it renders whatever the model says and calls its seams (onPick(id),onClose,onToast). It never mutates the ledger — a pick is only real whenGameScene.applyClassPick()accepts it, so the window cannot lie about state.
- The freeze contract — the console is NOT a Phaser scene; it is a UI layer
over
GameScene, and it joins the scene's existing_frozencomputation (classWindow.isOpen). Frozen means: input parked, velocities zeroed and damped, combat/AI/missions/bodies skipped through_parkForFreeze()— the same contract the ship console and every other full-screen window already honour. Closing it resumes everything (the world is never stepped while a class decision is open); audio ducked for Trestle's voice (sfx/music beds pulled down whileclass_briefplays, restored exactly once) comes back up whether the player picks or walks out. The ship console's own plate reads throughGameScene.classPlateSnapshot()→js/ship/ShipScreenModel.journeyState(), so a filed class replaces theCHOOSE YOUR CLASSdoor with crest + accent + banked skill points (the Skill Tree tab stays OFFLINE — points are banked, not spent). - The trigger rule (
GameScene._classBeat()) — ask ONCE when all of these hold: class layer enabled, nothing on file (classState.hasClass === false), the prompt unspent (_classPrompted), combat is QUIET (player up, no live hostiles, no wave in flight) and the screen is free (no comm playing, no marquee, no jump, no other major window). Media loads on demand — image, video, speech in one pass with a stall valve, so a slow file delays the console but never breaks it. Locking a class in marks the prompt spent, so the door never re-arms; closing WITHOUT picking DEFERS, andCHOOSE YOUR CLASSin the ship console is the way back in (the auto-prompt stays spent — no nagging). - The perk flags (
CombatSystem.classState, null/empty = base feel) — the class changes only the player's volley.'dualCannon'(Vanguard): a volley leavesperks.dualCannon.barrelsmuzzles symmetric about the firing line (barrelOffsetpx off the centreline), EACH carrying the weapon's full damage — double the metal, NOT the rate of fire (the cooldown stayscombat.player.weapon.cooldown).'targetingComputer'(Wraith): the shot is fired at the INTERCEPT js/combat/Aiming.js solves (where the hostile will be when the bolt lands), capped byperks.targetingComputer.maxLeadseconds — past the cap, or when the target simply outruns the bolt, it falls back to raw aim so the nose never snaps into nonsense. Both helpers are pure (no Phaser, no config, no RNG). The standalone training range (/combat,js/combat/CombatScene.js) has its own firing path and never reads the ledger: the range keeps its base single cannon. - Testing —
index.html?level5(aliases?cls,?class; optional?tab=wraith;?noclasscloses both dev doors) starts a fresh run and walks straight into the console, stepping over the boot comm so it is drivable with no hand on the menu. Normal runs never see that shortcut: they wait for the real level-5 crossing. - Evidence:
dev/classes.test.mjs(roster/frames/perk numbers/templates, malformed-data degradation, idempotent grants, the save round-trip),dev/class-combat.test.mjs(the intercept maths + barrel geometry),dev/classes-browser.test.mjs(no dev param: level-5 crossing opens it once, the world parks, ESC defers without nagging, the ship console's door re-opens it, the plate replaces the door, and the volleys through the real firing code: 1 bolt unclassed / 2 full-damage offset bolts Vanguard / a leading bolt for Wraith), and the screenshot passdev/class-shot.mjs(intro + video, both tabs, the confirm modal, the console plate →/tmp/orbit-class/).
The skill layer — banked points, a Skill Tree and eight seats
The class layer banks skillPoints (js/player/ClassState.js); this layer spends
them. Three files, strictly separated:
data/skills.json— every string and number:enabled, the icon sheet (assets/images/skills.png, 128×128 frames — frame 0 Ramming Charge, frame 1 Reinforced Plating), the TYPE taxonomy (each type carries its own accent colour so the tree reads at a glance), the seat/key fallback table, the per-class trees and all window/bar copy. Listed indata/manifest.json._-prefixed branches are documentation, never rows.js/skills/SkillModel.js— the pure READER: normalizes the JSON into skill records (id,label,type,energy,cooldown,maxPoints,requires[], effect numbers,frame, copy,layout— the authorednode: {col,row}cell), drops junk rows, supplies fallbacks so a missing/broken file degrades to "no skills" instead of a broken scene, and exposestreeClassIds()(who owns a tree),skillList(classId),windowSpec(),barSpec()and the icon key/frame lookup. No Phaser, no state.SkillModel.treeLayout(skills)is the grid the console draws — pure geometry, so the whole layout is testable without a canvas. An authored cell wins; an un-placed skill falls to its dependency DEPTH (a chain walks DOWN — and the cycle guard adds the id as it recurses, sodepthOfmust be called with an EMPTY trail: a self-trail trips the guard on every node and silently flattens the tree into one row); two skills authored into one cell slide right instead of stacking; junk innodeis NO placement (the skill joins the auto-layout rather than piling at 0,0). The joins come fromrequiresin both forms and never point outside the tree.js/skills/SkillState.js— the run's LEDGER:ranks,seats[],points(mirroring ClassState's bank),rankUp,bindSeat,clearSeat,applyDefaultSeats,rows()(what the console paints, including why a row is unspendable),hullBonus(),effectTotal().- THE SESSION lives in the ledger, not the view — it is the console's undo button and
it has to survive the view being rebuilt every frame.
beginSession()files a snapshot of the ranks (idempotent: one open stretch, however often the door is knocked on);pendingOf/pendingTotalread what was spent SINCE that snapshot;commitSession()drops the snapshot;resetSession(id)pays the pending points back throughClassState.awardSkillPoints. A reset CASCADES down a branch this session bought, and REFUSES (reason: 'blocked') rather than reaching below the baseline and stranding a point that was already committed — closing the console is what makes points permanent. Two rules fell out of writing it: a rank-0 skill has no prerequisite to break (an early version refused a legitimate whole-branch refund), andrestore()wipes an open session so a save/load can never refund across a reload.rows()carriespending/resettableso no view re-implements when RESET lights. Plain data +toJSON/restore with validation: a foreign id, a rank abovemaxPointsor a seat out of range is dropped, never trusted.
Data owns the numbers, code owns the behaviour. There is no damageMul = 4
anywhere in js/ — the effect branch in js/skills/SkillEffects.js reads
def.effect.* and applies it. Adding skill #3 is a data row; only a genuinely new
KIND of effect needs a branch in SkillEffects. Balance sweeps that grep the JS for
shipped values are part of the suite for exactly this reason.
Seats, not owners. An ACTIVE skill occupies one of the bar's eight seats
(?1–8). The seat is an address: rebinding is free, a cooldown follows the
SKILL (keyed by id), and SkillState guarantees one skill never holds two seats —
the displaced skill simply loses its seat. PASSIVE skills are refused a seat at the
doctor (reason: 'passive') and are live from rank 1 with no bar real estate.
The ledger owns bindings, so a combat stop/start, a save/load or a rebind in the
console all agree; the registry stamps playerSkills after every change.
Two rules the runtime settled on (js/combat/CombatSystem.js):
- A charge settles once. While a dash is in flight the ship×enemy
ramPairpath is skipped — the hull flies THROUGH the pack and the arrival blast is the sole settlement. Travel is clamped tospeed × remaining duration, so a charge covers exactly its data distance however the frame stepping falls out. - Refusals stay honest. The seat is resolved before the dash check, so pressing
the skill that is currently charging reports
'cooldown'with the seconds left rather than a generic "busy"; an empty seat still reads'empty'. Energy, inert (rank 0) and unknown-skill refusals each have their own copy in the data. - The bar's seat art is a STACK and the icon arrives late. The seat's icon image is
built on first use, mid-draw, which would drop it ABOVE the veil / sweep / edge / seconds /
rank art — an opaque sheet then buries the countdown and the stamp while every property
still reads correct.
CombatBar._iconForre-adds those five layers over it, in the orderdata/skills.json → bar._commentdescribes. The test proves it the only way that counts: shoot the seat's PIXELS, hide one layer, shoot again, require the bytes to differ. A seat state that reaches no pixels is not a state. (Do NOT "simplify" this by re-adding the icon on resize or per frame — it churns the display list and re-buries the text.) - Energy refusals are waited for, not forced. Zeroing the orb beside a live charge and
clearing
skillCooldownsmakes the door read whatever the clock says: that test flaked in the parallel run answering'cooldown'. It now waits for!combat.dashAND an expired recharge, THEN takes the energy away — one real 6 s wait per run, which is why that file holds an entry indev/run-all.mjs → BUDGETS(a longer timeout with a stated reason; never add one to mask a hang — and note that four Chromium+swiftshader browsers at once is the real reason browser tests time out in a fullrun-all: re-run the stragglers with--jobs 1before believing one). - Passives are re-derived, never compounded.
refreshSkillPassives()re-rollsmaxHull = baseMaxHull × (1 + hullBonus)from the base every time and grows the CURRENT hull by the delta only — a rank-up is not a free repair, and refresh, level-up, combat restart or save/load all land on the same number.
The consoles. js/ui/SkillWindow.js is a full-screen cabinet at depth 80 in
the ShipWindow/ResearchWindow idiom, cut into three regions from
data/skills.json → window.tree: the class plate upper-left (crest, feed tag,
the bank, the session note), the tree upper-right — square icon tiles on the
authored grid, elbow joins, n/m rank badges on the tile corner, dim sockets for the
cells the tree has not filled yet — and a detail drawer across the bottom third with
the copy and its three actions lower-right (RESET · ASSIGN SEAT/CLEAR SEAT · ADD A SKILL
POINT). It owns input while up (added to GameScene._screenIsBusy()), ESC and the ✕ close
it, the world parks through the existing _frozen contract, and it holds no state of its
own beyond selection and scroll — RANK UP, RESET, ASSIGN SEAT and CLEAR SEAT call
rankUpSkill, resetSkillPending, beginSkillAssign and clearSkillSeat, the same
doors the keyboard uses, and closing calls commitSkillSession() (the seat hand-off does
NOT: the session outlives the console while a seat is being picked). Four things this
window learned the hard way:
-
One interactive surface, tiles by geometry. A drag must keep scrolling while the cursor is over a tile, and Phaser's bubbling gives a child no way to hand a held
pointerdownback to its parent. So the pane carries ONE interactive rectangle and the window hit-tests tiles itself (_tileAt) onpointerup; hover only sets a flag that the per-frame_beatTree()paints. Hover/selection NEVER force a rebuild from a pointer handler — that destroys the object under the cursor mid-event. -
One container IS the scroll. Tiles, sockets and joins are drawn in local coordinates inside a single inner container at
(pane.x, pane.y − scroll); scrolling is one y-write. -
No clip mask in this Phaser build. A tile is drawn only when it fits the pane WHOLE (the BuyList rule) and each join segment is trimmed by hand (Liang–Barsky
clipSegment) — partial spill looks worse than pop-in at the veil. -
Rebuild signature excludes
_scroll. Scroll must never re-render; the signature is rows + selection + pending. The assign flow is a HAND-OFF: press ASSIGN SEAT, the console steps aside, the bar breathes (a pulse atbar.assignPulseHz), and a seat click or digit binds; ESC puts the skill back. The ship console's SKILL TREE tab is the door — it stays an OFFLINE socket until the filed class owns a tree (shipScreenSnapshot().skillDoor). -
Testing —
index.html?skillfiles a tree-owning class and banks exactly what that tree can absorb (sum(maxPoints) − spent, computed from data);?skill=wraithnames the class,?skill=max/?skill=allranks everything,?skilltreeopens the console once the deck is calm. No flag → the door never runs;?noclasscloses it. -
Evidence:
dev/skills.test.mjs(data contract + ledger + save round-trip),dev/skills-combat.test.mjs(cast pipeline, energy economy, blast damage at ranks 1/2/5 read from data, hull passives idempotent across refresh/level-up/restart/save),dev/skills-bar-browser.test.mjs,dev/skills-window-browser.test.mjsanddev/skills-devdoor-browser.test.mjs(real clicks and real keys in Chromium, seat art checked at the PIXEL level and the console's own copy/FX blocks asserted live — they printwindow.video.noSignalTagand paintwindow.fx.sweep/glitchfrom their data timings; screenshots indev/shots/skills-*.png, including the tightskills-seat.png).dev/skills-window-browser.test.mjsis the console's own pass: region geometry read offwindow.regions()(tree upper-right two-thirds, drawer bottom third), tiles on the cells the data authored, the session's begin/spend/reset/commit lifecycle through real clicks, RESET paying a PASSIVE back and the hull pool shrinking with it, and — because a tree of two skills cannot exercise a scroller — a longer tree substituted at the snapshot seam to prove overflow, culling, wheel, thumb and drag, plus an elbow join proven by counting GOLD PIXELS in the gap between two ranked tiles against the same band beside it (0) for control.
Roadmap (working list, intentionally rough)
- v0.1 foundation — menu → New Game → click-to-fly ship
- Galaxy seed on the main menu (displayed, editable, rerollable; same seed → same galaxy, shown before you commit)
- Two-level worldgen: seeded galaxy roster (60 systems) + lazy, order-independent system contents; system archetypes in JSON (theme + attributes + distribution weight/radius band)
- The lived-in layer: settlements (colonies, mining stations, cloud
bases, deep-space stations, beacons) with per-type rates, a
core→rim density gradient, populations, and a
ownerseam reserved for the factions/pirates to come; readable as a HUD dossier (SystemReport) - The system as a place: generated worlds laid out in the world (deterministic band), solid to the ship; discovery (within 540 px of an edge, once, per system) with rim ping + toast; off-screen compass — themed screen-edge arrows with type/name chips pointing at discovered worlds; pure, save-ready discovery state
- Compass autopilot: clicking a name tag sends the ship to that
world — it targets the keep-out rim on the side the ship is
approaching from (arrival facing the world; docking seam next).
The scene's click-to-fly guards against deck AND chip clicks
(
ActionBar.contains/DiscoveryCompass.contains) (js/galaxy/Discovery.js) - The tether — the player's range: a level-1 tether (5120 px) anchored
on the home world; the ship lives in the UNION of its tethers' zones
(overlap = no line, no wall), the rim is a hard barrier rendered as a
thick glitchy dotted line; click/autopilot targets clamp to the
boundary; contact feedback (line shudder, camera kick, toast).
Pure range math in
js/tether/Tether.js(Node-tested), visuals + constraint seam injs/tether/TetherField.js, tuning indata/tether.json - UI hover/click ticks:
ui_hover+ui_clickindata/sfx.jsonplay on every clickable item (menu/deck buttons, compass name tags, panel buttons, save slots) — hover on pointerover, click on press; the SCAN slot skipsui_click(it plays its own sonar ping —scanindata/sfx.json, now assets/fx/scan-01.mp3). Shared voice injs/utils/Sfx.js → playSfxOn(both scenes exposeplaySfx(name); components call thethis.scene.playSfx?.(…)seam), with a v4 cache guard so a missing asset never throws (js/utils/Sfx.js, dev/sfx.test.mjs) - Window whoosh:
ui_window(assets/fx/ui-window.mp3) plays when a window OPENS — the mining pop-up (MiningPopup.open) and the comms / request-landing panel (CommsPanel.open) play it from their own open seam (thethis.scene.playSfx?.(…)convention); the oldconstructplays at those two open sites were dropped so the whoosh stands on its own - Close tick:
ui_close(assets/fx/ui-close.mp3) plays when a UI element is CANCELLED/CLOSED — the menu sub-bar (MenuSubBar.close), the save vault (SavePanel.close) and the confirm dialog (ConfirmOverlay.startClose);type-deconstruct.mp3is reserved for text UNDECODING now (the dossier collapsing — the only remainingdeconstructplay), and stopping mining is silent (the hum just ends — the old deconstruct blip on 'stopped' is gone) - Mining hum:
mining_loop(assets/fx/mining-01.mp3) LOOPS while the mining beam is live — starts when the arm finishes extending (phase 'mining'), stops when the sequence ends (phase 'stopped'), one hum across retargets (isPlaying guard). v4 quirk: the manager stops by key —sound.stopByKey()(nosound.stop()in this build) - Music:
data/music.json— the menu loops mainmenu.mp3 while the main menu is up; in the game scene the DEEP-SPACE soundtrack (music.game — a plain file list: deepspace-01/02 for now, adding a track = adding a line) SHUFFLES: one track at its natural length (loop OFF), the next picked at random when one ends (never the same twice in a row) — the v4 build has no sound 'complete' event, so a 1 s scene-clock tick advances it (js/utils/Music.js → startMusicShuffleOn/stopMusicShuffleOn, files queue under their filename-derived key: deepspace-01.mp3 → music_deepspace_01); when COMBAT goes live the COMBAT soundtrack (music.combat — the SAME shuffled-voice contract: combat-01/02 for now, adding a track = adding a line) swaps in and the deep-space one stops — the flip rides the onActiveChange seam (GameScene.setCombatMusic, called from _setCombatBar, the same moment the combat bar comes up), and they swap back the moment the fight ends (asleep = on a world's surface: the surface owns the silence, the wake handler resumes the right context); startMusicShuffleOn takes the context so music.volumes.combat can override the combat shuffle's volume independently (shipped at the global mix, like game); on a world's surface the track for that world's planets.png frame (terran / gas-giant families — the SAME key as the landing videos) runs from the start of the landing clip to the end of the takeoff clip. Shared voice injs/utils/Music.js(looped play +stopByKey+ one-loop-per-track guard;music.enabled/music.volume(the ONE knob for the mix) + per-CONTEXT volume overrides inmusic.volumes— menu / game / combat / frames / stationFrames, a number per context or a per-frame object, the menu ships at 1.0 for full volume), stops hooked on the scene'sshutdownEVENT (v4 quirk — see the list above), so the hum dies on Take Off AND on Return to Main Menu, and the game soundtrack dies on landing AND on returning to the menu (js/utils/Music.js, dev/music.test.mjs). The soundtrack plays ONLY in space: landing is a launch+sleep (not a shutdown — v4 quirk), so it dies on the scene's'sleep'event and the shuffle restarts on'wake'(Take Off) — the mining hum rides the same seam, so neither leaks onto a world's surface. Also fixed the landing frame hand-off —startLandingpassed the textureframeOBJECT (→ NaN); it now passessheetFrame, so the land/surface/takeoff clips are actually selected per world. intro-01.mp3 is parked for the intro sequence that hasn't been built yet (js/scenes/GameScene.js → setMiningLoop, data/sfx.json) - Research rules + panel: start a project (one at a time), tick its
duration, award
unlocks; the Research slot on the deck opens the console window — looping archive feed, category tabs, the branching tech tree, per-tech dossier + RESEARCH button, deck progress bar. Pure rules/state injs/research/(Node-tested), the window is a passive view,effectscarry the payload (e.g.tether.levelgrows the home tether). First category Exploration is live (Tether Level 1–3); second category Mining is live (Improved Arm → Advanced Arm + Improved Storage — short button copy, the tree node has no room for "Mining" — all 60 s blueprints, each unlocking its ship-upgrade build in the console's Mining tab); add a category = one JSON file + one line in the registry + one line in the manifest (js/ui/ResearchWindow.js,js/research/*,data/research.json,data/research/exploration.json, dev/research-builds.test.mjs) - Tether progression: levels 2 and 3 are builds — research
tether_l2/tether_l3(blueprint, no effect) → BUILD slot on a world holding a level-1 / level-2 tether → 200 minerals / 20 s (L2) or 300 minerals / 30 s (L3) → the world's tether strengthens to level 2 (10240 px) or level 3 (20480 px), effect and all, outliving the stay. Anchoring extra rings on planets/stations remains (theadd/setLevel/onChangeseams are in place) (data/builds.json → tether-l2 / tether-l3,js/build/*,js/ui/BuildWindow.js,dev/builds.test.mjs) - Galaxy map (the MAP console's GALAXY tab): the whole galaxy on the
chart plate — glowing stars pulsing per archetype, the jump-lane
web (traveled lanes bright + flow packets, frontier lanes, the
charted region's outline), HOME/SHIP markers, hover readouts, and
CONFIRM JUMP on a live lane (the gate transport, unchanged). The
run's footprint (visited systems + traveled lanes) is save-ready;
the FACTIONS seam is reserved (per-system
faction: null+ the documented color-layer / territory-region passes) (js/ui/GalaxyView.js,js/galaxy/GalaxyChart.js,data/map.json → galaxy, dev/galaxy-map.test.mjs) - Factions & pirates: claim settlements (
owner), flags, borders, and the player's place in a populated galaxy (the reputation layer already resolves standing throughowner—Reputation. factionStanding()is the stub to fill; the galaxy tab's reserved seams are the star color layer + the territory region — see the Galaxy map section above) - Reputation (data layer): standing on each planet & space station,
−20…+20 (best = +20), neutral 0 default, home world pinned at +20,
faction check as a placeholder on the
ownerseam; stable place ids from the generator; save-ready + Node-tested (js/reputation/Reputation.js, data/reputation.json) - Landing & exploration: settlements become points of interest you
can approach (the data — kind, anchor, population — is already there).
The comms panel is the door: clicking a planet or space station opens
a comms panel at the click (NOT a fly-here — the ship stays put; a
world click is the landing request, the compass autopilot is how the
ship flies to a world) — the name decodes
in, the standing bar draws (settled), REQUEST LANDING (gated at standing
≤ −4) or LAND on an unsettled world + CANCEL (
js/ui/CommsPanel.js, a rusty-metal frame around a scanlined green CRT). Free-space stations are solid objects now (js/entities/Station.js,data/stations.json). Button actions are seams (GameScene.commsAction) — the landing sequence lands there - The player's loop, laid down as data + seams: research (time-based,
one at a time, gates builds/research) and building (credits + minerals,
ship/planet/station) data layers (
data/research.json,data/builds.json) with templates, and the command deck (js/ui/ActionBar.js+data/actionbar.json) — Research, Scan, Ship, ·, ·, Menu. The deck reuses the menu's CRT language: the same scanline tile recipe asCyberOverlay(clipped to the bar) and theGlitchTextRGB pull-apart (icons + labels at all times, panel outline during bursts) — seeactionbar.animation.rgb/actionbar.scanline - Build panel: the BUILD slot (on a planet surface) opens the console
— looping build feed, category tabs (Planet / Cargo), the build
LIST (locked items grayed with their missing gates), per-build
dossier + highlighted cost + BUILD button (available + affordable
only). Pay minerals up front, run the clock (game-loop clock —
survives takeoff mid-build), apply
effectson completion, respectrequires+planetRequires, one at a time, deck locked while it runs, records + in-progress build save with the run and resume with their remaining time (js/ui/BuildWindow.js,js/build/BuildModel.js,js/build/BuildState.js,data/builds.json, dev/builds.test.mjs) — incl. the Mining tab: three ship-scoped upgrades (the improved / advanced mining arm → 1.5 / 2.0 minerals per second, the improved storage → 350 hold), 200 / 300 / 200 minerals, 20 s each - Ship base stats:
data/ship.json → stats(hullIntegrity 100, shields 0, cargoHold 100, mineralStorage 250), exposed asship.stats— combat/trading/mining systems will read them as capacities; upgrades (ship-category builds) will layer deltas on top - Ship screen (the Ship slot) — inspect & upgrade the ship (ship-category builds) from one place
- Galaxy regions: wide 2:1 field (plate-shaped) + home→far
dcoordinate + near/middle/far zones + per-zone type mix (data/galaxy.json → distribution.zones/zoneMix) — the region layer is live - Factions: Voronoi territories around seeded capitals → each
system's
owner(the reserved seam in reputation + the galaxy plate), faction strength and hazard keyed offzone/d; the middle zone (where the faction borders cross) = the contested space - Trade: trade hubs on every planet/station, goods priced by zone + jump distance (hops already ≈ map distance)
- World model in play: the ship still flies unbounded open space;
wire in current-system boundaries, jumps between systems (use
galaxy.neighborsOf), and a star map scene - Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- HUD (speed, fuel/crew) — the current system's dossier (name, identity, settlements) is already shown top-left
- Save/load (the
config+ entity split should make this tractable; a save = seed + player state, since the galaxy regenerates) — 10-slot bank in localStorage, in-game SAVE/LOAD vault, and the menu Continue button (newest save) - The class layer — level 5 asks who you are (Vanguard / Wraith) in a full-screen console over the frozen world; the bonus is a perk flag the combat system reads (dual cannon / targeting computer)
- Economy/trading loop (the Privateer heart)