orbit/docs/PROJECT_NOTES.md

80 KiB
Raw Blame History

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 commit yourself. 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, see lib/PHASER_LICENSE.md).
  • Must be served over http(s), not file:// (ES modules + fetch of 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.json lists which files to load. Add a config file = drop it in data/ + one line in the manifest. It is then available as a section named after the file (ship.jsonconfig.get('ship.thrust')).
  • Code reads config through the config singleton (js/config/Config.js):
    import { config } from '../config/Config.js';
    const thrust = config.get('ship.thrust', 900);
    const menu   = config.section('menu', {});
    
    Always supply a sensible fallback so code never depends on a missing key.
  • 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), and data/naming.json (syllable pools + name banks); next up might be data/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 config
    • js/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, define preUpdate (the Phaser v4 hook — v4 does not auto-call update).
  • 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).

  1. RosterGalaxy.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.
  2. Contents — planets/moons/belts/settlements/hazards are generated lazily on first arrival (galaxy.ensureContent(id)), then cached. Each system's draw stream is Rng.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). Never Math.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:

  • themabletheme.color etc. for UI;
  • attribute-drivenattributes steer 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 in SystemGenerator.js;
  • object count is global, not per-typedata/systems.json → objectCount: barren (10%) of all systems are gate-only DEAD-END LEAVES of the jump network (no planets, no stations — barren0; 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 the objects table (2/3/4/5). Stations roll first (02, 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-widedata/planets.json → frames maps each class to its spritesheet face pool. A random per-system pick would let neighboring stars wear the same face, so Galaxy._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 on content.homeFrame, read by GameScene. 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/random still work), and the HOME→FAR diagonal is the progression axis: every record carries d (0 = home corner, 1 = far corner) and its zone (galaxy.distribution.zones, equal-area thirds: near/middle/far). Types mix PER ZONE — galaxy.distribution.zoneMix multiplies each type's global distribution.weight per zone (the old per-type radiusBand is gone: void/nebula favor the deep corner, habitable the home corner). Richer galaxy-level rules (faction territories, trade hubs, combat difficulty by zone) will read record.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). 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 kinds: deepSpaceStation (adrift in open space) and waypoint (a small beacon — the faint trace of a crossed galaxy), 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 vocabularydata/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 ruledata/settlements.json → allPlanetsSettled + settledKindByClass (class → kind, with a habitable override for rocky worlds). Flip allPlanetsSettled off to return to the old probabilistic layer (per-type chance + needs — the code path is kept behind the flag).
  • Per-type free-space ratestypes.<id>.attributes.settlements in data/systems.json: chance per free-space kind (deep-space station, waypoint). Same attribute-driven pattern as everything else.
  • Home→far gradientgalaxy.settlements.gradient in data/galaxy.json: the settled heart (the home corner, d = 0) is denser (factor 1.0), the deep corner (d = 1) is thinner (clamped to floor). Each record carries d (0 = home corner, 1 = far corner) and its zone so density — and later faction strength, hazard, and trade value — is per-system, not global.
  • Reserved seam: owner — every settlement has owner: null. That's where factions and pirates will plug in later (claim, flag, relations). Deliberately absent for now — no factions yet.
  • Pure report formatterjs/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").
  • AssignmentNameGenerator.planetDeck(rng) / stationDeck(rng) build one seeded shuffle of the whole bank; SystemGenerator deals 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 old planets.homeName is 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 layoutSystemGenerator.layoutSystem(seed, systemId, planets, freeSpace, isHome, targetAngles) (pure, js/galaxy/SystemGenerator.js) places each system's planets and free-space stations on a ring (a regular polygon) around the system center (the home world at the origin in the starting system; every other system's center is EMPTY — its star is invisible flavor, never rendered), enforcing the SOLAR SYSTEM BAND in data/planets.json → solarSystem: EVERY pair of layout objects — planets, stations (and the home world, starting system only) — sits center-to-center in [minSpacing, maxSpacing] (640015360 px; the starting system's band tightens to [minSpacing, homeMaxSpacing] = 640010240 px). The ring radius is chosen inside the band (non-home: maxSpacing over the chord of the N-gon; home: the (N+1)-gon side over the band's tight ratio), and the rotation (ring phase) is a deterministic scan that serves the system's gate target bearings. The starting system is capped at 3 objects (2 planets + ≤ 1 station) — five points cannot sit 640010240 px apart (the tightest 5-point spacing needs ratio ≥ φ > 1.6). Planet-bound settlements (colonies, mining stations, cloud bases) are features of their planet, not layout objects. Draws come from the dedicated fork Rng.derive(seed, 'system', id, 'layout') — layout results are seed-deterministic and don't perturb the content stream (lazy === eager is preserved). Class sizes/tints in classScale/classTint.
  • Discoveryjs/galaxy/Discovery.js (pure, no Phaser — Node- testable, save-ready: toJSON()/fromJSON()). Rule: the ship within game.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).
  • Compassjs/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 13 jump gates (data/gates.json), each one the exit toward a NEARBY star on the 2-D map. Two layers:

  • The networkjs/galaxy/JumpNetwork.js (pure, Node-tested; built once per galaxy in Galaxy._generate(), exposed as galaxy.jumpNetwork / galaxy.jumpGatesFor(id)). It is a degree-limited spanning tree of the nearest-star graph (each system's neighborPool = 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 than maxGates gates, 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 placementSystemGenerator.layoutGates(...) (pure) places each gate on the tether circle of 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), exactly anchorTetherLevel (level 1 = 5120 px, from data/tether.json) from it — so the tether rule is hard by construction. Candidates are tried in facing quality: 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 stay minRadius..maxRadius (204820480 px) from the system center, keep size + clearance from solid discs (planets, free-space stations, the home world) and 2·size + gateGap from each other, and get unique names ("Avidy Gate", "Avidy Gate II" — star names are syllable-generated and collide). Barren systems (no anchors — the objectCount → 0 stops) get their single gate on the center→destination ray at barrenDistance (8192 px) instead; their payload is 12 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 carries active: 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's rotation is 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's activate() 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.shipClearance from 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 (worldObjectAt excludes 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.jsonjump; the pure geometry is js/galaxy/JumpTravel.js, Node-tested by dev/jump-travel.test.mjs): GameScene.jumpThroughGate re-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, and scene.restart() rebuilds this scene onto it: the destination's native tethers re-form in create(), 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.videoVolume 0..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 default scrollFactor 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 (prepareLoad ran 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. If video is empty/missing the jump falls back to the short jumpDelayMs cut. 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 active flags, same jump arrivals. Verified: dev/jumps.test.mjs (network invariants, placement invariants, barren gates on-ray, active: false everywhere, 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 in dev/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.
  • 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 ConfirmOverlay is label-sized to fit the dialog (baseWidth + measured width, per-show width/height overrides).
  • GALAXY (live — tabs.galaxy.standby is the killswitch; true returns it to the old "OFFLINE" toast) — the whole-galaxy chart, js/ui/GalaxyView.js, fed by GameScene.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 heartbeatgalaxy.pulse.<type> in data/map.json (speed Hz + 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.jumpFromMapjumpThroughGate); dormant lane / no lane → the readouts. The window is a pure view: the galaxy tab polls getGalaxy() on the same 500 ms cycle the system chart is.

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 (planned — not yet implemented): the snapshot already reserves the seam — every system carries faction: null, and GalaxyView's header documents the two reserved rendering passes for when they ship: a faction color layer over the stars (a ring/tint on top of the archetype color) and a territory region fill (the exact hull/fill recipe of the charted region, per faction color + relation alpha). The settlements' owner: null seam (above) + Reputation.factionStanding() are the data-side counterparts.

  • Tuning — all of it in data/map.json → galaxy (stars, pulse per archetype, edges, hull, flow, dust, labels, zoom, reveal, dialog copy).
  • Testsdev/galaxy-map.test.mjs (edgeKey symmetry, the snapshot's systems/lanes/flags + the faction seam, 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 are alwaysFull (never fold to the text-less far chip) and resolvable by autopilotTo (which strips the route:/dest: prefix).
  • Jump (jumpThroughGate_routeForJump, BEFORE captureState so 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, clear destination (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: clear destination (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's create() a beat after spawn. resetRunState clears it (a New Game inherits no pending notice).
  • Savedestination is captured/restored/reset in js/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.

Tuningdata/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 by GameScene.routeEdgeKeys() (the consecutive system pairs of planRoute's path) and passed to buildGalaxySnapshot (which marks route: true on those edges and isDestination: true on the destination star). GalaxyView._redrawEdges draws the route lanes (three-pass glow, like traveled) and GalaxyView._drawMarkers draws 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.mapChartSnapshot sets destinationId on the snapshot; MapWindow.paintChart draws 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.mapChartSnapshot sets routeExitGate on the snapshot (the gate's id/x/y/name); MapWindow. paintChart draws 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.
  • SYSTEM tab — the destination object is circled in orange when viewing the destination system's chart. GameScene.systemChartSnapshotFor passes destinationId to systemChartSnapshot (which carries it on the snapshot); MapWindow.paintChart draws the ring (same step 8b).

Testsdev/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 in GameScene.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.contact in 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), and visibleArcs (circle-arc interval math on the unit circle). Node-tested by dev/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 in data/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^(level1) — 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, the categories registry (id, label, icon, accent), the video (file + aspect), and fx timing.
  • data/research/<category>.json — the tree: a flat nodes map where each node has label, description, duration (in timeUnit), requires (parent ids — a DAG), unlocks (what the tech OPENS, below), and optional effects. starting lists the pre-unlocked roots. Section name = file basename (research/exploration.jsonconfig.section('exploration')). **Add a category = one file + one line in research.json → categories
    • one line in data/manifest.json.**

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.jsbuildSystemTree), 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} Mapduration: 0, in starting: 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 {System} Jumpgates — 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 by main.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 pure navChart() core above.

    Activation wiring (a real bug lived here): completing the tech runs GameScene._applyResearchEffects, whose activateGates branch recovers the system id from the node id it's passed — via the pure systemIdOfGatesNode(nodeId) (S000137_gatesS000137). A node object carries no id field (its id is the key in the tree's nodes map), so reading node.id yields undefined and 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's requires edges (the DAG itself stays authoritative — requires only).
  • unlocks.builds — ids in data/builds.json the tech makes available. The authoritative gate is the build's own requires (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.mjs keeps 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), optional targets (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), optional planetRequires (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, effect tether: {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 in data/builds.json.
  • The console's dossier shows the line (UNLOCKS: TETHER LEVEL 3 · TETHER ANCHORING · BUILD · TETHER - LEVEL 2), read through ResearchModel.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 in data/builds.json (mining-arm-improved / mining-arm-advanced / mining-storage-improved, the console's Mining tab) — the build's requires: ["mining/<node id>"] is the authoritative gate, the node's unlocks.builds the 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 build requires id resolves). isAvailable also consults a tree's optional available(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 + the available/lockNote hooks), systemIdOfGatesNode(nodeId) (recover the system id a gates node id encodes — the node object has no id field, 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 the orbitNav() console diagnostic in js/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 by dev/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 by GameScene.jumpThroughGate (the transport — save pipeline + scene restart); Node-tested by dev/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 asks GameScene to start a run via onResearch(catId, id); the scene owns the rules, the effects, the toasts, and the save data. The window re-renders from ResearchState + ResearchModel only.
  • GameScenebeginResearch, _completeResearch, _applyResearchEffects, _deckResearchBar. The effects seam reads node.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's activatedGates set, 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.
  • The SYSTEM category's world state is the run's activatedGates set (activation keys "<sysId>><destId>") — registry-backed like discovery (GameScene.create reads it; resetRunState clears it on New Game, prepareLoad restores 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 via systemTree and 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.mjs open 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 by dev/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, and isBuiltAnywhere(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 via onBuild(buildId); SurfaceScene.beginSurfaceBuildGameScene.beginBuild enforces the rules (one at a time, not installed, research gate, planet gate, mineral cost — the full amount paid up front), then BuildState.start runs the clock. The window re-renders from BuildState + BuildModel only.
  • GameScenebeginBuild (the ship-scoped gate: isBuiltAnywhere for targets: ["ship"] builds), completeBuild, _applyBuildEffects (the effects seam: tether {level: N} → the world's tether strengthens to at least N via TetherField.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 in create() (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).

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.rateship.stats.miningSpeed (minerals/s — Mining._extract multiplies the base economy rate by it, read live per tick) and effects.mining.capacityship.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.jscanonicalPlanetName, 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, the built map, 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 loaded tether-l1.starting field.
  • orbitDiag() in DevTools → Console prints the full report: env, JS probes, the data/builds.json contract as loaded, home/current planet, built records, tether objects, research state, the save bank's builds per slot, pending restore, scene wiring, errors since boot.
  • If orbitDiag is undefined, the browser served a cached older js/main.js → serve with node dev/server.mjs (sends Cache-Control: no-store on every response) or hard-reload with the cache disabled. (A plain python3 -m http.server lets browsers heuristically cache the ES modules — the classic "new data + old JS" trap that makes an old GameScene.js keep running after edits.)
  • dev/build-check.html plays the real Build flow (menu → New Game → home world → build console) and prints the L1/L2 row states before and after the tether_l2 research; 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; factions later)

The player holds a REPUTATION (standing) on each planet and space station:

  • Scaledata/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 (placeholder)Reputation.standingFor(place) resolves: home → stored standing → the place's owner → faction standing → neutral. owner is the settlements' reserved seam (always null until factions exist) and Reputation.factionStanding(id) is a marked TODO(factions) stub returning null — so today the whole galaxy reads neutral (home: +20). When factions land: populate owner in generation + fill factionStanding(); the resolution order is final.
  • The mutation seamsset(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 id from 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 & savejs/reputation/Reputation.js (pure, no Phaser — the Discovery pattern): toJSON()/fromJSON(); the save record carries reputation (scale snapshot + stored standings); the scene keeps one instance in the shared registry (New Game resets it via resetRunState). 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.
  • Testsdev/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's TimeClock or TweenManager (the v3 scene-events PRE_UPDATE/UPDATE plumbing is not fired in the v4 loop). Each scene must call this.time.update(time, delta) and this.tweens.update() at the top of update(), or delayedCall/addEvent/tweens silently never run. (Done in MenuScene.update / GameScene.update.)
    • A GameObject constructed with new (e.g. our Container subclasses in js/ui/) is not added to the scene display list — the scene.add.* factories do that. Call scene.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 ONLY a (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 and setColor() must get CSS strings — use toCss() from js/utils/Color.js (keep toColor() for Graphics/shape APIs).
    • An Image created before its texture key exists can stay bound to the __MISSING texture forever — even after the key is generated later in the same session. Generate the texture first (see DiscoveryCompass.ensureArrowTexture: texture, then scene.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 a scrollFactor(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.) Set scrollFactor(0) on every child of a screen-fixed UI container — done in ActionBar.buildSlots.
  • sound.play() throws on a missing key — and cache.hasAudio() doesn't exist in this build. Playing a key that never loaded is a hard Error: Audio key "…" not found in cache, not a no-op, and the cache.hasAudio() guard the old playSfx relied on is absent here. The shared voice (js/utils/Sfx.js → playSfxOn) therefore checks scene.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 (a playSfx('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 exposes stopByKey(key) / stopAll() (and isPlaying(key) / getAll(key) / getAllPlaying()), but NOT a keyless stop(). So stopping a looping SFX is scene.sound.stopByKey('sfx_<name>') — the shared voice wraps it as js/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 on stopByKey.)
  • A scene transition does NOT call scene.shutdown(). The manager stops a scene via sys.shutdown(), which flips the status and emits the shutdown EVENT on the scene's emitter — the scene's own shutdown() method is never invoked (verified: a wrapped MenuScene.shutdown did 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', …) from create() (done for the menu hum and the surface hum); the shutdown() methods stay as the game-destroy path.
    • The surface is a LAUNCH + SLEEP, not a transition. Landing does scene.launch('SurfaceScene') and then scene.sleep() — a sleeping scene never emits shutdown (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, not once), while 'shutdown' covers return-to-menu and destroy (js/scenes/GameScene.js → create).
  • Ship art — the starter ship is now a spritesheet frame: data/ship.json → texture (assets/images/ships-player.png, 256×256 frames, frame 0), mirroring the planets pattern. The frame art faces artFacing ("north" — the nose is the frame's top edge; the glass cockpit section is aft, per the art's author); Ship applies 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 per data/gates.json → activation), spinning slowly clockwise and breathing its alpha between 0.6 and 0.9 (tunables in gates.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 hovers shipClearance px 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).

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 owner seam 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 in js/tether/TetherField.js, tuning in data/tether.json
  • UI hover/click ticks: ui_hover + ui_click in data/sfx.json play on every clickable item (menu/deck buttons, compass name tags, panel buttons, save slots) — hover on pointerover, click on press; the SCAN slot skips ui_click (it plays its own sonar ping — scan in data/sfx.json, now assets/fx/scan-01.mp3). Shared voice in js/utils/Sfx.js → playSfxOn (both scenes expose playSfx(name); components call the this.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 (the this.scene.playSfx?.(…) convention); the old construct plays 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.mp3 is reserved for text UNDECODING now (the dossier collapsing — the only remaining deconstruct play), 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() (no sound.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); 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 in js/utils/Music.js (looped play + stopByKey + one-loop-per-track guard; music.enabled / music.volume), stops hooked on the scene's shutdown EVENT (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 — startLanding passed the texture frame OBJECT (→ NaN); it now passes sheetFrame, 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 in js/research/ (Node-tested), the window is a passive view, effects carry the payload (e.g. tether.level grows the home tether). First category Exploration is live (Tether Level 13); second category Mining is live (Improved Mining Arm → Advanced Mining Arm + Improved Mining Storage — 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 (the add/setLevel/onChange seams 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 through ownerReputation. 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 owner seam; 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 as CyberOverlay (clipped to the bar) and the GlitchText RGB pull-apart (icons + labels at all times, panel outline during bursts) — see actionbar.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 effects on completion, respect requires + 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 as ship.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 d coordinate + 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 off zone/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)
  • Economy/trading loop (the Privateer heart)