Need for Speed World is a Windows game. It shipped in 2010 as an x86 executable that talks to Direct3D 9, Win32 and a handful of Microsoft DLLs. There is no source code, no Linux port, no web version. The servers were shut down years ago and the game now lives on through community servers.
We run it in Chrome. Not in a VM and not through streaming: the game’s own machine code is translated ahead of time to WebAssembly, its Direct3D 9 calls are drawn with WebGPU, and the Windows API it expects is re-implemented underneath it. The project is open source and will live at nfsworld.online.
The first time it drove through the city on the HIGH preset, it ran at 10–11 fps.
The goal was never “playable for a browser”. It was: never below 60 fps, and aim for 144. This post is about how we got from the first number towards the second, and what we learned about where time really goes when a Windows game runs inside a browser tab.
How the game gets into the browser#
Before talking about speed, it helps to see the layers. Every frame the game renders passes through all of them.
- The lifter reads the executable and its DLLs and turns every x86 function into a C function. Registers become variables, flags become variables, memory is a big linear array. Eight modules come out as about 2.1 GB of generated C.
- clang (wasi-sdk) compiles that into one WebAssembly module of a few hundred megabytes, which brotli shrinks to about 50 MB on the wire.
- The runtime is the part we wrote: the Windows API (threads, events, critical sections, files, sockets), COM, and a Direct3D 9 implementation that records draw calls and hands them to the page, where WebGPU draws them.
- Each Windows thread the game creates becomes a Web Worker sharing the same memory.
A rule we kept the whole way: graphics must stay 1:1 with the original. No lowered resolution, no skipped passes, no “close enough” shaders. Every speed-up had to leave the picture exactly as it was.
Rule one: measure, don’t guess#
The first instinct with a slow WebGPU port is “the GPU path is slow”. We measured instead, and that guess was wrong.
| Scene (HIGH) | Frame time | Our draw recording | Page encoding | GPU |
|---|---|---|---|---|
| City, driving | 42–91 ms | 1.5–3.9 ms | ~4–7 ms | ~14 ms |
The GPU finished its work in about 14 ms: enough for 70 fps. Our draw-call recording took a few milliseconds. The page encoded commands in 4–7 ms. Yet a frame took 42–91 ms. The game’s own thread was the bottleneck, and the time was somewhere inside the translated game code and the Windows layer under it.
To see inside, we built a small toolchain:
perfrunstarts a separate Chrome, logs in to a local test server, writes the graphics preset, drives into the world, and records CPU profiles of every worker and the page for a fixed time. It also takes a lossless screenshot of a fixed screen at a fixed viewport, so every run can be compared pixel by pixel.perfsumturns those profiles into tables: time per thread, split into running, sleeping, waiting for I/O and waiting for a lock, plus the hottest functions and their callers.imgdiffcompares two screenshots. Two runs of the same build differ in about 0.13% of pixels (engine idle, particles). A broken draw shows up as far more. Any optimisation that went above the noise was not taken.
One unglamorous lesson from measuring: the dev machine is a fanless MacBook Air M1. After a long build it throttles itself, and the same build runs at 71 fps cold and 31 fps warm. Every comparison in this post was made back to back, at the same temperature. Before we learned that, a few “regressions” were just a hot laptop.
What was actually slow#
The profiles told a story none of us expected. On HIGH, in the city, per frame:
Two things stood out.
The game was waiting on itself. The runtime ran all guest code under one big lock: only one game thread could execute at a time. On Windows the streaming loader, audio mixer, network thread and a worker pool run in parallel with the main thread. Here they took turns, and about 40% of every frame was the main thread waiting for them.
The Windows layer was hit hundreds of thousands of times per second. The game’s C runtime calls
TlsGetValue, GetLastError and SetLastError on almost every allocation and string operation. Each call went
through our generic import dispatcher: a fairness yield, statistics, and a chain of string comparisons to find
the handler. Individually cheap. At 380,000 calls a second, not cheap at all.
Step 1: a hot path through the Windows layer#
The first fixes were the boring, safe ones.
- Direct handlers for hot imports. The few dozen functions that make up most calls (critical sections,
TLS, last error,
Interlocked*,QueryPerformanceCounter, events) are now bound straight to their handler when the import is resolved. String comparisons on the main thread went from 10–15% of its time to about 1%. - A cheaper
RDTSC. The game’s audio mixer times every stage of its work with the x86 cycle counter. Our version went through WASI’sclock_time_get, which costs a BigInt and a DataView per call in JavaScript. That alone was a third of the audio thread. Reading the clock directly fixed it. - Native
memcpy/memset/strlen. The game calls the C runtime’s copy routines through its import table. The translated SSE copy loop was ~5% of the main thread. Replacing those few functions with native WebAssembly versions behaves identically and costs a fraction.
HIGH went from 16.7 to 23.8 fps standing in the world. Better, but nowhere near 60. The real problem was still the lock.
A detour: the clock the game didn’t want cached#
RDTSC was still a third of the audio thread after that, so we tried caching it: read the real clock every
16 calls and return the cached value plus one tick in between. Much faster, and the game immediately broke:
“Login Failed”.
NFS World times parts of its login handshake with RDTSC. With a counter that barely moved between real reads,
the game concluded that time had stopped. We reverted it. The lesson went into our notes as a rule: RDTSC
must advance on every read. Speed tricks that change what the game observes are not optimisations, they are
behaviour changes.
Step 2: let the game run in parallel#
This was the big one. The idea: the lock should protect our runtime, not the game. Guest code runs freely on every worker at the same time, like it would on a multi-core PC. A thread takes the lock only when it enters the runtime (a Windows or COM call) and releases it on the way back.
Simple to say. Making it correct took three pieces.
Locked x86 instructions become atomics. x86 code synchronises with lock cmpxchg, lock xadd,
xchg and friends. Once two threads really run at once, those must really be atomic, so the lifter now emits
WebAssembly atomics for every locked instruction.
x86 is strongly ordered; ARM and WebAssembly are not. On x86 a store from one core is seen by others in order. Code written for x86 relies on that without saying so. Spin-wait loops in particular.
That one cost us a hang. The game’s worker pool meets at a barrier: each worker spins until a shared counter reaches a target. In the translated code that is a plain loop reading memory. With no call left inside the loop, clang did what any optimiser may do with ordinary memory: it loaded the counter once, before the loop. The workers spun forever on a value that would never change.
// Emitted inside every lifted spin-wait loop.#define GUEST_SPIN(c_) do { \ if (g_gil_held) \ guest_spin(c_); /* old model: yield */ \ else /* parallel: memory may change */ \ __asm__ __volatile__("" ::: "memory"); \} while (0)An empty asm with a memory clobber costs nothing at runtime. It tells the compiler that memory may have
changed, so the loop reads the counter every time, like the x86 chip did.
The hottest imports must not take the lock at all. After going parallel, the profile showed workers waking
from an event and then queueing for the runtime lock just to return from WaitForSingleObject, with the main
thread’s next Direct3D call stuck behind them. On Windows, critical sections, TLS, last error, the
performance counter and event signalling don’t take a global lock either. So now they don’t here: each thread
keeps a small cache of the critical sections it uses, and these ~200,000 calls a second bypass the runtime lock
entirely.
The old model is still one environment variable away (NFSW_PARALLEL=0), which made a clean A/B possible.
Same build, same temperature, back to back:
| Old model (one lock) | Parallel | |
|---|---|---|
| HIGH | 19.3 fps | 31.5 fps |
| MEDIUM | 27.8 fps | 43.5 fps |
| Main thread waiting for the lock, per frame | 15–29 ms | ~1 ms |
That’s on a warm, throttled laptop. On a cool machine the same build reaches 59–71 fps on HIGH and 82 fps on MEDIUM. A 60-second drive on HIGH: no crash, no hang, no watchdog warning.
Step 3: making the translated code itself faster#
Everything so far was about the layers around the game. The last step went into the generated code itself.
- Registers in locals. Keeping x86 registers in C locals instead of a CPU struct in memory lets clang keep them in WebAssembly locals, and the engine in machine registers. We extended that to the XMM (SSE) registers: stored back before a call, reloaded after it.
- x87 stack in locals. The 80-bit floating point stack of the x87 FPU is emulated; within a basic block its slots now live in locals too.
- Flags only when someone reads them. Every x86 arithmetic instruction sets flags; most are never read. An inter-procedural analysis now proves which flags a function’s callers actually use, so the lifter stops computing the rest.
Counted in WebAssembly instructions, the hottest functions shrank by 5% to 46%. Measured, alternating old and new builds at the same temperature, the new one was 31% faster in a heavier scene: the game thread went from 18.7 to 13.5 ms a frame. The download got smaller too, 50.9 to 48.8 MB.
It also produced our favourite bug of the project: one of our own C macros, the one that compares two floating
point values, called its temporary _x, and the new x87 locals are called _x[...]. Two hundred files didn’t
compile. The fix was a rename; the lasting part is a test in our ISA suite that catches the pattern.
The bug the fast machine hid#
One more problem only showed up when the user, not the benchmark, played. Clicking Enter World in the garage, the screen froze for 20 seconds while the last two seconds of the menu music looped.
It never happened in our measuring Chrome, because that Chrome had everything cached. With an empty profile, a first visit, it happened every time. The cause:
- Every Windows thread is a Web Worker, and the page allowed at most 24 of them, a limit inherited from an earlier project, meant to save a phone’s memory.
- Our runtime’s own threads take about 7. Entering the world, NFS World runs 18 at once. That is 25.
- The 25th
CreateThreadwaited for a free worker while holding the runtime lock. Every other thread stopped, the sound thread included, and the thread whose exit would have freed a worker couldn’t exit.
The looping music was the most honest part: a DirectSound buffer that nobody refills plays what is in it, over and over. Real Windows does exactly that when a game hangs.
The fix: no artificial limit (a worker is still only started when a thread needs it), and CreateThread
gives the lock up while it waits, as no CreateThread on Windows stops the other threads. A first visit now
shows the loading screen after 3 seconds and the world after about 10.
Where we are#
From 10–11 fps while driving on HIGH, and 16.7 standing still, to 76–81 fps on the same preset: almost five times faster, without changing a pixel of what the game draws. MEDIUM went from 39.7 to around 100 fps.
The GPU is no longer the question: on HIGH it needs a median of 6.6 ms a frame, enough for about 150 fps. What still limits the frame rate is the game’s own thread, and that is where the next steps are:
- Tie the frame rate to the display. The game currently renders ~81 frames a second for a 60 Hz screen; the page shows only the newest. Waiting for the display’s refresh, as Windows does with vsync on, would free about a quarter of the CPU on a laptop, with no visual change.
- SSE as vector code. The single hottest function, 11% of the game thread, is SSE math lifted one lane at a time; WebAssembly has SIMD of its own.
- 144 fps needs a 6.9 ms frame. We are at ~12. It’s a long way, but no longer a different universe.
What we learned#
- Profile the whole stack, not the part you suspect. We expected the WebGPU path to be slow. It was a lock.
- Know your measuring instrument. A fanless laptop halves its speed when warm. Same temperature, back to back, or the numbers lie.
- Measure the first visit too. A warm cache hid a 20-second freeze that every new player would have seen.
- Every speed-up must be invisible. Pixel diffs against a baseline caught what eyes would miss. If a change moved the picture above noise, it didn’t go in.
- Emulate behaviour, not shortcuts. Caching the clock was faster and broke login. Atomics, memory barriers,
lock-free imports, a
CreateThreadthat doesn’t stop the world: each one is what the real CPU and the real Windows already do. - x86 assumptions hide in plain loops. Strong memory ordering is a property of the chip. When you move code off it, you have to put that property back by hand.
The game is open source and donation-based, and the browser build will be at nfsworld.online. The work towards 144 fps continues.