38 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(syllable pools + name banks); 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).
Naming — planets and stations get their names from curated banks in
data/naming.json → banks (distinct from the star/galaxy syllable pools):
banks.planet— a deep list mixing colonial names ("New Denver", "Port Tucson") and alien names ("Klaxoria", "Vexithunhal").banks.station— a deep list mixing official/procedural designations ("Deep Space SC-145", "Nav Relay V-77") and smuggler / outlaw hangouts ("Hell's Hideout", "The Rusty Anchor").- Assignment —
NameGenerator.planetDeck(rng)/stationDeck(rng)build one seeded shuffle of the whole bank;SystemGeneratordeals names out of it in order. So within a system there are no repeats (the first N draws are N distinct names), and a system only reuses a name if it has more bodies than the bank holds (impossible at these sizes). The decks are dealt from dedicated derived streams (Rng.derive(seed,'system',id,'names', 'planets'|'stations')) so they stay order-independent (lazy === eager). The home world (the player's planet, in the starting system only) takes the first name off that system's planet deck — so it gets a real bank name too, never clashing with one of the planets (content.homeName; the oldplanets.homeNameis now just a fallback). Stars and the galaxy are still synthesised from syllable pools (unbounded).
The current system is a place, not just a dossier (discovery + compass)
The player starts in a solar system, and the other worlds exist in the world — solid, rendered, flyable-to. Rules and seams:
- World layout —
SystemGenerator.layoutSystem(seed, systemId, planets, freeSpace)(pure,js/galaxy/SystemGenerator.js) places each system's planets and free-space stations (deep-space stations, waypoints) on one or two orbits (rings) around the home world (origin), enforcing the hard spacing rules indata/planets.json → solarSystem:minSpacing(6144 px, center-to-center, between ANY two objects — planets, stations, the home world) andmaxNeighbor(10240 px — whenever a system holds more than one object, every object is within it of at least one other; the inner orbit's near neighbor is the home world at the origin). ≤ 10 objects share one orbit; the 11-object maximum (9 planets + 2 stations) splits into a 3-ring + 8-ring. Planet-bound settlements (colonies, mining stations, cloud bases) are features of their planet, not layout objects. 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). 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).
The tether — the player's range (important)
The ship starts with one level-1 tether anchored on the home world
(system origin): a circular zone, 5120 px from the anchor's center
(data/tether.json → level1Radius). That rim is the player's boundary:
- The union is the space. The ship may be anywhere inside ANY of its
tethers' zones. Where two zones overlap there is no wall and no line
— the boundary of the union is where the barrier lives, and the line is
drawn only on each tether's visible arcs (
Tether.visibleArcs— the rim arcs not covered by another zone). - Outside the union is a hard barrier. The ship is clamped to the
closest boundary point with the outward normal; outward velocity AND
acceleration are stripped (same static-resolve pattern as
Planet.constrainShip, run after the planets inGameScene.update). Click-to-fly and autopilot targets are clamped too, so an out-of-range destination becomes "fly to the rim and rest on the line". - Feedback on contact: the line shudders locally at the hit point
(TetherField pulse + radial zap), a small camera kick, and a throttled
magenta console toast (
tether.contactin the config).
Layering (same rules as everything else):
js/tether/Tether.js— PURE (no Phaser): the record (id/anchor/level/ radius),radiusForLevel, union membership,clampPoint(nearest union boundary point + normal), andvisibleArcs(circle-arc interval math on the unit circle). Node-tested bydev/tether.test.mjs— including the two-direction boundary property (nothing visible is inside another zone; nothing exposed is missing from the visible arcs).js/tether/TetherField.js— the scene-facing field: owns the tether list, exposes the seam the build system will use (add(id, x, y, level),remove(id),setLevel(id, level),onChange→ HUD refresh), applies the constraint (constrainShip), and renders the barrier: dash dots of constant world size along the visible arcs, on-screen culling only, marching offset, additive glow + RGB ghost fringe under the main pass, ambient glitch bursts (dash flicker, radial displacement, data drops, sparks), contact pulses. All tuning indata/tether.json → line/contact.data/tether.json— level radii (level1Radius,radiusGrowth,maxLevel), the starting tether (homeId/homeLevel), and every visual/feel parameter.
Upgrade economics (planned): radius(level) = 5120 × 1.25^(level−1) — level 4 = 10000 px, which reaches every object of a ≤ 10-object system (all within 10240 px of the home world); the far 8-ring of the rare 11-object system (up to ~13380 px out) wants one level more. Extra tethers anchored on planets/stations will be the "expand your reach" verb; the seams are in place.
Phaser v4 note: the barrier is two Graphics layers redrawn per frame
(clear() → one strokePath per pass) — no textures, no physics bodies,
no camera math beyond culling. The scene drives tick()/draw() from
update() alongside the TimeClock/tween stepping.
Research — the progression gate (time-based, one at a time)
The RESEARCH deck slot opens the Research console (js/ui/ResearchWindow.js):
left pane loops the muted assets/videos/research-computer.mp4 archive feed
(scanlines, sweep band, REC pulse, ambient glitch bursts, decode-in reveals),
right pane holds the category tabs + the branching tech tree for the
selected category (starts at the top, unlocks downward), and the selected
tech's dossier (icon, description, duration) with a RESEARCH button that is
only present when the tech is researchable and nothing is in progress.
Data (one file per category):
data/research.json— global rules:enabled,timeUnit: "seconds",maxConcurrent: 1,defaultCategory, thecategoriesregistry (id, label, icon, accent), thevideo(file + aspect), andfxtiming.data/research/<category>.json— the tree: a flatnodesmap where each node haslabel,description,duration(intimeUnit),requires(parent ids — a DAG),unlocks(what the tech OPENS, below), and optionaleffects.startinglists the pre-unlocked roots. Section name = file basename (research/exploration.json→config.section('exploration')). **Add a category = one file + one line inresearch.json → categories- one line in
data/manifest.json.**
- one line in
The UNLOCKS space (research → everything else): a tech unlocks more
than follow-on tech. Each node's unlocks is the declaration side:
unlocks.research— the readable mirror of the children'srequiresedges (the DAG itself stays authoritative —requiresonly).unlocks.builds— ids indata/builds.jsonthe tech makes available. The authoritative gate is the build's ownrequires(a list of"<category>/<node id>"research ids — e.g.tether-l2.requires: ["exploration/tether_l2"]): a build is available once every id it names is researched.dev/research-builds.test.mjskeeps both sides in lock-step (the test fails if one names the other and the other doesn't name back, or an id dangles).- Builds also carry
category(ship / planet / station / general), optionaltargets(the surfaces it can be placed on — defaults to[category]; the tether ring targetsplanet+station),cost(credits + minerals),repeatable,effects,theme. - The first build entry,
tether-l2(level-2 tether ring, anchored on a world or station), is the placeholder proving the wiring — cost/effect are first guesses; tune them when the build panel lands. - The console's dossier shows the line (
UNLOCKS: TETHER LEVEL 3 · TETHER ANCHORING · BUILD · TETHER RING · LEVEL 2), read throughResearchModel.unlocksOf(tree, id)+buildDefs()— the one read point the build UI will use too.
Code layering (same rules as the tether):
js/research/ResearchModel.js— PURE (no Phaser):roots,issues(DAG validation),levels(longest-path level),layoutTree(deterministic column/row layout: DFS leaf-slot assignment, parent = mean of children),isAvailable,missingRequires, and the unlocks contract:unlocksOf(normalize{builds, research}),buildDefs(builds.json → map,_-keys excluded),unlockIssues(tree)(mirror + build wiring),buildIssues()(every buildrequiresid resolves). Node-tested.js/research/ResearchState.js— PURE:unlock,isUnlocked,getActive,start,progress(time),tick(time)(→ array of completions),restoreActive,toJSON(now)/fromJSON. Node-tested.js/research/ResearchIcons.js— procedural 128 px icon textures (tether rings / anchor / signal waves / diamond fallback), tinted.js/ui/ResearchWindow.js— the scene-facing window (depth 80, above the save panel). A passive view: it asksGameSceneto start a run viaonResearch(catId, id); the scene owns the rules, the effects, the toasts, and the save data. The window re-renders fromResearchState+ResearchModelonly.GameScene—beginResearch,_completeResearch,_applyResearchEffects,_deckResearchBar. The effects seam readsnode.effects:{ tether: { level: N } }→TetherField.setLevel(homeId, N)- toast;
{ capability: "flag" }→scene.researchCapabilities.add(flag); unknown shapes log and no-op. New effect kinds plug in there without touching tree data.
- toast;
Save: record.research = { unlocked: ["cat::id", …], active: {category, id, durationMs, remainingMs} | null }. remainingMs is captured
at save time; restoreActive(spec, now) rebuilds startedAt from the fresh
now. A save from before research exists loads as fresh (no unlocks, no
active run) — old saves keep working. No auto-save: research state persists
on the next explicit player save (SavePanel), consistent with the rest of
the game.
SFX: begin → construct, complete → discovery (both existing
data/sfx.json keys — there are no research_begin/research_complete
keys). Open/close → ui_window/ui_close.
Verified: dev/research-builds.test.mjs (manifest registration,
research.json globals, the exploration tree's raw-JSON contract — DAG, node
fields, effects — the real code path: ResearchModel layout/levels/determinism
- the unlocks contract (unlocksOf/unlockIssues/buildIssues) +
ResearchState start/tick/complete/restore round-trip, builds.json (the
tether-l2 build, both sides of the gate, the template), actionbar.json).
dev/research-shot.html+dev/cdp-shot.mjsopen the window and start a run through CDP for a screenshot.
Reputation — standing on planets & space stations (data layer; factions later)
The player holds a REPUTATION (standing) on each planet and space station:
- Scale —
data/reputation.json:min…max(−20…+20, max = best), integer steps,neutral= the standing with a place the player has no standing with (0),home= the home world's standing (+20). - The home-world exception — the player's home world is ALWAYS +20: pinned. Nothing sets, changes, or clears it (it's not even storable — the key is derived from the scale itself).
- The faction check (placeholder) —
Reputation.standingFor(place)resolves: home → stored standing → the place'sowner→ faction standing → neutral.owneris the settlements' reserved seam (alwaysnulluntil factions exist) andReputation.factionStanding(id)is a markedTODO(factions)stub returning null — so today the whole galaxy reads neutral (home: +20). When factions land: populateownerin generation + fillfactionStanding(); the resolution order is final. - The mutation seams —
set(key, value)/change(key, delta), both clamped to the scale; "ways to influence reputation" land on these. - Place identity — every planet and settlement now carries a stable
idfrom the generator (seed-deterministic: planets<systemId>-p<ordinal>, settlements<systemId>-s<n>, n = draw order); the home world is the fixed key'home'(Reputation.HOME_KEY, same id discovery uses). Same seed ⇒ same ids ⇒ saved standing lines up with the regenerated galaxy. Lazy === eager holds (id is content, from the per-system stream's own record/ordinal). - Module & save —
js/reputation/Reputation.js(pure, no Phaser — the Discovery pattern):toJSON()/fromJSON(); the save record carriesreputation(scale snapshot + stored standings); the scene keeps one instance in the shared registry (New Game resets it viaresetRunState). A save from before reputation exists loads as fresh all-neutral — old saves keep working. The comms panel shows it (a 41-mark bar, −20 → +20, red→green, lit up to the standing —Reputation.marksFor()) and gates REQUEST LANDING at standing ≤ −4; nothing CHANGES standing yet — the influence mechanics land on the mutation seams above. - Tests —
dev/reputation.test.mjs(scale, home pinning, clamping, faction-check order incl. a monkey-patched "factions exist" pass, save round-trips + legacy/corrupt records, capture/prepare/reset integration, generator place-id shape/uniqueness/stability).
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
sound.play()throws on a missing key — andcache.hasAudio()doesn't exist in this build. Playing a key that never loaded is a hardError: Audio key "…" not found in cache, not a no-op, and thecache.hasAudio()guard the old playSfx relied on is absent here. The shared voice (js/utils/Sfx.js → playSfxOn) therefore checksscene.cache.audio.has(key)directly (the real v4 API) before playing, so a not-yet-loaded asset is a silent no-op — verified in the browser (aplaySfx('ui_hover')against an unloaded key threw through the click handler until the guard was fixed).- The sound manager stops BY KEY — there is no
sound.stop(). In this build the manager exposesstopByKey(key)/stopAll()(andisPlaying(key)/getAll(key)/getAllPlaying()), but NOT a keylessstop(). So stopping a looping SFX isscene.sound.stopByKey('sfx_<name>')— the shared voice wraps it asjs/utils/Sfx.js → stopSfxOn. (sound.play(key, { loop: true })DOES loop — verified: a 20 s clip kept playing past its natural end, and stopped cleanly onstopByKey.) - A scene transition does NOT call
scene.shutdown(). The manager stops a scene viasys.shutdown(), which flips the status and emits theshutdownEVENT on the scene's emitter — the scene's ownshutdown()method is never invoked (verified: a wrappedMenuScene.shutdowndid not run on menu → game, while the'shutdown'event fired). Scene-level cleanup that must run on transition (stopping a music loop, …) therefore hooks the event:this.events.once('shutdown', …)fromcreate()(done for the menu hum and the surface hum); theshutdown()methods stay as the game-destroy path.- The surface is a LAUNCH + SLEEP, not a transition. Landing does
scene.launch('SurfaceScene')and thenscene.sleep()— a sleeping scene never emitsshutdown(verified: the game soundtrack and the sound manager kept running through the surface until the scene's'sleep'event was hooked). Take Off wakes the scene (scene.wake), which emits'wake'. So GameScene's music + mining hum stop on'sleep'and resume on'wake'(repeatable —events.on, notonce), while'shutdown'covers return-to-menu and destroy (js/scenes/GameScene.js → create).
- The surface is a LAUNCH + SLEEP, not a transition. Landing does
- Ship art — the starter ship is now a spritesheet frame:
data/ship.json → texture(assets/images/ships-player.png, 256×256 frames,frame0), mirroring the planets pattern. The frame art facesartFacing("north" — the nose is the frame's top edge; the glass cockpit section is aft, per the art's author);Shipapplies a constant render offset (artOffset) so heading math is identical and the ship rotates about the frame center. Sprite scale = (size×scale)/frameWidth keeps the 46 px world size (and 23 px collision radius) regardless of frame size. Missing/failed sheet → built-in procedural dart (facing east), with a console note. - To upgrade: replace the vendored file + note the version here (and re-check the quirks above — they may go away).
Roadmap (working list, intentionally rough)
- 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
- Compass autopilot: clicking a name tag sends the ship to that
world — it targets the keep-out rim on the side the ship is
approaching from (arrival facing the world; docking seam next).
The scene's click-to-fly guards against deck AND chip clicks
(
ActionBar.contains/DiscoveryCompass.contains) (js/galaxy/Discovery.js) - The tether — the player's range: a level-1 tether (5120 px) anchored
on the home world; the ship lives in the UNION of its tethers' zones
(overlap = no line, no wall), the rim is a hard barrier rendered as a
thick glitchy dotted line; click/autopilot targets clamp to the
boundary; contact feedback (line shudder, camera kick, toast).
Pure range math in
js/tether/Tether.js(Node-tested), visuals + constraint seam injs/tether/TetherField.js, tuning indata/tether.json - UI hover/click ticks:
ui_hover+ui_clickindata/sfx.jsonplay on every clickable item (menu/deck buttons, compass name tags, panel buttons, save slots) — hover on pointerover, click on press; the SCAN slot skipsui_click(it plays its own sonar ping —scanindata/sfx.json, now assets/fx/scan-01.mp3). Shared voice injs/utils/Sfx.js → playSfxOn(both scenes exposeplaySfx(name); components call thethis.scene.playSfx?.(…)seam), with a v4 cache guard so a missing asset never throws (js/utils/Sfx.js, dev/sfx.test.mjs) - Window whoosh:
ui_window(assets/fx/ui-window.mp3) plays when a window OPENS — the mining pop-up (MiningPopup.open) and the comms / request-landing panel (CommsPanel.open) play it from their own open seam (thethis.scene.playSfx?.(…)convention); the oldconstructplays at those two open sites were dropped so the whoosh stands on its own - Close tick:
ui_close(assets/fx/ui-close.mp3) plays when a UI element is CANCELLED/CLOSED — the menu sub-bar (MenuSubBar.close), the save vault (SavePanel.close) and the confirm dialog (ConfirmOverlay.startClose);type-deconstruct.mp3is reserved for text UNDECODING now (the dossier collapsing — the only remainingdeconstructplay), and stopping mining is silent (the hum just ends — the old deconstruct blip on 'stopped' is gone) - Mining hum:
mining_loop(assets/fx/mining-01.mp3) LOOPS while the mining beam is live — starts when the arm finishes extending (phase 'mining'), stops when the sequence ends (phase 'stopped'), one hum across retargets (isPlaying guard). v4 quirk: the manager stops by key —sound.stopByKey()(nosound.stop()in this build) - Music:
data/music.json— the menu loops mainmenu.mp3 while the main menu is up; in the game scene the DEEP-SPACE soundtrack (music.game — a plain file list: deepspace-01/02 for now, adding a track = adding a line) SHUFFLES: one track at its natural length (loop OFF), the next picked at random when one ends (never the same twice in a row) — the v4 build has no sound 'complete' event, so a 1 s scene-clock tick advances it (js/utils/Music.js → startMusicShuffleOn/stopMusicShuffleOn, files queue under their filename-derived key: deepspace-01.mp3 → music_deepspace_01); on a world's surface the track for that world's planets.png frame (terran / gas-giant families — the SAME key as the landing videos) runs from the start of the landing clip to the end of the takeoff clip. Shared voice injs/utils/Music.js(looped play +stopByKey+ one-loop-per-track guard;music.enabled/music.volume), stops hooked on the scene'sshutdownEVENT (v4 quirk — see the list above), so the hum dies on Take Off AND on Return to Main Menu, and the game soundtrack dies on landing AND on returning to the menu (js/utils/Music.js, dev/music.test.mjs). The soundtrack plays ONLY in space: landing is a launch+sleep (not a shutdown — v4 quirk), so it dies on the scene's'sleep'event and the shuffle restarts on'wake'(Take Off) — the mining hum rides the same seam, so neither leaks onto a world's surface. Also fixed the landing frame hand-off —startLandingpassed the textureframeOBJECT (→ NaN); it now passessheetFrame, so the land/surface/takeoff clips are actually selected per world. intro-01.mp3 is parked for the intro sequence that hasn't been built yet (js/scenes/GameScene.js → setMiningLoop, data/sfx.json) - Research rules + panel: start a project (one at a time), tick its
duration, award
unlocks; the Research slot on the deck opens the console window — looping archive feed, category tabs, the branching tech tree, per-tech dossier + RESEARCH button, deck progress bar. Pure rules/state injs/research/(Node-tested), the window is a passive view,effectscarry the payload (e.g.tether.levelgrows the home tether). First category Exploration is live (Tether Level 1–4, Tether Anchoring, Signal Amplification); add a category = one JSON file + one line in the registry + one line in the manifest (js/ui/ResearchWindow.js,js/research/*,data/research.json,data/research/exploration.json, dev/research-builds.test.mjs) - Tether progression (research side is done; the build side is next):
anchor tethers on planets/stations and upgrade levels beyond the
Exploration tree (the
add/setLevel/onChangeseams are in place; the build entrytether-l2+ the research→build unlock wiring exist indata/builds.json— the costs panel and the apply-effect seam come next) - Factions & pirates: claim settlements (
owner), flags, borders, and the player's place in a populated galaxy (the reputation layer already resolves standing throughowner—Reputation. factionStanding()is the stub to fill) - Reputation (data layer): standing on each planet & space station,
−20…+20 (best = +20), neutral 0 default, home world pinned at +20,
faction check as a placeholder on the
ownerseam; stable place ids from the generator; save-ready + Node-tested (js/reputation/Reputation.js, data/reputation.json) - Landing & exploration: settlements become points of interest you
can approach (the data — kind, anchor, population — is already there).
The comms panel is the door: clicking a planet or space station flies
the ship there AND opens a comms panel at the click — the name decodes
in, the standing bar draws (settled), REQUEST LANDING (gated at standing
≤ −4) or LAND on an unsettled world + CANCEL (
js/ui/CommsPanel.js, a rusty-metal frame around a scanlined green CRT). Free-space stations are solid objects now (js/entities/Station.js,data/stations.json). Button actions are seams (GameScene.commsAction) — the landing sequence lands 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, Scan, 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 - Build panel: pay credits/minerals, apply
effects, respectrequires; the Build slot on the deck opens it; a credits/minerals readout in the HUD - Ship base stats:
data/ship.json → stats(hullIntegrity 100, shields 0, cargoHold 100, mineralStorage 250), exposed asship.stats— combat/trading/mining systems will read them as capacities; upgrades (ship-category builds) will layer deltas on top - 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) — 10-slot bank in localStorage, in-game SAVE/LOAD vault, and the menu Continue button (newest save) - Economy/trading loop (the Privateer heart)