One computer from the 1980s. A small home server. A browser. And a surprisingly modern set of engineering problems.
Some computers become obsolete. Others stay with us long after their hardware has disappeared from everyday life.
For me, one of those computers is the Gradiente Expert 1.1, a Brazilian machine built around the MSX standard. It belongs to a period when computing was still something I was discovering, not something I could take for granted. The MSX was part of my introduction to computers and programming, and that experience helped shape the curiosity that eventually brought me into software development.
That is what makes it different from simply remembering an old game. I remember what the machine represented: the possibility of exploring a world that was new to me, of understanding a little more each time I interacted with it, and of discovering that a computer was something I could learn to work with—not just watch.
Decades later, I found myself maintaining a small DietPi server at home and asking a question that had been waiting somewhere in the background for years:
Could I bring that MSX experience into a modern browser without turning the project into an oversized application?
I already had WebMSX working in my private environment. The emulation engine was not the problem. The missing pieces were the experience around it: choosing between machine profiles, organizing media, creating a useful interface, and making all of it behave consistently over HTTP.
Over September 26–27, 2026, I worked on those pieces. What began as a nostalgic experiment became a small lesson in system design, static-site generation, browser APIs, Linux permissions, and debugging across several layers.
This is the story of that work—and the parts I would build differently today.
The MSX I wanted to remember
The MSX standard appeared in 1983 and became especially meaningful in Brazil with machines such as the Gradiente Expert and Sharp Hotbit, introduced to the Brazilian market in 1985. Different manufacturers could build compatible computers around the same general architecture, which was unusual and valuable in an era of competing home-computer platforms.
My reference point was the Expert 1.1, an MSX1 machine. But I also wanted to explore the later MSX2 generation, so the project needed two separate emulation profiles rather than one configuration pretending to support every title equally.
That distinction sounds small. It turned out to matter enormously during debugging: a wrong machine profile, firmware mapping, or disk extension can look like a completely broken emulator.
And there was a second goal. I did not want the result to be a directory full of files. I wanted to choose an item from a catalog and reach the appropriate virtual machine with as little friction as possible.
A deliberately simple architecture
I kept the responsibilities separated:
Browser | Searchable HTML catalog | Generated from manifest.json | Machine-specific link / \ MSX1 profile MSX2 profile \ / WebMSX | Authorized local media | lighttpd / DietPi Python builds the catalog; it is not needed per request. lighttpd serves static files; it does not emulate the MSX. The browser runs the emulator. The actual stack in my lab was:
| Layer | Choice | Why |
|---|---|---|
| Host | DietPi on Debian 13 | A small, always-on Linux environment |
| Web server | lighttpd 1.4.79 | Lightweight static-file delivery |
| Emulator | WebMSX 6.0.8 | MSX hardware emulation inside the browser |
| Catalog builder | Python 3 | Turn structured metadata into HTML |
| Catalog data | JSON manifest | One manageable source for titles and launch settings |
| Access | Tailscale | Keep my personal lab reachable without publishing it openly |
| Browser | Canvas 2D, WebAudio, JavaScript | Video, sound, timing, and user input |
Nothing here required a database, a frontend framework, or a backend API. After generation, the catalog was simply HTML, CSS, and a little JavaScript.
My lab snapshot showed the lighttpd process using roughly 2.7 MB of resident memory while serving a WebMSX JavaScript bundle of approximately 5.21 MB. These are measurements from that environment, not universal performance claims. They illustrate why a static architecture was sufficient.
A public example of the directory layout can be much smaller than my private deployment:
/var/www/html/retro/ ├── biblioteca/ │ └── index.html # Generated by my Python script ├── msx1/ │ └── index.html # My own configuration wrapper ├── msx2/ │ └── index.html # My own configuration wrapper └── media/ └── demo.rom # Only if I have permission to share it manifest.json build_catalog.py The diagram intentionally leaves out proprietary BIOS files and the WebMSX bundle. The examples in this article describe the integration layer I wrote, not a redistributable package containing somebody else's emulator, firmware, or games.
First, make the web server boring
One pleasant surprise was how little lighttpd needed to know about the MSX. From the web server's perspective, it was serving HTML, JavaScript, and binary files. It did not need to understand a Z80, a cartridge mapper, or a floppy-disk controller.
On Debian or DietPi, installing the server is straightforward:
sudo apt update sudo apt install lighttpd My deployment already had lighttpd running, so I added a directory beneath its existing document root rather than creating another service. The important checks were not glamorous:
- Does the URL resolve to the file I intended?
- Can the
www-dataprocess read the file and traverse the directories? - Are spaces and special characters correctly encoded in generated URLs?
- Is the browser receiving a usable binary response?
- Do cache headers help rather than disguise stale files?
For a new installation, the document root and index handling are the relevant ideas:
# Illustrative settings; adapt to the existing configuration. server.document-root = "/var/www/html" index-file.names = ( "index.html" ) I validated changes before touching the running service:
sudo lighttpd -tt -f /etc/lighttpd/lighttpd.conf sudo systemctl reload lighttpd And I tested the actual responses:
curl -I http://127.0.0.1/retro/biblioteca/ curl -I http://127.0.0.1/retro/media/demo.rom For files that my web server is supposed to expose, ordinary directory permissions such as 755 and read-only file permissions such as 644 can be sufficient when ownership and parent directories are configured appropriately. Public-facing permissions should never be used as a shortcut for deciding which files belong under the document root.
Four details that caused disproportionately large problems
1. Case sensitivity. A filename that worked on a Windows development machine could fail on Linux because Demo.rom and demo.rom are different paths.
2. URL encoding. Filenames containing spaces or parentheses needed proper URL encoding. I made the Python generator responsible for that rather than trying to fix links manually in HTML.
3. Relative paths. A wrapper at /retro/msx1/ resolves ../media/ differently from a page moved to /retro/. The browser resolves that URL before lighttpd ever looks at the filesystem.
4. Permissions and access. A 403 could be a file permission problem, not an emulation bug. Equally important, a 403 for the directory listing does not make the individual files private. Access control must happen at the network or server level.
The last distinction matters. I used Tailscale for private reachability; a hidden directory index is not a security mechanism. And a private network, by itself, does not grant copyright permissions.
The browser is doing the hard work
WebMSX runs its emulation logic in the browser. In the version deployed in my lab, the important browser capabilities included:
| Browser capability | Role |
|---|---|
| Canvas 2D | Display the emulated video |
requestAnimationFrame | Coordinate rendering with browser frames |
| WebAudio | Generate emulated sound |
| Binary HTTP requests | Load firmware and media as bytes |
localStorage | Keep preferences and some persisted emulator state |
| Keyboard and Gamepad APIs | Provide the original machine's inputs |
That also explains why the server did not need to run game logic. A successful HTTP request supplied the bytes; the browser did the emulation.
I learned two practical browser lessons here. First, audio playback may require a user gesture; a page that paints correctly can still appear silent until the user interacts with it. Second, browser storage belongs to an origin. Moving an application between a local IP address, a private hostname, or a different scheme or port can make preferences and stored states appear to vanish because the browser treats the new address as a different origin.
Those are browser rules, not MSX-specific bugs.
Three bugs that taught me more than a clean installation
The most educational part of the project was not the successful boot. It was the sequence of failures that looked almost identical from the outside.
Bug 1: replacing a global object instead of configuring it
My first approach assigned a new object to WMSX:
// The mistake: replacing an object owned by the emulator. WMSX = { MACHINE: "MSX1" }; In the version and initialization sequence I was testing, that destroyed properties and methods the engine had already attached. The visual symptom was a black screen, but the browser console exposed the underlying problem: expected emulator methods were missing.
The installed integration was corrected by supplying parameters without replacing the engine object. My particular bundle accepted the following pattern:
// Observed in my deployed integration; version-specific. WMSX.params = { MACHINE: "MSX1", AUTO_START: true }; There is a useful documentation detail here: the upstream WebMSX configuration reference documents public parameters on the existing WMSX object (for example, WMSX.MACHINE = "MSX1") after the emulator script has been imported. My local WMSX.params implementation was an observed integration detail, not a reason to assume every WebMSX release initializes the same way.
Lesson: before replacing a global object, find out who owns it and when it is initialized.
Bug 2: a believable configuration key can still be wrong
I initially tried to select the Brazilian firmware using a property named BIOS_URL. It sounded sensible. In my deployment, it did not select the firmware I intended.
The slot-loading setting I actually needed was SLOT0P_URL, which represents the image loaded into the relevant primary slot. I eventually used that mechanism to reproduce the machine profile I remembered.
The historical setup used original machine firmware locally. That is a separate question from what should be packaged with a public tutorial. In the examples I share, I describe the slot configuration without distributing the original BIOS, and readers must supply only firmware they are entitled to use.
There is an alternative worth researching: C-BIOS, an independently developed MSX-compatible BIOS. It is useful for some cartridge-oriented demonstrations, but it is not a drop-in replacement for every configuration: standard C-BIOS setups lack MSX-BASIC and disk/tape support, and they will not reproduce the exact original Gradiente boot experience.
Lesson: hardware compatibility, boot identity, and software licensing are three different concerns.
Bug 3: enabling a disk extension when there is no disk
In my Expert profile, enabling a disk extension unconditionally caused trouble when the emulator started without a disk image. The same configuration behaved differently when media was supplied.
My working fix was to enable the extension only when a disk URL was present:
const search = new URLSearchParams(window.location.search); const hasDisk = search.has("DISKA_URL") || search.has("DISKB_URL"); // Configuration fragment from my integration pattern: const extensionConfig = hasDisk ? { DISK: 2 } : {}; In the installed wrapper, extensionConfig was passed into the existing parameter configuration. This was a narrow, reproducible fix for my combination of settings; it was not evidence of a universal WebMSX defect.
Lesson: a feature that works with valid input need not be enabled during every boot.
A searchable catalog from JSON, not a database
Once the machine profiles were working, the next question was the interface. My private collection had grown well beyond a few manually maintained links. One manifest snapshot contained 204 game entries; the served catalog eventually displayed 206 cards, including two additional utilities. Keeping those numbers distinct mattered because a catalog entry, a file on disk, and a working launch are not automatically the same thing.
For the public explanation, I reduced the manifest to fictional names and a file that is assumed to be my own or otherwise cleared for distribution:
[ { "title": "My MSX Demo", "file": "demo.rom", "machine": "MSX1", "format": "rom" } ] The original project used filename conventions and a few exceptions to choose the correct machine. Looking back, an explicit machine field is cleaner. A format or title should not have to secretly encode hardware requirements.
Here is the core of a public-safe adaptation of my Python generator. It validates the expected data, checks the referenced file, builds an encoded launch URL, and escapes the resulting HTML:
import json from html import escape from pathlib import Path from urllib.parse import quote, urlencode root = Path("/var/www/html/retro") entries = json.loads(Path("manifest.json").read_text(encoding="utf-8")) routes = {"MSX1": "/retro/msx1/", "MSX2": "/retro/msx2/"} parameters = {"rom": "CARTRIDGE1_URL", "dsk": "DISKA_URL", "cas": "TAPE_URL"} cards = [] for item in sorted(entries, key=lambda e: e["title"].casefold()): name = item["file"] if Path(name).name != name: # Reject paths outside the media directory raise ValueError(f"Invalid media filename: {name}") if not (root / "media" / name).is_file(): raise FileNotFoundError(name) machine = routes[item["machine"]] # Reject unknown machine profiles parameter = parameters[item["format"]] # Reject unknown media types query = urlencode( {parameter: f"/retro/media/{name}"}, quote_via=quote, safe="/" ) href = f"{machine}?{query}" title = escape(item["title"]) cards.append(f'<li><a href="{escape(href, quote=True)}">{title}</a></li>') catalog = "<ul>\n" + "\n".join(cards) + "\n</ul>" print(catalog) # Embed this fragment in a styled HTML template. This is intentionally an excerpt rather than a copy of my full private script. The actual interface had instant search and media-format filters, implemented with a small amount of ordinary JavaScript. No database query was necessary because the catalog was already in the page.
A static approach also made testing straightforward: if the manifest was correct, the generated page should be reproducible. If a media file was missing, the generator could report the problem before producing a broken link.
A 200 OK is not a successful emulation test
At one point, it was tempting to accept HTTP responses as proof that everything worked. That would have missed most of the interesting failures.
I separated the checks into three layers:
| Layer | Check | What it proves |
|---|---|---|
| Filesystem | Every manifest entry points to an existing file | The catalog is internally consistent |
| HTTP | Wrapper and media URLs respond as expected | The server can deliver the requested resources |
| Browser | The emulated machine boots and displays output | The integration works at the user's level |
During one validation pass, all 206 generated card links matched files present in the private deployment, and the corresponding launch requests returned HTTP 200. That was useful evidence about links and routing—not proof that 206 programs had been played successfully.
The browser tests went further. I checked empty boots, representative cartridge and disk launches, and whether the expected screen actually appeared. For black-screen failures, the JavaScript console and rendered Canvas output revealed problems invisible to a simple curl command.
The other detail was caching. A stale HTML wrapper or JavaScript file can imitate a failed code change. I used hard reloads or temporary cache-busting parameters while debugging, and I checked validators such as ETag and Last-Modified on the server side.
Finally, I looked at the cost of serving the emulator. My local compression tests reduced the approximately 5.21 MB JavaScript bundle to about 3.4–3.5 MB, depending on the compression method. That is meaningful on a slower connection, although actual browser gains depend on server configuration and client support. lighttpd's mod_deflate documentation covers the deployment choices.
The general lesson was simple: test the experience, not just the transport.
What belongs in a preservation project—and what does not belong in a public download
A project like this raises an important question. The MSX is part of computing history, but the passage of time does not automatically make its commercial software, firmware, graphics, or music free to redistribute.
It is also important to distinguish the pieces involved:
- The MSX hardware standard is the historical platform I am discussing.
- WebMSX, created by Paulo A. Peccin, is a separate emulator project; its code and distribution terms must be respected. This article links to its official project instead of bundling it.
- Machine BIOS images and commercial game media can have separate rights holders and restrictions.
- My Python catalog generator, wrappers, and deployment notes are the original integration work described here.
For that reason, this article does not distribute original BIOS files, commercial games, or the WebMSX JavaScript bundle. Reproducible examples should use self-authored or explicitly authorized media and appropriately licensed firmware. Anyone building a similar lab should verify the terms of the materials they choose.
The private nature of my original setup is a deployment decision, not a statement that private use automatically authorizes every copy or transfer.
I think that distinction makes the technical story stronger, not weaker. The point of this article is to explain how I designed and debugged a browser-based retrocomputing environment—not to offer an unofficial software archive.
What I would keep, and what I would change
If I rebuilt the project, I would keep the lightweight approach: static HTML, one JSON manifest, a small Python generator, separate machine profiles, and a file server that simply serves files.
I would improve a few things:
- Store the machine profile explicitly in the manifest instead of inferring it from names.
- Validate file paths, permissions, and launch URLs before regenerating the catalog.
- Add checksums and a reproducible import report to distinguish duplicate files from different versions.
- Separate public, permission-cleared demo material from the private historical collection.
- Automate a few browser smoke tests, because an HTTP
200cannot tell me whether an MSX screen actually booted.
Those are not especially glamorous changes. They are exactly the changes that help a personal experiment mature into reliable software.
The computer was never just the hardware
When I started, I thought I was trying to bring back a machine from the 1980s. The technical work taught me something more interesting.
A familiar computer can be represented by a firmware image, a CPU implementation, a video chip model, and an audio engine. But the experience of using it also depends on the surrounding details: whether it starts, whether the keyboard responds, whether the display feels right, and whether you can reach it without fighting the infrastructure.
For me, the Gradiente Expert represents the beginning of a relationship with computing. Years later, I used the knowledge that grew from that early curiosity—Linux, Python, web technologies, testing, and system design—to revisit the machine that helped inspire it.
There is something satisfying about that circle closing.
Preservation is more than keeping the bytes. It is keeping the experience within reach—while respecting the people who created the software along the way.
References and further reading
- WebMSX — official project and configuration documentation
- WebMSX — online C-BIOS version
- openMSX — C-BIOS capabilities and limitations
- lighttpd — static-file module
- lighttpd — compression module
- DietPi — lightweight Linux distribution
- MSX Resource Center — Gradiente Expert Plus reference
This is a retrospective account of a personal lab built in September 2026. Measurements and observed behavior describe that particular environment; the public examples have been simplified to avoid redistributing third-party software and media.
