A from-scratch reimplementation of the 2004 PopCap game Insaniquarium Deluxe, running in the browser on TypeScript and Phaser 3.
The interesting part is not that it looks like the game. It is that the numbers are the game's own numbers — recovered by decompiling the original binary rather than guessed at and tuned by feel.
There is no source code for Insaniquarium. There is a 3.6 MB Windows executable from 2004, a folder of GIFs, and a readme. Everything else — how fast a fish gets hungry, what a food upgrade actually does, how the laser pushes an alien — exists only as compiled machine code.
The obvious approach is to watch the game, guess the constants, and tune until it feels close. That produces something that plays like the original and is wrong everywhere in ways nobody can name.
So the constants were recovered instead:
Insaniquarium.exeturned out to be a PopCap DRM stub. The real game,WinFish.exe, is an overlay inside it at offset0x188000.- Ghidra analysed the extracted binary — 9,139 functions, none of them named.
- Walking the MSVC RTTI graph (
TypeDescriptor→CompleteObjectLocator→ vftable) bound 2,156 functions to 39 of the original's own C++ classes. - Eight per-subsystem passes read the decompiled C and pulled out the constants, each with the address of the instruction that uses it.
- Three adversarial verifiers re-derived a sample from raw disassembly, independently, and refuted 29 claims — mostly transposed addresses, four of them substantive.
75 of 76 constants that a first-pass analysis had marked "must be tuned by feel" are now known values with citations. The one still open is honest about being open.
The engine tick is 28 ms — 35.714 Hz, not 60. WinFishApp::WinFishApp at 0x0054da98
overwrites the framework's 10 ms default with 0x1c, confirmed five independent ways. Every rate in
the game is denominated in those ticks, so a 60 Hz port would run the whole thing 1.68× fast.
Production freezes entirely while any alien is on the board. Hunger decay, coin drops, beetle production and breeding all stop. It is the central tempo mechanic — the reason the game feels phase-based rather than continuous — and it appears in no documentation of the game anywhere.
Deflection is a 3×3 cell grid over a fixed 160×160 box, not a radius. The push is a velocity set that discards the old velocity, damage lands before the push, the centre 40×40 cell pushes nothing, and one click hits every alien under the cursor with the loop breaking only on a kill. There are three different push tables: Psychosquid and the Bilaterus heads use 4.0 straight and 3.5 diagonal, while Destructor and Ulysses read the column only — so a Destructor shoved from the top-left moves horizontally and does not go down.
"Food quantity" is not pellets per click. A click always drops exactly one. The upgrade raises
the cap on how many pellets may exist at once (1 → 9) and speeds the auto-feed throttle to
16 − n ticks.
A resting coin lives 20 ticks — 0.56 s — not forever. Letting coins sit trivialises the economy.
Small tier-0 guppies never drop coins at all, because the early return sits ahead of the timer increment. Fish speed never changes with size: one divisor is rolled per fish at spawn and growth never touches it.
On tank 1 levels 3, 4 and 5 the egg piece is gated behind buying a carnivore. That is the actual progression rule of three of the first five levels, and without it they are unwinnable.
The load-bearing decision is a hard split between simulation and presentation.
src/sim/ pure TypeScript. No Phaser, no DOM, no clock, no Math.random.
src/game/ Phaser: scenes, views, HUD, input, audio.
src/platform/ save, profiles, scaling, achievements.
content/ pets, aliens, fish, levels, store, balance — all JSON.
tools/pipeline/ the offline asset build.
src/sim is enforced pure three ways: an ESLint rule, an import-graph test, and a source grep. The
whole gameplay layer is therefore unit-testable with no browser, replays are possible, 2× speed is
free, and every unresolved constant is a data edit rather than a code change.
- Fixed 28 ms timestep.
stepWorld(w)takes nodtat all — a variabledtmakes every replay test meaningless and makes collisions depend on frame rate. - 2× speed runs two ticks, never a scaled
dt. A test asserts that a 1× run and a 2× run of the same seed reach a bit-identical world hash. - The view interpolates between ticks, because 28 ms does not divide into a 16.67 ms frame — but nothing the player clicks is interpolated, so hit tests use the simulation's own positions and you hit what you aimed at.
- Generational entity handles, so a stale reference resolves to
undefinedrather than silently retargeting a recycled slot. - Wall-clock diagnostics live outside the world hash. They were briefly inside it, which would have made two identical replays diverge the moment one machine hitched.
Pets are expressed over a closed vocabulary — 7 locomotion kinds, 6 triggers, 20 actions, one shared query evaluator. Not one of the 24 needs bespoke code, and the escape hatch is empty. A coverage table checked against the content files fails the build if the two ever drift apart.
Getting there needed three additions, one of which nobody predicted: almost every contact test in
the original is a box, not a radius, and several are not even centred on their target. Rufus's
alien box is [+30,+140] × [+10,+150] against a 160×160 alien. Expressed as a radius it hits corners
the original misses and misses an axis it hits.
The retail art is 425 GIFs in PopCap's format, and it is full of traps:
- 16 sheets are real grids that declare
cols=1 rows=1— including every player-fish sheet. Loading them at face value renders a whole spritesheet as one frame. - Four different alpha conventions in one manifest, including one where the mask is a single cell that must be tiled across the grid.
- 17 additive-blend layers whose masks are pure white. Composited normally they render as opaque black rectangles over most of the sheet.
- A black fringe in 105 of 184 mask pairs. The original blitted 1:1 so it never showed; anything that scales the art pulls black into every sprite edge.
npm run assets reads the manifest and emits 42 atlases, 2,690 frames, 280 animations, 65 sounds and
15 bitmap fonts in about 25 seconds, behind a 42-assertion build gate. Determinism is proven rather
than assumed: a second run into a scratch tree diffs zero files by SHA-256.
| Adventure tank 1 | five levels, playable end to end |
| Fish | hunger, growth through tiers, coin drops, death — on the recovered constants |
| Aliens | nine types, the laser, the 3×3 deflection grid |
| Pets | all 24, as data |
| Store | twelve items, the recovered price table, egg pieces, the unlock gates |
| App | title, profiles, menu, level select, options, pause, save with JSON export/import |
| Audio | full cue map, variant pools, tracker music through libopenmpt |
| Tests | 4,967 |
Tanks 2–5, the boss, bonus rounds, the stats dashboard, localisation and touch support are designed and specified but not built.
![]() |
![]() |
| Level 1-1 — the store, the money counter, a banked coin | The store starts locked, exactly as the original does |
An alien wave, captured from the running WebGL build: the warning banner fires at 275 ticks, the
alien spawns inside the recovered spawn box, its grace window expires and it hunts, and the player's
click does 6 damage and shoves it — vx 1.8 → 6.000, a velocity set rather than an impulse add.
You need your own legitimate copy of Insaniquarium Deluxe. No game assets are in this repository — the retail install is the pipeline's input, not part of the project.
cd web
npm ci
GAME_SRC="/path/to/Insaniquarium Deluxe" npm run assets
npm run devnpm test # 4,967 tests
npm run typecheck
npm run lint
npx tsx tools/dev/simRun.ts --ticks 3200 # the simulation, headless, no browserThe headless run prints the game happening as numbers:
t= 1 SPAWN fish#0 tier=0 fullness=428 creditsPerTier=4 speedDiv=2 coinInterval=153t
t= 129 STARVING fish#0 fullness=300 crossed 301
t= 139 SEEK fish#0 target=0
t= 851 CREDIT fish#0 credits 3 -> 0 ; GROW tier 0 -> 1
t=1003 COIN dropped type=1 value=15
t=1156 COIN dropped type=1 value=15
Every number there is a recovered constant. coinT reads 0/153 for all 851 ticks before the
tier-up and starts counting on the tier-up tick, because tier-0 guppies genuinely do not drop coins.
web/ the game
docs/analysis/ the reverse-engineering write-ups
docs/superpowers/specs/ the implementation spec
docs/analysis/ is the substance behind everything above: the constant table with an address per
row, the recovered mechanics, the class map, the sprite atlas measurements, and the adversarial
reviews that refuted 29 of the first pass's claims.
Insaniquarium and Insaniquarium Deluxe are trademarks of PopCap Games / Electronic Arts. This project is an unaffiliated, non-commercial re-implementation written for study. It ships no game art, audio, fonts or executable code from the original; running it requires your own copy of the retail game, which the build reads locally and never redistributes.
The code in this repository is mine and is MIT licensed. Nothing in docs/analysis/ is a substitute
for owning the game.
If you are the rights holder and would like something changed or removed, open an issue and I will act on it.




