bitmax/docs/handbook/14-vol-V-inbox.md
monster 59fd95b707 Initial commit: BORING SOFTWARE — an anthology of incremental games
Five incremental games disguised as boring 1998-era desktop utilities, launched from a Win98 shell (ENTROPY OS):

- Vol I  qBitTorrz       (index.html)        — torrent-client idler; seed, climb hardware tiers, migrate
- Vol II  DEFRAG.EXE     (defrag.html)       — steer a disk defragmenter; drag-select; outrun entropy
- Vol III MACRO_VIRUS.XLS(spreadsheet.html)  — build a compounding economy by drag-filling formulas
- Vol IV  UPLINK         (terminal.html)     — terminal + live htop; manage load/heat; escalate to root
- Vol V   INBOX ZERO     (inbox.html)        — keyboard-triage an exponential inbox; write regex rules
- ENTROPY OS             (desktop.html)      — the Win98 launcher that holds them all

Each is a single self-contained HTML file (vanilla JS, no deps, no build), with idle/offline
progress, scientific-scale numbers, diegetic upgrades, and a prestige that re-frames the fiction.

Also includes MANIFESTO.md (design thesis) and docs/ — the Collector's Edition companion:
a Dev Handbook and a Lore Bible (lore complete; 5 handbook chapters land in the next commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:44:36 +10:00

23 KiB
Raw Permalink Blame History

Vol V Internals — INBOX ZERO

The engineering manual for inbox.html: how plausible mail is minted, how the flood ramps, the overwhelm penalty that nearly broke the economy, the rules engine that automates your judgment, and the Inbox-Zero prestige loop.

INBOX ZERO is Volume V of Boring Software — an Outlook 97 three-pane mail client that is secretly a survival game against exponential noise. Where Vol IV — UPLINK asks you to balance load against the OOM killer, this volume asks a simpler, crueler question: can you process mail faster than it arrives? The honest answer is no — not by hand — and the whole arc is the player discovering that, then encoding their judgment into rules until the inbox empties itself.

The save key is boringsoft_inbox_v1. The shared scaffolding (fmt, the ~100ms tick, offline catch-up, sanitizeState, toasts) is the same contract every volume inherits; this chapter documents only what is specific to the inbox, and assumes you know the DNA from §6 of the style canon.

· · ·

The MAIL model

A message is a flat object minted by genMail(forceType). The shape it returns:

return {
  id:_mid++, type, from:fromName, addr:fromAddr, subject,
  value: Math.max(0, baseVal),
  unread:true, flagged:meta.flag, vip:meta.vip,
  att: type==='work'&&Math.random()<0.45 || type==='phishing'&&Math.random()<0.6,
  t: now(), body: pick(BODIES[type]),
};
Field Type Meaning
id int Monotonic, from the module-level _mid counter. Selection (G.sel) and DOM rows key off this.
type string One of work, newsletter, spam, phishing, vip.
from / addr string Display name and user@domain, procedurally generated per type.
subject string A template from the per-type subject pool, with {n} and {proj} substituted.
value number The base Productivity the mail is worth — a per-action multiplier is applied later, never stored here.
unread bool Flipped to false the moment it's selected (selectMail).
flagged / vip bool flag/vip from the type's metadata; phishing and VIP both flag.
att bool Has an attachment. Decorative, except phishing attachments render as invoice_overdue.pdf.exe.
t epoch ms Arrival time, drives the relTime "3m ago" column.
body string A canned paragraph from BODIES[type].

DEV NOTEvalue is the base worth, not the banked Productivity. This is deliberate and load-bearing: a mail minted on the Startup plane and triaged after you've ascended to Government must pay out at Government rates. If we'd frozen the multiplier into value at spawn time, every mail that survived an ascension would underpay. Multipliers are always applied at the point of triage (manualMult, ruleMult), never baked in. The reading pane even previews this live: +fmt(m.value*manualMult()) PP if archived.

The procedural generators

The minting is unglamorous string assembly, and that is the point — it has to read like real corporate mail, not like a video game. Senders are built from name and domain pools (FIRST, LAST, WORK_DOMAINS, NEWS_DOMAINS, SPAM_DOMAINS, PHISH_DOMAINS, VIP_DOMAINS). The flavor is half The Office cast (Schrute, Beesly, Halpert) and half spam-folder archaeology (secure-paypa1.com, bank0famerica.net, nigerian-prince.org).

Subject lines come from a per-type pool with two placeholders:

let subject = pick(subT).replace(/\{n\}/g,n).replace(/\{proj\}/g,proj);

{n} is a random integer in [1, 9999); {proj} is one of the PROJ codenames (Phoenix, Atlas, Synergy, …). Address construction is type-specific — work mail derives the address from the display name (fromName.toLowerCase().replace(/[^a-z]/g,'.')), so "Dwight Schrute" becomes dwight.schrute@boringsoft.net, while VIP mail always lands at the current plane's domain (pick(VIP_DOMAINS)+'@'+plane().domain). That last detail matters for rules: a from contains CEO filter keeps working across planes because the VIP names are stable even as the domain shifts.

The five types — value and sign

MAIL_TYPES is the single source of truth for spawn weighting and base worth:

Type weight baseVal flag vip Triage intent
work 34 14 Archive (the bread and butter)
newsletter 24 5 Archive cheaply, or rule away
spam 24 0 Delete
phishing 10 0 Delete (it's flagged, not VIP)
vip 8 90 Reply — deleting it costs you

rollType() is a standard weighted pick over TYPE_ORDER (total weight 100, so the percentages read directly). Base value is then jittered by ±30% so two work mails are never identically priced:

const baseVal = meta.baseVal * (0.7+Math.random()*0.6);

Note that spam and phishing have baseVal: 0 — they are worth nothing to archive. The "negative value" of spam isn't stored as a negative number; it's expressed structurally, in the asymmetry of deleteSel. Deleting a worthless mail pays a tiny "tidied up" reward (m.value*0.1 + 0.5); deleting a VIP applies a flat penalty of half its banked value and a scolding toast. So the sign of an action is contextual: the same delete keystroke is correct on spam and a self-inflicted wound on a VIP. That is the whole risk surface of manual triage in one function.

· · ·

The arrival pipeline

spawnMail(n) is the funnel every new mail passes through, online and offline:

function spawnMail(n){
  for(let k=0;k<n;k++){
    const m = genMail();
    if(!applyRules(m)){
      // not auto-handled -> lands in inbox
      G.inbox.push(m);
    }
  }
  if(G.inbox.length && G.sel==null) G.sel = G.inbox[0].id;
}

The ordering is the design: rules run before the mail ever touches the inbox. If a rule matches, applyRules banks the Productivity and the mail never enters G.inbox (the one exception is flag, which re-pushes it — see the rules section). This is why a good rule-set produces a visibly self-clearing inbox rather than one that fills and then drains.

arrivalRate() — the flood

function arrivalRate(){
  const base = 0.8 * plane().rate;
  const ramp = 1 + Math.min(8, (G.peakThroughput>0? Math.log10(1+G.peakThroughput)*0.18 : 0));
  return base * ramp;
}

Base is 0.8 emails per second, multiplied by the current plane's rate. The ramp is a slow within-run time-pressure curve keyed off peakThroughput — the better you've been doing this run, the faster mail comes, capped at a 9× multiplier (1 + min(8, …)). The log-scaling means the ramp is brutal early and forgiving late; it exists to keep a stagnant inbox from being trivially idle-able while you're not investing.

step(dt) accumulates fractional arrivals so the rate is honored even at sub-1/s on the Startup plane:

G.spawnAccum += rate*dt;
let toSpawn = Math.floor(G.spawnAccum);
if(toSpawn>0){ G.spawnAccum -= toSpawn; if(toSpawn>400) toSpawn=400; spawnMail(toSpawn); }

The toSpawn>400 clamp is a per-tick ceiling so a throttled tab that hands us a 5-second dt (the tick's own cap) on The Void can't try to mint thousands of objects in one synchronous loop and jank the frame. Offline catch-up uses the same accumulator with a higher clamp (2000) and only piles unhandled mail up to inboxCap()*3 to bound memory.

inboxCap() — and the post-mortem

function inboxCap(){
  // base buffer gives a new player ~20s of grace before the flood overwhelms them;
  return 18 + lvl('cap')*UP_BY_ID.cap.add + perkAdd('serenity') + G.ascensions*0;
}

Base capacity is 18. The cap upgrade adds +14 per level; the serenity perk adds +10 per level permanently.

DEV NOTE — the post-mortem. The base cap was originally 8, which is roughly what a real "manageable inbox" feels like. It was a disaster. On the Startup plane the flood plus the first ramp tick could overflow an 8-slot cap inside the first ten seconds, before a new player had read the help dialog or bought a single upgrade. The status bar screamed OVERWHELMED, income halved, and the player's first impression was a game punishing them for not yet knowing the rules. We rebalanced the base to 18 — about 20 seconds of grace at the opening rate — and re-tuned the cap upgrade's add from +5 to +14 so a single purchase is a felt relief, not a rounding error. The vestigial + G.ascensions*0 term is the scar: we briefly granted cap per ascension, decided perks should own that axis instead (serenity), and left the zeroed term as a deliberate marker of where the knob used to be. Don't delete it without reading this note — it's a comment that compiles.

isOverwhelmed() and the penalty

function isOverwhelmed(){ return now() < G.overwhelmedUntil || G.inbox.length > inboxCap(); }
function overwhelmPenalty(){ return isOverwhelmed() ? 0.5 : 1; }

Overwhelm is sticky. The instant G.inbox.length exceeds the cap, step sets G.overwhelmedUntil = now()+1500 — a 1.5-second tail so that dropping to exactly cap doesn't flicker the status bar off and on every tick. While overwhelmed, overwhelmPenalty() returns 0.5 and is multiplied into every income path: manualMult, ruleMult, and through them every archive, reply, delete, and rule-banked dollar.

DEV NOTE — the wired-in fix. The original overwhelm mechanic slowed arrivals when you were drowning — which is exactly backwards. It made the game easier the worse you were doing, so the inbox self-corrected and the pressure evaporated. The comment in step is the fossil of that decision: if(isOverwhelmed()) rate *= 1.0; // arrivals don't slow; YOUR income tanks instead. The *= 1.0 is a no-op on purpose, a tombstone marking where the bad multiplier was. The real penalty moved into overwhelmPenalty() and was threaded through the multiplier functions so it could never be forgotten at a call site — there is exactly one place to be overwhelmed and exactly one place it bites. The status bar copy ("⚠ OVERWHELMED — income halved until you dig out") promises this to the player, so the constant is a contract, not a free parameter.

· · ·

Triage actions and the keyboard

There are three manual actions, each operating on the selected mail. Productivity is banked through one of three multiplier helpers:

function manualMult(){
  return plane().ppMult * Math.pow(UP_BY_ID.hotkeys.mult, lvl('hotkeys'))
       * perkMult('clarity') * overwhelmPenalty();
}
Action Function Key Payout Notes
Archive archiveSel e value × manualMult() ×readBonus() if already read; ×0.85 if archived unread before the reading upgrade.
Reply replySel r value × 1.5 × manualMult() × replyMult() Costs replyCost() Focus (floor 3, default 8). The only action VIPs reward.
Delete deleteSel # / Del / Backspace (value×0.1 + 0.5) × manualMult() On a VIP instead: value×0.5×manualMult(), clamped at 0, with a warning toast.

reading and Focus deserve a note. Archiving an unread mail "sight unseen" pays ×0.85 until you buy Speed-Reading Course (reading), after which reads pay a readBonus() premium instead. archiveAllRead() exploits this — it bulk-archives every read, non-VIP mail at full readBonus, which is the intended reward for the j/k-to-read-then-bulk-archive rhythm. Replies are Focus-gated: replyCost() currently floors at 3 but is wired to scale (the 8 - lvl('focusregen')*0.0 term is another zeroed knob awaiting a balance pass).

The keyboard handler is the volume's tactile hook. It early-returns inside form fields (so typing a rule pattern doesn't archive mail), respects Escape to close modals, reserves Ctrl/Cmd+S for save, and otherwise dispatches:

switch(e.key){
  case 'j': case 'ArrowDown': e.preventDefault(); moveSel(1); break;
  case 'k': case 'ArrowUp': e.preventDefault(); moveSel(-1); break;
  case 'e': e.preventDefault(); archiveSel(); break;
  case 'r': e.preventDefault(); replySel(); break;
  case '#': e.preventDefault(); deleteSel(); break;
  case 'Delete': case 'Backspace': e.preventDefault(); deleteSel(); break;
}

A nice touch: if you're viewing a non-inbox folder and press a triage key, the handler snaps you back to the inbox first, so muscle memory never silently no-ops. moveSel marks mail read as it passes, which is what feeds archiveAllRead. The same actions are reachable from the toolbar, the Edit menu, and a right-click context menu — but the keyboard is the one that feels like a real mail client, and that fidelity is the design (see §5 of the manifesto: fidelity over flash).

· · ·

The rules engine

Rules are the automation layer — the moment the game stops being twitch triage and becomes systems-building. A rule record:

{field, op, pattern, action, enabled, hits}
Field Domain Source
field from, subject, address, type RULE_FIELDS
op contains, matches, is RULE_OPS (matches is regex, gated behind an upgrade)
pattern string user-entered
action archive, delete, reply, flag RULE_ACTIONS
enabled bool toggled by the checkbox in the manager
hits int lifetime auto-triage count, shown per-rule and summed in the folder tree

Compilation

compileRule(r) precompiles the matcher once, not per-mail. For matches it builds a case-insensitive RegExp; for contains/is it lowercases the pattern into _needle. A bad regex is caught and the rule is flagged _bad rather than allowed to throw inside the hot path:

function compileRule(r){
  try{
    if(r.op==='matches'){ r._re = new RegExp(r.pattern, 'i'); }
    else { r._re = null; r._needle = String(r.pattern).toLowerCase(); }
    r._bad = false;
  }catch(e){ r._bad = true; r._re=null; }
}

contains is a String.includes; is is exact equality; matches runs the precompiled regex against the raw field value (the i flag handles case). The "glob/regex" distinction in practice is: without the regex upgrade you have substring and exact matching only, which covers most real filtering (address contains spam. → delete). The regex unlock turns on the matches operator and full pattern power — the builder even suggests ^(prize|winner|won) as a starter.

Application and slots

function applyRules(m){
  const slots = ruleSlots();
  const active = G.rules.filter(r=>r.enabled).slice(0, slots);
  for(const r of active){
    if(ruleMatches(r, m)){
      r.hits = (r.hits||0) + 1;
      autoTriage(m, r.action);
      return true;
    }
  }
  return false;
}

Two things are doing real work here. First, rule slots cap how many rules actually runruleSlots() is 1 + lvl('ruleslots') + perkAdd('preset'), and only the first slots enabled rules are evaluated. You can author more rules than you can run; the surplus sit dormant until you buy slots. Second, first match wins (the return true), so order is strategy — a broad archive rule above a specific delete rule will eat mail the delete rule wanted.

autoTriage mirrors the manual payouts but banks through ruleMult() (which adds the rulespeed upgrade and the enlightened perk on top of plane and clarity):

function autoTriage(m, action){
  let gain = 0;
  if(action==='archive'){ gain = m.value; folderInc('archive'); }
  else if(action==='delete'){ gain = m.value*0.1; folderInc('spam'); }
  else if(action==='reply'){ gain = m.value*1.4; folderInc('sent'); }
  else if(action==='flag'){ m.flagged=true; m._ruleHandled=false; G.inbox.push(m); return; }
  const banked = gain * ruleMult();
  G.pp += banked;
  if(hasUp('autoresponder')) G.pp += banked * lvl('autoresponder')*UP_BY_ID.autoresponder.mult;
  trackThroughput(banked);
}

flag is the odd one out: it doesn't bank or remove, it pushes the mail into the inbox with a highlight — the escape hatch for "auto-surface this VIP, don't auto-handle it." Everything else banks and is gone. The autoresponder upgrade skims a passive 12%-per-level bonus on top of rule income, which is the idle payoff the spec promised.

addRule() is the gatekeeper for authoring: it rejects empty patterns, refuses when G.rules.length >= ruleSlots(), blocks matches without the regex upgrade, charges 5 Focus to author, and refunds that Focus if the regex turns out invalid. So even building automation costs the regenerating resource — you can't spam-author rules while drowning.

DEV NOTE — rules run in simulateAway too. Offline catch-up calls the exact same applyRules per spawned mail, which is the whole reason an idle inbox can stay near zero: your encoded judgment keeps working while the tab is closed. The welcome-back toast reports it honestly — "your Rules auto-banked N PP" — and warns if the inbox overflowed past cap while you were gone. If you ever change the arrival or rule math, change it in one place that both step and simulateAway call, or online and offline economies will silently diverge. They currently share arrivalRate, genMail, and applyRules; keep it that way.

· · ·

Planes and prestige

The five planes are the prestige ladder, each a multiplier on both volume and power:

Plane rate ppMult Domain
Startup 1 1 boringsoft.net
Enterprise 6 7 enterprise-corp.com
Government 32 55 agency.gov
The Whole Internet 180 520 all-of.net
The Void 1100 6400 ∅.void

Note ppMult outpaces rate at every step (7 vs 6, 55 vs 32, 520 vs 180, 6400 vs 1100). Ascension is a net positive on throughput — the volume terrifies, but the power compounds faster. That's the carrot that makes climbing feel good rather than punishing.

The Inbox-Zero gate

You cannot prestige on cooldown or by spending; you must genuinely reach Inbox Zero:

function canAscend(){ return G.inbox.length===0 && enlightenmentFor()>=1; }

With the flood always arriving, hitting exactly zero is a real achievement — you have to out-triage or out-rule the incoming rate long enough to drain the last mail, then immediately ascend before the next spawn. enlightenmentFor() is the prestige currency, sqrt-scaled off peakThroughput with a floor of 2000 and a plane bonus:

function enlightenmentFor(){
  const p = G.peakThroughput;
  if(p < 2000) return 0;
  return Math.floor(Math.pow(p/2000, 0.42) * (G.plane+1));
}

doAscend() banks the Enlightenment, increments plane and ascensions, and resets the run: pp, inbox, peakThroughput, spawnAccum, overwhelmedUntil, and folderCounts all clear; Focus refills to max. Crucially it keeps perks and Enlightenment, and carries perkAdd('preset') rules forward — the Inherited Filters perk — re-stamping them with hits:0 and recompiling. Your judgment survives the reincarnation even though everything else burns.

Serenity perks (Enlightenment)

The five permanent perks, bought in the Ascend dialog with Enlightenment, persist across every run:

Perk Effect per level Max
clarity ×1.30 global Productivity 50
serenity +10 base Inbox cap 50
flow +0.4 Focus regen 40
preset +1 carried-over rule slot per ascension 20
enlightened ×1.25 Productivity from rules (compounds with rulespeed) 40

The endgame is reaching Inbox Zero on The Void — the secret ending — where the mail rate is 1100× and the only thing that empties the inbox is an automation stack so complete it triages the entire internet faster than it arrives. The Ascend dialog acknowledges it: "You are on The Void — the highest plane. Reaching Zero here is the secret ending." Thematically it rhymes with every other volume's terminus — Vol I — qBitTorrz's Singularity, Vol II — DEFRAG.EXE's migrated platter — the substrate grown so large it's indistinguishable from no substrate at all.

· · ·

Save-key registry and state shape

freshState() seeds the global G. The persistence-relevant keys:

Key Default Role
pp 0 Productivity (the spendable run currency)
focus / focusMax 30 / 30 Regenerating pool for Replies & rule authoring
enlightenment 0 Prestige currency (persists)
peakThroughput 0 Best PP/s this run; drives enlightenmentFor and the ramp
plane / ascensions 0 / 0 Prestige depth
inbox [] Live mail array; the survival gauge's numerator
sel / folder null / inbox Selection and active folder
folderCounts {archive,spam,sent} Lifetime-this-plane processed counters
upgrades / perks {} / {} Level maps, keyed by id
rules [] Rule records (sans the recompiled _re/_needle)
overwhelmedUntil 0 Sticky-overwhelm timestamp
spawnAccum 0 Fractional-arrival accumulator

sanitizeState() is the guard that makes a corrupt save un-brickable, per the shared contract. It coerces every numeric to a finite default, clamps plane into [0, PLANES.length-1], drops unknown upgrade/perk ids, rebuilds the inbox array element-by-element (defaulting unknown types to work), and — importantly — revalidates every rule against RULE_FIELDS/RULE_OPS/RULE_ACTIONS and recompiles it, so a save edited to contain a malicious or malformed regex can't execute as anything but a flagged-_bad no-op. It also reconciles _mid past the highest surviving id so new mail never collides with loaded mail.

· · ·

If you change this

  • inboxCap() base (18). This is a tuned grace window, not a free number — read the cap post-mortem note above. Lowering it reintroduces the open-second overwhelm that made new players feel punished. If you change it, re-tune the cap upgrade's add (14) in the same pass.
  • The overwhelm penalty. overwhelmPenalty() returns 0.5 and the status bar literally promises "income halved." It is wired through manualMult, ruleMult, and autoTriage. If you change the factor, change the UI copy; if you add a new income path, route it through a multiplier helper, not a raw G.pp +=. Do not resurrect the arrivals-slow-down mechanic — the *= 1.0 tombstone in step exists to stop exactly that.
  • arrivalRate() and the ramp. Online (step) and offline (simulateAway) must call the same rate, generator, and rule code or the two economies diverge. The 0.8 base and the log10×0.18, cap-8 ramp are balanced against the plane rate table — bumping one without the other breaks the climb.
  • value is base, not banked. Never bake a multiplier into a stored value, or post-ascension mail underpays. Apply multipliers at triage time, always.
  • First-match-wins + slot truncation. applyRules evaluates only the first ruleSlots() enabled rules and stops on first match. If you make rules run in parallel or all-match, you change the core strategy (rule ordering) and must rewrite the manager's "Active rules run top-to-bottom; first match wins" copy.
  • The zeroed knobs (G.ascensions*0, lvl('focusregen')*0.0). These are intentional markers of retired or reserved balance axes. Don't prune them as dead code without reading why they're zero — they document where a tuning lever used to live, or will.