15 KiB
Orbit — Project Notes
Working agreements and conventions for building this game. Read this before adding new systems — these are the rules that keep the project scalable.
Commits (important)
- Brian makes the commits, manually. After finishing a change, leave the
work staged/unstaged in the working tree with a suggested commit message
(in the chat reply, or a note here), and do NOT run
git commityourself. This gives Brian the chance to review the code before it enters history. - Amends/reverts are fine if asked explicitly.
What we're building
A procedurally generated space RPG in the spirit of Privateer, but in top-down 2D: stars/sectors you can jump between, ships to fly, crew, trading/economy, stations, quests — to be scoped as we go.
Hard technical constraints:
- Plain ES6 modules, no transpilation, no build step.
- No package managers required to run — the game must work from any static HTTP server (the browser fetches everything).
- Third-party libraries are vendored into
lib/(right now:lib/phaser.min.js, Phaser 4.2.1 UMD build, seelib/PHASER_LICENSE.md). - Must be served over http(s), not
file://(ES modules +fetchof JSON).
Config lives in JSON (important)
- Tunable data lives in
data/*.json: dimensions, colors, text, physics, balance, spawn tables, and anything a non-programmer might want to tweak. data/manifest.jsonlists which files to load. Add a config file = drop it indata/+ one line in the manifest. It is then available as a section named after the file (ship.json→config.get('ship.thrust')).- Code reads config through the
configsingleton (js/config/Config.js):
Always supply a sensible fallback so code never depends on a missing key.import { config } from '../config/Config.js'; const thrust = config.get('ship.thrust', 900); const menu = config.section('menu', {}); - Rule of thumb: if a value might ever change (balance, layout, copy, colors), it belongs in JSON, not code. Code owns behavior, JSON owns parameters.
- Split config by concern as the game grows: world generation already has
data/galaxy.json(shape/scale),data/systems.json(archetypes), anddata/naming.json(name pools); next up might bedata/economy.json,data/ships.json,data/crew.json, etc. One file per system beats one giant file.
Code is modular & class-based (important)
- One class per file, ES module exports, no globals (except the deliberate
singletons:
config). - Layering:
js/scenes/— Phaser scenes: state + orchestration only (thin classes)js/entities/— in-world objects that own their behavior (Ship, NPC, …)js/ui/— reusable UI components (MenuButton, panels, HUD)js/visuals/— decorative, non-interactive visuals (Starfield, …)js/galaxy/— the world model: seeded Galaxy, system archetypes, lazy content generation (Galaxy, SystemGenerator)js/utils/— small pure helpers (Color, Rng, NameGenerator)js/config/— config loading + Phaser game configjs/vendor/— shims to pinned third-party libraries
- Scenes stay thin: they compose entities/UI and wire input. They don't hold balance numbers or game rules.
- Every class should be standalone-constructible: it takes what it needs
(
scene, config values) in its constructor rather than reaching for globals — that keeps things testable and reusable. - Entities drive themselves from their own
update(time, delta)method, called by the owning scene (e.g.GameScene.update). If an entity ever needs the engine's auto-update instead, definepreUpdate(the Phaser v4 hook — v4 does not auto-callupdate). - Import Phaser only through
js/vendor/phaser.js— the single place that changes if we swap framework versions.
World model — the galaxy (important)
The galaxy is seeded and two-level. The seed is chosen on the main menu (displayed, editable, rerollable; same seed ⇒ same galaxy).
- Roster —
Galaxy.create(seed)builds every system's identity (id, name, type, x, y) up front. 40,000 systems is ~70 ms, so the whole galaxy is always known: the player can never "discover" a layout that wasn't already implied by the seed. - Contents — planets/moons/belts/settlements/hazards are
generated lazily on first arrival (
galaxy.ensureContent(id)), then cached. Each system's draw stream isRng.derive(seed, 'system', id)— independent of generation order — so lazy and eager (galaxy.generateAll()) results are identical. Pick whichever is cheaper at runtime; correctness never depends on it.
Determinism rules (keep them!):
- All generation draws go through
Rng(js/utils/Rng.js). NeverMath.random()in a generation path. - Anything that must be order-independent (names, contents) draws from a
Rng.derive(seed, <purpose>, id)stream — never from the galaxy-level sequence. The roster loop is allowed to use its own sequence because the loop order itself is part of the seed contract. - Seeds are trimmed; seed → hash → stream is one-way.
- Dev tools that assert determinism:
dev/galaxy.test.mjs.
System archetypes live in data/systems.json. Each type is:
- themable —
theme.coloretc. for UI; - attribute-driven —
attributessteer the SystemGenerator (star classes, binary chance, planet count spread, planet class weights, moon/belt chances, habitability, hazard). New attribute key = JSON + a few lines inSystemGenerator.js; - distributed —
distribution.weight(how common) anddistribution.radiusBand(first proximity rule: e.g.voidsystems live in the outer rim). Richer galaxy-level rules (clustering, faction borders, adjacency affinity) will slot intodata/galaxy.json→distribution.rules[], read inGalaxy._generate()— the hook is marked in code.
The player's current system starts at galaxy.currentSystem()
(startingSystem.policy: center or random). Jumping between systems
(the eventual star map / jump drives) will use galaxy.neighborsOf(id)
and the spatial hash already built for it.
The galaxy is already lived in. It was settled long before the
player arrives. Every system's content can include settlements:
colony (on a habitable world), miningStation (over a resource
world), cloudBase (riding a gas giant), deepSpaceStation (adrift in
open space), waypoint (a small beacon — the faint trace of a crossed
galaxy). Not everything is inhabited: many systems report "charted ·
unclaimed". Model & seams:
- Kinds vocabulary —
data/settlements.json(label, description, theme color, population range, anchor type). Add a kind = JSON + naming pool; the generator picks it up by name. - Per-type rates —
types.<id>.attributes.settlementsindata/systems.json:chance+ optionalneeds(planet classes) per kind. Same attribute-driven pattern as everything else. - Core→rim gradient —
galaxy.settlements.gradientindata/galaxy.json: the settled heart is denser (factor 1.0), the rim is thinner (clamped tofloor). Each record carriesrNorm(0 = center, 1 = rim) so density is per-system, not global. - Reserved seam:
owner— every settlement hasowner: null. That's where factions and pirates will plug in later (claim, flag, relations). Deliberately absent for now — no factions yet. - Pure report formatter —
js/galaxy/SystemReport.js(formatSystemReport(content)→ title/subtitle/settlements/summary). The GameScene HUD renders it; future star map / terminal UI reuse it. - Landing/exploration (a future feature) will treat settlements as points of interest: the data already says what's there and where (anchor = planet ordinal or open space).
The current system is a place, not just a dossier (discovery + compass)
The player starts in a solar system, and the other worlds exist in the world — solid, rendered, flyable-to. Rules and seams:
- World layout —
SystemGenerator.layoutSystemPlanets(seed, systemId, planets)(pure,js/galaxy/SystemGenerator.js) places each generated world in an annular band around the home world (origin), enforcing edge-to-edge separation from every other disc. Draws come from the dedicated forkRng.derive(seed, 'system', id, 'layout')— layout results are seed-deterministic and don't perturb the content stream (lazy === eager is preserved). Band params live indata/planets.json → solarSystem(enabled, minOrbit, maxOrbit, minEdgeGap); class sizes/tints inclassScale/classTint. - Discovery —
js/galaxy/Discovery.js(pure, no Phaser — Node- testable, save-ready:toJSON()/fromJSON()). Rule: the ship withingame.discovery.distance(data/game.json, default 540 px) of an object's edge (center distance ≤ radius + distance) discovers it, once, per system. Home world is discovered at spawn (the ship starts beside it). Feedback: rim ping + "DISCOVERED — NAME · TYPE" toast (GameScene.celebrateDiscovery). - Compass —
js/ui/DiscoveryCompass.js(screen-space Container, scrollFactor 0). Every frame it refreshes the set of discovered objects that are off-screen, drawing a themed chevron arrow on the screen edge with a type/name chip, separated by angle when rays crowd. Edge-anchor geometry (edgeAnchor,circleInView,lerpAngle) is exported pure for tests. Arrow texture is generated procedurally on first use (__compass_arrow). - World solids — the ship collides with every system planet
(
GameScene.solidPlanets), not just home: you can approach a rim, never pass through. - Verified:
dev/discovery.test.mjs(rule, boundaries, per-system state, JSON round-trip, layout bounds + determinism, compass geometry).
Phaser version
- Pinned: Phaser 4.2.1 ("Giedi"), vendored in
lib/phaser.min.js. - App code is v4-specific where v4 changed things (e.g.
Phaser.Math.Angle.RotateTo,banner: false,Clamp(value, min, max)). - v4 quirks that bite (verified against this build, Sept 2026):
- The engine calls a scene's
update(time, delta)directly but does not step the scene'sTimeClockorTweenManager(the v3 scene-events PRE_UPDATE/UPDATE plumbing is not fired in the v4 loop). Each scene must callthis.time.update(time, delta)andthis.tweens.update()at the top ofupdate(), ordelayedCall/addEvent/tweens silently never run. (Done inMenuScene.update/GameScene.update.) - A GameObject constructed with
new(e.g. ourContainersubclasses injs/ui/) is not added to the scene display list — thescene.add.*factories do that. Callscene.add.existing(this)in the constructor (Ship/Planet already do; GlitchText/MenuButton now do too). - Text colors go straight to the canvas: v4 writes
fillStyle = style.color, so a numeric color is an invalid fillStyle and the text silently renders black. Text styles andsetColor()must get CSS strings — usetoCss()fromjs/utils/Color.js(keeptoColor()for Graphics/shape APIs). - An
Imagecreated before its texture key exists can stay bound to the__MISSINGtexture forever — even after the key is generated later in the same session. Generate the texture first (seeDiscoveryCompass.ensureArrowTexture: texture, thenscene.add.image). - Input on screen-fixed containers is per-child. Hit-testing uses
each object's own scrollFactor (
InputManager:g = worldX + scrollX*sf - scrollX), while a child of ascrollFactor(0)container renders screen-fixed. So a child whose scrollFactor is left at the default (1) is drawn pinned but hit-tested in world space — clicks miss it whenever the camera has scrolled. (MenuButton never bit by this: the menu camera doesn't move.) SetscrollFactor(0)on every child of a screen-fixed UI container — done inActionBar.buildSlots.
- The engine calls a scene's
- To upgrade: replace the vendored file + note the version here (and re-check the quirks above — they may go away).
Roadmap (working list, intentionally rough)
- v0.1 foundation — menu → New Game → click-to-fly ship
- Galaxy seed on the main menu (displayed, editable, rerollable; same seed → same galaxy, shown before you commit)
- Two-level worldgen: seeded galaxy roster (40k systems) + lazy, order-independent system contents; system archetypes in JSON (theme + attributes + distribution weight/radius band)
- The lived-in layer: settlements (colonies, mining stations, cloud
bases, deep-space stations, beacons) with per-type rates, a
core→rim density gradient, populations, and a
ownerseam reserved for the factions/pirates to come; readable as a HUD dossier (SystemReport) - The system as a place: generated worlds laid out in the world
(deterministic band), solid to the ship; discovery (within 540 px
of an edge, once, per system) with rim ping + toast; off-screen
compass — themed screen-edge arrows with type/name chips pointing
at discovered worlds; pure, save-ready discovery state
(
js/galaxy/Discovery.js) - Factions & pirates: claim settlements (
owner), flags, borders, and the player's place in a populated galaxy - Landing & exploration: settlements become points of interest you can approach (the data — kind, anchor, population — is already there)
- The player's loop, laid down as data + seams: research (time-based,
one at a time, gates builds/research) and building (credits + minerals,
ship/planet/station) data layers (
data/research.json,data/builds.json) with templates, and the command deck (js/ui/ActionBar.js+data/actionbar.json) — Research, Build, Ship, ·, ·, Menu. The deck reuses the menu's CRT language: the same scanline tile recipe asCyberOverlay(clipped to the bar) and theGlitchTextRGB pull-apart (icons + labels at all times, panel outline during bursts) — seeactionbar.animation.rgb/actionbar.scanline - Research rules + panel: start a project (one at a time), tick its
duration, award
unlocks; the Research slot on the deck opens it - Build panel: pay credits/minerals, apply
effects, respectrequires; the Build slot on the deck opens it; a credits/minerals readout in the HUD - Ship screen (the Ship slot) — inspect & upgrade the ship (ship-category builds) from one place
- Richer galaxy distribution rules (
galaxy.distribution.rules[]: clustering by type, borders, adjacency affinity) — hook marked inGalaxy._generate() - World model in play: the ship still flies unbounded open space;
wire in current-system boundaries, jumps between systems (use
galaxy.neighborsOf), and a star map scene - Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- HUD (speed, fuel/crew) — the current system's dossier (name, identity, settlements) is already shown top-left
- Save/load (the
config+ entity split should make this tractable; a save = seed + player state, since the galaxy regenerates) - Economy/trading loop (the Privateer heart)