
The browser client
How a 2006 game client ended up in a browser tab: Rust, WebAssembly and WebGPU, the game's files streamed instead of installed, a relay for the network, and the measured work that took a cold start from 25 seconds to 7.
Where it started
The client is benilla, an open-source reimplementation of the World of Warcraft 1.12.1 client written in Rust on the Bevy engine (by Sam, MIT or Apache-2.0). Upstream built a desktop client over 569 commits. It reads the original game's data formats and runs the original interface code, so it looks and behaves like the client people played in 2006.
We forked it and took it into the browser. The port so far:
| Commits on top of upstream | 209, between 7 and 12 October 2026 |
| Lines | about 104,000 added: 34,000 in our crates, 64,000 in patched third-party code |
| Crates | 25 (upstream has 19): the client plus a host layer, a browser storage layer, the relay, the web server, the wallet sign-in service and a test probe |
| First play in a browser | 7 October 2026: sign-in, realm list, character creation, Northshire, chat, logout, reload, at 90 fps |
| On its own server | since 14 October 2026 |
The stack
| Layer | What we use |
|---|---|
| Language | Rust, stable toolchain (no nightly features) |
| Engine | Bevy 0.18 |
| Graphics | wgpu 27 on WebGPU. No WebGL fallback: WebGPU is what makes it run at full speed |
| Target | wasm32-unknown-unknown through wasm-bindgen |
| Interface | Lua 5.1.5 compiled to WebAssembly, running the stock 1.12 interface code (FrameXML and GlueXML) and addons |
| Sound | the kira mixer on its own AudioWorklet thread |
| Storage | the browser's private file system (OPFS) for settings and the game-data cache |
| Network | WebSocket to our relay, which speaks the realm's own protocol |
You need Chrome or Edge 137+, or Firefox 141+. Besides WebGPU, the interface depends on WebAssembly exception handling: that's how Lua's error recovery works in a page. The Play page tests both in your browser.
What a browser doesn't have
A game client assumes things a web page can't do. Each one needed its own answer.
A scripting language written in C
The whole 1.12 interface is Lua. We compile Lua 5.1.5 itself to WebAssembly with clang, and map its setjmp/longjmp error handling onto WebAssembly exceptions. The stock interface code runs unchanged; a 30-check smoke test passes natively, in Node and in the browser.
Files on a disk
The 1.12.1 client reads 12 archives, about 3.6 GiB and 79,733 files. Nobody wants to download that before playing, so nothing is installed:
- The archives sit on a data host in 8 MiB pieces that never change. The client reads them with HTTP range requests, 16 KiB at a time, only the parts it needs.
- A memory cache and a prefetch queue with priorities fetch what you'll need next (the zone ahead, the city you're flying to) without ever making a frame wait for the network.
- A disk cache in the browser keeps what you've already loaded, so a second visit doesn't download it again.
A first visit to Northshire fetches about 125 to 130 MB. A return visit makes 3 to 10 requests and downloads almost nothing.
Raw network connections
Pages can't open TCP sockets, and the realm speaks TCP. The relay turns the page's WebSocket into the realm's protocol and back. It pumps each direction separately, turns Nagle off, pings every 30 seconds so proxies don't cut idle players, checks that connections come from our site, caps connections per network, and only ever connects to the realm. It adds 0.3 to 0.5 ms over a direct connection.
Threads
A page's code runs on one main thread. The game runs there: about a thousand engine systems a frame. Sound runs on an AudioWorklet (a second copy of the same WebAssembly module, mixing at 48 kHz on its own thread), and the disk cache on a worker. We also built a fully multithreaded build and an off-thread file decoder; both worked, and both are on the "tried and dropped" list below, with the numbers.
Graphics that don't freeze the page
Compiling GPU pipelines the normal way blocks the browser's GPU queue: 400 of them held it for 4.6 seconds in a bare test. We patched the graphics stack to compile them asynchronously, which freed the queue after 0.09 seconds.
Accounts and passwords
There are none to type. Phantom signs a sign-in message, the sign-in service checks it, maps the wallet to one realm account for good, and hands the client a fresh random password that works once. The client logs in with it on its own. See How it's built.
Tabs, settings, and the back button
- Your key bindings, interface layout and addon settings live in the browser and are saved every minute and when you leave the page.
- One tab per browser profile runs the game, so two tabs can't overwrite each other's settings.
- A tab you can't see gets no frames, so the game pauses there. The realm's movement checks were tuned so a paused tab isn't mistaken for a cheat.
Measured, before and after
Every change below was measured on both sides of the change. These are the ones that mattered.
Getting into the game
| Change | Before | After |
|---|---|---|
| The realm moved from a home PC to its own server | cold page to login screen 25.5 s | 7.3 s |
| Brotli instead of gzip for the client | 23.1 MB download | 12.9 MB (1.2 s to download, 2.9 s to start) |
| Memory reserved once per load instead of growing step by step | cold Stormwind entry 12.5 s | 8.1 s |
| City building files fetched in one batch | cold Stormwind entry 23.0 s | 14.3 s |
| Both loading fixes, Northshire | loading screen 25 to 50 s | 9.7 s |
| Disk cache in the browser | return visit 1,900 requests, 130 MB | 3 to 10 requests, about 0 MB |
| Asynchronous GPU pipelines | page blocked 851 ms per world entry | 86 ms |
| Game data from a data host instead of the home PC | page start to in world 27.0 s | 22.3 s |
On the live server, Enter World takes 9.1 seconds the first time in Northshire and 5.3 to 6.2 seconds after that.
Frames
| Change | Before | After |
|---|---|---|
| No tracing bookkeeping in the page (it leaked 24 MiB a minute) | frame work p50 10.9 ms | 9.8 ms, leak gone |
| Ground decals clipped to what's visible | walking in Northshire, slow-CPU test, p50 88 ms | 62 ms |
| Unused shadow maps dropped | GPU memory 359 MiB | 317 MiB |
| Idle 3D portraits stop re-binding every frame | 63 GPU bindings a frame | 0 |
The GPU isn't what limits the frame rate: a frame costs 0.2 to 0.4 ms of GPU time at 1080p against 11 to 17 ms of CPU.
Network and sound
| Change | Before | After |
|---|---|---|
| Relay pumps each direction separately | your moves while the server sends a burst: p50 741 ms | 0.27 ms |
| Moves and casts written before rendering | key press to packet p50 18.1 ms | 12.6 to 14.9 ms |
| Other players drawn from when they moved | visible hitches on steady movers 210 | 87 |
| The relay sees players' real addresses behind the tunnel | 16 of 40 test players admitted | 40 of 40 |
| Sound on its own AudioWorklet thread | interface sound latency 192 ms, 5 dropouts entering the world | 51 ms, none |
| Sound effects warmed up before you need them | 9 to 16% of first plays silent in a fresh browser | 0 in 3 of 4 runs |
The realm
| Change | Before | After |
|---|---|---|
| World updates every 50 ms instead of 100 | chat p95 102 ms, others' moves p95 202 ms | 54 ms, 153 ms, same CPU |
| More database connections and worker threads | stall after a restart's login rush 9.3 s | 1.2 s |
| The Mithril bridge reads accounts in batches | 250 players: a coin settled in p50 18.2 s and falling behind | 1.9 s, keeping up |
Tried and dropped
Measured, and not worth shipping yet:
| Idea | What we measured |
|---|---|
| WebAssembly threads | Works, but three workers on animation saved 0.2 to 0.7 ms of a 10.5 ms frame. About three weeks of work for 2 to 6%. |
| Decoding city files on a worker | The loading screen took 7.2 s either way: decoding was already under 0.6 s. |
| SIMD instructions | No gain (11.4 vs 12.5 ms in Northshire, 10.9 vs 10.8 ms in Stormwind). |
| A more aggressive optimizer pass | Not faster, and 3.4 MB bigger. |
| A different memory allocator | Slower loading. |
| Render bundles for static scenery | Same GPU time, no CPU saved. |
| An IndexedDB cache | Cold entry 80 to 94 s with it, 47 to 50 s without. Replaced by the OPFS cache. |
Where it runs
| Server | 4 vCPU, 16 GB, New Jersey: the realm, its database, the Mithril bridge, the web services and this website |
| Exposure | No public ports except SSH. Every service listens on the machine itself; players arrive through a Cloudflare tunnel |
| Address | play.wowonchain.com |
| Game data | a separate host in Amsterdam; every archive checked equal to its pieces |
| Backups | a nightly database dump, kept 14 days, plus the provider's server backups |
| The move | 14 October 2026, 10 minutes start to finish: every character, account and ledger row came across, and game and chain still agreed to the copper |
On the server, the world updates every 50 ms (p50 50, p99 51), and the whole stack uses under 2 GB of its 16 with a player online.
What's next for the client
- A smaller disk cache. A capped cache that keeps only the blocks you actually use (5 GiB on disk down to 34 MiB in our test) is built and ships next.
- The movement clock. The browser's movement clock drifts slightly against the server's, so a few clock-based anticheat checks log for review instead of kicking until the client's fix lands. The checks that catch speed hacks, teleports, flying and wall climbing kick today.
- More browsers. Chrome, Edge and Firefox today; Safari as its WebGPU support matures.
- Game data closer to you. One data host today; more regions as the playerbase grows.
- More cores. The game runs on one CPU thread, which a modern desktop handles easily; WebAssembly threads are proven to work and wait until they're worth it.
- Room to grow. The Mithril bridge settles for about 230 to 250 players playing at once with one batch in flight; parallel lanes take it further when the realm needs it.
How each part was tested: Tested.