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>
22 KiB
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()becausedefaultTrackers()is called inside the state seed, andfmt()is referenced everywhere. Keep that ordering when you port the shell — movingfreshState()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.assignoverfreshState()guarantees every key exists;num()and the type guards insanitizeState()guarantee every value is sane; theCATALOG/CAT_BY_IDcross-check guarantees every reference is live. Each volume'ssanitizeState()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) andlastTick(live, in-memory, drives the visibility gap). The boot path readslastSave; the refocus path readslastTick. ResetlastTickafter 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()andrenderStatus()set.textContenton a handful of knownids (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 assigninnerHTMLonce. 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_vsuffix if the new save shape can't surviveObject.assignoverfreshState(). A silent shape change behind the same key mis-loads every existing save.- Add every new state field to
freshState()andsanitizeState(). A field that exists in only one is either un-migratable (missing fromfreshState) or un-repairable (missing fromsanitize).- Keep two clocks straight.
lastSavedrivesapplyOffline()at boot;lastTickdrives thevisibilitychangegap. ResetlastTickafter crediting a gap, or the next tick double-bills it.- Never widen the
dtcap 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-actverb and ahandleActcase, not a freshaddEventListener— or rebuilt modals will quietly stop responding.- Sub-linear prestige stays sub-linear.
creditsFor's0.42exponent is what keeps migration a choice. Make the payout linear and the loop collapses into spam-prestige.