orbit/README.md

536 lines
36 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
> A procedurally generated space RPG in the spirit of *Privateer*, in
> top-down 2D. Built with **Phaser 4** as plain **ES6 modules** — no build
> step, no package managers required to run.
## Run it
Any static file server works (Python, Node, Caddy, nginx, …):
```sh
cd orbit
node dev/server.mjs 8080
# → http://localhost:8080
```
> **Why not `python3 -m http.server`?** It sends `Last-Modified` with no
> `Cache-Control`, so browsers apply *heuristic* caching to the ES
> modules. While iterating, the browser then keeps running an **old**
> `js/*.js` module graph (e.g. a `GameScene.js` from before a feature
> landed) long after the files on disk changed — while the JSON data is
> fresh. That mix (new data + old JS) is the classic "it still behaves
> like the old version" bug, and clearing the JSON cache or hard-resetting
> the game does not fix it. The dev server above sends
> `Cache-Control: no-store` on every response, so a normal reload always
> runs what's on disk.
> Must be served over **http(s)** — opening `index.html` via `file://` won't
> work, because the game uses ES modules and `fetch`es its JSON config.
**Combat** (tuned before it ships in the real game) has two fronts, and
every combat number — thrust, speeds, hulls, shields, the lance, rams,
respawns, the bar — lives in **`data/combat.json`**:
- **The training range** (a standalone arena — where the feel is tuned):
the dev server maps **`/combat`** → `combat.html` (any server can too).
One fixed arena: you (WASD/arrows + SPACE) against a pack of the enemy
craft (`assets/images/ships-enemy.png`). `R` resets the range, `P`
freezes it (positions hold while you poke
`window.__COMBAT__ = { reset, spawnEnemy, player, kills }` in DevTools).
- **In the main game** (the integration stand-in — what players will
eventually feel): **`index.html?combat`** boots the *normal* game, and
when the player starts, a pack of hostiles spawns around the ship.
The mouse steers; while a hostile is in range the ship **auto-fires**
at the nearest one (the cannon tracks — `js/combat/CombatSystem.js`).
While combat is live the action deck **swaps for the combat bar**
(`js/ui/CombatBar.js`) — Diablo 4 style: vertical shields + hull on
the left, the ENERGY gauge on the right, eight skill seats wearing
keys **1–8** in the middle (the skills land later — for now they're
empty seats; a press lands on the system's `cast()` stub). Wipe the
pack and the deck comes back. Kills also earn **combat XP** (the
run's level ledger — `js/combat/XpManager.js`; the curve is
`data/progression.json`, each type's value is its own `xp` in
`data/combat.json`) — a level-up bumps the ship's armor (the
placeholder toughness), fires a burst + a distinct voice, and lands
a line in the kill feed. Poke the live fight in DevTools via
`window.__ORBIT_COMBAT__ = { system, bar, player, kills, spawn,
damagePlayer, cast, xp, grantXp }`.
- **Neutral encounter** — **`index.html?neutral`** boots that same normal
game with a NEUTRAL-ONLY encounter live instead of hostiles: two
pilots stream in on a short escort window — the neutral hum replaces
the combat music the moment one is on screen, the pilot's SCAN comm
lands as it flies in, and the SCAN-COMPLETE comm rides the break-off
(the dev stand-in for the neutral encounter beat — safe to run even at
home, where hostiles never go).
- **Feel** — the handling is what the range tunes. Every feel number
(thrust, top speed, drag, turn rate + turn response, throttle ramp/
decay, brake) lives in `data/combat.json` under `combat.player`, and
both fronts run the same pure `handleFeel` integration
(`js/combat/Combat.js`) — so what you feel in the range is what the
main game ships. Thrust ramps in and decays out, turns ease on, and
the brake kills the velocity instantly.
- **Feedback — VFX** — every combat event has a distinct *visual* cue,
all procedural (`js/combat/Fx.js` — canvas dots / additive glows, no
new art): a tracer + muzzle flash on **firing**, a spark burst +
damage number on **impact**, a cyan flash + damage number on a
**shield hit**, an orange flash + damage number on a **hull hit**, a
big burst + double ring on an **enemy kill**, a DOWN burst when the
**player goes down**, and a REFORMED bloom on **respawn**. Shield
reads cyan and hull reads orange in both the flash and the number, so
a glance tells you which layer took the hit.
- **Feedback — SFX** — the same ten events each get a *distinct* voice,
through one shared event→voice map + playback path
(`js/combat/CombatSfx.js`) so the two fronts can't drift: the player's
cannon = `mass_cannon` (assets/fx/combat-mass-cannon.mp3), a hostile
taking the player's shot = `mass_cannon_hit`
(assets/fx/combat-mass-cannon-hit.mp3), the enemy's laser = `laser`
(assets/fx/combat-laser.mp3), impact = `ui_click`, shield =
`solar_buzz`, hull = `mining_split`, kill = `thunder_01..04`
(one at random), down = `deconstruct`, respawn = `discovery`,
level-up = `solar_flare_01`.
- **Readability layer** — a shared HUD (`js/combat/CombatHud.js`, pure
rules in `js/combat/Combat.js`) makes the fight *readable* in both
fronts: a rotating **lock reticle** on the current target (the same
target the cannon auto-fires — one rule, one target), pulsing
**threat markers** over every live hostile, **off-screen arrows** at
hostiles outside the camera (the main game's camera follows the ship),
a **kill feed** (newest-first, top-right), and a **gauge flash** — the
combat bar's shields/hull gauge lights the moment it takes the hit.
- **Combat levels (XP)** — kills grant the hostile's type's own `xp`
(the roster sets XP-per-threat: the base craft 10, the bomber 25 —
`data/combat.json → enemy.xp`), banked against a curve that's easy at
first and hard later, never a grind (`data/progression.json` —
`base × n^power`, shipped 50 × n², capped at level 80; five base kills
buy level 1, the whole run to the cap is 8.7M XP). A crossed level
fires a `levelup` EVENT (one per level — the armor bump
(`progression.reward`: +3 hull / +2 shields, full heal), the burst +
voice, the feed's line all ride it; the skill layer — active and
passive, the post-cap progression — will subscribe to that same event
later). At the cap the level holds and the overflow is harmlessly
banked. The ledger is part of the save as `{ level, xp }` (old saves
load — a fresh run starts at level 0), and the bar's XP row (below
the orbs — the gold summary strip) shows the level, the run toward
the next, and the numbers (banked over the next cost; at the cap the
banked total). `node dev/xp.test.mjs` covers
the curve, the grants, the cap, the events, and the save round-trip.
- **Dev hooks + checkers** — besides the two pokes above, every SFX call
is logged to `window.__COMBAT_SFX__ = { log, last }` (capped), so the
wiring is verifiable headless in both fronts. CDP checkers drive the
real game and read state back: `node dev/combat-flycheck.mjs`
(flight feel — t60/t30, spin, stop distance, top speed),
`node dev/combat.sfxcheck.mjs` (all the SFX events fire through the
shared path, in both the range and the main game), and
`node dev/combat-shot.mjs [outDir]` (captures a labeled feedback frame
per context through the production paths into `dev/shots/` — the
committed `combat-range.png` + `combat-main.png` are that evidence).
## The class layer — who you are, picked at level 5
Cross **combat level 5** and, once the shooting stops, the world **freezes** and
the **class console** opens full-screen (`js/ui/ClassWindow.js`): Trestle's muted
feed loops on the left (`assets/videos/classes.mp4`), the class tabs ride the
right in their own colours (`assets/images/classes.png`, 128x128 frames — frame
0 Vanguard, frame 1 Wraith). Its first state plays
`assets/speech/trestle/classes.mp3` over his briefing copy; every tab then shows
that class's doctrine and a **PICK THIS CLASS** action that asks once more before
it locks.
- **Vanguard** (red) — **DUAL CANNON**: the volley leaves two barrels, each bolt
carrying the weapon's full damage. Double the metal per beat, same rate of fire.
- **Wraith** (purple) — **ADVANCED TARGETING COMPUTER**: predictive aim. The
cannon fires where the hostile *will* be when the bolt lands (a real intercept
solve in `js/combat/Aiming.js`), capped so an unreachable lead can't whip the
nose around — no solution, and it aims at where the ship is now.
- Both bank **+5 skill points** into the run (`js/player/ClassState.js`, saved with
the run). The ship console's plate then shows the crest, the class name in its
accent colour and the banked points; the Skill Tree tab lights up as soon as the
filed class owns a tree (below) — otherwise it stays an offline socket.
- **The rules**: asked once (the prompt is spent on lock-in, never nagged), only
when combat is quiet and nothing else owns the screen. Closing without picking
DEFERS — `CHOOSE YOUR CLASS` in the ship console is the way back. While the
console is up the world is parked through `GameScene`'s existing `_frozen`
contract (bodies damped, AI/combat/missions skipped), and Trestle's voice ducks
the other audio beds, restoring them on the way out whatever you chose.
- **Try it**: **`index.html?level5`** (aliases `?cls`, `?class`) starts a fresh run
and walks straight into the console — no grinding required. Add `?tab=wraith`
to open on a specific class; `?noclass` closes both dev doors for an honest run.
Evidence: `dev/classes.test.mjs`, `dev/class-combat.test.mjs`,
`dev/classes-browser.test.mjs`, and the screenshot pass `dev/class-shot.mjs`.
- Everything is data — roster, colours, icon frames, all of Trestle's copy, the
confirm wording and each perk's mechanics numbers live in **`data/classes.json`**.
See *The class layer* in `docs/PROJECT_NOTES.md`.
## The skill layer — spend the points in the SKILL TREE
The bank the class layer fills has somewhere to go. Open the **ship console**
and press **SKILL TREE** for the full-screen console (`js/ui/SkillWindow.js`) — a
cabinet in three regions: the **class plate** upper-left (crest, feed tag, the points
still unspent), the **tree** upper-right — square icon tiles on the grid the data
authored, joined by elbow lines and badged **`n/m`** on the corner — and a **detail
drawer** across the bottom third with that skill's copy and its actions lower-right.
- **One point per press.** ADD A SKILL POINT spends a single banked point
(`0/5` → `5/5`) — for the selected skill, whether you press the button or click
its tile again; points are never lost and a rank 0 skill is inert. The ledger
(`js/skills/SkillState.js`) travels with the run in the save; an old save simply
starts with an empty ledger.
- **A session is undoable, a closed console is not.** Opening the console opens a
SESSION: every point spent inside it stays PENDING (the plate says `+n THIS SESSION`),
and **RESET** hands them back — down a branch this session bought, never past a point
committed earlier (a reset that would strand one says so instead of de-powering the
ship). Closing the console — ✕, ESC or a click on the scrim — COMMITS what is left (and
so does leaving the run: an open session never survives a save/load). Handing a skill to
a seat steps the console aside but does NOT commit — the undo stays live while a seat is
being picked.
- **The tree scrolls.** Wheel, drag or the thumb; this Phaser build has no clip mask,
so a tile is drawn only while it fits the pane WHOLE and the join lines are clipped by
hand — the same rule the shipyard's buy list follows.
- **ACTIVE skills take a seat.** ASSIGN SEAT puts the skill "in the hand" — the
console steps aside, the combat bar breathes, and a click on a seat (or the key
**1**–**8**) binds it. Casting spends its energy and starts its own recharge;
right-click a seat to clear it. Seats are addresses, not owners: rebinds are free
and a cooldown follows the skill.
- **PASSIVE skills never sit in the bar.** Ranked ≥ 1 means always on — Vanguard's
Reinforced Plating grows the hull pool from the moment its first point lands, and
growth is never a free repair.
- **The two Vanguard skills**: **Ramming Charge** (OFFENSE — dash past the nearest
hostile and arrive with a blast; the pack is flown *through*, so one charge is
one settlement) and **Reinforced Plating** (PASSIVE — more hull to spend).
- **Try it**: **`index.html?skill`** files a tree-owning class and banks exactly
what that tree can absorb; `?skill=wraith` names the class, `?skill=max`
(`?skill=all`) ranks the whole tree, and `?skilltree` walks into the console on
boot. `?noclass` closes these doors too. No flag, no effect — normal runs are
untouched.
- Everything is data — tree membership, ids, labels, types, energy, recharge, max
points, `requires` edges, effect numbers, icon frames, tooltips, toasts, the
**authored grid** each skill sits on (`node: {col, row}`), the console's whole
geometry/palette block (`window.tree`: region split, tile size, join colours,
rank badge, empty sockets, scroller) and the bar's whole look live in
**`data/skills.json`**. Code owns behaviour only: adding
skill #3 is a data row (plus one branch in `js/skills/SkillEffects.js` if it needs
a new kind of effect). See *The skill layer* in `docs/PROJECT_NOTES.md`.
## Current state — v0.2: a seedable galaxy
- Main menu with **Continue** (resumes the newest save — grayed out while
the save bank is empty), **New Game**, **Load Game** (the full slot
bank), and a **Galaxy Seed** panel: the seed is displayed, editable
(click it and type), and rerollable — and the menu shows what that seed
builds (the galaxy's name, system count, archetype count) **before** you
commit. Same seed ⇒ same galaxy.
- **Procedural galaxy**: 90 star systems in a seeded wide (2:1, plate-shaped) field —
even, organic spacing (Poisson disk, `data/galaxy.json`), the player's
home in the lower-right corner, and a home→far difficulty axis split
into near/middle/far zones — typed into six themed archetypes
(`data/systems.json`) with per-type weights and per-zone mixing
(`galaxy.distribution.zoneMix`) — the galaxy lays itself out on a
progression the trade/combat economy can lean on.
- **Two-level generation**: the whole galaxy roster is generated at New
Game (~15 ms); each system's planets/moons/belts/settlements are
generated lazily on arrival, deterministically (seed + system id), so
lazy and eager give identical results.
- **A lived-in galaxy**: the galaxy was settled long before you arrive.
Every planet is settled (for now): colonies on habitable worlds, mining
stations over the rest, cloud bases riding gas giants — plus stations
adrift in open space and beacons, whose odds are per-archetype
(`data/systems.json`) and thin out from the settled core to the wilder
rim (`data/galaxy.json`). **Barren systems** (~10%, the
`objectCount` → 0 stops) are the DEAD-END LEAVES of the jump-gate maze:
they hold nothing but their single jump gate (and 1–2 asteroid
clusters drifting inside that gate's tether, their only payload) and
report *charted · unclaimed*; you enter and exit through the same
gate. Non-home systems hold 0/2/3/4/5 objects
(planets + free-space stations, `data/systems.json → objectCount`);
every non-barren system holds at least one planet, and the gate's
tether anchors on one of them (so the player can always build and
expand out from the gate);
the starting system always holds the home world, a gas giant,
and a rocky world. Each settlement has a name, population, and an
`owner` seam reserved for the factions/pirates to come.
The current system's dossier (name, identity, what's there) shows
top-left in the game scene.
- **Names with a point of view** (`data/naming.json`): planets draw from a
deep curated bank — colonial "New Denver" worlds beside alien "Klaxoria"
worlds — and stations from a bank of official designations ("Deep Space
SC-145") and smuggler hangouts ("Hell's Hideout"). Names are dealt out
randomly per system **without repeats until the bank is exhausted** (a
seeded shuffle, so the first N draws are N distinct names). Stars and the
galaxy itself are still synthesised from syllable pools, so you never run
out of those. Within a system a planet never shares a name with another
planet and a station never with another station.
- Game screen with a top-down ship (real spritesheet art — frame 0 of
`assets/images/ships-player.png`, see `data/ship.json`): **click anywhere to fly there**
in the current system's open space. Every system also holds 1–3 JUMP
GATES — solid, discoverable exits that face their destination star —
wired into a PURE SPANNING TREE gate network (data/gates.json, no
shortcuts, no closed loops: exactly one route between any two systems —
a maze of dead ends and long hauls); every gate is `active: false` for
now — DORMANT (dimmed, field still) — and activation is the seam for
the tether mechanic (an activated gate anchors a level-1 tether);
the jump drive itself comes next.
Discovered worlds get a screen-edge arrow + a name tag showing the
world's **name** (e.g. `HOME WORLD · ESHKAELURA`); **clicking the
name tag autopilots the ship there** (it arrives on the keep-out rim,
facing the world)
- **The home planet** — the player's world — in the system you start in:
a 1024 px disc rendered 1:1 from the `assets/images/planets.png`
spritesheet, with a **proper name drawn from the planet name bank** (the
first name off the starting system's deck, so it never clashes with one
of the other worlds). Which Terran face it shows (frames 0–2) is a
seed-deterministic pick, so the same galaxy always yields the same home
world. The ship spawns ~150 px (edge-to-edge) off its rim in a
seed-derived direction, may fly in as close as 50 px from the rim, and
can never cross it — a planet is solid (tuning in `data/planets.json`)
- **The tether — your range.** You start with a level-1 tether anchored
on the home world: a circular zone, **5120 px from the planet's center**
(`data/tether.json`). The ship cannot leave it — anywhere in the union
of its tethers' zones is open space (where zones overlap there is no
line and no wall), and the rim is a hard barrier rendered as a thick,
glitchy, marching dotted line (neon core + RGB fringe + ambient glitch
bursts, contact shudder where the ship bumps it). Click-to-fly and
autopilot targets are clamped to the union boundary, so a world outside
your range is where the ship parks — on the line — until a tether grows.
Upgrades and extra anchors (planets/stations) plug into
`TetherField.add/setLevel` (tuning in `data/tether.json`)
- **The Research console** — the **RESEARCH** deck button opens a full-screen
window: the left pane loops the muted `assets/videos/research-computer.mp4`
archive feed (scanlines, sweep band, REC pulse, glitch bursts), the right
pane holds the category tabs and the **branching tech tree** for the
selected category — it starts at the top and unlocks downward, one branch
at a time. Selecting a tech shows its dossier (icon + description +
duration) and a **RESEARCH** button, which appears only when the tech is
researchable and nothing else is in progress (one project at a time,
`maxConcurrent: 1`). A deck progress bar tracks the run; completion fires
the tech's `effects` (e.g. `tether: {level: N}` grows the home tether's
range) and a toast. Each category's tree is its own JSON file under
`data/research/` (**Exploration**: Tether Level 1–3;
**Mining**: Improved Mining Arm, Advanced Mining
Arm, Improved Mining Storage — all three unlock the matching build in
the console's MINING tab). Unlocks and in-progress work
persist in the save bank — and resume correctly on load
- **The Build console** — the **BUILD** deck button (on a planet surface,
where the deck's SHOP/BUILD/TAKE OFF live) opens a full-screen window:
the left pane loops the muted 2:3 `assets/videos/build.mp4` build feed
(scanlines, sweep band, REC pulse), the right pane holds the category
tabs (**Planet / Cargo**, `data/builds.json`) and a **list** of the
category's buildable items — not a tree: builds are one-off installs on
the planet. Locked items are grayed out with their missing requirements
(research, tether level); installed ones read **BUILT ✓**. Selecting a
build shows its dossier (glyph + description) with the **highlighted
cost** and a **BUILD** button that appears only when the build is
available and affordable (one build at a time, `maxConcurrent: 1`).
Every world — planet or deep-space station — gets **Tether – Level 1**
installed automatically the moment you first land (free + instant, no
console step); the home world ships with it from the start. **Tether
– Level 2** needs the level-2 tether research plus a world holding a
level-1 tether, and installs in **20 s for 200 minerals** — the planet's
tether range then grows from 5120 to 10240 px (world state that outlives
the surface stay). While a build runs the whole deck is locked (BUILD
stays open to watch the progress); completion fires the build's
`effects` and a toast. Build records + the in-progress build persist in
the save bank — and resume with their remaining time on load
- **The Map console** — the **MAP** deck button opens a full-screen
cartography window (`data/map.json`, `js/ui/MapWindow.js`), the
ResearchWindow's bones: the left pane loops the muted 2:3
`assets/videos/map.mp4` feed (scanlines, sweep band, REC pulse, glitch
bursts). Two tabs on the chart plate:
- **CURRENT SYSTEM** — the whole solar system on a padded frame
(discovered objects, the tether range as a glitch-dotted union
boundary, and a fog-of-war dim over what the tether doesn't reach
yet), the system's discovery + resources readout, hover tooltips,
and click-to-ENGAGE AUTOPILOT (the same autopilot as a world click);
wheel zoom / drag pan (the chart repaints crisp at the new scale)
- **GALAXY** — the whole galaxy: a **glowing star per system**, each
colored by its archetype and pulsing on that archetype's heartbeat
(binary stars beat fast with a twin core), the **jump-lane web** as
thin lanes — traveled lanes glow brighter with a flow packet drifting
the route, frontier lanes (one end charted) brighter still, unexplored
lanes faint threads — plus the **charted region** (the hull of the
visited systems, soft fill + subtle outline), HOME/SHIP markers, and
a reveal ripple out of home. Hover a star for its readout + its link
state to your current system; **click a star on a live lane → CONFIRM
JUMP** (the same gate transport as the world's gates). The run's
footprint — visited systems + traveled lanes — is part of the save
(old saves load, defaulting to “your system is charted, nothing
traveled”). All of it tuned in `data/map.json → galaxy`
- Camera gently trails the ship; the **parallax starfield** streams past
while it flies and the view slowly recenters (≈1.5 s) once the ship
comes to rest
- Config-driven setup: every tunable value lives in `data/*.json`
## Project layout
```
orbit/
├── index.html # boots the game
├── data/ # ← ALL tunable config (edit these freely)
│ ├── manifest.json # which config files exist
│ ├── game.json # dimensions, colors, starfield, …
│ ├── menu.json # menu text, colors, button layout, seed panel
│ ├── theme.json # shared UI theme: fonts, palette, CRT/glitch tuning
│ ├── ship.json # the ship: art, feel (thrust, drag, maxSpeed…), base stats (hull, shields, cargo, minerals)
│ ├── combat.json # COMBAT: the range (player feel, enemy craft + AI + lance, rams, respawns, arena) + the main-game demo (game.*, energy, the bar)
│ ├── progression.json # COMBAT LEVELS: the XP curve (base × n^power, the cap) + the per-level armor reward
│ ├── planets.json # home world + system layout + solid-disc rules
│ ├── map.json # the MAP console: tabs, the system chart, the GALAXY tab (stars/pulse/lanes/region), stats, zoom
│ ├── tether.json # the tether (your range): level radii, barrier line, glitch, contact
│ ├── galaxy.json # galaxy scale & shape (count, 2:1 field, zones, corner home…)
│ ├── systems.json # system archetypes: theme, attributes, distribution
│ ├── settlements.json # the lived-in layer: settlement kinds & populations
│ ├── gates.json # JUMP GATES: network (1–3 gates, local jumps, pure spanning tree — maze, no shortcuts) + placement (tether anchor, facing, radii, gaps)
│ ├── research.json # RESEARCH: global rules (time unit, one at a time) + category registry
│ │ # trees live one-per-file below (section name = file basename)
│ ├── research/
│ │ ├── exploration.json# the Exploration tree: tether levels, anchoring, signal amp
│ │ └── mining.json # the Mining tree: improved/advanced mining arm, improved storage (blueprints)
│ ├── builds.json # BUILDING: the build console's categories/resources + the buildable items
│ ├── actionbar.json # the command deck: 6 slots (Research, Scan, Ship, ·, ·, Menu)
│ ├── naming.json # names: star/galaxy syllable pools + the curated PLANET & STATION name banks
│ └── classes.json # THE CLASS LAYER: Vanguard/Wraith roster, accents, icon frames, Trestle's copy, perk numbers
├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 frames), ships-enemy.png (128×128 frames)
├── assets/fonts/ # UI typefaces: Ethnocentric (headers), Centauri (body)
├── lib/ # vendored third-party libs (Phaser 4.2.1)
├── js/
│ ├── main.js # entry point: load config → boot Phaser (?combat boots the normal game with the combat demo live)
│ ├── config/ # Config singleton, ConfigLoader, game config
│ ├── scenes/ # MenuScene, GameScene (thin, orchestration)
│ ├── player/ # THE CLASS LAYER: ClassModel (pure reads of data/classes.json), ClassState (the run ledger: class + perks + banked skill points)
│ ├── combat/ # COMBAT: CombatSystem (the main-game pack — auto-fire, tracers, rams, armor, energy), the XP ledger (XpManager — levels from kills, data/progression.json), the range (CombatScene, js/combat/main.js boots it), EnemyShip, Bullet, Fx, StarDrift, pure rules in Combat.js
│ ├── entities/ # Ship (own behavior), Planet (home world, solid), Station, JumpGate (solids)
│ ├── galaxy/ # Galaxy (seeded world model), SystemGenerator, SystemReport, JumpNetwork (the gate network)
│ ├── tether/ # Tether (pure range math) + TetherField (constraint + barrier line)
│ ├── research/ # ResearchModel (pure tree rules/layout), ResearchState (unlocks + active run), ResearchIcons
│ ├── build/ # BuildModel (pure build rules), BuildState (installed + in-progress records)
│ ├── ui/ # MenuButton, GlitchText, CyberShape, ActionBar, CombatBar (the combat-mode deck), DiscoveryCompass (reusable), ResearchWindow, BuildWindow, ClassWindow (the level-5 class console)
│ ├── visuals/ # Starfield, CyberOverlay (CRT/glitch, shared)
│ ├── utils/ # small pure helpers (Color, Rng, NameGenerator)
│ └── vendor/ # shim to the vendored Phaser
└── docs/PROJECT_NOTES.md # ← project conventions: read this
```
## Conventions (short version)
- **Config in JSON.** If a value might change, it goes in `data/`, not code.
Add a file = one line in `data/manifest.json`.
- **One class per file**, ES modules, scenes stay thin, entities own their
behavior. Details in [`docs/PROJECT_NOTES.md`](docs/PROJECT_NOTES.md).
- Phaser is imported only via `js/vendor/phaser.js` (one-file version swap).
## The player's loop: research + building
Beyond flying, Orbit is built around two progression verbs:
- **Research** (`data/research.json` + `data/research/<category>.json`) —
*time-based*. A project takes a fixed `duration` (`timeUnit: seconds`);
the player researches **one thing at a time** (`maxConcurrent: 1`).
Research is the gate: it unlocks **builds** and **further research**
(each category tree is a DAG — a node's `requires` must be unlocked
first). The RESEARCH deck button opens the console window
(`js/ui/ResearchWindow.js`): looping archive feed, category tabs, the
branching tree, per-tech dossier + RESEARCH button, and a deck progress
bar. The rules are pure (`js/research/ResearchModel.js`,
`ResearchState.js` — Node-testable); the window is a passive view that
asks `GameScene` to start a run. `effects` on a finished node carry the
payload (e.g. `{ tether: { level: 2 } }` → `TetherField.setLevel`), so new
effect kinds plug in without touching the tree data.
- **Building** (`data/builds.json`) — *cost-based*, paid in **minerals**
(and credits, when they land; `resources`). A build is a **one-off
install** (`category: planet | cargo | mining`): planet builds land on
the world's surface (`targets: ["planet"]`); the Mining tab's builds are
**ship upgrades** (`targets: ["ship"]` — the improved/advanced mining
arm set the ship's mining rate to 1.5 / 2.0 minerals per second, the
improved storage grows the hold to 350): built from any planet's
console, the effect is ship-wide, and it counts as built wherever the
ship lands (no re-buy). Each build names its `cost`, its `duration`
(seconds), the research that unlocks it (`requires`, mirroring the
research tree's `unlocks.builds`), optional world-state gates
(`planetRequires`, e.g. the tether level on that world), and its
`effects` (e.g. `tether: {level: 2}` → the world's tether field
strengthens; `mining: {rate: 2.0}` / `mining: {capacity: 350}` → the
ship's stats, MAX wins). The BUILD deck button (on a planet surface)
opens the console window (`js/ui/BuildWindow.js`): looping build feed,
category tabs, the build LIST (locked items grayed with their missing
gates), per-build dossier + highlighted cost + BUILD button. While a
build runs the deck is locked (one at a time) and the state ticks on
the game-loop clock — so a build started on the surface keeps running
if the player takes off. The rules are pure (`js/build/BuildModel.js`,
`BuildState.js` — Node-tested in `dev/builds.test.mjs`); the window is
a passive view that asks `GameScene.beginBuild()` to run the gates,
charge the cost, and start the clock, and the scene applies `effects`
on completion (ship upgrades re-derive from the build records on load,
`GameScene._restoreMiningUpgrades`).
- **The command deck** (`data/actionbar.json`, rendered by
`js/ui/ActionBar.js`) — the cyberpunk bar across the bottom of the
screen. Six evenly spaced slots: **Research, Scan, Ship, ·, ·, Menu**
in space; on a planet surface the same bar re-deals itself —
**Shop, Build, Ship, ·, Take Off, Menu** (`surface.buttons`). The
slots fire `onAction` (GameScene / SurfaceScene own the behavior);
two slots are reserved. Dressed with the menu's CRT language —
scanlines over the strip and the GlitchText RGB pull-apart on labels
and the panel outline during bursts. All layout, palette, and motion
(boot flicker, rail comet, glitch bursts, RGB split, scanlines) live in
the config.
`_`-prefixed keys (`_comment`, `_template`, …) are documentation, not
runtime data — loaders and tests ignore them.
## Dev tools
```sh
node dev/run-all.mjs # the whole suite (dev/*.test.mjs) — every test runs with plain `node`
node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node
node dev/starfield.test.mjs # runs the real Starfield.create() in Node
node dev/galaxy.test.mjs # galaxy determinism, composition, lazy vs eager, gradient + report
node dev/frames.test.mjs # the galaxy-wide (class, frame) pass: in-pool, spread, determinism
node dev/jumps.test.mjs # the gate network: 1–3 gates, locality, strong connectivity + placement invariants
node dev/jumpgate.test.mjs # the JumpGate entity: discovery fields, dormant/active, the solid contract (Node)
node dev/discovery.test.mjs # discovery rules + compass geometry + chip hit test
node dev/tether.test.mjs # tether range math: union, clamp, visible arcs (no line in overlaps)
node dev/research-builds.test.mjs # data contract: research/builds/actionbar shapes + manifest
node dev/builds.test.mjs # the build console's pure rules + state machine (Node)
node dev/world-names.test.mjs # world-name casing: display casing must never leak into the data
node dev/saves.test.mjs # the save bank + capture/restore hand-off (incl. builds)
node dev/decode.test.mjs # the shared decode scramble (menu seed + system dossier)
node dev/system-hud.test.mjs # real GameScene dossier: layout + staggered decode to final report
node dev/quest-tracker.test.mjs # the quest tracker HUD: slide in/out, live checklist, priority, SHOW MORE, the popup contract
node dev/perimeter-shimmer.test.mjs # the READY row's dual-shimmer rim: perimeter arc math + the counter-streaming comets
node dev/quests.test.mjs # the quest ledger: grant/claim + the ONE priority (set/clear/sticks/auto-assign) + save round-trip
node dev/sfx.test.mjs # the shared SFX voice: guards + sfx.json keys/files
node dev/music.test.mjs # the shared music voice: guards + music.json contract
node dev/skills.test.mjs # the skill layer's pure half: data contract, the ledger (points/ranks/seats), save round-trip
node dev/skills-combat.test.mjs # the cast pipeline in Node: energy, per-skill recharge, the dash + arrival blast, hull passives
node dev/skills-bar-browser.test.mjs # the real bar: seat binding, icons, cooldown sweep, right-click clear, cast by key (Playwright)
node dev/skills-window-browser.test.mjs # ship console → SKILL TREE console: rank up with real clicks, hand a skill to a seat (Playwright)
node dev/skills-devdoor-browser.test.mjs # the ?skill doors — and that no flag means nothing happens at all
node dev/combat.test.mjs # the combat RULES: armor damage, the enemy's ring AI + lance decision, bullet life, CombatSystem (pack, auto-fire, hits, rams, armor, respawn), ship FEEL (handleFeel), the SFX event→voice map, and the READABILITY rules (pickTarget, feed, off-screen arrows, the shared CombatHud) — real data/combat.json
```
`dev/test-game.html` boots straight into the GameScene (no menu click),
handy for manual testing of the flight feel. `dev/research-shot.html`
does the same but opens the Research console and starts a run, and
`dev/cdp-shot.mjs` waits for a readiness expression before screenshotting
through CDP (any Chromium-CDP browser on `127.0.0.1:9333`):
```sh
node dev/cdp-shot.mjs "http://127.0.0.1:8081/dev/research-shot.html" shot.png \
"window.__RESEARCH_SHOT && window.__RESEARCH_SHOT.ready" 90000
```
`dev/build-check.html` plays the real Build flow (menu → New Game → home
world → build console) and prints a plain-text report of the L1/L2 row
states. Every real page (index.html) also carries a read-only console
diagnostic: opening the Build console logs a one-line `[orbit-diag v…]`
summary (planet, home, built records, tether level, L1/L2 row states,
stale-JS probe), and `orbitDiag()` in DevTools prints the full report
(data contract, save bank, scene wiring, errors since boot). If
`orbitDiag` is *undefined*, the browser served a cached older
`js/main.js` — run the dev server above, or hard-reload with the cache
disabled (DevTools → Network → Disable cache).
## Phaser
Phaser 4.2.1 is vendored at `lib/phaser.min.js` (UMD build, MIT license —
see `lib/PHASER_LICENSE.md`). No internet or npm needed at runtime.