A few weeks ago I set myself a question: can a 2000s Windows MMORPG client run in a browser tab, essentially unchanged, and play online? Not a remake, not a video stream of a PC somewhere, not an x86 emulator. The game's own C++ code, compiled to WebAssembly, talking to the game's own server software, with several players in the same world. The server is a private test instance on my PC, never the official servers.

It can. This is how we did it, what broke along the way, and what we measured. "We" is me plus a team of AI coding agents, and that part of the story is in here too.

The Direct3D 9 layer of this work, Portico, runs Direct3D 9 C++ programs in the browser from source. It is private for now; the live demo runs Microsoft's DirectX tutorials unchanged.

About this project. A personal, non-commercial research project on porting legacy software, written up to describe the engineering. The game is not named and is not distributed in any form: no source code, no WebAssembly build, no game assets, no installer. Every server in this article is a private test instance of the game's server software running on my own PC, with test accounts only: not the official servers, not connected to them and not open to the public. I am not affiliated with the game's developer or publisher, and all rights to the game belong to their owners.

The starting point

The client is a typical PC game of its era:

  • about 400,000 lines of C++ in 396 source files, built with Visual C++;
  • MFC for strings, files, serialization and the application skeleton;
  • Win32 for windows, messages, timers, threads, critical sections and the registry;
  • Direct3D 9 with the fixed-function pipeline: no shaders, just render states, texture stages and lights;
  • DirectSound 8 with 3D positional audio;
  • Winsock with WSAAsyncSelect, so network events arrive as window messages;
  • a game loop that never returns: PeekMessage, update, draw, Present, repeat.

The server side is the game's own server software (Login, World, Map and Control, in C++, with a SQL Server database), run as a private test instance on my PC. It stayed native on Windows: we rebuilt it from source with twelve small patches, described below with the network path between it and the browser.

The rules I set:

  1. No rewrite. The game code compiles as it is. Changes to it are allowed only when unavoidable and must be small and justified.
  2. No streaming and no emulation. The client runs in the browser as WebAssembly.
  3. The game must stay runnable at every step, and every claim is checked against the real game, not a toy.

Choosing the approach

There are four ways to put an old Windows game in a browser.

Approach How it works Why not (for us)
Streaming Run the .exe on a server, send video Latency, server cost, it's not in the browser
Rewrite New client in a web engine, same protocol Years of work, and it's no longer the same game
Binary emulation Run the unmodified .exe on an emulated x86 with reimplemented Windows APIs (BottleShip, Boxedwine) Great when you only have binaries; we had source
Source port over an API layer Compile the game's C++ with Emscripten, implement the Windows APIs it calls Lots of API surface to reimplement, but only what the game actually uses

We went with the last one. The first prototype tried a different idea: reimplement the renderer in Three.js and compile isolated parts of the game into separate WebAssembly modules. An independent audit put its real progress at about 5%. Each module had its own heap and its own copy of the Windows stubs, and the stubs disagreed with each other. We threw it away and started over with one rule: one client, one heap, one platform layer.

The platform layer

Everything the game expects from Windows lives in about 21,000 lines of our own code:

flowchart TB
  subgraph Tab["Browser tab"]
    Page[Page: installer, loading screen, input]
    subgraph Worker["Game worker (pthread)"]
      Game[The game's C++ client<br/>~400k lines, essentially unchanged]
      W32[win32-lite<br/>windows, messages, timers, threads, files]
      AFX[afx-lite<br/>MFC subset: CString, CArchive, CFile...]
      D3D[D3D9 → GLES3 / WebGL2<br/>fixed function as generated GLSL]
      DS[dsound8-lite<br/>DirectSound 8 on miniaudio]
      NET[Winsock over WebSocket]
      FS[Game files from OPFS]
    end
    GL[(WebGL2 on OffscreenCanvas)]
    WA[(Web Audio / AudioWorklet)]
  end
  GW[WebSocket ↔ TCP gateway]
  SRV[Private test server<br/>game's server software + SQL Server]

  Game --> W32 & AFX & D3D & DS & NET & FS
  D3D --> GL
  DS --> WA
  NET --> GW --> SRV
  Page <--> W32
  • win32-lite implements the windowing and message queue, timers, threads, events and critical sections, files, the registry, code pages, IME input and the CryptoAPI calls the game uses. It started from an automatic list: every Windows symbol the linker could not find became a stub that logs its first call. The work list was simply what the game really called, in the order it called it.
  • afx-lite is the slice of MFC the game uses: CString, CFile, CArchive, runtime classes, CWinApp, message maps. CArchive is checked byte for byte against real MFC.
  • The Direct3D 9 layer turns the fixed-function pipeline into GLSL ES 3.00 programs, one per combination of states: up to 8 lights, 8 texture stages, vertex blending, fog modes, multiple vertex streams. A command queue preserves the ordering that D3DCREATE_MULTITHREADED promises, because the game's loader thread draws the loading screen while the main thread waits for it.
  • dsound8-lite implements DirectSound 8 on miniaudio, including 3D sound. Its pan and attenuation model was measured on real DirectSound on a Windows PC and reproduced, not guessed.
  • Networking maps Winsock, including select and WSAAsyncSelect, onto one WebSocket per connection, and a small gateway forwards each one to its TCP server (below).
  • Game files (2.2 GB) are installed once into the browser's Origin Private File System by an installer, served from my PC to my own devices only, with a manifest, SHA-256 checks, resumable downloads and atomic updates. The game reads them through Emscripten's WasmFS as if they were on disk.

Graphics: native first, then the browser

The single best decision of the project was to develop the graphics layer inside the native Windows game first. The same D3D9-to-GLES3 code runs in the Windows build over ANGLE (Google's GLES implementation, the one Chrome uses to run WebGL on Windows) in WebGL-compatibility mode. So we could switch the same executable between real Direct3D 9 and our layer, take screenshots of the same scene with both, and compare them.

The comparison is tile-based. Each screenshot is split into tiles, and a tile fails only if it differs in a way you would actually notice. For animated scenes, it compares against the nearest frame in a short D3D9 recording. In the final runs, the difference from real Direct3D 9 averages 0.4 to 3.1 on a 0–255 scale across the reference scenes, and what's left is text rendering, animation timing and the moment the screenshot was taken.

Developing natively also gave us a debugger, RenderDoc and a fast edit-compile-run loop. And a surprise: on the development PC the game ran its 3D world at 4–6 fps on the system's Direct3D 9 (it switches between hardware and software vertex processing for every mesh) and at 78–90 fps on our layer in the same executable.

The runtime model: a loop that never returns

A browser wants your code to return to its event loop every frame. A 2000s game does the opposite: its main loop runs forever, and it also blocks on purpose, with Sleep, WaitForSingleObject and a loader thread it waits for.

We kept the game's model intact:

  • The whole client runs on a worker thread (Emscripten's PROXY_TO_PTHREAD), so blocking calls block the worker and never freeze the page.
  • The WebGL2 context lives on that same worker through an OffscreenCanvas, so the game's draw calls never cross threads.
  • After every Present, the layer yields to the worker's event loop using JSPI (WebAssembly JavaScript Promise Integration). The game's call stack is suspended, the browser shows the frame and delivers network and file events, and the call returns at the next animation frame. From the game's point of view, Present just took a few milliseconds.
sequenceDiagram
  participant G as Game loop (worker)
  participant L as Platform layer
  participant B as Browser (worker event loop)
  G->>L: draw calls (D3D9)
  L->>B: WebGL2 commands
  G->>L: Present()
  L->>B: await next animation frame (JSPI suspends the wasm stack)
  B-->>B: shows the frame, runs WebSocket / fetch / timers
  B-->>L: resolve
  L-->>G: Present returns
  G->>L: PeekMessage() — input and socket messages are waiting

We tried the alternatives first:

  • rewriting the loop as a per-frame callback would have meant changing the game;
  • Emscripten's commit-frame call did nothing in Chrome;
  • proxying GL to the page deadlocked against the loader thread;
  • Asyncify would have instrumented a 400,000-line program.

JSPI does exactly what we needed with no change to the game. It shipped in Chrome and Edge 137, Firefox 153 and, in September 2026, Safari 27.

The servers and the network path

Multiplayer was the point: browser tabs and the Windows client playing together in the same world, on the same server software. Here that server is a private test instance on my PC, not the game's official one.

flowchart LR
  subgraph Tab["Browser tab"]
    C[Game client<br/>Winsock calls]
    WS[Winsock over WebSocket<br/>one socket per connection]
  end
  subgraph Host["Server host"]
    GW[Gateway<br/>allowlist, Origin, limits]
    L[Login]
    M[Map]
    W[World]
    DB[(SQL Server)]
  end
  C --> WS -->|WebSocket| GW
  GW -->|TCP| L
  GW -->|TCP| M
  M <--> W
  L & M & W --> DB

The server software, rebuilt. The release we started from had the servers' source but no server binaries, and one precompiled library, which we replaced with one built from the same code. With Visual Studio 2022 they build from scratch in about two minutes and depend only on system DLLs. They needed twelve small patches, each written down with its reason:

  • build and operations: one #include that today's compiler needs, settings in the user's registry instead of a key only an administrator can open, a listen address so that nothing is reachable from the network during tests, logs in the working directory instead of hard-coded folders;
  • hardening: lengths read from network packets are validated before anything is copied, log calls no longer take incoming text as their format string, and the checks on which server may send which messages now run: they had been written inside a switch, before its first case label, where control never reaches, so each group of messages now sits behind its own check;
  • operations: one rule for character names whatever the server's region setting, leftover debug output removed, a war state that only reset when the server restarted, and a stricter position check on teleport requests.

None of them changes the game's rules or its protocol. Under load the servers held 100 bots moving together for 90 seconds with no disconnections, a 95th-percentile login of 467 ms and the map server at 80 MB.

No TCP in a browser. The client talks to its servers over TCP with Winsock, and a browser only has WebSockets. We compared three ways across:

  • Emscripten's socket emulation does not exist with WasmFS, the file system we use: every socket call fails. With the older file system it also rejects a socket option the game sets before connecting.
  • Emscripten's POSIX socket proxy sends socket calls to a native helper and blocks the game until it answers; it has no select and no non-blocking sockets, and it is an open proxy anyone could use.
  • Our own Winsock over WebSocket in the platform layer, about 300 lines, with the behaviour the game expects from Windows.

We built the third. A non-blocking connect completes when the TCP connection behind it does. WSAAsyncSelect events arrive as window messages each time PeekMessage yields to the browser, with the Windows rules: FD_READ again only after a recv, FD_CLOSE only once the data has been read. One detail mattered more than it looks: socket handles rotate instead of reusing the lowest free number. The game closes its Login connection and immediately opens the Map one, and a late FD_CLOSE for the first would otherwise have closed the second.

The gateway. A small Node.js server with a single dependency turns each WebSocket into a TCP connection:

  • it completes the WebSocket handshake only once the TCP connection is up, so for the client "open" really means "connected", and a refused or silent server becomes a failed connect, as on Windows;
  • bytes pass through untouched: the game's own encryption and packet framing never change;
  • an allowlist maps the addresses the game knows (the Login from its command line, the Map address the Login sends) to the test servers; anything else is refused before a connection is made, and the World, Control and database servers are never reachable from outside;
  • it checks the page's Origin, limits connections per IP and handshakes per minute, and logs every connection but never its payload, which contains the login.

Data from the network reaches the game at its next yield, once per frame, which is also when the native client reads its sockets: once per turn of its loop. The same gateway served the native client first, through a small TCP-to-WebSocket tunnel, so the whole path was tested with the real game before the browser build could log in. The servers run only on my PC: my other devices reach them over a private network, and nothing is exposed to the internet.

War stories

CString through .... MSVC passes an MFC CString to a variadic function like printf as a raw copy of its single pointer member, so printf("%s", str) happens to work. Clang refuses to compile it, and if you silence the error it plants a trap instruction in its place. We tested every compiler flag and attribute combination against real MFC. With -fms-compatibility, clang passes a pointer to a copy: wrong text in 11 of 13 cases. With trivial_abi the text is right, but every call leaks the copy. The only correct fix was a cast at each call site. A small tool rewrote the 255 call sites mechanically, and the build now rejects new ones.

Loading in 80 seconds, then in 8. The first browser build took 80 seconds to reach the login screen. MFC's CArchive reads files in buffered blocks, and our first version read byte by byte through WasmFS. Matching MFC's buffering, plus read-ahead in CFile, brought it to 8 seconds, and entering the world dropped from 14–18 seconds to 0.4. Later, reading game files from OPFS in large blocks took the complete player experience, from clicking "Play" to the login screen, from 35–55 seconds down to 16–20.

From 50 fps to the display rate. The first working build ran at 50–60 fps. The fix was to present every finished frame, paced by the display, and to stop keeping CPU copies of resources the game never reads back (the city scene went from 335 MB of copies to 25 MB). After that the login screen ran at 277–280 fps, the refresh rate of the 279 Hz monitor, and the world at 150–250 fps. A cache of GL bindings later took one Firefox scene from 75 to 164 fps.

The unfair lock. For a while, Chromium sometimes froze at the login screen and Firefox never did. The cause was our EnterCriticalSection: a plain mutex lets the thread that releases it take it again straight away, so the main thread kept winning while the loader thread starved. The game, written against Windows, never expected that. A ticket lock, which serves waiters in arrival order, fixed it, and the test suite now measures the worst wait.

A bug Windows forgave. While recording a demo of two browsers dueling, both clients crashed two seconds into the fight. The cause was in the game, not in our layer: its anti-macro check erased entries from a std::map while walking it with a range-for, so the loop went on from a freed node. On Windows it went unnoticed; in WebAssembly it was an out-of-bounds access as soon as the players chained a few skills. The fix is a few lines, and one of the short list of commits we made to the game.

Memory. At the end of loading the client's WebAssembly heap is about 820 MB. That's fine on a desktop, and it's the main obstacle on an iPhone, where Safari closes a page that grows past a budget of a few hundred megabytes to about a gigabyte, without an error. That's the next problem to solve.

Testing a whole game

Unit tests don't tell you whether a game works. What we used instead:

  • An autopilot inside the client, behind a test-only switch: scripted scenes that log in, pick a character, walk into town, fight, meet another player. Each one ends with a single RESULT ok or RESULT fail: <reason>.

  • A suite with one command: input, locks, page behavior, login, character selection, town, combat and two browsers meeting each other, in headless Chromium and Firefox on the real GPU, with screenshot parity against Direct3D 9 for every scene. The final run: 13 of 13, and the two-player check 14 of 14 (each player sees the other and reads their chat line).

  • Game systems end to end against the test server and its database, with the client in the browser and a second one native at the same time:

    • combat, skills (including the server refusing a skill used before its cooldown ends), inventory and equipment, leveling up;
    • quests, NPC shops, mail, trading between players, parties and guilds;
    • entering and leaving a dungeon through its portal, a duel, a fort war;
    • after each run the database must hold exactly the state the client last showed. A full restart of servers and database must lose nothing.

    Each test prepares its characters from SQL first, so the runs can be repeated.

How it was built

The port, from the moment a coordinating session took over to the closing verification, took nearly 600 commits in about 32 hours. I didn't write most of that code. I coordinated a team of AI coding agents (Claude, in Claude Code), each working as a separate lane with its own worktree, folder and job:

Lane Job
graphics the D3D9 layer and screenshot parity
wasm base and test bench the browser build and the test suite
networking the gateway and the installer
text and game systems fonts, then the end-to-end tests
autopilot and audio the scripted scenes and DirectSound

A coordinator session merged each verified step into main and kept a single status file.

What made it work:

  • Objective measures only. Progress was counted in milestones reached, scenes matching Direct3D 9, files compiling to wasm32 and game systems green against the test server. Never in the number of checks or documents. The first prototype had looked busy and was at 5%.
  • Everything verified on the real game. No "it should work": a build, a scene, a log line, a database row.
  • Independent review, verified. At the end of every large piece, a second model (OpenAI's Codex) reviewed the change adversarially. Its findings were checked against the cited code before being accepted, because a confident review is often wrong. In the last two reviews, 13 findings were checked: 10 were real and fixed, 1 was rejected, and 2 concerned the legacy server code and were recorded for later.
  • Small details written down, not chased. The goal came first.

What didn't work as well: parallel lanes sometimes fixed the same thing twice, a shared gateway port went down under another lane's test, and a few results were claimed before they were checked. The fix was always the same: a single owner per folder, one test account per lane, and re-running the claim on the coordinator's side before merging.

What's next

The reusable part of this work, the platform layer, is being extracted into a library, Portico. The first module, the Direct3D 9 to WebGL2 layer, is done: it runs Microsoft's open-source DirectX samples unchanged (live demo) and is tested against real Direct3D 9. Win32, MFC, DirectSound, networking and the file installer will follow as modules. Portico is private for now: if you have an old Win32 or Direct3D 9 program you would like to see in a browser, get in touch. As far as I could find, nothing else lets you port an old Win32/Direct3D 9 C++ game to the browser from source, without rewriting it. There are excellent binary emulators and single-game ports, and a much smaller Direct3D 9 to WebGL project.

The game is not distributed in any form, and won't be: not its source code, not the WebAssembly build, not its assets, not an installer, and its servers stay a private test instance. It isn't mine to share, and the point was never this particular game: it was finding out whether twenty-year-old software can simply run in the browser.

It can.