Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fungaloids

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 --build

Then open http://localhost:8080.


Screenshots

Main menu Evolved Mode
Main menu Evolved Mode - a crop coming up
Sporulation Classic Mode
A fungaloid reaching sporulation Classic Mode - monochrome, three lives

The idea

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 vs Evolved

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.


Controls

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.


Docker

One command, from a clean checkout:

docker compose up --build

The game is then at http://localhost:8080. To stop it:

docker compose down

A 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 up

That serves http://localhost:5173 with your working tree mounted.

What the image does

The Dockerfile is two stages:

  1. 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.
  2. Runtime (nginx:stable-alpine) - nothing but dist/ and nginx.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.


Local development

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

Developer mode

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.


Architecture

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

Determinism

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.

Performance

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.


Configuration and balance

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.

Difficulty over time

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.


Testing

npm test          # 179 unit tests, no browser needed
npm run test:e2e  # 24 Playwright tests against the production build

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


Accessibility

  • Reduced effects - fewer particles. Also honours the operating system's prefers-reduced-motion setting 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.


Persistence

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


Copyright and provenance

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.

About

Classic Fungaloids style game from the 80s

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages