An alien crop is coming up fast. When a fungaloid finishes growing it starts throwing spores, and the crop gets away from you. You have a flyer, eight bombs and a hundred units of fuel - and both of those live in depots at opposite ends of the map.
You cannot simply bomb everything. That is the game.
Fungaloids is a modern browser reimplementation inspired by the gameplay concepts of the 1982 ZX81 game by Michael Orwin. It is original code, original artwork and original audio; see Copyright and provenance.
docker compose up --buildThen open http://localhost:8080.
![]() |
![]() |
| Main menu | Evolved Mode - a crop coming up |
![]() |
![]() |
| A fungaloid reaching sporulation | Classic Mode - monochrome, three lives |
The original's cleverness was not in what it drew, it was in what it withheld. Everything the player needs is finite and somewhere else:
- Fuel burns continuously, and faster when you manoeuvre. It comes from a tank at the west end of the map, and refuelling takes real time on the ground while everything else keeps shooting.
- Bombs are eight at a time. Reloading means flying to the east end.
- Depots hold a finite reserve. What is in them at the start of a crop is all there is. Run one dry and that resource is gone until the next crop.
- Settlements are dotted along the surface and must never be bombed.
- The air is not empty. Flitters blunder about, splodges swallow bombs, mutant columns rise across your flight path, and mature fungi shoot back.
So every sortie is a judgement: which organism threatens you soonest, whether it is worth the trip, and whether to take one more pass or go home now with two bombs left. Letting a fungaloid ripen almost to sporulation is worth far more points - and it might sporulate before you get back to it.
That tension is the whole design. Everything else serves it.
| Classic | Evolved | |
|---|---|---|
| Presentation | Monochrome, chunky ZX81-style block graphics | Coloured pixel art, lit sky, species colours |
| Lives | Three, each with a full hull | One hull |
| Fungal species | Common Fungaloid only | Seven, introduced roughly one per crop |
| Scoring | Straight points | Combo multiplier up to x5, decaying between kills |
| Threat indicator | None | Ground-map strip showing which organisms are ripening |
| Ecosystem | Fungi grow independently | Crowding, shock waves, spore-seeded colonies |
| Mutant fungus | Fixed position and rhythm | Varying timing; relocates while retracted |
Both modes use exactly the same core loop, the same economy and the same depots. Evolved adds texture; it does not change what the game is.
Classic Mode can be played with the colour palette (Settings -> Enhanced visuals in Classic) without changing any of its rules.
| Action | Keyboard | Gamepad |
|---|---|---|
| Move | Arrow keys or WASD |
Left stick / D-pad |
| Drop bomb | Space |
A / X |
| Pause | P or Esc |
Start |
| Menus | Arrows + Enter |
D-pad + A, B backs |
Every key is rebindable in Settings. Touch devices get an on-screen stick and bomb button. The game is fully playable without a mouse after launch.
Landing. Fly over a depot pad, slow, low and level, and hold your descent. The autopilot takes over and the transfer begins. Climb away to release.
One command, from a clean checkout:
docker compose up --buildThe game is then at http://localhost:8080. To stop it:
docker compose downA Vite development server with hot module replacement is available under an
opt-in profile, so the default up never starts it:
docker compose --profile dev upThat serves http://localhost:5173 with your working tree mounted.
The Dockerfile is two stages:
- Build (
node:22-alpine) -npm ci, then lint, type-check, unit tests and the production build. A lint error, a type error or a failing test stops the image being produced at all. - Runtime (
nginx:stable-alpine) - nothing butdist/andnginx.conf. No Node, no sources, no toolchain.
nginx exposes /healthz, used by both the container HEALTHCHECK and the
Compose healthcheck. Hashed assets under /assets/ are served
immutable, max-age=1y; index.html is no-cache, must-revalidate, so a
deploy can never leave a client asking for an asset hash that no longer exists.
Port 8080 already in use? Compose will refuse to start. Either stop whatever holds it, or map a different host port:
docker compose run --service-ports --publish 8081:80 fungaloids.
Requires Node 20.19+ (Node 22 recommended).
npm install
npm run dev # http://localhost:5173, with HMR| Command | What it does |
|---|---|
npm run dev |
Vite dev server with hot module replacement |
npm run build |
Type-check, then build to dist/ |
npm run preview |
Serve the built output on port 4173 |
npm run typecheck |
tsc --noEmit |
npm run lint |
ESLint, zero warnings tolerated |
npm run format |
Prettier, write mode |
npm test |
Vitest unit tests |
npm run test:coverage |
Unit tests with a V8 coverage report |
npm run test:e2e |
Playwright browser tests (builds and previews first) |
npm run test:e2e:install |
Install the Playwright browser |
npm run verify |
Lint, type-check and unit tests in one go |
npm run screenshots |
Regenerate the README screenshots |
Development builds (npm run dev) show a debug overlay with the RNG seed, FPS,
entity and particle counts, and the current wave phase. Press F1 for the
command list:
| Key | Command | Key | Command |
|---|---|---|---|
| F1 | Toggle this help | F8 | Spawn a fungaloid |
| F2 | Collision boxes | F9 | Spawn a Flitter |
| F3 | Refill fuel | F10 | Spawn a Splodge |
| F4 | Refill bombs | 0 | Clear the crop |
| F6 | Mature all fungi | 9 | Toggle invulnerability |
| F7 | Kill all fungi |
None of this exists in a production build - the overlay and every command sit
behind import.meta.env.DEV, so the bundler removes them entirely.
The end-to-end tests need a handle on the game object in the production build.
Rather than shipping one unconditionally, the production bundle exposes
window.game only when the page is opened with ?automation=1.
The single most important decision in this codebase: the simulation does not import Phaser.
src/game/systems/World.ts and everything it touches - the player, the crop,
the economy, collision, scoring - is plain TypeScript. It advances on a
update(dtMs, input) call and announces what happened through a typed event
bus. The Phaser layer subscribes to those events and draws the result.
That split buys three things:
- A whole wave can be played to completion inside a unit test in milliseconds, with no browser and no canvas. Most of the 179 unit tests do exactly that.
- Collision and movement are deterministic, so a seeded run replays identically.
- Rendering decisions cannot accidentally change gameplay, because the renderer is strictly a reader.
src/
main.ts Entry point; boots Phaser, nothing else
style.css Page shell and the CSS-based CRT overlay
game/
GameContext.ts Long-lived services shared by every scene
config/
balance.ts Every gameplay number, named
difficulty.ts Four presets, expressed purely as multipliers
species.ts The seven fungal species
gameConfig.ts Phaser game configuration
types/ Shared vocabulary; no Phaser imports
utils/ Seeded RNG, object pool, event bus, maths
entities/ Pure state + geometry (Player, Fungaloid, ...)
systems/ Pure logic
World.ts Orchestrates everything; the simulation entry point
FungaloidManager.ts Growth, sporulation, defensive fire, regrowth
SpawnManager.ts Seeded placement of the crop and the creatures
WaveManager.ts Crop progression and difficulty scaling
ResourceManager.ts Fuel, bombs and the two depot reserves
ScoreManager.ts Points, combo multiplier, penalties
StatsTracker.ts Run statistics
GameEvents.ts The typed event contract
render/ Phaser drawing (reads the simulation, never writes)
TextureFactory.ts Every sprite, generated at runtime
WorldRenderer.ts Draws the world
ParticleSystem.ts Hard-capped pooled particles
Theme.ts The two palettes
scenes/ Boot, Preload, MainMenu, Game, Hud, Pause,
GameOver, HighScores, Settings, HowToPlay, Credits
components/ DevOverlay, TouchControls
ui/ Menu widget, panel chrome, text styles
audio/ WebAudio synthesis; no audio files
input/ Keyboard, gamepad and touch, unified
persistence/ Versioned localStorage with defensive validation
All gameplay randomness comes from one seeded Rng (mulberry32). Given the
same seed and the same input sequence, a run plays out identically - which is
what makes an awkward situation reproducible. The seed is shown in the dev
overlay. Cosmetic randomness (particles, star field) uses a separate stream, so
turning effects on or off cannot change the outcome.
The internal resolution is fixed at 960x540 and scaled up with nearest-neighbour filtering. Every frequently created object comes from a fixed-capacity pool - bombs, spores, projectiles, flitters, splodges, mutants, fungaloids and particles - so the simulation cannot allocate without bound and steady-state garbage stays near zero. Effects are capped independently of gameplay: a chain of explosions can dim the fireworks, never the frame rate. The simulation clamps its own timestep, and the loop sleeps when the tab is hidden.
Every number that affects how the game feels lives in src/game/config/. There
are no magic numbers in gameplay code; if a system needs a value, it is named
in a config object first.
balance.ts holds the base values:
| Group | Examples |
|---|---|
WORLD |
width, groundY, ceilingY |
PLAYER |
maxFuel, bombCapacity, maxSpeedX, idleFuelPerSec, damage |
DOCKING |
padHalfWidth, approachSpeed, fuelTransferPerSec |
BOMB |
gravity, inheritedMomentum, splashRadius |
FUNGUS |
baseGrowthRate, stageThresholds, regrowProbability |
ECOSYSTEM |
crowdingRadius, shockGrowthFactor, seededColonyCap |
WAVE |
baseFungaloids, growthMultiplier, depotFuelDecayPerWave |
SCORE |
stageValues, nearSporeBonus, settlementPenalty, comboMax |
DEPOT |
initialFuel, initialBombs, carryOverFraction |
difficulty.ts expresses the four presets purely as multipliers over those
values. Nothing else in the codebase branches on the difficulty id, so adding a
preset is a one-object change:
| Preset | Growth | Spores | Depots | Damage taken | Score |
|---|---|---|---|---|---|
| Relaxed | 0.72x | 0.60x | 1.40x | 0.60x | 0.70x |
| Normal | 1.00x | 1.00x | 1.00x | 1.00x | 1.00x |
| Hard | 1.28x | 1.35x | 0.82x | 1.30x | 1.35x |
| ZX81 | 1.62x | 1.75x | 0.62x | 1.85x | 1.90x |
species.ts defines the seven fungal species relative to the same base values,
along with the crop each is introduced on.
Later crops are harder by combination, not by cranking one number. Raw speed barely changes; what changes is how many decisions you owe at once - more colonies, awkward new species, leaner depots, more in the air.
npm test # 179 unit tests, no browser needed
npm run test:e2e # 24 Playwright tests against the production buildUnit tests (Vitest) cover the simulation directly, because it is Phaser-free: scoring and the near-spore bonus, the combo multiplier and its decay, fuel consumption, depot transfer and conservation, bomb inventory, wave progression and difficulty scaling, growth stages and rates, colony regrowth and stump eradication, species behaviour, settlement penalties, bomb interception, the mutant's cycle, creature behaviour, hull and lives, pool ceilings, timestep clamping, and persistence validation and migration.
Browser tests (Playwright) build the production bundle, serve it, and drive it with real keystrokes: the app loads, the menu appears, a game starts, the flyer responds to arrows and to WASD, bombs are consumed, fuel drains, pause genuinely freezes the simulation, resume works, quit returns to the menu, settings persist across a reload, a corrupted save still boots, the game-over flow records a run and a high score, both modes are playable, the canvas scales correctly across four desktop resolutions, and no unexpected console errors occur throughout.
- Reduced effects - fewer particles. Also honours the operating system's
prefers-reduced-motionsetting automatically. - Screen shake - independently switchable, and suppressed by
prefers-reduced-motion. - CRT effects - scanlines, phosphor glow and vignette are three separate toggles. They are CSS over the canvas, not shaders, so they cost nothing and can never make the picture unreadable.
- High-contrast HUD - brighter, plainer instrument colours.
- Full remapping - every control, keyboard and gamepad alike.
- No mouse required after launch; touch devices get on-screen controls.
- Mute and independent master, music and effects volumes.
All settings persist in localStorage.
Everything is stored locally under the single key fungaloids.save. There is
no backend, no account and no network traffic of any kind.
The structure is versioned. On load, every field is validated independently and falls back to its default, so a save that is partially corrupt, hand-edited or written by an older build still produces a usable configuration rather than an exception. A browser with storage disabled degrades to an in-memory session instead of failing to start.
Stored: settings, control bindings, a local top-ten leaderboard (name, score, mode, difficulty, crop reached, date) and lifetime statistics (runs flown, best score, highest crop, fungi and spores destroyed, bombs dropped, bombing accuracy, settlements hit, time airborne).
This is an original work, inspired by the gameplay concepts of Fungaloids, written by Michael Orwin for the Sinclair ZX81 in 1982.
No source code, machine code, tape or ROM images, graphics, audio or any other asset from the original were used, copied, disassembled or reproduced. The design, code, artwork and audio here are original.
There are, in fact, no asset files at all: every sprite is drawn at runtime
from rectangles in TextureFactory.ts, and every sound is synthesised from
oscillators and shaped noise in src/game/audio/.
Dependency licences are documented in THIRD-PARTY-LICENCES.md. This project is released under the MIT Licence.



