Skip to content

Simulating a runtime that says no: how we sandbox the browser to act like WeChat

By Oleg Sidorkin, CTO and Co-Founder of Cinevva

A small 3D game scene inside a transparent glass dome, with crossed-out tool icons floating outside the glass

We recently shipped a WeChat mini game mode in our Game Creator: a build profile that keeps AI-generated games inside the subset of the web platform that survives a port to WeChat's runtime. The profile is a set of generation rules. No DOM UI, no Pointer Lock, mp3-only audio, no dynamic code. The model follows them the way models follow rules, which is to say: usually.

"Usually" doesn't cut it when every violation is invisible in the browser and fatal after export. A game with an HTML health bar runs perfectly in our preview and produces a blank screen on WeChat, because WeChat mini games have no DOM at all. So we built the thing that turns those rules from suggestions into physics: a strict-mode sandbox that makes the browser itself refuse to do what WeChat can't. This post is about how it works.

The core decision: simulate absence, not presence

The obvious approach is to emulate WeChat: implement wx.createCanvas, wx.onTouchStart, wx.setStorageSync, and run games against the emulated wx.* surface. We went the opposite way, and the reason is the export pipeline's shape.

Games in WeChat mode are still written against browser APIs. At packaging time an adapter (the community-standard weapp-adapter approach) maps those browser APIs onto wx.*. That means our games never call wx.* directly, so a wx emulator would test code paths that don't exist. What actually breaks a port isn't the absence of wx.* in the browser. It's the presence of browser APIs that have no WeChat equivalent, quietly used during generation. document.createElement('div'). requestPointerLock(). IndexedDB. An .ogg file that decodes fine in Chrome and never will on WeChat iOS.

So the sandbox simulates absence. Everything WeChat doesn't have gets removed, poisoned, or flagged in the preview, and the game develops inside the intersection of the two platforms from its first frame.

Where it lives: a service worker that was already there

Our editor preview has an unusual serving path that made this almost free. Games in the editor aren't served from the network. A service worker intercepts /game/* requests and serves files out of IndexedDB, which is how the preview iframe gets instant reloads without a round trip. That worker already injects two virtual files into every game page: an error-capture script that forwards console output and crashes to the editor, and a live scene inspector.

The sandbox is virtual file number three, _wechat-strict.js, injected into the page <head> immediately after the capture script and before any game code runs. Ordering is load-bearing twice over. It must run after capture so that its console.error calls are already hooked and flow to the editor, and before game modules so the poisons are in place when the first line of game code executes.

Activation is a one-line trick. The WeChat toggle in the creator persists in localStorage, and the preview iframe is same-origin with the editor, so the harness just reads the flag directly:

js
try {
  if (localStorage.getItem('cinevva-gc-profile') !== 'wechat-minigame') return;
} catch (e) { return; }

For every non-WeChat game the script is a single string comparison and an early return. No build flag, no server round trip, no state to synchronize with the service worker.

The poison list, and the two-tier taxonomy

Not every violation deserves the same response, so the harness distinguishes two tiers. APIs that do not exist on WeChat throw, because that's what happens after export and a crash during generation is the honest preview of a crash in review. Things that exist but break later get flagged as loud console errors without altering behavior, because blocking them would hide the game's actual state from the person iterating on it.

The throw tier: creating any HTML UI element, through both createElement and createElementNS (Three.js creates its canvas via the NS variant, so both paths need the same gate), eval, new Function, requestPointerLock, and service worker registration. Each throw carries the fix in its message:

js
throw violate("document.createElement('" + tag + "') — WeChat mini games have no DOM. " +
  "Draw UI on canvas (offscreen 2D canvas -> THREE.CanvasTexture on a screen-space quad) " +
  "and hit-test taps yourself.");

The flag tier: reading indexedDB (returns undefined, exactly as WeChat does, plus an error pointing at localStorage), audio in any format WeChat iOS can't decode (checked in three places: the Audio constructor, the src setter on the media element prototype, and fetched URLs), network requests to any origin that isn't the game's own or our asset CDN (on WeChat those need a whitelisted, ICP-filed domain), and Tone.js showing up at all.

There's also a post-load audit that runs a moment after the page settles: it walks <body> and reports any HTML elements that came in through the game's own markup rather than dynamic creation. Static HUDs sneak past a createElement gate, so the gate alone isn't enough.

Every report deduplicates by message and caps at fifty per session. An inverted-loop bug that creates a div per frame produces one error, not a firehose that drowns the signal.

The four exceptions that taught us the most

Building a sandbox is mostly deciding what not to poison, and each exception we carved came from our own tooling breaking during development.

Our debugger runs on eval. The creator's execute_js tool, which the AI uses to probe the live game, evaluates code via the capture script's message handler, which calls eval. Poisoning eval naively would have blinded the AI's own eyes. The fix: before poisoning, the harness stashes the real function on a non-enumerable property, and the capture script's handler falls back to it:

js
Object.defineProperty(window, '__cinevvaRealEval', { value: window.eval, enumerable: false });
window.eval = function () { throw violate('eval() is banned in WeChat mini games.'); };
// capture script, at message time:
returnValue = (window.__cinevvaRealEval || eval)(e.data.code);

Game code that reaches for eval still dies. The debugger doesn't.

The recorder creates elements too. Our reel recorder gets injected into the game iframe as a <script> created with the iframe's own document.createElement, and it downloads finished videos through a synthetic <a> click. Blocking those tags would have broken recording exactly and only in WeChat mode. So script stays allowed and a warns without throwing. A game shipping a real link still gets flagged, and the tooling still works.

Keyboard events stay. The AI verifies controls with a tool that synthesizes keypresses and reads back how the game state moved. Suppressing keyboard input to simulate a phone would have destroyed the control-verification loop that catches inverted-camera bugs. Touch-first is enforced by the generation rules and the profile's control scheme, not by the sandbox.

WebGL2 stays, and the reason deserves its own section below. The draft poison list blocked getContext('webgl2') to force the "WebGL1-safe" rendering the profile demanded. Except Three.js removed WebGL1 support entirely in r163, and we pin r181: blocking WebGL2 would have blanked every 3D game in the mode. The profile's rendering rule was incoherent, written from a stale mental model of the platform. The sandbox audit audited the sandbox's spec, and pulling on that thread led somewhere important.

The WebGL2 question, answered properly

Leaving the context creation alone raised the obvious follow-up: if our engine requires WebGL2, do the games break on WeChat devices that only have WebGL1? Chasing that produced the clearest picture we have of the platform's rendering floor, so here it is with sources.

On Android, WeChat's mini game runtime provides WebGL2 on any modern base library, and the underlying GLES3 hardware is effectively universal. iOS is the real story. WeChat's own engineering docs for WebGL2 support and the high-performance+ mode spell out that on iPhone, WebGL2 is only properly available in the high-performance+ runtime, which requires a recent WeChat client (8.0.45 and up, higher on iOS 14) and effectively iOS 15.5 or newer. Plain high-performance mode runs WebGL2 "with more issues" by Tencent's own description, and the normal iOS runtime doesn't have it at all.

The truly dangerous part is the failure shape. In unsupported environments, getContext('webgl2') can return a truthy but broken context instead of null, a behavior developers have asked Tencent to fix directly. A game that trusts the return value doesn't fail cleanly there. It renders garbage or a silent black screen.

So here's what we do about it, in three layers. The sandbox leaves WebGL2 context creation untouched, because poisoning it would fight our own engine. The generation profile now requires a boot gate in every game: renderer creation wrapped in try/catch, followed by a functional probe (typeof gl.createVertexArray === 'function', a WebGL2-only capability a lying context won't have), with a friendly canvas-drawn "please update WeChat" message on failure instead of a blank screen. That's the same pattern Unity-converted mini games ship, which is why you see upgrade prompts in the wild rather than black screens. And the exporter, when it lands, pins high-performance+ mode in game.json so the capable path is the default one.

What's the audience cost of a WebGL2 floor? Users below roughly iOS 15.5 or on WeChat clients older than 8.0.45, low single digits in 2026 but concentrated in older devices, which matters more for some genres than others. If real distribution data ever says that tail is worth chasing, we hold a cheap escape hatch in reserve: pinning the WeChat profile to three r162, the last WebGL1-capable release. Profile games only touch stable core APIs, so the downgrade is a one-line import map change, deliberately unspent until data demands it.

Closing the loop with the model

Here's the part that makes this worth building for an AI-first product. After every build, the creator's agent calls a tool that reads the game's console and waits for boot to complete. The capture script forwards console.error to that stream. So a [wechat-strict] violation isn't a warning a human might scroll past. It lands in the exact channel the model already checks before declaring a build done, in the same turn, with the fix spelled out in the message.

The generation profile closes the last gap with one instruction: treat every [wechat-strict] message as a build-breaking bug, fix the cause, and never attempt to detect or bypass the harness. A game that only runs with the sandbox off is a game that fails after export, and the model is told exactly that.

In effect the sandbox converts a fuzzy compliance problem ("did the model follow all fourteen rules?") into the debugging loop the system is already good at ("the console shows an error, fix it").

What a browser can't simulate

Honesty section. This sandbox catches API-surface violations, which in our estimation is the large majority of what kills a port. It cannot catch: performance on a real phone, iOS running JavaScript without JIT, WeChat's WebGL implementation quirks, or codec behavior differences beyond file extensions. Those need the real runtime.

So the sandbox is ring one of three. Ring two, once our exporter emits WeChat DevTools projects, is the official simulator driven headlessly through the DevTools CLI and miniprogram-automator: boot the exported package, assert the first frame renders, script a tap. Ring three is the official device path, preview QR codes and remote debugging on physical phones, which is where the truths nothing else reveals live. Each ring is slower and more faithful than the last, and the job of each is to make trips to the next one rare.

If you want to try the mode, it's the "WeChat mini game mode" toggle in the Game Creator. For the market and regulatory context around all this, see our field guide to reaching WeChat's half-billion players.

References