Nanoscript: I Built a Fake 90s Computer to Run It in the Browser
- Published
- Read
- 4 min
- By
- creationwasteland
RelatedY2K CSS: Making a Library Follow a Theme Toggle It Doesn't Know About
Nanoscript is a small typed scripting language I have been working on for a while, and for most of that time the only way to try it was to install the npm package. I wanted a page on this site where you could type a program and run it. The obvious version of that is an editor and a console. What I built instead is a fake 1990s computer called NSOS, with a boot sequence, a login screen, a desktop, a file system, a code editor, a notepad, a browser and a game. This post is about why, and about the parts that were harder than they looked.
Why a whole computer
Partly because it fits the site, which already looks like it was designed on a Macintosh in 1996. Mostly because a language sandbox has a lot of UI around the editor anyway: a list of example programs, somewhere for output to go, a way to save your work, settings. Once you have files, a console and windows, you are three features away from a desktop, and a desktop is a better frame for "this is a working thing" than a textarea is.
The engine runs in a worker
The nanoscript engine is JavaScript, with a WebAssembly backend as an option, and running someone's script on the page thread would freeze the whole UI the first time they wrote while (true). So the engine lives in a Web Worker. The editor posts source to it, the worker compiles and runs, and output streams back as messages. If a run passes its time budget, the page asks whether you want to keep waiting, and if not it terminates the worker and starts a fresh one. Every run gets a clean engine, so nothing leaks between programs.
One thing that cost me an afternoon: Turbopack's static analysis fails on new Worker(url, { type: "module" }). The options object breaks it. The worker has to be a classic worker, created without the second argument.
A disk you are allowed to break
NSOS has a file system called NanoFS. The seed disk (the example programs, a readme, the game's source) is a JSON file that is meant to load from S3, so I can update the demos without deploying the site, with a bundled copy as the fallback if the fetch fails. Everything you change goes into an overlay in localStorage, so your edits survive a refresh, and a Reset Disk item in the start menu throws the overlay away. The games directory is deliberately writable. The Missile Command clone on the desktop is itself a nanoscript program; you can open its source in the editor, change it, and play the result.
A game loop in a scripting language
That game was the most fun part. The script returns an update(dt) function, and the host calls it once per frame through the engine's public API, so the game's state lives in the script's own closures between frames. A small host module exposes drawing primitives (pixels, lines, rectangles, text) and input to the script.
// MISSILES.NS, roughly. The host calls update every frame.
let cities = [ ... ];
let missiles = [];
function update(dt: float): void {
if (clicked()) { fire(mouseX(), mouseY()); }
for (let m of missiles) { m.y = m.y + m.vy * dt; }
draw();
}
return update;
The same host module exists as a no-op stub in the editor's worker, so game scripts type-check there without a screen to draw on. Keeping three copies of one interface in sync (the live module, the stub, and the build-time validator) was a lesson I only needed to learn once, after a bug where common names like line and text became illegal variable names in every script because the stub was registered for all of them instead of just the games.
The CRT
The screen has an optional CRT look: scanlines, a faint phosphor stripe pattern, a vignette, glare on the glass, and chromatic aberration done with an SVG filter that splits the colour channels and shifts red and blue apart. It defaults on for desktop browsers that render SVG filters on HTML reliably, which rules out Safari, and off on phones, where it costs GPU time nobody asked for. You can toggle it in the control panel either way.
The filter had one trap I did not see coming. The desktop wallpaper is a one-pixel checkerboard dither. Shift its colour channels by one pixel and the entire field turns solid magenta and green, because every pixel's red channel now lands on a neighbour of the opposite colour. The fix was to keep the wallpaper out of the filter and apply it to the windows and icons sitting on top instead, and to swap any other one-pixel dither inside a filtered window for horizontal pinstripes, which a horizontal shift leaves alone.
Boot theatre, honestly
The boot screen's progress bar is real. It tracks the preload tasks (the disk, the engine chunk, the apps), blended with a slow time-based creep so it always looks alive. Any key or click skips ahead. A tab that has already booted goes straight to the desktop, and so does anyone with reduced motion turned on. The startup chime is a square-wave arpeggio synthesised with WebAudio, and it is off by default, because nobody wants a website to make a sound they did not ask for.
You can try it from the Nanoscript page. Log in as guest.

Like what I said? Hate what I said? Tell me what you think: kameron@creation-wasteland.com