Skip to content

Latest commit

 

History

22 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Insaniquarium — a web re-implementation

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.

The playable tank


Why this was hard in an unusual way

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:

  1. Insaniquarium.exe turned out to be a PopCap DRM stub. The real game, WinFish.exe, is an overlay inside it at offset 0x188000.
  2. Ghidra analysed the extracted binary — 9,139 functions, none of them named.
  3. Walking the MSVC RTTI graph (TypeDescriptorCompleteObjectLocator → vftable) bound 2,156 functions to 39 of the original's own C++ classes.
  4. Eight per-subsystem passes read the decompiled C and pulled out the constants, each with the address of the instruction that uses it.
  5. 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.

Things that came out of the binary that no design document had

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.


Architecture

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 no dt at all — a variable dt makes 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 undefined rather 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.

All 24 pets are data

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 asset pipeline

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.


What is here

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.

Screenshots

Level 1-1 — the store, the money counter, a banked coin The store starts locked, exactly as the original does

An alien encounter

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.

The application


Running it

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 dev
npm test          # 4,967 tests
npm run typecheck
npm run lint
npx tsx tools/dev/simRun.ts --ticks 3200   # the simulation, headless, no browser

The 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.


Repository layout

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.


Legal

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.

About

A browser re-implementation of PopCap's Insaniquarium Deluxe (2004), built on constants recovered by decompiling the original binary rather than tuned by feel. TypeScript, Phaser 3, a pure deterministic simulation, 4,967 tests.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages