# 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` ## The job board — every faction posts in its own voice Land on a world, open **EXPLORE → JOB BOARD**, and the contracts you read are written by whoever owns the system. `data/sidequests.json → descriptions` holds **nine faction pools × 20 variants = 180 listings** (`faction.apex`, `faction.mindweave`, `faction.zenith`, each with `patrol`, `bounty`, `escort`): Apex files Ministry-targeting orders, MindWeave writes in the Chorus, Zenith audits your pedigree. - **The pool chain** (`js/quests/SideQuests.js → descriptionPool()`): a faction board reads its **own** pool → the shared `descriptions.faction.` war voice (the fallback for a power with no voice of its own) → the neutral pool. A **neutral** board starts at its own pirate-safety pool and never reads war copy. The draw rides the posting's existing seeded stream, so a world's contract is stable across visits and saves. - **Every listing names the rival you actually fight.** Each body carries `{enemy}`, resolved at generation to the posting's seeded `enemyFaction` — the sighted patrol, the mark's own faction, the transport's captors — and the bounty fight is drawn from *that* faction's roster, so the copy never lies. (That is why faction dossiers leave `craft` unset: a declared craft id would override the engine's frame pick and fly the wrong power.) - **Copy is linted in every resolution it can take.** A line written once is read against a *seeded* rival, so `dev/lib/faction-voice-lint.mjs` resolves each entry as **each** rival its board can name and fails on an article that disagrees (“an Apex hull”), a doubled word (“MindWeave MindWeave profiles”), or a brace that never resolved. No article may be written in front of a placeholder. - **Copy respects the panel:** the detail readout gives a patrol/escort contract up to five body lines, but only two under a hunt's dossier block — longer copy ships with an ellipsis. `node dev/jobboard-voice-shot.mjs` renders all 180 listings through the real window and fails on any brace left unresolved, any clipped body, or any line past the panel edge (artifacts: `dev/shots/voice-*.txt`). ## 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/.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.