a tiny tale of tremendous artillery β a browser-based artillery game in the spirit of Team17's Worms, told across four storybook lands.
Built with Phaser 3 + TypeScript + Vite. All art is procedural (canvas-generated
textures, hand-drawn vector characters). Sound is a hybrid: a curated CC0 sample
pack (Kenney SFX + FreePD music, see public/audio/CREDITS.md) carries explosions,
footsteps, UI and the per-biome soundtrack, while WebAudio synthesis still voices the
characterful cues (charge hum, sheep baa, ceremony chimes) and covers every sample
while it loads β the game is never silent, even offline.
npm install
npm --prefix server install
npm run dev:all # client β http://localhost:5173 Β· game server β :9770npm run dev runs just the client (local/hotseat play works without the server);
npm run dev:server runs just the game server. There is no separate database or
docker service β the server embeds SQLite (node:sqlite, file created on boot).
Node β₯ 22.5 is required for the server.
Production build: npm run build (output in dist/, servable from any static
host β point it at your server with VITE_SERVER_URL in .env.local).
Start the game server in one terminal, then launch the native simulator build from another:
# iOS Simulator
npm run dev:server:ios:local
npm run cap:run:ios:local
npm run verify:mobile:native:ios:local
# Android Emulator
npm run dev:server:android:local
npm run cap:run:android:local
npm run verify:mobile:native:android:localLocal native bundles are platform-specific because the iOS Simulator reaches the
server through http://localhost:9770, while the Android Emulator reaches the
host through http://10.0.2.2:9770. Use npm run cap:sync:ios:local or
npm run cap:sync:android:local before the matching scoped verifier when you
switch simulator platforms.
The Android npm run/build helpers resolve JDK 21 and common Android SDK
locations before invoking Gradle or Capacitor. If you run Gradle directly, set
JAVA_HOME and ANDROID_HOME / ANDROID_SDK_ROOT, or create Android Studio's
ignored android/local.properties with sdk.dir=....
Guest login, hosted lobbies, quick match, and bot skirmish work without Discord
secrets. Discord login on a simulator also needs DISCORD_CLIENT_ID and
DISCORD_CLIENT_SECRET in the server environment, plus the matching local
redirect URL registered in Discord: http://localhost:9770/auth/discord/callback
for iOS Simulator or http://10.0.2.2:9770/auth/discord/callback for Android
Emulator.
The Sprouts face The Nightcaps β against the AI or a friend in 2-player hotseat β taking alternating turns on a fully destructible island. Last team wiggling wins.
- π Dusk Meadow β fireflies, flowers, fable trees and a low apricot sun
- βοΈ Frozen Tundra β snowfall, ice crystals, snowy pines, aurora and floating ice shelves
- π Ember Hollow β charred spires, magma veins, old bones, and a sea of lava
- π Glimmer Glen β a bioluminescent night glen: giant glowing mushrooms, drifting spores
- π² Random β surprise me
Clicking a land on the menu repaints the world behind it, live. Each map also rolls a landform archetype β rolling hills, a valley with flanking cliffs, terraced mesas, or jagged spires β so no two islands feel alike.
Nine skills ride on the hotbar (keys 1β9); everything else lives in the
backpack (key 0, or the π slot at the end of the dock). The backpack
dialog lets you stow hotbar skills and equip packed ones mid-battle, and the
Locker β Armory arranges the same dock between battles β your loadout is
remembered between sessions. On desktop the dock stands upright in the
bottom-left corner; on phones it lies flat along the bottom.
Everyone starts with the classic fourteen:
| # | # | ||||
|---|---|---|---|---|---|
| 1 | π Bazooka | wind-riding rocket | 8 | π« Shotgun | two quick shots |
| 2 | ποΈ Homing Pigeon | seeks the nearest foe | 9 | β‘ Uzi | 14-round steerable spray |
| 3 | π£ Grenade | 3s fuse, bouncy | 0 | 𧨠Dynamite | drop it and RUN |
| 4 | π Cluster Bomb | pops into five | Β· | π± Mine | arms itself, holds a grudge |
| 5 | ⨠Holy Grenade | hallelujah⦠| · | click a target | |
| 6 | π Sheep | hops merrily; fire again to boom | Β· | π₯ Napalm Strike | rains burning fire |
| 7 | π Baseball Bat | point-blank home run | Β· | π Teleport | click: blink away |
Six more are earned exactly like cosmetics β XP levels, rank tiers, or acorns:
| unlock | ||
|---|---|---|
| π― Marksman Rifle | one pinpoint 34-damage crack across the island | level 7 |
| π₯ Mortar | an impact shell that pops into four bomblets | level 10 |
| πΎ Molotov | a warm hello that leaves burning patches | level 14 |
| π Banana Bomb | five more where that came from | Ember rank |
| π The Prod | one point of damage, one enormous shove | π° 200 |
| πͺοΈ Minigun | 24 rounds of overkill | π° 600 |
Mines chain-react to explosions, napalm and molotovs leave burning patches that hurt over time, and the sheep answers to no one after launch. Online, each team fights with its own unlocked arsenal (crates only ever gift weapons you own), so your opponent's banana is no business of yours β yet.
- Foe β π€ vs AI or π₯ 2-player hotseat (the dock switches sides each turn)
- Worms β 3, 4 or 5 per team
- Sudden death β from round 8 the sea rises every turn; high ground or bust
- Difficulty β Cozy (aim preview, gentle AI), Classic, Brutal (deadeye AI, 30s turns)
Wind changes every turn, crates parachute in with health or bonus weapons, fallen worms leave gravestones, and the match ends in a full prize ceremony: the verdict slams in letter by letter under a slow sunburst, paper confetti rains for the victor, the scrapbook stat cards slap down and the MVP takes a bow. One tap anywhere skips to the settled ledger.
Damage lands the classic Worms way: a hit flings the worm first, and only once it tumbles to a rest does the camera lean in, the number pop, and the health drain (multiple victims get a little camera tour each). Worms are solid to each other β walk into a teammate and you stop, drop onto one and you stand on their head (knocked worms still sail through, and blast landings get gently nudged apart). And when a worm finally goes, it goes its own way: the equipped farewell animation plays and its chosen gravestone marks the spot β both cosmetic slots in the Locker.
LOCKER on the menu opens your inventory, in two panes sharing one unlock language (starter kit β XP levels β rank tiers β the shop):
- π Wardrobe β the live worm mannequin (the real in-game renderer, pupils following your pointer) and rarity-framed racks across eight slots: hats, eyes (buttons, sleepy lids, hearts, a cyclops, the Will-o'-Glowβ¦), specs, whiskers, bling, auras, gravestones (wooden cross, glowing glen cap, a rubber ducky, the gilded acornβ¦) and farewells β the death animation itself (sky ascent, restless spirit, grand-finale firework, supernovaβ¦). The grave rack poses the stone beside the mannequin; the farewell rack plays a full live-flicker-boom rehearsal on the stage. Hover to try on, click to dress the whole team.
- βοΈ Armory β every weapon on the same racks, with a spotlight card (damage, ammo, unlock story) and your 9-slot dock to arrange between battles. Locked weapons hang in shadow with their price of admission.
Shop items are bought with acorns π° β earned by finishing matches
(8 + 0.1/damage + 18 for the win, ranked Γ1.15, bots Γ0.35), stored on your
account, spent with one tap on the price tag. Purchases, like every other
unlock, are validated server-side (POST /me/buy).
PLAY ONLINE on the menu opens the hub. Play as a guest (just a name) or sign in with Discord β accounts earn XP / levels, a rank, and acorns for the Locker shop.
- βοΈ Quick Match β casual 1v1 matchmaking, first come first fought
- π Ranked Match β Elo matchmaking with a widening rating window; climbs the tiers (Seedling β Sprout β Bramble β Nightcap β Ember β Tempest β Mythic). Needs a Discord account.
- π€ Bot Skirmish β the server-side AI at cozy/classic/brutal. Counts for XP while signed in, at a reduced rate (Γ0.35).
- ποΈ Browse / Host an Island β public lobby browser, or host with your own settings (land, worms, turn time, sudden death, public or code-only) and share the room code.
The menu wears an identity chip (avatar, level, rank crest) and a Friends button in the top-right. The chip opens your profile page: the live worm mannequin in your full loadout keeping vigil beside your chosen gravestone, your rank crest with rating progress to the next tier, the XP bar, nine lifetime stat tiles (battles, win rate, ranked record, damage dealt & taken, biggest hit, worms KO'd & lost, shots fired) and your recent match history β every online match with an account aboard is recorded.
The Friends drawer (accounts only) searches islanders by name, sends and accepts friend requests (pings arrive live, with a badge on the button), shows presence for every friend β online, in a lobby, in queue, in battle β and lets you tap any name for their profile page, where you can compare stats side by side or invite them to battle. Invites ride a persistent social channel: the inviter's lobby (or a freshly auto-hosted code-only island) reaches the friend as a floating Join pop wherever they are in the menus. Lobby seats are tappable too β size up your opponent's profile before the horn sounds.
Matches are fully server-authoritative: the server runs the entire simulation (movement, physics, all 20 weapons, wind, crates, sudden death) at 30 Hz and clients only send intents β walk, aim, charge, fire. Each team's in-match arsenal is derived from its account on the server (level, rank, purchases) β never from anything the client claims. Clients render 15 Hz snapshots with interpolation and replay a discrete event stream for effects. The destructible terrain is regenerated from the match seed on each client and kept in sync by carve events, so both players (and the server) agree on every pixel. A dropped player gets a 60-second reconnection window (the sim pauses); quitting mid-match forfeits.
XP: base 30 + 0.4/damage + 70 for the win (+15% ranked, Γ0.35 vs bots). Acorns follow the same shape at roughly a quarter scale. Ranked rating: Elo, K=32, starting at 1000.
Signed in, the end ceremony tallies the night on a kraft Field Report: the XP bar genuinely fills (crossing a level flashes the bar, rolls the badge and bursts), acorns click into the pouch, and the rating odometer spins β crossing a tier line stages a whole rank promotion, crest swap, ribbon and all. Every weapon or cosmetic whose level/rank threshold was crossed this match is dealt face-down and flipped as a rarity-glowing prize card (the unlock rules are the same shared catalog the server enforces, so the cards can never lie), and a "road ahead" strip teases the nearest thresholds still to chase. Guests earn nothing β the ceremony shows them the exact XP and acorns that washed away with the tide, next to a Discord sign-in button.
| Input | Action |
|---|---|
A / D (or arrows) |
walk |
W / double-tap S |
jump / backflip |
| Mouse | aim |
Hold click / Space, release |
charge & fire |
| Click | fire instant weapons Β· detonate the sheep Β· place targeted strikes |
1β9, Tab, or click the dock |
switch weapon |
0 |
open the backpack (stow & equip skills) |
Wheel / right-drag / F |
zoom, pan, re-focus camera |
M / Esc |
mute / pause |
The game is installable as a PWA (manifest + service worker) and insists on landscape: a rotate prompt covers portrait, and starting a match requests fullscreen + an orientation lock where the platform allows it. Touch play: tap-hold to aim & charge, release to fire, on-screen pads walk and jump/backflip, pinch to zoom, and the dock + backpack are all tappable.
src/
const.ts world dimensions, teams, difficulty, match settings,
THEMES (palette, sky, hills, sea, ambient), render depths
utils.ts math, seeded RNG, 1-D value noise
audio.ts hybrid SFX engine: CC0 samples (public/audio/sfx) with
pitch-jittered round-robin variants + WebAudio synthesis
for bespoke cues and as an instant fallback
music.ts crossfading BGM (menu + one track per biome) and
synthesized ambience beds (wind / cave drips / lava)
terrain.ts destructible pixel terrain: Uint8Array mask + painted
CanvasTexture + tinted silhouette for the cartoon outline;
landform archetypes (rolling / valley / mesa / spires /
cliffs / skyisles / cavern β the last fully roofed, where
airstrikes splat on the rock), ledge notches, jutting
shelves, worm tunnels and hidden pockets; theme-driven
paint with 2D-noise mottling, strata, mineral veins,
per-seed palette drift, cave-moss floors and hanging
ceiling fringe (roots / icicles / magma drips / crystals),
buried inclusions & decorations, from grass tufts up to
trees, pines, bone arches and giant glow-mushrooms
background.ts theme-driven sky, stars, aurora, sun, parallax hills,
clouds, sea or lava, fireflies / snow / embers; textures
repaint in place so theme swaps are live
fx.ts particle emitters, explosions, flames, feathers, splashes,
damage text, wind streaks
weapons.ts weapon table, firing (charged / hitscan / melee / placed),
the one true explode() with mine chain-reactions
ai.ts enemy brain β simulates real trajectories over an
angleΓpower grid, scores impacts, knows when to bat,
drop dynamite, spray the uzi or release the pigeon
ui.ts DOM chrome: menu with match options, HUD, the 9-slot
hotbar dock + backpack dialog (loadout persisted in
localStorage), touch controls, fullscreen/orientation
helpers, round/sudden-death pill; hands the match
verdict to endscreen.ts
endscreen.ts the post-match prize ceremony: skippable timeline
(letter-slam verdict, confetti, staged reveals), the
Field Report (XP bar with level-up bursts, acorn
ticker, rating odometer + rank promotions), prize
cards for every unlock threshold crossed this match
(diffed with the shared catalogs), the road-ahead
teaser and the guest nudge
entities/ worm (mask physics + worm-vs-worm collision + deferred
damage that lands once the worm settles, the upright
Team17-style character standing on a coiled tail,
slope-aware posture, and eight cosmetic skin slots β
hat / eyes / glasses / mustache / earring / aura /
gravestone / farewell β rolled from the worm's name or
dressed by the wardrobe), wormdraw (the figure renderer,
shared with the wardrobe preview), gravedraw (every
equippable resting place, same GfxLike trick), deathfx
(the farewell animations as stateless frame painters β
identical in-game and on the wardrobe stage),
projectile (incl. homing steering), crate, gravestone,
strike plane, mine, sheep
wardrobe.ts the equipped loadout: localStorage + account sync
inventory.ts the client's view of ownership: acorn balance, shop
purchases, which weapons this account may field, /me/buy
wardrobe-ui.ts the Locker dialog: Wardrobe pane (live mannequin, rarity
racks) + Armory pane (weapon cards, spotlight, dock
shelf) sharing one unlock/purchase grammar
profile-ui.ts the profile page: live mannequin + gravestone, rank
crest & tier progress, stat tiles, match history,
friend / invite / stat-compare actions
social-ui.ts the people layer: menu identity chip, friends drawer
(search, requests, presence), floating game-invite pops
gfx2d.ts canvas-2D adapter so wormdraw renders in the DOM
scenes/GameScene.ts turn state machine, sudden-death sea, fire patches,
camera direction (incl. the damage-number zoom-in tour),
input, aim UI
net/ online play:
config.ts server URL (VITE_SERVER_URL)
auth.ts token store, Discord OAuth return, guest login, /me
client.ts @colyseus/sdk wrapper (create/join/queue/reserve)
social.ts the social channel: friends cache, presence, invites,
profile fetches (REST + the persistent social room)
netui.ts online hub, lobby browser, lobby room, queue overlay;
feeds the match result + progression report to the
end ceremony (endscreen.ts)
NetGameScene.ts renders online matches: sends intents, interpolates
snapshots into puppet entities, replays the event stream
shared/ dependency-free code run by BOTH client and server
sconst.ts physics/weapon/terrain-gen constants (single source)
srng.ts deterministic RNG + noise
mask.ts seed β identical terrain mask everywhere
sim.ts the authoritative match simulation (headless port of
the whole game: worms, weapons, mines, sheep, planes,
fires, turn machine, sudden death)
sai.ts the bot brain, running server-side
protocol.ts every websocket message type
progression.ts XP curve, match XP, Elo, rank tiers
cosmetics.ts the wardrobe catalog: items, rarities, unlock rules
(levels / rank tiers / acorn shop / future lootbox) +
the loadout validator both client and server run
arsenal.ts the weapon unlock catalog + per-account arsenal builder
economy.ts acorn payouts, shop prices, owned-keys validation
server/ Colyseus + Express game server
src/index.ts HTTP + websocket entry, /health, /lobbies
src/auth.ts Discord OAuth2 code flow, guest JWTs, /me, /me/buy
src/profile.ts public profile pages, player search, friends CRUD
src/social.ts in-memory presence + push hub (friend pings, invites)
src/db.ts SQLite (node:sqlite) accounts, progression, acorns,
owned items, lifetime battle stats, match history,
friendships
src/rooms/MatchRoom lobby phase β 30 Hz authoritative sim β results;
reconnection grace, forfeits, XP/Elo persistence +
lifetime stats & match_log rows
src/rooms/QueueRoom casual & ranked matchmaking (widening Elo window)
src/rooms/SocialRoom the always-on social channel: presence, game invites
src/simtest.ts headless bot-vs-bot smoke test (npm run simtest)
scripts/verify*.mjs headless Playwright smoke tests (needs Chrome installed)
The terrain is the heart of it: a Uint8Array solidity mask mirrored by a painted
canvas texture. Explosions carve both (plus a scorch rim) and re-upload the texture;
worms, projectiles, sheep and the AI's trajectory simulation all sample the same mask.
The sea level (WORLD.waterY) is mutable β sudden death raises it turn by turn and
everything (drowning, splashes, AI pathing, the painted sea) reads the live value.
Copy server/.env.example to server/.env and fill it in:
| Variable | What |
|---|---|
JWT_SECRET |
long random string (openssl rand -hex 32) β signs session tokens |
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET |
from the Discord developer portal |
SERVER_URL |
public URL of the game server (dev: http://localhost:9770) |
CLIENT_ORIGIN |
allowed client origins, comma-separated (dev: http://localhost:5173) |
In the Discord application, under OAuth2 β Redirects, add:
http://localhost:9770/auth/discord/callback (dev)
https://<your-server-domain>/auth/discord/callback (prod)
Only the identify scope is used. Without the Discord secrets the server
still runs β guest play, lobbies, quick match and bot matches all work;
only Discord sign-in (and therefore XP/rank persistence) is disabled.
With the dev server running and Google Chrome installed
(screenshots land in shots/):
node scripts/verify.mjs # menu, live theme previews, a shot, AI turn
node scripts/verify-weapons.mjs # grenade, airstrike, real victory flow
node scripts/verify-arsenal.mjs # sheep/uzi/dynamite/pigeon/napalm/mine/
# teleport/bat + hotseat + sudden death
node scripts/verify-deep.mjs # explosions, drowning, death, endscreen, fps
node scripts/verify-ux.mjs # close-ups, pause, quit-to-menu, replay
node scripts/verify-online.mjs # needs dev:all β guest auth, bot skirmish,
# host+join lobby, PvP turns, forfeit flow
node scripts/verify-wardrobe.mjs # wardrobe pane: tabs, rarities, locks,
# equip + hover try-on, loadout in a match
node scripts/verify-wardrobe-online.mjs # needs dev:all β loadout validated
# by the server and worn in a bot skirmish
node scripts/verify-locker.mjs # the Locker: armory cards, lock states, buy
# tags, dock arranging, and the new weapons
# fired in a real match (dev unlock-all)
node scripts/verify-endscreen.mjs # the end ceremony: full jackpot rehearsal
# (level-up, rank-up, prize cards, road
# ahead), tap-to-skip, guest nudge, and a
# real local victory rolling the ceremony
npm run simtest # headless server sim: full bot-vs-bot matches