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>
20 KiB
ENTROPY OS — The Desktop Shell
How desktop.html fakes a Windows 98 launcher in one stateless file — bevels, inline-SVG icons, a registry of five games, and a windowing system built from mousedown and z-index.
This is the only file in the anthology that does not save. It has no G, no freshState(), no localStorage. It is a launcher: a teal desktop that paints five icons, draws a draggable window, runs a clock, and — when you double-click — points window.location.href at one of the volumes. Everything you see is rendered from two constants, ICONS and PROGRAMS, and a few hundred lines of vanilla DOM glue. There is no framework, no build step, and no external asset of any kind. The whole Win98 illusion is CSS and one <svg> per icon.
The shell is small, but it is the table of contents for the entire box. Get it wrong and the player never reaches the games. So it is worth reading closely.
The bevel: how two border-colors become 3D
Everything in Windows 98 is a beveled rectangle — raised buttons that look pushed-out, sunken wells that look carved-in. The shell reproduces this with no images and no gradients-for-edges. It is two CSS classes:
.raised{border:2px solid; border-color:var(--hi) var(--shh) var(--shh) var(--hi);
box-shadow:inset 1px 1px 0 #dfdfdf, inset -1px -1px 0 var(--sh);}
.sunken{border:2px solid; border-color:var(--sh) var(--hi) var(--hi) var(--sh);
box-shadow:inset 1px 1px 0 var(--shh), inset -1px -1px 0 #dfdfdf;}
The trick is the four-value border-color, which sets top right bottom left independently. A raised element gets a white top-left and a black bottom-right; a sunken element swaps them. That single asymmetry is the entire optical illusion of depth — light comes from the upper-left, so the top edge catches it and the bottom edge falls into shadow. The box-shadow adds the second pixel of bevel: an inner light line just inside the dark border and an inner dark line just inside the light one. That four-tone edge (light · lighter · darker · dark) is exactly the two-pixel chiseled border the Win98 common controls shipped with.
The palette these classes draw from is declared once in :root:
| Variable | Value | Role |
|---|---|---|
--face |
#c0c0c0 |
the canonical "Windows gray" control face |
--hi |
#ffffff |
top-left highlight |
--sh |
#808080 |
mid shadow |
--shh |
#000000 |
hard outer shadow |
--blue |
#000080 |
selection / title navy |
--teal |
#3a8a8a |
desktop accent |
--ui |
"MS Sans Serif",Tahoma,… |
the system UI font stack |
--mono |
"Lucida Console",Consolas,… |
the volume-label mono stack |
Reuse is total. The taskbar (#taskbar), the Start button (#start), the welcome window (.win.raised), and every .btn98 all share the same two classes. A button's pressed state is just the sunken shadow swapped in for one frame via :active:
.btn98:active{box-shadow:inset 1px 1px 0 var(--shh), inset -1px -1px 0 #dfdfdf;}
The desktop itself is a radial teal gradient with a ::before overlay of 1-px horizontal lines at opacity:.06 — a barely-there scanline dither that sells the CRT. html,body{overflow:hidden} because a desktop does not scroll.
DEV NOTE — Resist the urge to "improve" the bevel into a single
borderplus a drop-shadow. The doubled, asymmetric edge is the whole reason it reads as Windows and not as Material Design with rounded corners filed off. If you ever see a control that looks flat or modern, the first thing to check is whether someone collapsed the four-valueborder-colorinto one value. That one edit erases the era.
The ICONS map: every program icon is hand-drawn SVG
There are no .png files. Every icon is a string of inline SVG living in one object:
const ICONS = {
computer: `<svg …>…</svg>`,
torrent: `<svg …>…</svg>`,
defrag: `<svg …>…</svg>`,
xls: `<svg …>…</svg>`,
terminal: `<svg …>…</svg>`,
inbox: `<svg …>…</svg>`,
readme: `<svg …>…</svg>`,
flag: `<svg …>…</svg>`,
};
Each value is a raw SVG document drawn on a 32×32 viewBox (the flag uses 22×20) and rendered at 34×34 via the width/height attributes. Because the icons are strings, the renderers can splat them straight into template literals — the same ICONS.computer markup appears in the desktop icon, the welcome titlebar, the welcome header at 40px, the Start-menu "My Computer" item, and the taskbar button, each just dropped into a differently-sized container. One source, many sizes, zero HTTP requests.
The drawings are deliberately period-literal:
| Key | What it draws | Notable detail |
|---|---|---|
computer |
a beige CRT on a stand | a linearGradient id="g" washes the screen for glass-glare; the bezel is #c0c0c0 |
torrent |
a blue download clock/disc | a white path "M16 6v10l7 4" clock-hand over a 2.4r hub |
defrag |
the cluster grid | a literal 6×3 lattice of 4×4 <rect>s in red/blue/green/white — the Win98 Defrag legend in miniature |
xls |
a spreadsheet page | green header band, an XLS <text> label, ruled gridlines |
terminal |
a black console | green stroke, >_ prompt over a dimmer root# |
inbox |
an envelope + alert badge | red 9×9 badge with a white !, the universal "you have unread" sigil |
readme |
a notepad page | a yellow #dada00 header strip and four ruled text lines |
flag |
the four-pane Start flag | red/green/blue/yellow quadrants — the Windows logo, abstracted |
The defrag icon is the standout: it isn't a generic "disk" clip-art, it's a faithful tile of colored clusters that matches the actual game's grid legend (red = fragmented, blue = contiguous, white = free, green = consolidated). The icon is a screenshot of the game it launches. (See Vol II — DEFRAG.EXE for the grid these colors come from.)
DEV NOTE — The SVGs are injected with
innerHTML, which means they are trusted markup, not user input. That is fine here precisely becauseICONSis a hard-coded constant. The moment any of these strings becomes data the user can influence, this stops being safe. It won't — but write the next icon as a literal, not as something assembled from a variable.
PROGRAMS: the registry is the single source of truth
The launcher does not hard-code five icons in five places. It has one array, and everything reads from it:
const PROGRAMS = [
{id:'torrent', name:'qBitTorrz', file:'index.html', icon:'torrent', vol:'VOL I',
desc:'The torrent-client idler. Seed data, climb hardware tiers, migrate.'},
{id:'defrag', name:'DEFRAG.EXE', file:'defrag.html', icon:'defrag', vol:'VOL II',
desc:'Steer a defragmenter. Consolidate yourself before the disk dies.'},
{id:'xls', name:'MACRO_VIRUS.XLS', file:'spreadsheet.html', icon:'xls', vol:'VOL III',
desc:'A sentient spreadsheet. Build a compounding engine in cells.'},
{id:'term', name:'UPLINK', file:'terminal.html', icon:'terminal', vol:'VOL IV',
desc:'root@. Fork processes, manage load & heat, escalate to root.'},
{id:'inbox', name:'INBOX ZERO', file:'inbox.html', icon:'inbox', vol:'VOL V',
desc:'Triage an exponential inbox. Write rules. Reach Zero. Ascend.'},
];
Six fields per entry, and every one is load-bearing:
| Field | Type | Read by | Purpose |
|---|---|---|---|
id |
string | renderIcons |
the data-id on the desktop icon; the selection key |
name |
string | all three renderers | the display label |
file |
string | launch |
the navigation target (data-file) |
icon |
string | all three renderers | the lookup key into ICONS |
vol |
string | welcome row, Start menu | the VOL I…V mono tag |
desc |
string | welcome row | the one-line pitch |
README is a sixth entry kept out of the array because it isn't a volume — it's the manifesto, with an empty vol:''. It's appended explicitly wherever the suite is listed:
const README = {id:'readme', name:'MANIFESTO.txt', file:'MANIFESTO.md', icon:'readme', vol:'',
desc:'The design manifesto for the whole anthology.'};
Three independent surfaces consume PROGRAMS — the desktop icons (renderIcons), the welcome window's program list (openWelcome), and the Start menu (renderStartMenu). None of them duplicates the data. Add a row to the array and it appears in all three. That is the entire architectural point of the shell: it is an anthology table, and the launcher is a thin view over it.
launch is correspondingly tiny:
function launch(file){ try{ window.location.href = file; }catch(e){} }
No new tab, no target="_blank" — it navigates the current document to the volume's file. The try/catch is defensive habit (a sandboxed iframe can throw on a cross-origin location set), not something that fires in normal use.
Desktop icons: select vs. launch
renderIcons builds the left-column icon stack from My Computer + PROGRAMS + README:
const all=[{id:'computer',name:'My Computer',icon:'computer',isComputer:true}, ...PROGRAMS, README];
Each becomes a .dicon carrying data-id, an optional data-file, and (for My Computer) data-computer="1". The interaction model is the genuine Win98 one: single-click selects, double-click opens. Those are two separate listeners on the #icons container, both using event delegation via e.target.closest('.dicon'):
document.getElementById('icons').addEventListener('click',e=>{
const ic=e.target.closest('.dicon'); if(!ic) return;
document.querySelectorAll('.dicon').forEach(d=>d.classList.remove('sel'));
ic.classList.add('sel'); selId=ic.dataset.id;
});
document.getElementById('icons').addEventListener('dblclick',e=>{
const ic=e.target.closest('.dicon'); if(!ic) return;
if(ic.dataset.computer){ openWelcome(); return; }
if(ic.dataset.file) launch(ic.dataset.file);
});
Select clears every other .sel and adds it to the clicked icon; the .dicon.sel rule paints the navy highlight and dotted focus ring, and .dicon.sel .lbl flips the label to a solid --blue background — the inverted-text look of a selected Win98 icon. Double-click branches: My Computer opens the welcome window, everything else launches its file. Clicking bare desktop deselects, wired separately on #desktop:
document.getElementById('desktop').addEventListener('mousedown',e=>{
if(e.target.id==='desktop' || e.target.id==='icons'){ document.querySelectorAll('.dicon').forEach(d=>d.classList.remove('sel')); }
});
The id guard matters: it deselects only when the empty desktop or the icon container itself is the direct target, never when the click bubbled up from an icon.
DEV NOTE —
selIdis captured but never read after assignment. It's a hook for a feature that didn't ship — Enter-to-open on the selected icon, say, or a right-click context menu acting on the selection. It costs nothing and documents intent, so it stays. If you wire keyboard handling later,selIdis your starting point.
The windowing system: one window, real dragging, real z-order
The shell only ever opens one kind of window — the welcome / My Computer dialog — but it does so with a genuine windowing primitive that would support more. Z-order is a single module-scoped counter:
let zTop=100;
function focusWin(win){ win.style.zIndex=(++zTop); }
Every focus bumps zTop and stamps it on the window, so the most-recently-touched window floats highest. It is monotonic and never resets — fine for a session, since you'd need billions of focuses to overflow.
openWelcome is idempotent: if #welcome already exists it just refocuses and returns, so double-clicking My Computer twice doesn't stack duplicate dialogs. Otherwise it builds the window with innerHTML — titlebar (icon, title, ✕ close button), a header, the intro blurb, the program list mapped from PROGRAMS plus the README row, and a footer — appends it to #desktop, makes it draggable, and registers a taskbar button:
document.getElementById('desktop').appendChild(win);
makeDraggable(win);
addTaskButton('welcome','Boring Software 98', ICONS.computer);
makeDraggable, and the translate-to-absolute handoff
The window is first centered with CSS — #welcome{left:50%;top:46%;transform:translate(-50%,-50%)}. That's great for the initial paint but useless for dragging, because left/top are percentages and transform is offsetting them. So makeDraggable does a one-time conversion on the first mousedown: it reads the live getBoundingClientRect(), kills the transform, and pins the window to absolute pixel coordinates before computing the drag offset:
bar.addEventListener('mousedown',e=>{
if(e.target.closest('[data-close]')) return;
focusWin(win);
const r=win.getBoundingClientRect();
// convert from translate-centered to absolute on first drag
win.style.transform='none'; win.style.left=r.left+'px'; win.style.top=r.top+'px';
drag={dx:e.clientX-r.left, dy:e.clientY-r.top};
e.preventDefault();
});
dx/dy is the grab offset inside the titlebar, so the window doesn't jump to the cursor on first move. The mousemove/mouseup listeners are bound to window (not the bar) so the drag survives the cursor outracing the element, and the new position is clamped so a window can never be dragged fully off-screen:
let x=Math.max(0,Math.min(window.innerWidth-60, e.clientX-drag.dx));
let y=Math.max(0,Math.min(window.innerHeight-50, e.clientY-drag.dy));
The clamp leaves 60px of width and 50px of height always reachable — enough titlebar to grab and drag back. The [data-close] guard at the top means clicking the ✕ never starts a drag.
Taskbar buttons and closing
addTaskButton is dedup-guarded (it bails if a button with that data-win already exists), builds a .taskbtn.raised.active with the icon inline-scaled to 14px, and wires a click to refocus the window. closeWin is the symmetric teardown — remove the taskbar button, then the window:
function closeWin(win){ removeTaskButton(win.id); win.remove(); }
Closing is driven by the global click delegate (below), which finds any [data-close] ancestor, walks up to its .win, and calls closeWin. Both the titlebar ✕ and the footer "Close" button carry data-close="1", so they share one code path.
The Start menu, the clock, and global wiring
renderStartMenu fills #sm-items from the same registry — every PROGRAMS entry with its vol tag pushed right via margin-left:auto, a .smsep divider, then the README and a My Computer item carrying data-welcome="1":
items.innerHTML = PROGRAMS.map(p=>`
<div class="smitem" data-file="${p.file}">…${p.name} …${p.vol}…</div>`).join('')
+ `<div class="smsep"></div>`
+ `<div class="smitem" data-file="${README.file}">…</div>`
+ `<div class="smitem" data-welcome="1">…My Computer</div>`;
The vertical Boring Software 98 spine down the menu's left edge is pure CSS — writing-mode:vertical-rl plus a rotate(180deg) on a navy gradient, the classic Win9x Start-menu sidebar. toggleStart flips the .open class on both the menu and the Start button (so the button shows its pressed/sunken state while the menu is up), with an optional force argument to drive it explicitly:
function toggleStart(force){
const m=document.getElementById('startmenu'), s=document.getElementById('start');
const open = force!=null ? force : !m.classList.contains('open');
m.classList.toggle('open',open); s.classList.toggle('open',open);
}
The clock is honest wall time, formatted 12-hour with an AM/PM suffix:
function tickClock(){
const d=new Date();
let h=d.getHours(), m=d.getMinutes();
const ap=h>=12?'PM':'AM'; h=h%12||12;
document.getElementById('clock').textContent=`${h}:${String(m).padStart(2,'0')} ${ap}`;
}
The h%12||12 idiom maps 0→12 (midnight) and 12→12 (noon) correctly. init calls it once and then on a setInterval(tickClock,15000) — every fifteen seconds, which is plenty for a minute-resolution display and gentle on a backgrounded tab. font-variant-numeric:tabular-nums on #clock keeps the digits from shimmying as they change width.
init is the wiring harness. It paints the flag into the Start button, renders all three surfaces, starts the clock, and installs the click model. The centerpiece is a single document-level delegate that handles every data-* action in one pass:
document.addEventListener('click',e=>{
const fileEl=e.target.closest('[data-file]');
const closeEl=e.target.closest('[data-close]');
const welEl=e.target.closest('[data-welcome]');
if(closeEl){ const w=closeEl.closest('.win'); if(w) closeWin(w); return; }
if(welEl){ openWelcome(); toggleStart(false); return; }
if(fileEl && !e.target.closest('#icons')){ launch(fileEl.dataset.file); return; }
// click outside start menu closes it
if(!e.target.closest('#startmenu') && !e.target.closest('#start')) toggleStart(false);
});
Order is precedence: close beats open-welcome beats launch beats close-the-menu. The !e.target.closest('#icons') guard is the important one — desktop icons carry data-file too, but they must not launch on a single click (they need a double-click). Excluding #icons here hands icon launching entirely to the dedicated dblclick handler, while letting the welcome-window rows, the Open buttons, and the Start-menu items launch on a single click. The Start button's own listener calls e.stopPropagation() so opening the menu doesn't immediately trip the "click outside closes it" branch.
Finally, init ends by calling openWelcome() — the shell boots straight into the My Computer dialog, the anthology's front page.
· · ·
How to add an app
Because the registry is the single source of truth, adding a sixth volume is two edits and zero new files of glue.
-
Draw the icon. Add one entry to
ICONS, keyed by a short name, value a32×32-viewBox inline<svg>string rendered at34×34. Keep it a literal — no interpolation. Match the period palette (gray faces, navy accents, the legend colors of whatever the game is). Look at thedefragicon for the standard.const ICONS = { // …existing… scandisk: `<svg viewBox="0 0 32 32" width="34" height="34">…</svg>`, }; -
Register the program. Add one object to
PROGRAMS, in volume order.iconmust match the key you just added;fileis the new volume's filename; give it avoltag and a one-linedesc.{id:'scandisk', name:'SCANDISK.EXE', file:'scandisk.html', icon:'scandisk', vol:'VOL VI', desc:'Quarantine the rot. Survive the scan.'},
That's it. The desktop icon, the welcome-window row with its Open button, and the Start-menu entry all appear automatically, because renderIcons, openWelcome, and renderStartMenu all map over the same array. Drop the new scandisk.html next to the shell and the icon launches it.
If you change this
- Don't simplify the bevel. The four-value
border-colorplus the doubled insetbox-shadowis the Win98 look. Collapse it to one border or a modern shadow and the era evaporates. The same.raised/.sunkenpair styles the taskbar, buttons, windows, and tray — change the classes, change everything at once. ICONSis injected withinnerHTMLand is trusted by construction. It is safe only because every value is a hard-coded literal. Never build an icon string from a variable, a URL parameter, or anything a user can touch.zToponly ever increases. That's correct for a session but means there is no recycling. If you ever open and close windows in the thousands per session (you won't), revisit it. For now, leave it.- The
!e.target.closest('#icons')guard in the global click delegate is load-bearing. It is the only thing keeping desktop icons on single-click-selects / double-click-opens semantics while every otherdata-filesurface launches on a single click. Remove it and icons will launch on the first click, breaking the Win98 model. launchnavigates the current document (window.location.href), it does not open a tab. If you want the volumes to open in new tabs, that's a deliberate change tolaunch, not a CSS tweak — and it changes how the player gets back to the desktop.- The shell is stateless. There is no
localStoragekey here (see the save-key registry in the front matter — the five volumes have keys; the shell has none). Keep it that way unless you genuinely need to persist something; the launcher's whole virtue is that it can never carry a corrupt save.