ef44314f 2026-06-30 Pavel Safronov | 1 | # basin |
| 2 | |
85526504 2026-07-01 Pavel Safronov | 3 | A browser puzzle game about water basins — read a coastline and deduce where the |
| 4 | rivers begin. |
| 5 | |
f8e9c765 2026-10-27 Pavel Safronov | 6 | - **▶ Play:** <https://apps.fuzzyworld.net/basin/> |
| 7 | - **Source:** <https://code.fuzzyworld.net/basin/> |
85526504 2026-07-01 Pavel Safronov | 8 | |
| 9 | Built with Vite + vanilla TypeScript + Canvas2D; no framework, no backend. Every |
| 10 | board is a seed, so a board is just a link. |
| 11 | |
| 12 | ## How it works |
a7bf123a 2026-07-01 Pavel Safronov | 13 | |
| 14 | Each board has a heightmap that falls to the sea at its **ocean** sides. Water |
| 15 | released from a *spring* flows downhill, branching to every strictly-lower |
| 16 | neighbour, and exits wherever it reaches an ocean edge. Every board is generated |
| 17 | with a hidden set of springs; their combined flow lights up a **target edge |
36e6a8b9 2026-07-02 Pavel Safronov | 18 | signature**, shown as gold outlines on the *shore* — the land cells immediately |
| 19 | inland of each wet ocean edge, rather than the ocean cells themselves. |
a7bf123a 2026-07-01 Pavel Safronov | 20 | |
| 21 | Each of the four sides is independently rolled **ocean** (blue — water exits) or |
| 22 | a **mountain wall** (high ground that blocks water). Walls make canyons: e.g. |
| 23 | north + south walls with east + west oceans is a valley that drains sideways. |
| 24 | Water can never leave through a wall. |
| 25 | |
03c2cc12 2026-07-01 Pavel Safronov | 26 | The terrain can also be **aged**: an *erosion* amount simulates how much |
| 27 | weathering happened before you arrived. At 0 the board is a pristine dome; turn |
| 28 | it up and simulated hydraulic erosion carves grooves and channels, ragging the |
| 29 | coastline and moving where water finally reaches the sea — so a heavily-aged |
36e6a8b9 2026-07-02 Pavel Safronov | 30 | board reads a visibly different edge signature from its pristine self. Erosion |
| 31 | is self-limiting: if a particular seed would wash away too much standing land, |
| 32 | generation automatically backs off the effective aging for that board so it |
| 33 | never degenerates into an almost-all-water board. |
| 34 | |
| 35 | **Reach** controls how far from the shore the hidden springs are drawn from: |
| 36 | low values favour land just above the waterline, high values favour high |
| 37 | ground near the peak, and the midpoint has no preference (the original |
| 38 | behaviour). This only shapes which legible answer generation reaches for — it |
| 39 | never affects the unambiguous-solution guarantee below. |
03c2cc12 2026-07-01 Pavel Safronov | 40 | |
85526504 2026-07-01 Pavel Safronov | 41 | ## How to play |
| 42 | |
a7bf123a 2026-07-01 Pavel Safronov | 43 | **Goal:** find the positions of the hidden springs that reproduce that signature. |
| 44 | Tap cells to explore the terrain — a wrong tap just previews its flow (and marks |
| 45 | any edges it wrongly wets in red), while landing on a real spring **locks it in** |
| 46 | (green, numbered). Find them all to solve the board. |
| 47 | |
85526504 2026-07-01 Pavel Safronov | 48 | Generation guarantees the puzzle is **unambiguous**: the hidden springs are the |
| 49 | *only* set of that size whose combined flow produces the target signature, so the |
| 50 | gold-edge hint always pins down a single answer (no redundant or interchangeable |
| 51 | springs). Boards that can't be made unique fall back to the richest signature |
| 52 | found, so a board is always produced. |
| 53 | |
36e6a8b9 2026-07-02 Pavel Safronov | 54 | Springs also never sit on a shore cell — the gold outline never doubles as a |
| 55 | giveaway for one of the hidden positions. (Only an extreme combination, like a |
| 56 | 5×5 board with 6 springs, can make this strictly impossible; generation falls |
| 57 | back gracefully there rather than failing.) |
| 58 | |
85526504 2026-07-01 Pavel Safronov | 59 | ## Configuring & sharing boards |
a7bf123a 2026-07-01 Pavel Safronov | 60 | |
| 61 | Every axis of a board is a control in the panel: **size**, **spring count**, which |
36e6a8b9 2026-07-02 Pavel Safronov | 62 | sides are **walls**, the **aging** (erosion) amount, **reach**, and the **terrain |
| 63 | seed**. All of it is captured in a single shareable **code** — a 9-character token |
| 64 | (e.g. `B0B0bgTL9`) that packs the six axes into 52 bits and base64url-encodes |
| 65 | them, so the settings aren't legible at a glance (reversible obfuscation, not |
| 66 | encryption). The code and the controls are two-way views of the same config — |
| 67 | changing any control rewrites the code, and pasting a code sets every control. |
| 68 | The reach axis grew the code by a character, so pre-reach 8-character codes no |
| 69 | longer decode — a stale shared link just rolls a fresh board instead. |
a7bf123a 2026-07-01 Pavel Safronov | 70 | |
| 71 | The code also lives in the URL as `?code=<code>`, so a link reproduces the exact |
| 72 | board. **🎲 Random** rolls a whole new config; the 🎲 next to Terrain rerolls just |
| 73 | the terrain while keeping the other settings. |
| 74 | |
b9acd697 2026-07-01 Pavel Safronov | 75 | Each axis also has a **lock** (🔓/🔒) toggle. Locking an axis pins it: **🎲 Random** |
| 76 | then rerolls every *other* setting while leaving the locked ones untouched — so you |
| 77 | can, say, freeze the size and walls and keep rolling only the terrain and springs. |
| 78 | Locks are a session convenience and aren't part of the shared code. |
| 79 | |
a7bf123a 2026-07-01 Pavel Safronov | 80 | ## Develop |
| 81 | |
| 82 | ```bash |
| 83 | npm install |
| 84 | npm run dev # Vite dev server |
| 85 | npm run build # type-check + production build |
| 86 | npm test # run the model tests (Vitest) |
| 87 | ``` |
| 88 | |
85526504 2026-07-01 Pavel Safronov | 89 | ## Deploy |
| 90 | |
| 91 | `.gitlab-ci.yml` builds the app and publishes it to **GitLab Pages** on every push |
| 92 | to the default branch. Vite is configured with `base: '/basin/'` and builds into |
| 93 | `public/` (the dir Pages serves), so the live site is |
f8e9c765 2026-10-27 Pavel Safronov | 94 | <https://apps.fuzzyworld.net/basin/>. |
85526504 2026-07-01 Pavel Safronov | 95 | |
a7bf123a 2026-07-01 Pavel Safronov | 96 | ## Architecture |
| 97 | |
| 98 | The model (typed arrays) is decoupled from the renderer so a tiled/WebGL renderer |
| 99 | can replace Canvas2D for large boards later. |
| 100 | |
| 101 | - `src/model/heightmap.ts` — terrain + per-side edge types (ocean/wall) + |
03c2cc12 2026-07-01 Pavel Safronov | 102 | droplet-based hydraulic **erosion** (the aging axis; skipped, and rng-free, at |
| 103 | 0) + priority-flood depression filling (guarantees every cell drains to an |
| 104 | *ocean* edge, seeded only from ocean sides). |
a7bf123a 2026-07-01 Pavel Safronov | 105 | - `src/model/flow.ts` — the branching watershed flood (single- and multi-spring). |
| 106 | - `src/model/puzzle.ts` — the board `Config`, its shareable code (encode/decode), |
85526504 2026-07-01 Pavel Safronov | 107 | config-driven puzzle generation (with an unambiguous-solution check), and the |
| 108 | solution check. |
a7bf123a 2026-07-01 Pavel Safronov | 109 | - `src/render/renderer.ts` — Canvas2D board + puzzle overlay. |
| 110 | - `src/game.ts` — puzzle state: exploration probe, locked springs, win detection. |