orbit/docs/PROJECT_NOTES.md

1030 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.json` → `config.get('ship.thrust')`).
- Code reads config through the `config` singleton (`js/config/Config.js`):
```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. **Roster**`Galaxy.create(seed)` builds every system's *identity*
(id, name, type, x, y) up front. 200 systems is ~15 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:
- **themable** — `theme.color` etc. for UI;
- **attribute-driven** — `attributes` 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-type** — `data/systems.json →
objectCount`: `barren` (10%) of all systems are jump-gate-only (no
planets, no stations — `barren``0`), 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). 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 → 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** — `distribution.weight` (how common) and
`distribution.radiusBand` (first proximity rule: e.g. `void` systems
live in the outer rim). Richer galaxy-level rules (clustering,
faction borders, adjacency affinity) will slot into
`data/galaxy.json``distribution.rules[]`, read in
`Galaxy._generate()` — the hook is marked in code.
The player's **current system** starts at `galaxy.currentSystem()`
(`startingSystem.policy`: `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 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 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 rates** — `types.<id>.attributes.settlements` in
`data/systems.json`: `chance` per free-space kind (deep-space station,
waypoint). Same attribute-driven pattern as everything else.
- **Core→rim gradient** — `galaxy.settlements.gradient` in
`data/galaxy.json`: the settled heart is denser (factor 1.0), the rim
is thinner (clamped to `floor`). Each record carries `rNorm` (0 =
center, 1 = rim) so density 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 formatter** — `js/galaxy/SystemReport.js`
(`formatSystemReport(content)` → title/subtitle/settlements/gates/
summary). The GameScene HUD renders it; future star map / terminal UI
reuse 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; `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 layout** — `SystemGenerator.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 central body (the home world at the origin
in the starting system, the star elsewhere), enforcing the SOLAR
SYSTEM BAND in `data/planets.json → solarSystem`: EVERY pair of layout
objects — planets, stations, the central body — 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`.
- **Discovery** — `js/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 central body is discovered at spawn in EVERY
system: the home world (the ship starts beside it) in the starting
system — "Home World" exists in exactly one system — and the system's
STAR ("G-type Star" etc., `js/entities/Star.js`) in every other one;
both read the chart's central NAV point (id 'home'). 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 central body (the home world in the starting
system, the star elsewhere — both the '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 network** — `js/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 plus optional one-way `shortcuts` bought with the
spare gate budget. Result: the directed graph is **strongly
connected** (from any star you can reach any other — no closed
systems, no trapped sets), every jump is local, and no system holds
more than `maxGates` gates. 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 the tether circle of one of the system's ANCHORS
(a planet, a free-space station, 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 star, keep `size` + `clearance` from anchor discs 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 stardestination ray at `barrenDistance`
(8192 px) instead. 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
<Star>"). 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.json` → `jump`; 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; one-way SHORTCUT jumps have no return gate (JumpNetwork:
tree edges run both ways, shortcuts don't — ~⅓ of a typical galaxy's
gates) and land on the destination's star, inside its home-tether
zone. 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`.
## 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.json` → `config.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.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`, 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 central body, all
planets, all space stations, all jump gates — the scene's discoverable
set minus the asteroid clusters); 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_gates` → `S000137`). 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.
**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 — one-way shortcuts have none),
`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 star). 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.
- `GameScene``beginResearch`, `_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), `startingPairs` (pre-installed on fresh runs —
`tether-l1.starting: ["home"]`), `missingRequirements` (research +
`planetRequires.tetherLevel`), `isAvailable`, `rowState` (built /
active / available / locked), `costLines`, `canAfford`. 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`. 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.beginSurfaceBuild``GameScene.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.
- `GameScene``beginBuild`, `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`; unknown shapes log
and no-op). 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`).
**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`, the `built` map, the planet's tether level, the
L1/L2 row states (with missing gates), 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:
- **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 (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 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
`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 & save** — `js/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.
- **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'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)
- [x] v0.1 foundation — menu → New Game → click-to-fly ship
- [x] Galaxy seed on the main menu (displayed, editable, rerollable;
same seed → same galaxy, shown before you commit)
- [x] Two-level worldgen: seeded galaxy roster (200 systems) + lazy,
order-independent system contents; system archetypes in JSON
(theme + attributes + distribution weight/radius band)
- [x] 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`)
- [x] 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
- [x] 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`)
- [x] 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`
- [x] 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)
- [x] 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
- [x] 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)
- [x] 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)
- [x] 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)
- [x] 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, Tether Anchoring, Signal Amplification); 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)
- [x] Tether progression: level 2 is a **build** — research
`tether_l2` (blueprint, no effect) → BUILD slot on a world holding a
level-1 tether → 200 minerals / 20 s → the world's tether strengthens
to level 2 (10240 px), effect and all, outliving the stay. Level 3
is still research-direct (`effects.tether.level` on the home
tether); anchoring extra rings on planets/stations remains
(the `add`/`setLevel`/`onChange` seams are in place)
(`data/builds.json → tether-l2`, `js/build/*`,
`js/ui/BuildWindow.js`, `dev/builds.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 `owner``Reputation.
factionStanding()` is the stub to fill)
- [x] 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
- [x] 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`
- [x] 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)
- [x] 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
- [ ] Richer galaxy distribution rules (`galaxy.distribution.rules[]`:
clustering by type, borders, adjacency affinity) — hook marked in
`Galaxy._generate()`
- [ ] 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
- [x] 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)