# 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. ## 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`). The home world starts with **Tether – Level 1** already installed; **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) │ ├── 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 ├── assets/images/ # art: planets.png (1024×1024 frames), ships-player.png (256×256 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 │ ├── config/ # Config singleton, ConfigLoader, game config │ ├── scenes/ # MenuScene, GameScene (thin, orchestration) │ ├── 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, DiscoveryCompass (reusable), ResearchWindow, BuildWindow │ ├── 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/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/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 ``` `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.