basin
A browser puzzle game about water basins — read a coastline and deduce where the rivers begin.
- ▶ Play: https://apps.fuzzyworld.net/basin/
- Source: https://code.fuzzyworld.net/basin/
Built with Vite + vanilla TypeScript + Canvas2D; no framework, no backend. Every board is a seed, so a board is just a link.
How it works
Each board has a heightmap that falls to the sea at its ocean sides. Water released from a spring flows downhill, branching to every strictly-lower neighbour, and exits wherever it reaches an ocean edge. Every board is generated with a hidden set of springs; their combined flow lights up a target edge signature, shown as gold outlines on the shore — the land cells immediately inland of each wet ocean edge, rather than the ocean cells themselves.
Each of the four sides is independently rolled ocean (blue — water exits) or a mountain wall (high ground that blocks water). Walls make canyons: e.g. north + south walls with east + west oceans is a valley that drains sideways. Water can never leave through a wall.
The terrain can also be aged: an erosion amount simulates how much weathering happened before you arrived. At 0 the board is a pristine dome; turn it up and simulated hydraulic erosion carves grooves and channels, ragging the coastline and moving where water finally reaches the sea — so a heavily-aged board reads a visibly different edge signature from its pristine self. Erosion is self-limiting: if a particular seed would wash away too much standing land, generation automatically backs off the effective aging for that board so it never degenerates into an almost-all-water board.
Reach controls how far from the shore the hidden springs are drawn from: low values favour land just above the waterline, high values favour high ground near the peak, and the midpoint has no preference (the original behaviour). This only shapes which legible answer generation reaches for — it never affects the unambiguous-solution guarantee below.
How to play
Goal: find the positions of the hidden springs that reproduce that signature. Tap cells to explore the terrain — a wrong tap just previews its flow (and marks any edges it wrongly wets in red), while landing on a real spring locks it in (green, numbered). Find them all to solve the board.
Generation guarantees the puzzle is unambiguous: the hidden springs are the only set of that size whose combined flow produces the target signature, so the gold-edge hint always pins down a single answer (no redundant or interchangeable springs). Boards that can't be made unique fall back to the richest signature found, so a board is always produced.
Springs also never sit on a shore cell — the gold outline never doubles as a giveaway for one of the hidden positions. (Only an extreme combination, like a 5×5 board with 6 springs, can make this strictly impossible; generation falls back gracefully there rather than failing.)
Configuring & sharing boards
Every axis of a board is a control in the panel: size, spring count, which
sides are walls, the aging (erosion) amount, reach, and the terrain
seed. All of it is captured in a single shareable code — a 9-character token
(e.g. B0B0bgTL9) that packs the six axes into 52 bits and base64url-encodes
them, so the settings aren't legible at a glance (reversible obfuscation, not
encryption). The code and the controls are two-way views of the same config —
changing any control rewrites the code, and pasting a code sets every control.
The reach axis grew the code by a character, so pre-reach 8-character codes no
longer decode — a stale shared link just rolls a fresh board instead.
The code also lives in the URL as ?code=<code>, so a link reproduces the exact
board. 🎲 Random rolls a whole new config; the 🎲 next to Terrain rerolls just
the terrain while keeping the other settings.
Each axis also has a lock (🔓/🔒) toggle. Locking an axis pins it: 🎲 Random then rerolls every other setting while leaving the locked ones untouched — so you can, say, freeze the size and walls and keep rolling only the terrain and springs. Locks are a session convenience and aren't part of the shared code.
Develop
npm install
npm run dev # Vite dev server
npm run build # type-check + production build
npm test # run the model tests (Vitest)
Deploy
.gitlab-ci.yml builds the app and publishes it to GitLab Pages on every push
to the default branch. Vite is configured with base: '/basin/' and builds into
public/ (the dir Pages serves), so the live site is
https://apps.fuzzyworld.net/basin/.
Architecture
The model (typed arrays) is decoupled from the renderer so a tiled/WebGL renderer can replace Canvas2D for large boards later.
src/model/heightmap.ts— terrain + per-side edge types (ocean/wall) + droplet-based hydraulic erosion (the aging axis; skipped, and rng-free, at 0) + priority-flood depression filling (guarantees every cell drains to an ocean edge, seeded only from ocean sides).src/model/flow.ts— the branching watershed flood (single- and multi-spring).src/model/puzzle.ts— the boardConfig, its shareable code (encode/decode), config-driven puzzle generation (with an unambiguous-solution check), and the solution check.src/render/renderer.ts— Canvas2D board + puzzle overlay.src/game.ts— puzzle state: exploration probe, locked springs, win detection.
