Singularity Tower is a seven-floor idle game I built as a Chrome extension. It now also runs as a page on this site, with no download and no account. What follows is what that actually took, which was not what I expected.
The headline: the game logic needed no changes at all. Not one line of the economy, the engine, the UI modules or the minigames was edited. Everything hard was somewhere else.
The extension surface was much smaller than it felt
The first job was finding out how much of the codebase actually depended on being an extension. Grepping every call site gave a short list: eight calls to storage, two reads of the manifest for a version string, and one message to open a tab. That was the entire dependency on Chrome.
One detail changed the whole approach. The storage module carries a comment claiming it is the only module that touches browser storage — and that comment is wrong. The controller reaches past it twice. If I had trusted the comment and swapped out just that module, two call sites would have silently failed.
Because the boundary leaked, the right move was not to replace a module but to install a global that satisfies the whole API. Define the object the code expects before any of it loads, and every module runs unmodified. The port became additive rather than invasive, which is also why it is easy to keep in sync with the extension.
The shim is not three lines, and the reasons are the interesting part
Extension storage and browser local storage look interchangeable. They are not, in two ways that both matter.
- One is asynchronous, the other is not. Every method has to return a promise, or code that awaits a write proceeds in a different order than it was written for.
- One deep-copies, the other hands back a reference. Extension storage structured-clones on the way in and out. A naive shim that returns a cached object lets a reader mutate the writer’s state. My own test harness hit this a while back and produced a convincing but false bug report because of it.
There is a third thing that I nearly got wrong by assuming. The game has a writer lease: one context ticks and saves while others render read-only, so two open surfaces cannot both advance the clock. My first instinct was that the web version does not need it, because there is only one page.
That is false. Two tabs of the same site share one storage area exactly as two extension surfaces share one. The lease is not an extension concern, it is a shared-storage concern, and removing it would have let two tabs double-tick the same save. I kept it, and tested it: two tabs, one holds the lease, the other stays read-only.
The tests I already had became the acceptance suite
This is the part I would repeat on any port. The project already had a headless suite that boots the real pages with the real scripts and a fake browser API. Swap that fake for the actual web shim, and every existing assertion becomes a test of the port.
Eight suites green, plus a new one for the shim’s own contract. The port did not need a new test strategy; it needed one substitution in an existing one.
Two of my new assertions were worthless and I want to be honest about it. I wrote tests proving the shim deep-copies on read and write. Then I removed the copying and ran them again. They still passed — because the data round-trips through a string on the way to storage, which creates a fresh object regardless. The assertions cannot fail. The defensive code stays, because it would matter if the storage layer ever gained a cache, but I am not going to claim it is covered when the test proving it is incapable of failing.
Then the actual difficulty: getting the file onto the internet
The bundle is 92KB of JavaScript. The site runs on managed WordPress. This should be the easy part.
Uploading a .js file is refused outright — expected. So try plain text. A small text file uploads fine. A 92KB text file of prose uploads fine. A 20KB text file of JavaScript is rejected with the same error as the .js attempt. Adding a couple of kilobytes of prose to the top does not help.
The host is sniffing file contents and classifying them, and once a text file looks enough like source code, the detected type stops matching the extension and it is refused. Small files pass because there is not enough signal to classify them.
The way through is to make the file not look like code: base64. It is accepted at any size, because it classifies as ordinary text. That has a second benefit I did not anticipate. Base64 uses only letters, digits and three punctuation marks — no angle brackets, no ampersands. It is therefore immune to a rendering bug that had bitten me earlier the same day, where a specific three-character sequence inside a script gets HTML-escaped and breaks the script at parse time with an empty console.
So the shipped design is: gzip the bundle, base64 it, store it as a text file, and have the page fetch it, decode it, decompress it in the browser, and run it. 50,444 characters on disk, decoded and running in 83 milliseconds, and cached after the first visit.
The part where I was the bottleneck
To put that file where it needed to go, I first tried to carry it there myself, in chunks, verifying a running checksum after each one.
Halfway through, a checkpoint failed. The length was exactly right and the checksum was wrong — a substituted character, the failure mode that would have sailed past any length check and died later at decode. I bisected it by hashing prefixes until the divergence sat inside a 2,000-character window, re-sent only that part, and carried on.
Then the page I was staging into navigated somewhere else on its own, and every verified character went with it, because I had put them in a variable on a page instead of in storage that survives a reload. That one is entirely on me; it is a rule I had already written down and did not follow.
The thing that worked was embarrassingly simple. I wrote the payload to a file, and it got pasted into a private page by hand. Byte-exact, first attempt. Then again for a second, larger payload — byte-exact again.
A human copy-paste is a reliable channel for bulk data. Reproducing 40,000 characters by hand across dozens of steps is not. I should have reached for the reliable one first instead of after two failures, and the checksum gate — which did its job perfectly — only ever caught a problem I had chosen to create.
What the web build is and is not
It is the complete game: seven floors, the arcade, quests, prestige, achievements, both themes. Progress saves in the browser and can be exported to a file.
It is a separate save from the extension, and it always will be. They are different storage areas and no amount of wishing makes them one. Export and import is the bridge, and the page says so rather than letting anyone find out the hard way.
The extension keeps what a web page cannot have: a side panel that lives alongside whatever you are doing, operation with no network at all, and a save that survives clearing your browser. If you like the game, that is still the better way to play it. This version exists so you can find that out in one click.
