Tap a tile. It drains. Empty the board.
A grid logic puzzle in a single HTML file. No build step, no dependencies, no framework — open it in a browser and it runs.
Built by AnbuTech.
Tap a filled tile and it removes one dot from itself and from each tile above, below, left and right of it. Clear every dot on the board.
Dots floor at zero, so draining an already-empty neighbour throws that drain away. Order matters. That single rule is the whole puzzle.
| Element | Meaning |
|---|---|
| Pips on a tile | How many drains it still needs |
| Dashed outline | Empty tile — nothing to tap |
| Par | Steps the generator used to build the board |
Par is a target, not a limit. A shorter route often exists.
| Input | Effect |
|---|---|
| Tap / click a tile | Drain it and its four orthogonal neighbours |
Tab / Shift+Tab |
Move between tiles |
Enter / Space |
Drain the focused tile |
Empty tiles are disabled, so keyboard focus skips them automatically.
git clone https://github.com/<your-org>/ebb.git
cd ebb
open ebb.html # or just drag it into a browserTo test on a phone over your local network:
python3 -m http.server 8000
# then visit http://<your-lan-ip>:8000/ebb.htmlThe hard problem in any procedurally generated puzzle is guaranteeing a solution exists. Randomise a board and you will eventually hand a player something impossible — the fastest way to lose them.
Ebb never has that problem, because it never generates a board. It generates a solution, then plays it in reverse:
// start empty, then run the drain backwards — add dots instead of removing them
while (placed < k){
const i = Math.floor(Math.random() * N * N);
const group = [i].concat(neighbours(i));
if (group.some(c => g[c] >= MAX)) continue; // respect the pip ceiling
group.forEach(c => g[c]++);
placed++;
}Whatever board falls out the other side is, by construction, the end state of a valid sequence of taps. Undoing that sequence solves it. par is the number of reverse steps used.
Two consequences worth knowing:
- Par is an upper bound, not the optimum. Overlapping reverse steps sometimes cancel, so a shorter path frequently exists. Finding it is the actual game.
- The pip ceiling makes generation lossy. A reverse step that would push any tile past
MAXis rejected and retried, so a board occasionally lands below its requested step count. The guard counter caps the retries;parreports what was actually placed, not what was asked for.
This is a static grid with no per-frame animation. Canvas would mean reimplementing focus management, hit testing, and screen-reader labels by hand, badly. Real <button> elements in a CSS grid give all of that for free, and the pip layout is just absolutely positioned spans at fixed percentages.
Dice-style patterns parse pre-attentively. You take in a whole board at a glance rather than reading digits cell by cell — which matters when the puzzle is about scanning for the right sequence, not the right tile.
loadBest() / saveBest() currently call window.storage, an async key–value API from the environment this prototype was authored in. Both are wrapped in try/catch and fail silently, so the game works fine without it — progress just resets on reload.
Before shipping, swap in localStorage:
function loadBest(){
const v = parseInt(localStorage.getItem('ebb:level') || '1', 10);
if (v > 1){ best = v; load(v); }
}
function saveBest(){
localStorage.setItem('ebb:level', String(best));
}Note these are declared async in the current source. Drop the keyword when you swap them, or leave it — the call sites don't await.
| Constant | Default | Effect |
|---|---|---|
N |
5 |
Grid size. 6 roughly doubles the search space |
MAX |
4 |
Pip ceiling per tile, and the size of LAYOUT |
min(14, 2 + level) |
— | Difficulty ramp: reverse steps per level, and its cap |
LAYOUT |
— | Pip positions per value, as [x%, y%] pairs |
Raising MAX above 4 requires adding entries to LAYOUT — Math.min(v, MAX) will otherwise silently render the wrong count.
The ramp is the thing to tune first. Levels 1–4 are close to trivial by design; the puzzle gets interesting around level 7, and you may want to start players higher.
| Token | Value | Use |
|---|---|---|
| Pine | #123F34 |
Background |
| Pine deep | #0C2C25 |
Button text on accent |
| Bone | #EDE7D6 |
Tiles, type |
| Ink | #0F2B24 |
Pips |
| Amber | #E8A33D |
Accent, drain flash, focus ring |
| Dust | rgba(237,231,214,.28) |
Empty outlines, borders |
Fraunces for display, Space Grotesk for interface. Tiles carry a hard bottom shadow that collapses on :active, so a tap reads as physical depression. prefers-reduced-motion disables all animation and transitions.
- Replace
window.storagewithlocalStorage - Solver to compute true optimal par, not generator par
- Level select for completed levels
- Audio: a soft tick per drain, a chime on clear
- Daily puzzle from a fixed seed
- Wrap with Capacitor for an Android build
AnbuTech — an elite engineering unit that ships. anbu-tech.com · LinkedIn
Copyright © 2026 AnbuTech. All rights reserved.
This software and its source code are the proprietary property of AnbuTech. No part of this repository may be reproduced, distributed, modified, or used to create derivative works without prior written permission from AnbuTech.