366 lines
21 KiB
Markdown
366 lines
21 KiB
Markdown
# 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^(level−1) —
|
||
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)
|