bitmax/docs/handbook/01-architecture-and-the-dna.md
monster 87cfead4d3 docs: complete the Dev Handbook
Adds the 5 chapters that hit a transient rate-limit on first pass:
- 01 Architecture & the Shared DNA
- 02 Building a New Volume (the Cookbook)
- 10 Vol I Internals — qBitTorrz
- 12 Vol III Internals — MACRO_VIRUS.XLS
- 13 Vol IV Internals — UPLINK

The Dev Handbook (Disc One) is now complete at 10 chapters; the Lore Bible (Disc Two) was already complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:21:31 +10:00

22 KiB
Raw Permalink Blame History

Architecture & the Shared DNA

The reference architecture every volume re-implements — documented from index.html, the canonical Vol I implementation (qBitTorrz 4.6.2).

This is the scaffolding chapter. Five volumes ship five hooks, but they all stand on the same skeleton, established here in index.html and re-implemented, never imported, by DEFRAG.EXE, MACRO_VIRUS.XLS, UPLINK, and INBOX ZERO. If you understand the qBitTorrz shell — its number system, its G global, its save round-trip, its tick, its offline catch-up, its render strategy — you understand the other four before you open them. What follows is that skeleton, with the real function names, the real constants, and the reasons each choice exists.

The single-file app-shell anatomy

Every volume is one self-contained .html: an inline <style>, an inline <script>, vanilla JS, zero external resources, no build step, double-click to run. index.html opens with "use strict"; and four module constants that the rest of the file hangs off of:

const SAVE_KEY = "qbittorrz_save_v1";
const TICK_MS = 100;
const MAX_OFFLINE_S = 8*3600;       // cap offline progress at 8h
const SPD_SAMPLES = 160;

The script is laid out in fixed sections, top to bottom, separated by banner comments: number formatting, the data tables (CATALOG, UPGRADES, PERKS), the network-model constants, STATE, the derived getters, the game-logic tick, rendering, modals, save/load, event wiring, the main loop, and finally boot(). The DOM is declared once in the body — a menu bar, a toolbar, a body split with a sidebar and a center column, a status bar, plus three always-present singletons: #modal-back, #ctxmenu, and #toasts. JS never builds the chrome; it only fills innerHTML into the containers that already exist.

DEV NOTE — The section order is load-bearing. Number formatting and the data tables come before freshState() because defaultTrackers() is called inside the state seed, and fmt() is referenced everywhere. Keep that ordering when you port the shell — moving freshState() above the tables it depends on is the classic "works until you reset" bug.

The number system

Numbers in an idle game cross twenty orders of magnitude, so the formatter is foundational. index.html defines one core function, fmt(n), and three thin wrappers — fmtSpd, fmtInt, and fmtTime — plus a sciStr(n) helper for the scientific tail.

fmt() walks an SI suffix table, dividing by 1000 each step, and falls back to exponential notation once it runs out of suffixes:

const SI = ['B','kB','MB','GB','TB','PB','EB','ZB','YB','RB','QB'];
function fmt(n){
  if(n===Infinity) return '∞';
  if(!isFinite(n)||isNaN(n)) return '0 B';
  if(n<0) n=0;
  if(n<1000) return Math.floor(n)+' B';
  if(G&&G.sci) return sciStr(n);
  let i=0,x=n;
  while(x>=1000 && i<SI.length-1){x/=1000;i++;}
  if(i===SI.length-1 && x>=1000) return sciStr(n);   // past QB -> scientific
  return x.toFixed(x<100?2:1)+' '+SI[i];
}

Note the deliberate defenses: Infinity renders as , NaN/non-finite collapses to 0 B, and negatives clamp to zero — so a garbage number can never reach the screen as NaN B. Precision is adaptive: two decimals under 100, one decimal otherwise. The G&&G.sci guard means fmt() is safe to call even before G is assigned.

Range (bytes) Suffix Example output
< 1000 B (integer, no decimals) 512 B
1e3 1e6 kB 5.20 kB
1e6 1e9 MB 1.30 MB
1e9 1e12 GB 4.20 GB
1e12 1e15 TB 1.60 TB
1e15 1e18 PB 1.30 PB
1e18 1e21 EB 5.00 EB
1e21 1e24 ZB
1e24 1e27 YB
1e27 1e30 RB
1e30 1e33 QB (last suffix)
≥ 1e33, or G.sci on scientific via sciStr 1.23e34 B

sciStr is (n/Math.pow(10,e)).toFixed(2)+'e'+e+' B' with e=Math.floor(Math.log10(n)) — a fixed two-significant-digit mantissa with the raw exponent. The G.sci toggle (View → Toggle Number Format, or the toggle-sci action) flips the whole UI into pure scientific even mid-SI-range, for players who would rather read 4.40e6 B than 4.40 MB. It is a single boolean on state, read inside fmt(), so one flag re-formats the entire app on the next render.

The wrappers are trivial and intentionally so. fmtSpd(n) is fmt(n)+'/s'. fmtInt(n) reuses fmt for large counts but strips the B suffix (so seed counts read 1.20k, not 1.20 kB). fmtTime(s) formats seconds into d/h/m/s with two units shown, and — critically for an idle game — returns for anything over 99 days, so an un-seeded ETA never prints a meaningless nine-digit countdown.

The G global and freshState()

There is exactly one mutable global: G, the entire save in a single object. It starts null and is populated only by load(). Everything the game knows — currencies, the torrent array, upgrade levels, perk levels, event timers, the IRC log, UI preferences — lives on G. Derived values are never stored; they are computed on demand by small pure getters (downMult(), maxSlots(), lineSpeed(), creditsFor()), so there is no cache to invalidate and no derived field to corrupt.

freshState() returns the canonical shape with every key seeded to a safe default:

function freshState(){
  return {
    v:1,
    data:0, totalUp:0, totalDown:0,
    runUp:0,                 // upload accrued this prestige run (for credits)
    credits:0, prestiges:0, lifetimeUp:0,
    torrents:[], upgrades:{}, perks:{},
    seq:1, sel:null, filter:'all', dtab:'general', sci:false,
    seenCatalog:{},
    trackerList:defaultTrackers(),
    // ... event timers, irc:[], lastSave, started, sidebarW, detailH
  };
}

Two distinctions matter for prestige later: runUp (uploaded this run, reset on migration) versus totalUp and lifetimeUp (which persist), and upgrades/perks as separate maps because upgrades are wiped on prestige and perks are not. Note that freshState() is also the schema authority for load(): every key that exists must appear here, or it won't survive a partial save.

The save / load / sanitize round-trip

Persistence is three functions: save(), load(), and sanitizeState(). save() stamps G.lastSave and writes JSON.stringify(G) to localStorage under SAVE_KEY, wrapped in try/catch. On failure (private mode, quota) it toasts once — guarded by a module-level saveFailed flag — and returns false, so a storage-less browser degrades to a playable-but-unsaved session instead of throwing on every tick.

load() is the boot path and is paranoid by design:

function load(){
  let raw=null;
  try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
  if(!raw){ G=freshState(); seedIrc(); return false; }
  try{
    const s=JSON.parse(raw);
    if(!s||typeof s!=='object'||Array.isArray(s)) throw new Error('bad shape');
    G=Object.assign(freshState(), s);
    sanitizeState();
    if(!G.irc.length) seedIrc();
    return true;
  }catch(e){ G=freshState(); seedIrc(); return false; }
}

The Object.assign(freshState(), s) is the migration mechanism: a save written by an older version that lacks a newer key still gets that key from freshState(), and any stray key from a tampered save is harmless because the getters only ever read the keys they know about. Anything that throws — bad JSON, an array at the top level, a null — falls through to a clean freshState(). A corrupt save costs your progress, never your boot.

sanitizeState() is the repair pass that runs after the merge, and it is the most defensively-written function in the file. It defines a local num(v,d) that accepts a value only if it is a finite, non-negative number and otherwise substitutes a default, then runs every scalar through it:

const num=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
G.data=num(G.data,0); G.totalUp=num(G.totalUp,0); /* …all scalars… */
if(!G.upgrades||typeof G.upgrades!=='object'||Array.isArray(G.upgrades)) G.upgrades={};
if(!Array.isArray(G.trackerList)||!G.trackerList.length) G.trackerList=defaultTrackers();
if(!Array.isArray(G.torrents)) G.torrents=[];
G.torrents=G.torrents.filter(t=>t&&CAT_BY_ID[t.cid]);

The repairs, in order of paranoia:

Hazard in the save What sanitizeState() does
data: NaN or a negative / string scalar num() substitutes the default — no NaN ever enters the economy
upgrades is null, an array, or missing reset to {} so G.upgrades[id] can't crash
trackerList empty or not an array restored to defaultTrackers()
a torrent referencing a deleted catalog id filtered out (CAT_BY_ID[t.cid] is falsy)
t.downloaded exceeds the file size clamped to c.size
duplicate / non-numeric t.uid reassigned from G.seq++
sel points at a torrent that no longer exists set to null
lastSave in the future (clock skew / edited save) clamped to Date.now() — a future-dated save self-heals

That last one matters more than it looks: offline catch-up trusts lastSave, so a future timestamp would otherwise yield a negative elapsed gap. Clamping it is what stops an edited save from breaking the welcome-back math.

DEV NOTE — The pattern to copy is the three-layer funnel: Object.assign over freshState() guarantees every key exists; num() and the type guards in sanitizeState() guarantee every value is sane; the CATALOG/CAT_BY_ID cross-check guarantees every reference is live. Each volume's sanitizeState() differs only in which arrays it walks (clusters, cells, processes, mail) — the funnel shape is identical, and it is the single best defense against a community of save-editors.

The tick loop

One setInterval(tick, TICK_MS) at 100 ms drives everything. The tick is delta-time based, not fixed-step, so it stays correct when the browser starves it:

let lastTick=Date.now(), saveAccum=0;
function tick(){
  const t=Date.now();
  let dt=(t-lastTick)/1000; lastTick=t;
  if(dt<0) dt=0; if(dt>5) dt=5; // cap big jumps (throttled tab)
  step(dt);
  // sample speed, render HUD/table/status…
  saveAccum+=dt;
  if(saveAccum>=10){ saveAccum=0; save(); }
}

The dt cap of 5 seconds is deliberate: a backgrounded or throttled tab can deliver a tick with several minutes of elapsed wall-clock, and feeding that straight into step() would credit a giant lump in one frame — and, worse, let download speeds overshoot. Capping dt keeps each online tick bounded; the real elapsed time is recovered separately by the offline/visibility path below, so nothing is lost, it's just credited through the more careful simulation. Autosave is an accumulator: dt sums into saveAccum, and every ~10 s of game time it flushes and resets. Plus boot() registers window.addEventListener('beforeunload', save) so closing the tab always writes one last time.

tick() also does the per-frame rendering — HUD, table, status bar every tick; the sidebar only when the 700 ms bucket rolls over (Math.floor(t/700)!==Math.floor((t-TICK_MS)/700)); the detail pane only if it's showing live data; and the open modal's affordance state via updateStoreAfford() / updateAddAfford(). The simulation (step()) and the presentation are cleanly separated — step(dt) mutates G, the render functions read it.

Offline catch-up and the refocus handler

The idle contract is: it plays while you're gone and rewards your return. Two code paths honor it, and they share one simulator, simulateAway(dt).

simulateAway is a coarse fixed-step replay: it chooses a step count scaled to dt and clamped to a sane band — Math.min(240, Math.max(20, Math.ceil(dt/30))) — and runs the download/seed economy that many times. The clamp is the point: enough sub-steps that a torrent which finishes mid-gap still spends the rest of the gap seeding (so you aren't cheated of completion income), but never so many that catching up eight hours hangs the boot.

applyOffline() runs at boot, after load():

function applyOffline(){
  const dt=Math.min(MAX_OFFLINE_S, Math.max(0,(Date.now()-(G.lastSave||Date.now()))/1000));
  if(dt<2) return;
  const beforeData=G.data;
  simulateAway(dt);
  const earned=G.data-beforeData;
  if(earned>0){
    setTimeout(()=>toast('info','💤 Welcome back',
      'While away ('+fmtTime(dt)+') your seeds earned <b>'+fmt(earned)+'</b> Data.'),400);
  }
}

The gap is the difference between now and lastSave, clamped to MAX_OFFLINE_S (8 h) on the high end and 0 on the low — the latter being why sanitizeState() heals future-dated saves. The welcome-back toast is deferred 400 ms so it lands after the first render rather than fighting it.

The second path is the visibilitychange handler wired in boot(). Browsers throttle setInterval in background tabs, so the dt cap inside tick() would silently discard most of a long background stretch. On refocus, the handler measures the gap against lastTick, and if it exceeds 10 s, credits it through simulateAway and — crucially — resets lastTick so the very next tick() doesn't double-count the same span:

document.addEventListener('visibilitychange',()=>{
  if(document.visibilityState!=='visible') return;
  const gap=(Date.now()-lastTick)/1000;
  if(gap>10){ simulateAway(Math.min(MAX_OFFLINE_S,gap)); save(); renderAll(); }
  lastTick=Date.now();
});

DEV NOTE — There are two clocks, and conflating them is a real bug we want to avoid: lastSave (wall time, persisted, drives boot-time offline) and lastTick (live, in-memory, drives the visibility gap). The boot path reads lastSave; the refocus path reads lastTick. Reset lastTick after crediting the gap or the next online tick re-bills it. This is exactly the kind of double-credit that, in DEFRAG.EXE, would matter in the opposite direction — see the QA chapter's entropy death-spiral, where the lesson is that the away-sim must reach equilibrium, never runaway.

The toast system

toast(kind, title, body) is the only notification primitive. It builds a <div class="toast {kind}">, appends it to the always-present #toasts container, and schedules its own removal — 6 s for prestige, 3.8 s for everything else — then caps the stack so only the five most recent remain (while(wrap.children.length>5) wrap.firstChild.remove()). kind is one of '', info, warn, bad, or prestige, each a left-border color via CSS. title and body are injected as HTML, which is what lets toasts carry <b> emphasis and the occasional inline link. Every player-facing event — a completed download, an unlocked tier, an ISP throttle, a migration — routes through this one function.

The modal and event-delegation pattern

There is one modal host (#modal-back wrapping #modal) and one context-menu host (#ctxmenu). openModal(html) drops markup into #modal and shows the backdrop; closeModal() hides it and clears modalKind. A module-level modalKind string identifies which dialog is open, so the tick can live-update only the right one and the helpers refreshStoreModal() / refreshPrestigeModal() can no-op when their dialog isn't showing.

The wiring is event delegation, not per-element handlers. A single document-level click listener walks e.target.closest(...) for the relevant data-* attribute and dispatches:

document.addEventListener('click',e=>{
  hideCtx();
  const actEl=e.target.closest('[data-act]');
  // …filterEl, storeTabEl, catTabEl, dtabEl, rowEl…
  if(actEl){ if(actEl.tagName==='A') e.preventDefault(); handleAct(actEl.dataset.act, actEl); return; }
  if(rowEl){ selectTorrent(parseInt(rowEl.dataset.uid)); return; }
});

Every interactive element declares its intent in markup (data-act="open-store", data-cid, data-uid), and handleAct(act, el) is one big switch over those verbs. Because the listener is on document, markup that renderStoreModal() regenerates wholesale on every purchase keeps working with zero re-binding — you can blow away and rebuild a modal's innerHTML and the buttons inside it still fire. contextmenu, dblclick, and keydown follow the same delegated shape. This is the reason the rebuild-heavy render strategy below is cheap to maintain.

The render strategy: update-in-place vs. rebuild

renderAll() calls five renderers — renderHud, renderSidebar, renderTable, renderDetail, renderStatus. The split between rebuild and update-in-place is intentional and the policy is worth copying:

  • Update-in-place for small, fixed sets of nodes that change every tick: renderHud() and renderStatus() set .textContent on a handful of known ids (hud-data, sb-down, sb-ratio, …). No DOM churn 10×/second.
  • Rebuild for variable-length lists: renderTable(), renderSidebar(), and the modals assemble an HTML string and assign innerHTML once. The transfer table re-emits every visible row each frame. This is fine because the row count is small and event delegation means rebuilt rows need no handler re-attachment.

The one bespoke renderer is drawSpeed(), the Speed-tab graph: a <canvas> painted from two ring buffers, spdDownHist and spdUpHist, each SPD_SAMPLES (160) long. The tick pushes the current totals and shifts the oldest every frame, so the canvas always shows the last ~16 s. The manifesto's note for the bigger grids in DEFRAG.EXE generalizes this: when the cell count gets large, canvas it — don't ask the DOM to hold 960 nodes.

The save-key registry

Each volume owns a distinct localStorage key. They never collide, so all five can run side-by-side under ENTROPY OS with independent saves. This table is canon; do not reuse a key.

Vol App SAVE_KEY constant
I qBitTorrz qbittorrz_save_v1
II DEFRAG.EXE boringsoft_defrag_v1
III MACRO_VIRUS.XLS boringsoft_xls_v1
IV UPLINK boringsoft_uplink_v1
V INBOX ZERO boringsoft_inbox_v1
ENTROPY OS (desktop.html) (none — stateless launcher)

The _v1 suffix is the migration escape hatch: if a future save shape becomes genuinely incompatible with Object.assign-over-freshState() recovery, bump to _v2 and the old key is simply ignored rather than mis-parsed.

The prestige pattern

Prestige is the hard reset that grants a permanent currency and persistent perks, re-skinned per volume (migrate / compress / audit / ssh / ascend). In qBitTorrz it is Client Migration to a Seedbox Cluster, and the math lives in two functions. creditsFor(runUp) is the payout curve — deliberately sub-linear so each migration is worth less per byte than the last, which is what makes the next prestige a decision rather than a reflex:

function creditsFor(runUp){
  if(runUp<1e9) return 0;                       // gate: ~1e9 uploaded this run
  return Math.floor(Math.pow(runUp/1e9, 0.42)); // sub-linear in run upload
}

doPrestige() performs the reset: it banks gain into G.credits, increments G.prestiges, then zeroes the run state while preserving the meta state. Specifically it wipes data, runUp, torrents, upgrades, sel, restores trackerList to defaultTrackers(), and clears every event timer — but keeps credits, prestiges, perks, totalUp, and lifetimeUp. The currency is spent in the prestige modal on PERKS, which carry across resets because they live in G.perks, never touched by doPrestige(). The two endgame perks — darknet and singularity — are once:true unlocks that gate the highest catalog tiers, so prestige is also the only door to the Exabyte-and-beyond content.

The diegetic-upgrade pattern

The final law of the DNA: upgrades are bought through the app's own dialogs, never a cartoon shop. There is no "+1 click" button. In qBitTorrz the UPGRADES table is configuration — Network Hardware, Cat6 Cable Upgrade, UPnP Port Forwarding, Automated RSS Filters, Quantum Encryption Protocol — and each row's effect is legible: the store card computes and shows the before/after of the actual model term it touches. Buying bandwidth literally reads Gigabit Fiber → 10G Fiber · line 4.40 MB/s → 9.68 MB/s, because upgradeCard() renders the real hwTierName() and lineSpeed()*u.mult. Two kinds drive the curve: repeatable leveled upgrades (kind:'mult-up', 'hardware', 'storage', etc., cost base * growth^level) and one-time 'unlock's (flat base, on/off). The player can always answer "where does my next number come from" by reading the dialog — which is Design Law 4, legible numbers, made concrete. Every sibling volume inherits this contract: the shop is the Options pane, the formula bar, the apt install list, the Rules editor — never a video-game menu.

· · ·

If you change the shared scaffolding

  • Bump SAVE_KEY's _v suffix if the new save shape can't survive Object.assign over freshState(). A silent shape change behind the same key mis-loads every existing save.
  • Add every new state field to freshState() and sanitizeState(). A field that exists in only one is either un-migratable (missing from freshState) or un-repairable (missing from sanitize).
  • Keep two clocks straight. lastSave drives applyOffline() at boot; lastTick drives the visibilitychange gap. Reset lastTick after crediting a gap, or the next tick double-bills it.
  • Never widen the dt cap to "fix" idle. The 5 s cap is the online safety valve; long gaps are the offline simulator's job. Loosening the cap reintroduces lump-credit and speed overshoot.
  • Route new UI through event delegation. Add a data-act verb and a handleAct case, not a fresh addEventListener — or rebuilt modals will quietly stop responding.
  • Sub-linear prestige stays sub-linear. creditsFor's 0.42 exponent is what keeps migration a choice. Make the payout linear and the loop collapses into spam-prestige.