./jerome{m}

~/Experiments/Deus Ex in the Browser.md · experiment · 19 min · updated

Deus Ex in the Browser

2026 · JavaScript · Rust · WebAssembly · three.js · WebGL2 · local only · BSP notes · Debugging notes

The Deus Ex intro cinematic running in the browser runtime

Deus Ex in the browser is a runtime that loads the original 2000 Deus Ex (Game of the Year Edition) straight from a local install and runs it in a browser tab. It is not a remake, and nothing is converted ahead of time. The game's own code runs: a JavaScript reader parses the Unreal Engine 1 packages, and the UnrealScript bytecode inside them, which holds the gameplay, is executed by an interpreter written in Rust and compiled to WebAssembly. What is rebuilt is the engine underneath: the native C++ the scripts call into, translated into Rust and JavaScript, with three.js drawing the result on WebGL2. Maps, scripts, textures, meshes, sounds and music all come from your own copy, read in place through the folder picker. Nothing is uploaded.

That is also why there is no demo link at the bottom of this page. The runtime is useless without the original game data, and that data stays on your disk. The pictures and clips below were captured on my machine on October 15, 2026.

Updated October 28. In the two weeks since this page first went up, the presentation, gameplay and campaign stages have all closed. The page now starts like the original, saves and loads in its format, and has run the campaign from New Game to an ending in one session. What changed is below.

Tonnochi Road, Hong Kong: a Luminous Path member walks up — "You are an unfamiliar face on Tonnochi Road" — and the game's own conversation camera takes over. Recorded from headless Chromium at 1280×720.

One rule: 100% original

The project has a single rule, and everything else falls out of it: every behaviour must come from evidence. That means the original data, its bytecode, analysis of its native code, or reproducible measurements of the original game running. Lookalike substitutes are banned. Passing our own tests does not count as evidence, and anything that has not been verified stays labelled unsupported.

UnrealScript is the easier half. The bytecode ships inside the packages, so an interpreter that follows it faithfully gets the gameplay logic without rewriting any of it, as far as the engine beneath it reaches. The hard half is that engine: the native C++ the scripts call into for physics, rendering, lighting, fire and water textures, music, the UI and the HUD, compiled for 32-bit x86 in 2000 and shipped as DLLs. That code has to be translated, and translation is exactly where lookalikes creep in.

Oracles: how you prove a translation is right

The original DLLs are the authority, each pinned by its SHA-256. They never run in the browser — the runtime does not execute Windows binaries. They run on the side, as references the translation has to match.

  • Native oracles. Python harnesses load the original function bodies into the Unicorn CPU emulator, feed them the same inputs as the JavaScript or Rust translation, and diff the results byte for byte. Planted wrong bits and mutants have to be caught, or the oracle itself is not trusted. The repository has 372 oracle scripts and 677 validators.
  • Captures of the running original. DeusEx.exe runs under CrossOver on my Mac and, since October 20, under Wine on a Linux machine as well. Both sides get the same console inputs — ghost, ShowHud False, an exact location and view rotation — the level is frozen with bPlayersOnly, and its clock is held with TimeDilation 0.001. Raw 1280×720 frames are then compared on mean absolute difference, Sobel edge recall and precision, and a 16×9 luminance grid. The October 7 pass covered 26 maps and 130 views, with 124 matching after a fix.
  • Live scenes. A small tool attaches to the original like a debugger and breaks on ULevel::Tick. Each tick it records the time delta, the clock, the C runtime's random-number state and the fields of the watched actors, and since Stage 5 the AI events and every object the game creates. The browser then replays exactly those inputs. On three maps, every watched actor (59/59, 20/20 and 50/50) agrees tick by tick for ten seconds, and a sweep runs 28 maps for 1,200 ticks each; its latest replay agrees on 957 of 989 watched actors.
  • State digests. In headless runs of Training and Liberty Island, the JavaScript VM and the Rust VM produce the same state digest at all 30 checkpoints, one every ten ticks, and the worker build produces the same digests and frames as the inline one.
  • Placed chains. For the campaign, a mission's required steps (conversations and their choices, computers, keypads, map exits) run from the same save in the browser and in the original under Wine. The flags, door states and travel text are compared at every snapshot.
byte-for-byte diffnoyesOriginal DLLs, SHA-256pinnedUnicorn harnessTranslation in JS orRustMatch?DeusEx.exe under WineFrame captures andtick tracesBrowser runtimeBehaviour acceptedbyte-for-byte diffnoyesOriginal DLLs,SHA-256 pinnedUnicorn harnessTranslation in JSor RustMatch?DeusEx.exe underWineFrame captures andtick tracesBrowser runtimeBehaviour accepted
diagramhover a box to trace it · click to zoomtap to zoom and pan

Working this way turns up things you would never think to fake:

  • The x87 FPU control word was read inside the running original, per phase of the frame (0x007f, 0x027f, 0x037f), because rounding and precision change what the game computes.
  • printf and atof behaviour comes from the game's own MSVCRT.dll under Wine: 290k printf rows and 11.5k atof rows, so the options menus print numbers exactly the way the original does.
  • The x87 FCOS instruction is not correctly rounded at 5,820 of 44,101 tested inputs, and the stored reverb coefficients still match the model.
  • A half-pixel offset between the two images was traced to Render.dll's FX15 = (SizeX + 1.0001)·0.5 and D3DDrv's −0.5 vertex offset. The browser now reproduces it.
  • When a colour argument is omitted, one UI window call leaves the alpha byte holding whatever sat in a reused argument slot: the high byte of the caller's stack-frame address.
  • Doors and lifts block shots, moves and spawns where they were last drawn. Collision reads the moving-brush model the renderer updates, in the original and now in the browser.
  • A breakable door's fragments are placed the way Engine.dll's FindSpot places them, including its unordered compare, which rejects a NaN candidate.

Seven weeks, 1,624 commits

The first commit landed on September 9, 2026 with the message Render original Deus Ex training map in the browser. By October 28 the repository had 1,624 commits across 45 days of work, 34,400 lines of Rust, about 190k lines of JavaScript in the runtime (343k with the tooling and tests), and 396 design documents. When this page first went up on October 15, it had 995.

The work is staged, and a stage only closes when its acceptance gate passes. Since Stage 4, each gate is a finite list of criteria that I sign off before the stage closes:

StageScopeStatus
0Validate every packagedone
1Render Training with an inspection cameradone
1.5The original static lightingdone
2A small playable slicedelivered
3Script and native runtime, object lifecycledone
4Presentationdone Oct 17: 70 of 70 criteria
5Gameplay systems: AI, combat, stealth, RPGdone Oct 21: 50 of 50 scenarios
6Saves, map travel, the full campaigndone Oct 26: 54 of 54 rows
7Browser compatibility, performance, reliabilityin progress
DateMilestone
Sep 9First commit renders the Training map; static lighting and the first playable slice land the same day
Sep 17A shared Unicorn probe harness
Sep 20The first Rust kernel compiled to wasm: mesh posing
Sep 27Stage 3 gate closed: 406 validators and 545/545 unit tests pass
Sep 28All-maps census: 83/83 single-player maps reach Stage 3; mesh lighting, moving lights, mirror floors in Training
Sep 30120 fps becomes the target; the Rust interpreter becomes the default; the GPU mesh path
Oct 1Conversation camera and lip sync
Oct 2Every static mirror, Fire.dll fire and water, animated textures and lights, particles, coronas, volumetric fog, lasers, ambient sound; 77/83 maps clean in the conversation census
Oct 4BSP occlusion in Rust; the simulation moves to a worker by default
Oct 6Galaxy music in an AudioWorklet, sound effects and reverb through Galaxy's own mixer, decals; visual comparison on 14 maps
Oct 726 maps compared; mirror and sky child frames; mesh LOD; security computers in Play
Oct 8Options menus
Oct 11Camera views as full frames; CRT shaders
Oct 13Live scenes compared tick by tick; a 28-map sweep
Oct 14Frame projection bit-exact in the vertex shader; security camera views compared with the original
Oct 15Firefox defaults to the inline simulation
Oct 16Speech through Galaxy's own voices, down to its output callback
Oct 17Stage 4 gate closed: 70 of 70 in one run
Oct 20The original captured under Wine on a Linux machine
Oct 21Stage 5 gate closed: 50 of 50; the page starts as the original does, from the splash to the main menu
Oct 22Map travel, saves and loads in the original's format; the intro, credits and endings from the menu
Oct 23Stage 6 criteria adopted; all 198 map transitions arrive
Oct 24Every mission's conversations run; the three endings from their in-game triggers
Oct 25One session from New Game to an ending: 86 map changes
Oct 26Stage 6 closed: 54 of 54; one script process kept across map changes
Oct 27One LoadMap per arrival, as in the original; path search in Rust
Oct 28Stage 7 criteria adopted; a lost WebGL context recovers as D3DDrv does

Architecture

Simulation workerfolder picker, readin placesnapshot,transferred buffersMessageChannelYour Deus Ex install:219 packagesPackage reader, JSObject memory: oneWebAssembly.Memoryscript-vm, Rust towasmPage: three.js onWebGL2GPU mesh passesAudioWorklet:galaxy-kernelSimulation workerfolder picker, readin placesnapshot,transferred buffersMessageChannelYour Deus Exinstall: 219packagesPackage reader, JSObject memory: oneWebAssembly.Memoryscript-vm, Rust towasmPage: three.js onWebGL2GPU mesh passesAudioWorklet:galaxy-kernel
diagramhover a box to trace it · click to zoomtap to zoom and pan

The package reader is plain JavaScript, shared between Node and the browser. The install holds 219 packages — 88 maps, 38 object and script packages, 56 texture packages, 35 music packages and 2 sound packages — with 1,958,568 visible triangles across the maps. Training alone is 70 textures and 24,427 triangles.

Object memory is a single WebAssembly.Memory holding every object's property image, the interpreter's frames and statics, and the lowered script code, with object references stored as handles, so the Rust side reads and writes game state without copying it. Names and strings still live on the JavaScript side.

Scripts run from the original bytecode, translated on the fly. The first time a function is needed, its bytecode is read from the package and decoded into a tree, which becomes two things: JavaScript closures, the reference interpreter, and a compact word code for the Rust interpreter, which is the default. Anything the Rust side cannot evaluate yet drops back to the JavaScript closure for that one expression, so every function runs either way.

Three Rust crates compile to wasm32:

CrateLinesWhat it runs
script-vm25,458The UnrealScript VM, tick dispatch, physics, path search and AI events, BSP occlusion, render and light scans, particles, Fire.dll, fog, lip sync
galaxy-kernel6,738The Galaxy music and sound engine, down to the output callback and the ring that streams speech
lodmesh-kernel2,204Mesh posing, including the classic, non-LOD meshes of the logo map

Natives not yet moved to Rust are JavaScript functions the interpreter calls through imports, and each port keeps its JavaScript or oracle reference behind a switch, a URL parameter or an environment variable, so the two can be compared on demand.

Why Rust

On September 29, UNATCO Island ran at about 46 to 50 ms per frame, and the simulation tick was 25 to 29 ms of it. The profile was damning: about 300 ns per expression node, 116,000 receiver-to-instance lookups per tick, and 2,584 of the 2,586 actor objects stuck in V8's dictionary mode, so every lookup was a hash. Exact x87 arithmetic also needed BigInt in JavaScript where Rust has plain u128. After the port the tick dropped to 13.5–14 ms, against about 25 ms in JavaScript. Almost every script statement now runs in Rust; most of what is left of a tick is the engine code around the scripts, natives and physics glue still in JavaScript, which is moving to Rust port by port.

Why a worker, and why the GPU

With the simulation on the main thread, a frame took 25.5–27.5 ms against 12.9 ms without it. The tick, HUD, scans, BSP occlusion and Galaxy updates now run in a module worker that posts a plain-data snapshot with transferred buffers, at the cost of one frame of latency. Mesh posing moved to WebGL2 passes for the same reason: the frame had 1.46 ms of GPU work against 30.6 ms of host work. The GPU path is bit-exact with the CPU kernels, down to a barrier that stops ANGLE on Metal from contracting multiplies and adds into fused operations.

Galaxy, the game's music engine, runs in an AudioWorklet. The page hands one end of a MessageChannel to the worklet and the other to the worker, so music costs the page nothing per frame: each 20 ms render takes 135 µs at the median.

The places

Every frame and clip here is the browser runtime, captured on October 15 from headless Chromium on Metal (stills at 1920×1080, clips at 1280×720) with the viewer's page chrome hidden. The HUD, location titles, infolinks and conversation letterboxes are all the game's own. Hover a frame to swap the dither for the real thing, or click it to open the full 1920×1080 frame and step through the rest with the arrow keys.

The intro: Bob Page and Walton Simons, cut together by the game's own conversation camera, lip sync included.

The UNATCO training lobby with its mirror floor and the location title Liberty Island at night from the dock, with the statue and the Manhattan skyline

Training's lobby, the first map that ever rendered, now with its mirror floor · Liberty Island from the dock, statue and skyline included.

Alex Jacobson's infolink typing in on the Liberty Island dock A UNATCO trooper speaking at the door of UNATCO headquarters

Alex Jacobson's infolink typing itself in on the dock · a UNATCO trooper at the headquarters door.

Anna Navarre in a letterboxed conversation at Battery Park The Hell's Kitchen subway station with its location title

Anna Navarre at Battery Park, in a letterboxed conversation · the subway station under Hell's Kitchen.

The MJ12 cell with the title Location: Unknown, Time: Unknown The MJ12 helicopter base on top of the VersaLife building in Hong Kong

Location: unknown — the MJ12 cell · then Hong Kong, on the helipad atop the VersaLife building.

Tonnochi Road in the Wan Chai district with neon signs and a pedestrian The Wan Chai market entrance with its location title and the Canal Road sign

Tonnochi Road · the Wan Chai market.

The VersaLife lobby doors under the company logo Tracer Tong's laboratory with Tong at work

VersaLife's front doors · Tracer Tong's laboratory.

The Chateau DuClare garden outside Paris at night Toby Atanwe in a letterboxed conversation at Everett's

The Chateau DuClare garden outside Paris · Toby Atanwe, offering a ride to Everett.

A rescued scientist in conversation at Vandenberg Area 51 with an incoming transmission and the location title

A rescued scientist at Vandenberg · Area 51, with an incoming transmission.

From the main menu to the credits

When this page first went up, every single-player map loaded and ticked, and its conversations played to their end. The game around the maps was missing: no menu, no saves, no way from one map to the next, and no proof that a fight or a stealth approach behaved as it does in the original. Stages 5 and 6 added that, and both closed in the two weeks since.

Gameplay (Stage 5, closed October 21) was defined as 50 scenarios on the three Training maps and Liberty Island, across combat, stealth, conversations, exploration and alternate routes. A pistol shot kills a Terrorist. A Terrorist sees the player standing in the light and not in the shadow. A broken laser beam raises the alarm, and the key from Filben opens the Statue's front doors. Each scenario runs in the browser through the player's own controls and is replayed tick by tick against a trace of the original given the same inputs. The official run took 2 h 21 min: 118 replays of the original's traces, plus two scenes where Engine.dll itself runs on the browser's recorded inputs. All 50 pass.

The campaign (Stage 6, closed October 26) needed the rest of the game:

  • The page starts as the original does: the splash, the logo map with its camera flight, and the main menu on Escape. The debugging tools moved behind ?debug=1.
  • Map travel follows the original's chain from SendPlayer to the next level's login, and a map you return to within a mission is restored as you left it. All 198 map exits, teleporters and scripted travels of the single-player maps arrive.
  • Saves use the original's format, kept in the browser or in a folder you pick. All 41 desktop saves from other players' games, found online, load, and the original under Wine loads saves the browser wrote. Settings persist the way the original writes its INI files.
  • Every mission's conversations start and run to their end: 1,520 of 1,622 from a cold open of their map, the other 102 stopping only where the original's own code or data refuses them, and 823 more from the state of 34 real save slots.
  • Each mission's required chain (its conversations and choices, computers, keypads and exits) runs from the same save in the browser and in the original, and the flags, goals, notes and travel text must match at the mission's exit. Both sides of every choice that carries into a later mission are checked where that mission reads it, 66 cases in all. The three endings run from their in-game triggers through the credits.
  • On October 25, one browser session went from New Game through every mission's chain to the third ending: 86 map changes, with each mission's exit text equal to the original's.

The limit is in how those chains run. The player is placed at each step, as in the gameplay scenarios, and the walking and fighting between steps are not part of the comparison. Guards are walked past in God mode. What the original itself varies between runs (a random draw, a fight's outcome, a door that closes on its own) is checked by presence or not at all. That was my decision: it proves the campaign can be finished and that its choices carry, not that every fight plays out as it would in the original. Stage 6 also closed on each row's last run, without the single combined gate run that Stages 4 and 5 had.

The campaign turned up its own share of things nobody would think to fake:

  • Rendering keeps the AI awake. The original's BSP occlusion pass stamps the camera's zone every frame, and pawns near the player stay awake because of it. Until the headless runs did the same, the staff at UNATCO headquarters never barked.
  • A flag's spelling depends on history. The engine spells a name the way it was first registered in the process, so a session started from New Game and a fresh load of a save can disagree on a flag's case.
  • Some of the shipped data leads nowhere. The numbering skips missions 7 and 13, which have no maps or scripts of their own, and mission 9 reads GreenKnowsAboutDowd only together with suprisePoint, a flag nothing in the game ever sets. The browser has to do exactly what the original does with both.

Chasing 120 fps

The target is 120 fps — 8.3 ms per frame — on an Apple M2 Pro at 1440×960. It has been a long road, and most of it is in the measure first school: interleaved A/B runs, medians, and no fix without a number behind it.

WhenWhereBefore → after
Sep 26Training, JavaScript tuning29.7 → 17.3 ms per frame
Sep 28Liberty Island, walking86 → 57 ms per frame
Sep 30Liberty Island tick, JavaScript VM → Rust VM~25 → 13.5–14 ms
Oct 4Paris club tunnel, BSP occlusion15 → 59 fps
Oct 4Firefox, light-map uploads with the mirror floor in view38.9 → 0.31 ms per frame
Oct 14Worker, one temporary list per BSP leaf removed10.2–10.6 → 7.9–8.3 ms per frame (median)
Oct 14Chromium worker heap after the leak fixes601 / 818 / 1,124 → 387 / 305 / 386 MB
Oct 26Map change in Chromium, one script process kept across travelIsland 6.9 → 4.1 s, Training 4.1 → 1.5 s
Oct 27Island worker on Linux, path search moved to Rust14.6 → 12.2 ms per frame (median); 150–210 ms stalls gone
Oct 28Firefox inline GC heap, 5.5 M light-map texel objects removed596 → 404 MB

The leak hunt read like a detective story. On Paris's Everett map, three.js called createBuffer 14,657 times and deleteBuffer 20 times in five minutes, and the previous map's runtime stayed alive after every map change. The fix released its handles and deliberately pushed two V8 retainers into dictionary mode — the same mode that had been the cost on September 29.

Firefox had one more surprise: Gecko runs a full compacting garbage collection on an idle worker about every 5.5 seconds. Even after the leak fix those pauses still cost 208–398 ms on my Mac, down from 1.1–2.4 s. The inline simulation had no pause over 200 ms at the same 111 fps, so Firefox now defaults to inline, and the worker stays one URL switch away. Inline is not free either: on the Island it draws six to eight frames of 100 to 133 ms a minute, from Firefox's incremental collections. The pauses follow the size of the live heap, and most of that is the pointer-rich script object model. Moving it out of the GC heap is planned, and parked for now.

Map changes were the other slow spot. Each arrival used to load and link every script class again, which the original's process does once, and run the level's startup twice. The original keeps about 1,000 classes for the whole session and collects only the departing level's own. The browser now does the same, down to the spawned-name counters, and each arrival is a single LoadMap.

Stage 7 turned the target into a gate on October 28: a median of at most 8.3 ms of host work per frame on each map of a fixed set, in Chrome on the reference Mac, and a 60 fps floor in every supported browser. It does not pass yet. The newest frame numbers come from the Linux machine, where the Island's worker frame is 12.2 ms at the median, and the Mac has not been re-measured since early October.

Reliability follows the original too. When the WebGL context is lost, the browser applies D3DDrv's rule for a lost device: Lock sleeps in 100 ms steps until the device comes back. Nothing is simulated meanwhile, and the first tick after the restore carries the whole wait. The frames after a loss equal a run with no loss, byte for byte.

Built with agents

I could not have done this alone in seven weeks, and I didn't. 1,413 of the 1,624 commits carry an AI co-author line, 1,385 of them Claude's, from September 13 on; Devin and Cursor also appear on a few dozen in the second week. The agents read the bytecode, wrote the Unicorn harnesses, ported the natives, ran the captures against the original and wrote most of the 396 docs. Since mid-October they drive the original on two machines, and run stage gates that take more than two hours each. My job looked more like a lead's: set the rule, pick what to attack next, adopt each stage's criteria, and decide when a piece of evidence was good enough.

The fidelity rule is what makes that work. An agent is very good at producing something that looks right, and looking right is exactly what the rule bans. Every claim has to land on an oracle the agent did not write — the original DLL in an emulator, a frame from the running game, a tick trace from the real process. An agent can be wrong as often as it likes; it cannot be wrong and still pass.

My favourite part was the moment it started to feel alive: not a render of a map any more, but the game itself running in a tab.

Liberty Island: the location title types in, JC walks the dock, and Alex Jacobson's infolink arrives. The funniest was a night when Claude was playing the game on its own, going back and forth between the native and the web versions in the Paris club to compare them, and woke me up.

It feels like a new era for reverse engineering. Reimplementations like this used to be multi-year community efforts; now one person with agents can take a closed binary apart, measure it, and rebuild its behaviour in the open in a few weeks. I see people pointing the same kind of tooling at much bigger closed targets, from the PS5 to Photoshop, and that raises real questions about proprietary software. If the behaviour of a binary can be recovered this cheaply, what does closed source still protect — the code, or mostly the licence, the assets and the law? This project stays on the careful side of that line: it ships none of the original data, runs only on your own copy, and never executes the original binaries in the browser. But the question is not going away.

Where it stands

Stages 0 to 6 are closed. The page starts like the original, all 50 gameplay scenarios on Training and Liberty Island match the original's traces, saves go both ways between the browser and the desktop game, and the campaign runs from New Game to an ending, with all three endings playing through to the credits. Stage 4's gate went from 44 of 70 on October 15 to 70 of 70 two days later.

Stage 7 is what is left: browser compatibility, performance and reliability. Its criteria were adopted on October 28: 34 rows and 3 exclusions. They cover Chrome on macOS and Linux, Firefox on both, and Safari on macOS, which has never been run. Windows is not covered, because neither machine runs it. The 120 fps gate is the largest piece of work, then Firefox's GC pauses, then long sessions and repeated map changes that must stay stable.

It is still ongoing, rough edges and open bugs included. The campaign proof places the player at each step, so free play between the steps is checked only where the gameplay scenarios reach. It is also the kind of project where the scaffolding is the real work. The renderer is a few weeks old; the harnesses, oracles and captures that keep it honest are what let it reach the end of the game without turning into a lookalike.


Back to Experiments · Spatial acceleration structures · Debugging Graphics Without Guessing · Home.