# Cyberpunk 2077 in a browser tab: what an unofficial port's own page source shows

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/cyberpunk-2077-in-a-browser-tab
> date: 2026-10-27
> tags: webgpu, gpu

On 2026-10-22 at 22:00 UTC a developer who goes by `@omonkeyzz` posted that Cyberpunk 2077 was live in the browser, at `cyberpunk2077.lol`. I opened it. It boots and I could play. That is the whole of my first-hand testing: I took no timings, so there is no frame rate in this piece, and I will not borrow anyone else's.

What I did instead is read it. The two pages it serves, `/` and `/game`, and the public loader script `/game/loader.js` are plain text, and they are unusually talkative: the developer left dated comments, with numbers, next to nearly every workaround. Everything below comes from those files as fetched on 2026-10-27, from two press reports, and from the developer's public posts. I did not fetch any engine file, game archive or token-gated address, and I did not try to get past the gate that the page describes. I show no game art.

One thing first, because it shapes how to read the rest. The port is unofficial. I searched both pages for `CD Projekt`, `licen`, `unofficial`, `disclaim`, `trademark`, `affiliat` and `copyright` and got no hits at all (measured). I searched for a statement from CD Projekt RED about it and found none (as of this date). The two press reports I found both call it unofficial, and one says outright that the developer has no connection to the studio. Neither is the Opera GX "Cyberpunk 2077 Browser" mod from 2024, which was made with CD Projekt RED and is a theme pack for the Opera GX browser, not a playable game ([Opera press release](https://press.opera.com/?p=3289)). Similar names, unrelated things.

Three labels run through this article. **Measured** means I read it in a file or computed it. **Reported** means someone else says so: the developer in a code comment, or a news site. **Reasoned** means I inferred it from the code and could be wrong.

## What the two pages are

The landing page is a countdown over a looping background video, with a "Play now" button. Its clock is more careful than it needs to be. It asks the developer's Cloudflare Worker for the server's time, computes `skew = j.now - Date.now()`, and counts down to a launch constant of `1792706400000`, which is 2026-10-22 22:00:00 UTC, 5 PM Central (measured). A countdown that cannot be shifted by setting your laptop clock forward tells you the developer was thinking about the launch as a coordinated event.

`/game` is where the work is: 2,109 lines of HTML and inline script (measured), most of it not the page but a stack of patches. The head holds a "cross-origin prelude" that rewrites every engine and game URL to a second origin, a Cloudflare Worker, with an access token in the path so that relative module imports keep working. A comment explains why: the page lives on one host and the data on another, and a Worker needs to gate the data. The header of that script describes a code-entry panel, an early-access path for testers; a later comment, dated 2026-10-22, says there is no access code for players, and that from launch a public path token boots the game. I read that gate and left it alone.

After the head come the capability checks, an engine selector, a loader script, and a launcher that boots a canvas. A comment in the launcher puts the design in one line: `Only the original engine renders into this canvas; no HTML menu UI.` Everything you see in the tab, including the menus, is the game's own UI drawn by the engine.

## What has to be true before the engine is requested

The launcher throws before it does anything else unless the page is a secure context and is cross-origin isolated: `Native engine needs a secure origin and COOP/COEP headers for its worker threads.` That is the first of six checks, and the page's own capability script runs the others in a fixed order. Pick a browser profile below and click a plate to see the condition and where it sits in the page.

<BootStack />

The reason for each layer is mechanical. Shared memory needs `SharedArrayBuffer`, which browsers expose only to isolated pages. A game heap of this size wants 64-bit WebAssembly memory, which the page tests by asking `WebAssembly.validate` about the bytes of `(module (memory i64 0))`. The renderer is a WebGPU backend; the page and loader never mention WebGL (measured: no hits for `webgl` or `getContext` in either; I did not read the engine itself). If a check fails, the engine script is never requested, and a panel names the reason.

The launcher then passes the engine its command line (measured): `-qualityLevel=Low -texQualityLevel=Low`, and a render size of 1280 by 720. So the port starts at the game's lowest preset and a fixed 720p canvas. That is a design decision, not a measurement of what the hardware could do.

## Two builds of one engine

The page does not request "the engine". It requests a bundle named by a SHA-256, chosen by a script called `engine-select.js`. There are at least five (measured, from its header comment):

| Selector | Bundle (first 8 hex) | What it is |
|---|---|---|
| none (site default) | `aecc73a0` | the default, with the streaming parameters below |
| `?io=classic` | `430ea959` | the bundle the page embeds, no extra parameters |
| `?io=shared` | `30ceeb82` | the shared-cache build |
| `?io=stream` | `e199e240` | adds a persistent OPFS tier |
| no memory64 | `64651401` | a separate wasm32 build for iPhone and iPad Safari |

The last row is the interesting one. Safari has no 64-bit WebAssembly, so the developer built a second engine with 32-bit memory and a different parameter set (`mem=ios`, one worker, `streamMax=300`, `asyncPresent=0`, `mlPools=0`). The selector sends a browser there only if it has shared memory, isolation and WebGPU; the capability check exempts it from the 64-bit test and from the texture-format test. On 2026-10-25 the developer posted that iOS was "like half way there" and "Boots into menu now" (reported). The page already has a route for it.

The site default is not a fixed flag set; the page fills in whatever the URL does not already say, then rewrites the address bar with `history.replaceState`. The owner of this site shared the URL he launches with. Every parameter in it is one the page would add on its own, the default set plus `floodKeep=1`, so it is the site default written out:

```text
ioShared=1  ioSharedMB=128  ioInflight=4  ioPersist=1  ioPersistMB=6144
binkRecycle=1  psoWarm=0  skipIntro=1  ioLookahead=16  behindLoad=0
floodKeep=1
```

That list is reported as the flag set the site's owner launches with. I did not open it. I did check the page source for each name, and this is all it says (measured):

- `ioShared`, `ioSharedMB`, `ioInflight`: appear in the selector's comment as the parameters of the "io3 shared-cache bundle" (`?io=shared`), with values 1, 128 and 4. The page does not say what in-flight counts.
- `ioPersist`, `ioPersistMB`: the comment for `?io=stream` says the bundle adds them, with "OPFS tier in the helper worker, OPFS belongs to the page's origin". So the persistent tier is the browser's Origin Private File System.
- `floodKeep=1`: documented in a dated comment. The loading screen keeps one job worker free instead of two; the developer reports a world load of 5.6 to 5.8 s against 6.8 to 7.0 s with local data, in paired runs on 2026-10-24.
- `psoWarm`: the name appears in the default set only. A loader comment mentions `psoWarmK=12` finishing pipeline warm-up "during the intro", which is a different name.
- `binkRecycle`, `skipIntro`, `ioLookahead`, `behindLoad`: names seen in a shared URL, semantics not documented in the public page. I will not guess.

Arithmetic, which the page does support: `ioPersistMB=6144` is 6,144 MB, which is exactly 6 GiB, of requested persistent cache, and `ioSharedMB=128` a 128 MB shared cache. The first is a request to the browser, not a reservation. Reasoned: a reader should expect the site's origin to ask for up to 6 GiB of local storage, grown as game data streams in, and browsers cap or evict origin storage under pressure, so a small disk means a smaller cache and more re-downloading rather than a failure. The loader also reads `navigator.storage.estimate()` after loading and keeps the usage in `localStorage`, though a later comment says the selector now sets `floodKeep=1` on every visit regardless.

Per-device tuning is in the same script, with the developer's own justification (reported): Android phones with 4 GB of memory or less get `texDrop=2`, two texture mip levels dropped instead of one. Android gets `potato=2` by default, a 0.8 render scale plus distance caps on small objects, props, foliage and decals; the comment says a city A/B on desktop gave about 49.6 fps against about 44. That is a number from the developer's harness, labelled as theirs. I did not measure it and I do not know the hardware.

## The download before the download

The page embeds a loader config for the classic bundle (measured):

<LoadBudget />

The engine is a 248,252,583-byte wasm module that ships as 56,561,622 bytes, a ratio of 4.39x. With the 2.5 MB data file and 145 kB of glue script, the compressed total comes to 59,223,793 bytes, or about 59.2 MB. A comment in the loader mentions a "59 MB download", consistent with that. Those are the classic bundle's sizes; the page does not state the site default's.

The loader's progress bar is weighted rather than timed: link 3, engine download 40, compile 10, mount archives 7, build shaders 15, load the world 25 (measured). A comment states the bar is driven by real signals: decoded wasm bytes, the instantiate promise, the archive manifest, the pipeline-cache entry count, and the engine's own "loading stage" probe counting to 15. When it finishes it prints `Ready · Wake up, Samurai`.

Three details in the loader are worth copying if you ship anything big on the web.

- **Hand V8 the original response.** A comment explains that the engine gets the original `Response`, with its URL and cache identity intact, so V8 can stream-compile and keep its code cache for repeat visits; bytes are counted on a clone. A reconstructed `Response` has no URL and turns the code cache off.
- **Resume the download, not the file.** The developer reports that about 70 sessions a day died with "both async and sync fetching of the wasm failed" a few MB into the download, because Emscripten's fallback refetches the whole file and fails the same way. The fix, used only after a failed streaming compile, downloads the wasm in 8 MiB Range chunks with backoff.
- **A stall is a rule, not a feeling.** With the tab visible and no change in bar or asset bytes for 75 s, the page reloads, at most twice per tab session; the second reload also wipes the block cache. Frames still arriving stretch the limit to 240 s, and changing job slots to 300 s, because a slow CPU spends 49 to 201 s in the engine's resource-gathering step with nothing on screen (reported, from probes on 2026-10-24).

## How the game data arrives

The page's own loading hints say it: `Assets stream on demand: only what you see is downloaded.` and `Archives stream on demand, so the first minutes load the most.` The loader counts asset throughput per worker over a `BroadcastChannel`. For in-world starts it calls the world ready once 20 steady frames have been presented; the older rule wanted 60 frames and asset reads under 4 MB/s, and held the loading screen about 6.5 s longer while textures kept streaming (numbers from a dated comment). The default menu start waits for the front-end UI and steady frames instead.

Where do the bytes land? Reasoned from the parameters: a 128 MB shared in-memory cache, up to four requests in flight, and an OPFS tier of up to 6 GiB so that a second visit does not download again. The loader confirms the persistent tier exists from the other direction: when the engine reports `Oodle decode failed` or a Kraken error, the loader treats it as a corrupt cached block, wipes the block cache and reloads once. Oodle and Kraken are the game's own archive compression, so those errors mean a cached archive block failed to decode (reasoned). User data lives in a different store: when the game's browser profile fails to load, the loader's repair path deletes one profile file from the `IDBFS` database `/userdata` and reloads once.

What the page does not say is the block size, the eviction rule, or the size of the archives. So I ran a toy instead, and I want to be exact about what it is.

<CacheCurve />

It is a synthetic LRU simulation on a CPU, with a 40 by 40 grid of tiles, 24 private blocks per tile and 3,000 shared blocks drawn from a Zipf distribution, and a viewer that touches ten blocks per step. It says nothing about the port's real cache. It shows a shape that is true of any block cache with a persistent tier: a second session only pulls clearly ahead of the first once the cache holds most of what a session touches. In the walking trace the two are within three points of each other up to half of the touched set, and the return visit leads by 5 to 6 points at three quarters and 9 to 11 at a full set. A cache sized in gibibytes is a bet that the working set of a stretch of city is smaller than that. Whether it is, nobody outside has published.

## The WebGPU gap and the shim

The renderer is where a port like this either holds or does not. The page's diagnostic probes name the subsystems the developer watches (measured, from the launcher): shader conversion (`graphics shader has no exact original-bytecode conversion`), a segmented TLSF allocator, GPU hand-off delays, multilayer demand, script bindings, loading stages. The shape resembles the one in this site's earlier [GTA V in a browser tab](/articles/gta5-in-the-browser) teardown, where a D3D11 command stream is replayed on WebGPU. Whether this port does that is not stated in the page, so that comparison is reasoned.

The most concrete piece of renderer engineering in the public source is the `[notier1]` shim. WebGPU exposes some storage-texture formats, packed and 8 or 16 bit, only behind the optional `texture-formats-tier1` feature. On 2026-10-23 the developer found 112 desktop sessions, mostly Chrome 114 to 134 on the 32-bit bundle, that failed to create an `RG11B10Ufloat` storage texture and sat on the loader for minutes (reported). Three days later the 64-bit engine learned to run without the feature: it stores those formats in the nearest core format, and the page rewrites every WGSL storage declaration and bind-group-layout entry to match. The map is 23 entries long, and the regular expression is short enough to run here on three shaders I wrote:

<Tier1Rewriter />

The capability panel changed with it. It still blocks a 32-bit engine without the feature, and a 64-bit one without `float32-filterable`, which the core formats need; everywhere else the shim handles it silently. That is a nice case of a compatibility layer written against a spec that has a feature flag in it.

There are two more of these workarounds, both dated and both reported.

- **`popErrorScope` that never answers.** On 2026-10-24 the developer found that on some Android GPUs the promises from `popErrorScope` stayed unanswered for 7 to 63 s, and 8 of 15 resource-gathering freezes were those. The engine waits for them around every texture and buffer upload, so the load deadlocked. After 5 s the page answers "no error" itself and ignores a late real answer. It is a trade of correctness for liveness, switchable with `?scopeTimeout=0`.
- **A `BigInt` in a log line.** The 64-bit engine uses `BigInt` for pointers and sizes. On 2026-10-23, 728 launches died because one reached `JSON.stringify`. The fix is to serialise it as a `Number`, and to never let the reporter throw into the game.

Shader pipeline warm-up is the third. A cold run builds roughly 500 pipelines and takes 60 to 90 s (reported), which froze new players right after the loader. The developer's fix on 2026-10-22 was to stop gating readiness on it: draws with unready pipelines are skipped (`asyncPso`) and warm-up finishes during the intro. Their fresh-user test: loader 43 s down to 37 s, with no hitches, start screen complete. First visits are described as bimodal, about 40 to 55 s or about 85 to 115 s, whichever floodKeep setting is used (reported). I measured none of these. I would read them as what one developer saw on their harness over four days, in the order they hit each problem.

## What the page sends home

I checked, because the loader talks to the Worker. It posts small JSON reports to `/__probe` on the developer's Worker. They fire when a load stalls or fails, on an opt-in `?report=1`, as boot telemetry for the iPhone build, and, on Android, as a heartbeat every 30 s for ten minutes after the game is ready so that a tab the phone killed leaves a trace (measured). The payload includes the user agent, the GPU adapter info, core count, device memory, engine heap size, the last lines of the engine's log, timing marks, and the page's query string with any `key=` removed. The page's comment says "No personal data". There is no login, no account field and no cookie in what I read, and each report uses a random per-load id. I would still not call user agent, GPU, memory and core count nothing: together they narrow a device down. It is diagnostics, not an identity, and it is also not described anywhere a player would see.

## The developer and the claims

The account joined X in August 2026. Its bio reads "i do roblox and rewrite popular games" (reported, from the profile). Its posts describe a Terraria browser side project from 2026-10-23, "Multiplayer is coming" on 10-24, and the iOS update on 10-25. At launch the account also announced a memecoin on a token-launch platform, and third parties replied with the contract; on 2026-10-23 it wrote "No coin will be attached for now or wont be" (reported). I mention the token because it is part of how the launch travelled, not as advice either way, and I have not linked it.

Two outlets covered the launch. [iXBT Games](https://ixbt.games/en/news/2026/10/23/v-cyberpunk-2077-mozno-poigrat-v-brauzere-vysla-neoficialnaia-versiia-igry-cdpr.html) says the project runs through WebGPU, that such ports usually ask players to supply their own game data to stay clear of copyright trouble while this one does not for now, and that it expects CD Projekt RED's lawyers to act. It also reports that Take-Two's lawyers quickly shut down an earlier GTA V browser version. The Russian-language report on [cybersport.metaratings.ru](https://cybersport.metaratings.ru/news/vyshel-neoficialnyi-brauzernyi-port-cyberpunk-2077-igra-zapuskaetsya-na-pk-i-android-664580/) says the developer has no connection to the studio, that it runs best in Chrome and Edge with WebGPU, and that an earlier browser build of the Fortnite map by the same developer was closed after Epic's lawyers stepped in. It also warns, reasonably, that scammers use such pages to steal accounts; the page I read asks for none, with one exception, the pre-launch early-access code field, which is a password-type input for a code and not for an account.

## What I could not check

The comments in the launcher name source files of the engine: `gameFramework preGameRequestsHandler.cpp overlay` and `gpu-texture-web.cpp Tier1CoreFormat`. That is the language of someone building from source. The page does not say where that source or the game data came from, and I am not going to guess.

Here is the list of what nobody outside the project has shown, and what would settle each.

- **Performance.** I took no timings. The developer's 49.6 and 44 fps, and the load times, are theirs, on hardware that is not stated. What would settle it: a run with the machine, browser, GPU and settings written down.
- **Whether the served bytes are the original game's.** The page's Kraken and Oodle handling and its archive names suggest so (reasoned). A byte-for-byte comparison against a licensed install would settle it, and I have neither.
- **The size and shape of the streamed set.** Archive size, block size and eviction rule are not in the page. A network trace of a first and a second session would give the first two.
- **The cache simulation.** Synthetic. Treat it as the shape of the problem.
- **Rights.** I found no licence, and no statement from CD Projekt RED. A statement from the studio would settle it. The press reports expect a takedown; I do not know whether one has been sent.
- **How long the page stays like this.** Its comments are dated 2026-10-22 to 2026-10-26, and the capability rules changed in that window. Every claim here is as of 2026-10-27.

## How I checked

I fetched `/`, `/game` and `/game/loader.js` with a plain HTTP client on 2026-10-27, read them whole, and grepped them for each flag and licence term quoted above. I asked the public `__launch` endpoint for the launch time. I did not request the engine, any game archive, the runtime scripts, the Worker's admin or upload endpoints, or any token path, and I did not try to obtain an access code. I computed the byte ratios and the bandwidth floors with Python. I ran the cache simulation on a CPU at low priority: 200,000 references per trace, seed 2077, LRU, in a scratch directory outside the repository.

I opened the port once as a player and it worked. That is a statement about one session, written down once, not a measurement.
