orbit/docs/PROJECT_NOTES.md

366 lines
21 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. 40,000 systems is ~70 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 count spread, planet class weights,
moon/belt chances, habitability, hazard). New attribute key = JSON +
a few lines in `SystemGenerator.js`;
- **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
(the eventual star map / jump drives) will use `galaxy.neighborsOf(id)`
and the spatial hash already built for it.
**The galaxy is already lived in.** It was settled long before the
player arrives. Every system's content can include **settlements**:
`colony` (on a habitable world), `miningStation` (over a resource
world), `cloudBase` (riding a gas giant), `deepSpaceStation` (adrift in
open space), `waypoint` (a small beacon — the faint trace of a crossed
galaxy). Not everything is inhabited: many systems report "charted ·
unclaimed". 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.
- **Per-type rates** — `types.<id>.attributes.settlements` in
`data/systems.json`: `chance` + optional `needs` (planet classes) per
kind. 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/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.layoutSystemPlanets(seed, systemId,
planets)` (pure, `js/galaxy/SystemGenerator.js`) places each generated
world in an annular band around the home world (origin), enforcing
edge-to-edge separation from every other disc. 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). Band params live in
`data/planets.json → solarSystem` (enabled, minOrbit, maxOrbit,
minEdgeGap); 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. Home world is discovered at spawn (the ship starts
beside it). 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* system planet
(`GameScene.solidPlanets`), not just home: you can approach a rim,
never pass through.
- Verified: `dev/discovery.test.mjs` (rule, boundaries, per-system
state, JSON round-trip, layout bounds + determinism, compass geometry).
## 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 × 1.25^(level1) —
level 4 = 10000 px, which reaches the far system orbit (maxOrbit 8800)
plus rim clearance. 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.
## 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).
- 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`.
- **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.
- 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 (40k 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`
- [ ] Tether progression: the build/research verbs that anchor tethers on
planets/stations and upgrade levels (the `add`/`setLevel`/`onChange`
seams are in place; the costs + panel + research gate come next)
- [ ] Factions & pirates: claim settlements (`owner`), flags, borders,
and the player's place in a populated galaxy
- [ ] Landing & exploration: settlements become points of interest you
can approach (the data — kind, anchor, population — is already 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, Build,
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`
- [ ] Research rules + panel: start a project (one at a time), tick its
duration, award `unlocks`; the Research slot on the deck opens it
- [ ] Build panel: pay credits/minerals, apply `effects`, respect
`requires`; the Build slot on the deck opens it; a credits/minerals
readout in the HUD
- [ ] 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
- [ ] Save/load (the `config` + entity split should make this tractable;
a save = seed + player state, since the galaxy regenerates)
- [ ] Economy/trading loop (the Privateer heart)