Loot Hole is a game about digging up your front yard. You dig, find buried things, fill your backpack, sell what you found at the yard stall, buy a better shovel and go deeper. You can play it free in the browser on Playgama.
The repository is three weeks old. The first commit was on 25 September 2026, and by 15 October the same game ran in the browser and natively on Windows, Linux, macOS, Android, iOS and Meta Quest, in 23 languages, with co-op for up to four players (the portal builds leave co-op out for now). Getting there meant changing renderers more often than I’d like to admit: Babylon Lite and WebGPU first, then three.js and WebGL 2, then a Rust gameplay core with a native wgpu renderer, and then three.js again for the browser.
Every switch had a reason. WebGPU shut out players. Rust let several platforms share one game. And the browser still needed a small download, even once the native renderer could run there too.
What came out is one Rust game with two kinds of presentation: three.js and DOM/CSS on the web, wgpu and a native GUI everywhere else. The browser’s launch budget is 1,000,000 compressed bytes. That covers what the first frame needs, not every model, sound and language in the game.
Starting with Babylon Lite
I started with TypeScript, Vite and Babylon Lite, because Lite made a small WebGPU scene practical. In an early comparison, Lite scenes with glTF and shadows came to roughly 90 to 100 KB raw. The smallest three.js WebGPU sample with its renderer, GLTFLoader and shadows was 863 KB raw (237 KB gzip). Those were renderer samples, not versions of the game, so they explain the first choice and not much more.
From the start I wanted players in the yard before they downloaded anything they wouldn’t need for minutes. Deep finds, wardrobe content and later scenery could load when needed. The first shovel swing could not wait for them.
Then a portal playtest reported that about 15% of its players couldn’t run the WebGPU version. That’s one playtest of this game, not a measure of WebGPU support worldwide, but it settled the priority. The next renderer had to reach those players.
Moving to three.js and WebGL 2
The WebGL 2 port landed on 2 October. I used three.js’s classic WebGLRenderer behind a small adapter for what the existing views already did: meshes from arrays, instances, parenting, visibility and materials. That let me port the yard piece by piece instead of rewriting every view at once.
The first working port was much bigger, and the size work only started once it ran:
| 2 October build | Entry JavaScript, gzip | First-frame core, gzip |
|---|---|---|
| Babylon Lite baseline | 127.2 KiB | 265.4 KiB |
| First three.js port, untrimmed | 247.2 KiB | not recorded |
| three.js source modules, trimmed shader library, unused systems stubbed | 213.7 KiB | 344.7 KiB |
| The game’s own look shaders instead of three’s | 206.5 KiB | 337.5 KiB |
| Unused methods removed at build time | 202.1 KiB | 333.1 KiB |
The finished port cost about 68 KiB more on the first-frame path than Lite. I took that for the players it brought back.
Part of the saving came from importing three.js’s source modules instead of its prebuilt bundle. The rest was specific to this game: pruning shader chunks, stubbing the WebXR and environment-map preprocessing code, and a build pass that removes methods nothing calls. That last pass has to leave alone the methods JavaScript or the browser call implicitly.
All of these patches depend on three.js internals, so each one fails the build loudly if an upgrade moves the line it expects. The dev server’s prebundle gets the same shader trims, so a scene can’t work in development and then miss a shader chunk in production.
The port also turned up costs a bundle report can’t show. On the Galaxy A40’s Mali driver, every extra framebuffer pass was expensive, however small its target. The low quality tier draws straight to the canvas with ambient occlusion off. A second cheap phone, a Redmi 9C with a PowerVR GE8320, had a worse surprise: any update to a 3D texture that a shader samples stalled its GPU process for about a quarter of a second, even a 10 KB layer. The sky light lived in a 3D texture and was updated on every dig, which was the stutter players would have felt with each swing. Making it a 2D texture array with the layer blend done by hand removed the stall. I wouldn’t have found either of these on a desktop GPU.
Sharing the shader math
The stylized lighting, wind, sky and terrain shading are Slang modules. The web compiles them to GLSL snippets and the native renderer compiles them to SPIR-V. Each renderer still owns its bindings, resources and draw calls.
That kept every change to the game’s look from turning into two shading projects. It didn’t make the renderer portable by itself: the coordinate system, matrix conventions, shadows, material variants and GPU capabilities still had to agree.
One three.js detail that cost frames: a material shared by skinned and instanced meshes made three.js re-select its shader program on every switch, so the game keeps a variant of the material for each kind of mesh. Meshes that load later compile their shaders before they’re first drawn, so the compile doesn’t land in the middle of play.
Terrain went to Rust first
Terrain was the first part to move to Rust. World generation, the voxel field, carving, meshing and the sky light became a Rust kernel compiled to WebAssembly, and the same sources build natively.
The port reproduced the TypeScript version bit for bit, rounding and integer conversions included, not just closely enough to look the same. Players who share a yard need the same ground, and saved chunks need stable bytes and checksums. That rule shapes the code: the kernel avoids fused multiply-add and approximate square roots, and the WebAssembly SIMD build has to write exactly what the scalar build writes. A test digs the same pit with both and compares every chunk.
The port measured roughly 1.5 to 2.3 times the throughput for the same terrain work. That’s the kernel alone, not a speedup for the whole game.
Once Rust became the authority, keeping the old TypeScript gameplay as a permanent test oracle would have meant maintaining a second game. The checks now use recorded fixtures, traces that Rust records itself, and golden answers taken through the real browser interface. The TypeScript oracles are gone.
Adding a native wgpu renderer
By 7 October the native proof drew the real terrain, a skinned character, instancing, shadows and the shared Slang look through wgpu. The gameplay port into Rust followed, and it opened the way to desktop, Android, iOS and OpenXR hosts without shipping a browser inside each one.
Quest was good at finding what a desktop driver forgives. An OpenXR runtime takes part in creating the Vulkan instance and device and hands out the swapchain images, and the game has to respect that and the acquire, wait, render and release cycle of each frame. That work produced three wgpu pull requests (below).
It also made a full Rust/wgpu browser build possible, and for a while one renderer and one GUI everywhere looked attractive. The phone measurements showed what it cost.
Bringing three.js back for the browser
On 10 October I compared three builds on a physical Galaxy A40: the old TypeScript/three.js game, an early prototype with the Rust core under three.js, and the full Rust/wgpu browser build. These are medians of six balanced startup runs per build, on a network emulated at 9 Mbps with 150 ms latency, with the HTTP cache off and the OS, driver and WASM caches kept.
| 10 October startup | TypeScript + three.js | Early Rust core + three.js | Rust + wgpu |
|---|---|---|---|
| First visible game draw | 1.654 s | 1.837 s | 2.820 s |
| Play button enabled | 4.389 s | 4.576 s | 5.840 s |
| Response bytes completed by first draw | 0.400 MB | 0.511 MB | 1.047 MB |
“First draw” means the first animation frame that issued game drawing, not a hardware presentation timestamp.
The hybrid got most of the startup back while keeping the browser’s existing presentation. It was only a component prototype, though; some orchestration was still in TypeScript.
The fuller Rust session, measured on 11 October against the old TypeScript game, looked less good. The Play button was ready at 4.818 s against 4.411 s, digging ran at 49.39 FPS against 50.95, and Chrome as a whole held about 13 MiB more memory while digging (that’s every Chrome process, not page RAM or GPU memory). I accepted those costs to have one gameplay authority. Rust was earning its place through shared behaviour, and through speed only in specific workloads.
I also tried a small custom WebGL 2 backend behind wgpu’s public custom-backend interface. It cut some startup machinery, but its apparent FPS lead mostly disappeared once I compared runs at the same device clock state, and it still carried the native GUI into the browser. I dropped it rather than maintain another graphics backend for a benefit I couldn’t show reliably.
The shared Rust gameplay and the native hosts merged into the main game on 11 October. The full-wgpu browser host was retired on 14 October. Native rendering stays on wgpu.
What crosses the Rust boundary
Rust owns movement, digging, the economy, progression, save validation and every decision the hosts must agree on. The page supplies input and platform facts and draws the answers with three.js and DOM/CSS. The native hosts draw them with wgpu and their own GUI.
The hot path uses numeric buffers and bounded projections. JSON is still used for rarer commands, saves and events. Every buffer slot is named once in Rust and generated into TypeScript, so a slot renumbered on one side can’t leave the two reading different values.
Moving code across that boundary can remove expensive calls. The TypeScript dig debris asked the Rust field for its gradient once per piece per sub-step. Moving the debris simulation next to the field took its update from 13.8 ms to 0.45 ms a frame on the A40 during repeated debris bursts, and the median frame went from 33.4 to 16.7 ms. The win came from the number of calls across the boundary more than from Rust being faster.
The gameplay module is one unsplit WASM file. The terrain workers use separate kernel builds, and the optional audio decoder has its own module.
Five hosts, one set of rules
The boundary matters most for the hosts. Early on, each native host carried its own copy of some shared flows, and they drifted. An audit on 12 October found that starting a new dig at bedrock did three different things on five hosts, and that Rebirth hung on Android and iOS because their hosts never handled it. Both flows now live once in shared Rust, Android and iOS share one touch host with only platform glue in their own crates, and a check fails the build when 12 or more lines are copied between host crates.
The rule I ended up with is that a host supplies platform facts (window, input devices, storage paths, SDKs) and calls shared code. It never decides gameplay. When one host needs what another already does, that code moves into the shared native crate.
Keeping the two UIs comparable
Shared rules still leave two implementations of how a panel looks and responds: DOM/CSS in the browser, a drawn GUI natively. The colours, sizes, shadows and breakpoints live in one shared config that both read, and each host does its own drawing.
A comparison tool screenshots every screen on both hosts over the same flat background and scores each pair against a list of known, accepted differences. A pair that differs more than its entry allows fails. That catches a missing element, and a panel that only happens to fit in one renderer, and it stops the native UI from quietly becoming a different design. A separate headless check lays out every native screen at every size in all 23 languages and fails on overflow.
Coordinates needed the same care. The first three.js port kept Babylon’s left-handed numbers and flipped cameras and models to compensate. That kept the port moving, but every later host and importer inherited the flips. The game later moved to the right-handed conventions three.js and glTF use and removed the flips together, so a model’s visible wall and the collision measured from its file land on the same side of the room.
Making the launch budget describe the launch
The build checks three numbers: the entry JavaScript, the first-frame core, and everything requested concurrently at launch. The last one matters because a model download competes for bandwidth even when the first frame doesn’t wait for it. The entry script has a 217 KiB gate, and the core and the concurrent launch requests have a 1,000,000-byte ceiling each.
The build of 15 October measured 212.0 KiB of entry JavaScript, 879.5 KiB for the first-frame core and 917.7 KiB for the concurrent launch requests (gzip level 9, by the build’s own static accounting, not a measurement of the live Playgama upload). Most of the growth since the October 2 port is the Rust core, which now holds what the page and the native hosts used to decide separately.
Most of the practical work was about when something becomes necessary:
- Models that aren’t critical stream in, with simple fallbacks shown first.
- Each language’s font subset loads when its characters are needed.
- Panel content and assets wait for the interaction that needs them.
- HTML preloads start the critical requests before JavaScript discovers them.
- Models ship compressed, rather than trusting the portal to compress a GLB response.
- Portal builds can ship a Brotli-compressed gameplay module, with a plain WASM fallback when the host doesn’t send the encoding header.
Compression and scheduling answer different questions. In a local HTTP/2 test on 13 October, preloading the decoder, portal adapter and SDK moved the Play button from 4.55 to 3.80 seconds on a 20 Mbps line with 300 ms latency, but only from 4.90 to 4.87 seconds on 4 Mbps with 150 ms. Those were three alternating runs per condition, useful locally but not a measurement of a live portal.
Meeting the launch budget also doesn’t mean the game is under 1 MB. The same test recorded about 1.65 MB downloaded before Dig In on the slower line. Drawing the first frame, enabling Play and downloading the game’s content are three different budgets.
Sending fixes upstream
The native and audio work led to seven library pull requests. Their status as of 15 October:
| Pull request | Problem | Status |
|---|---|---|
| wgpu #10566 | Enable Vulkan’s depth-bias clamp when the device supports it | Open |
| wgpu #10567 | Limit device API assumptions to the Vulkan instance version | Closed, not merged |
| wgpu #10568 | Accept a signed ViewIndex from Slang/glslang in Naga’s SPIR-V reader | Open |
| opus-rs #58 | Decode and crossfade the redundancy around an Opus mode switch | Open |
| opus-rs #59 | Initialize CELT energy history the way libopus does | Open |
| opus-rs #60 | Use the packet bandwidth to choose a Hybrid frame’s CELT end band | Open |
| opus-rs #61 | Reset SILK and bridge correctly when switching out of CELT (builds on #58) | Open |
I closed the Vulkan-version PR because I didn’t know that part of wgpu-hal well enough to defend the broader changes it needed. The bug report stays open, and creating the instance at Vulkan 1.3 works around it in this game.
The Opus bugs turned up when I compared the game’s 64 sound effects against libopus’s decoded output. Whole-clip comparisons looked fine; 20 ms windows exposed short glitches that an average hides. Four fixes cover startup energy history, bandwidth and codec-mode transitions. Mean agreement across the corpus went from 58.7 to 70.4 dB (error relative to the decoded signal, not loudness). Each PR has a focused test against libopus. The game’s corpus is a regression check, not proof of full conformance, and #61’s concealment bridge is close to libopus rather than sample-exact.
The native hosts use that Rust decoder. The browser tries its built-in decoder first and loads the worker/WASM fallback only when a file is rejected, so browsers that decode the files themselves never download it.
Before Loot Hole existed, while working on another project, I sent six PRs to Babylon Lite in August. Four merged: matrix allocation #573, floating-origin ShaderMaterial #574, billboard precision #576 and interleaved glTF visual coverage #584. The particle export #575 and the GPU geometry/compute proposal #583 closed unmerged, and storage-backed geometry later landed through the maintainer’s own #737.
Building it with coding agents
Most of this was written with coding agents. 1,394 of the repository’s 1,567 commits carry a Claude Code co-author line, and the busiest day, 8 October, the native gameplay port, had 293 commits. The published PRs disclose the AI assistance too: Claude Code helped with the implementation, the tests and the descriptions.
What kept that pace from wrecking the game wasn’t clever prompting. It was the repo:
- One place for each rule. The drift between hosts described above is exactly what agents produce: they fix the copy in front of them. Rust owning gameplay, a written table of “what changes, and where it goes”, and the duplication check are what stopped it.
- Checks an agent can run without eyes: golden traces that may only be regenerated for an intended rule change, with the trace diff explained in the commit message; a headless bot harness that plays the real game; the layout and UI comparison checks above; and measurements on real phones for anything that touches performance.
- A list of lessons. The rules file has an entry for every mistake that cost a session real time: build output that once grew past 140 GB, a worktree cleanup that followed a junction and deleted the main checkout’s
node_modules, a fractionalDate.nowthat broke the save checksum. Each one is a sentence in that file now. - Fewer parallel agents than you’d expect. Rust builds share the machine’s cores, so more than four agents at once made everything slower and the checks flaky. Mostly one session works through things in order.
The useful output for another developer is the reproductions, the reference comparisons and the exact changes, not how fast an agent produced them.
What I’d carry into the next browser game
I’d measure the first draw and a usable game separately from day one. I’d test on an ordinary phone, and on a second phone with a different GPU vendor, before committing to an effect or a graphics API. And I’d share the rules and the content across platforms before trying to share the presentation.
The less obvious lesson was how easily a number loses its context. A renderer sample becomes a claim about the whole game’s size, a warm load becomes a cold-start promise, a faster kernel becomes a faster application. Keeping each measurement tied to its build made the architecture decisions much less of a guess.
Loot Hole is live on Playgama and in review on CrazyGames. The next test is whether someone who has never seen it can work out their first dig, sale and upgrade without help.