Technical guide

Puzzles

Aquarium

A logic puzzle where you fill tanks with water until each row and column holds as much water as the number beside it says.

Checked against the code on 2026-10-30. Play Aquarium

On this page
  1. At a glance
  2. The rules in code
    1. A puzzle
    2. Water is one number per tank
    3. Checking an answer
  3. Making a puzzle
    1. 1. Grow the tanks
    2. 2. Pour the water
    3. 3. Keep it only if it rates exactly
  4. The solver
    1. The independent count
  5. Sizes and difficulty
  6. What the browser holds
  7. Your move
  8. Posting and the referee
  9. Rating
  10. The interface
    1. What the board keeps
    2. Filling and crossing out
    3. The helpers
    4. Solved
    5. Accessibility
    6. The look
    7. Stored in the browser
  11. Leaderboards and stats
  12. Its look
  13. Testing
  14. Changing it
  15. Known limitations
  16. Known issues

Aquarium is a solo logic puzzle. Heavy lines divide a square grid into tanks, and you fill cells with water. Water in a tank is always level, and each row and column must hold exactly the number of water cells printed beside it.

Each puzzle is made in your browser from a six-letter code and has exactly one answer, reachable with the techniques its size promises. The pencil-puzzle kit provides the hub, clock, saving, posting, dailies, archive and result sheet (The pencil-puzzle kit); this chapter covers what’s Aquarium’s own.

At a glance#

Part Where What it is
Engine games/static/games/aquarium/engine/ board.js (puzzle shape, rules, check), generate.js (tanks, water, keeping a puzzle), solver.js (the human-style solver and rater), count.js (a plain search that re-proves uniqueness), index.js (the kit’s engine contract)
Interface games/static/games/aquarium/js/ puzzle.js (words, settings, How to play), board.js (water, crosses, helpers), art.js (emblem, saving, shapes, the small grid), app.js (the kit’s start)
Look games/static/games/aquarium/aquarium.css Accent, water colors, numbers and crosses, the solved shine
Server puzzles/aquarium.py Its Kind: sizes, daily lineup, minimum times, the answer’s shape. The rest is the kit’s server app, puzzles/
Pages games/templates/games/aquarium.html, games/templates/games/thumbs/aquarium.html The kit’s page, nothing of its own; the shelf’s box art
Tests tests/test_aquarium.py, tools/puzzles/aquarium.test.mjs The server and real referee; the board under Node, played from the keyboard
Design notes docs/aquarium.md Rules, sizes, the solver’s ladder, open questions for David

It rests on ADR 0023 (the kit), ADR 0027 (a size is a difficulty), ADR 0032 (the archive), ADR 0034 (one daily a game) and ADR 0030 (ratings).

The rules in code#

A puzzle#

A puzzle is a plain object: the grid’s side n, each cell’s tank, and the printed counts. Cells are numbered row by row from the top left (row * n + column).

{ engine, slug, code, size, difficulty, n,
  tanks: [n × n tank numbers, row by row],
  rows:  [n counts, or null if unprinted],
  cols:  [n counts, or null if unprinted] }

Tanks are numbered in reading order of their first cell. The engine allows an unprinted count (null), but the generator always prints every number (Known limitations). Facts about the tanks are worked out once per puzzle and cached:

games/static/games/aquarium/engine/board.jsshape · lines 22–56
/**
 * Everything the rules and the solvers need about a puzzle's tanks:
 *   { n, cells, count, tankOf, tanks, top, bottom, rowOf, colOf,
 *     inRow: per row, [[tank, cells]], inCol: per column, [[tank, rows]] }
 * tanks[t] is its cells, top[t] and bottom[t] its first and last rows (a
 * tank is joined side to side, so it has cells in every row between).
 * inRow[r] lists the tanks with cells in row r and how many; inCol[c] the
 * tanks with cells in column c and those cells' rows, top first.
 */
export function shape(puzzle) {
  if (cache.has(puzzle)) return cache.get(puzzle);
  const { n, tanks: tankOf } = puzzle;
  const cells = n * n;
  const count = Math.max(...tankOf) + 1;
  const tanks = Array.from({ length: count }, () => []);
  tankOf.forEach((t, i) => tanks[t].push(i));
  const rowOf = i => Math.floor(i / n), colOf = i => i % n;
  const top = tanks.map(list => Math.min(...list.map(rowOf)));
  const bottom = tanks.map(list => Math.max(...list.map(rowOf)));
  const inRow = Array.from({ length: n }, () => new Map());
  const inCol = Array.from({ length: n }, () => new Map());
  for (let i = 0; i < cells; i++) {
    const t = tankOf[i], r = rowOf(i), c = colOf(i);
    inRow[r].set(t, (inRow[r].get(t) || 0) + 1);
    if (!inCol[c].has(t)) inCol[c].set(t, []);
    inCol[c].get(t).push(r);
  }
  const sh = {
    n, cells, count, tankOf, tanks, top, bottom,
    inRow: inRow.map(m => [...m.entries()]),
    inCol: inCol.map(m => [...m.entries()]),
  };
  cache.set(puzzle, sh);
  return sh;
}

Water is one number per tank#

Water is level and fills from the bottom, so a tank’s water is one number, its level: the top row with water. Every cell of the tank in that row and below is wet; every cell above is dry. A dry tank’s level is its bottom row plus one, so a tank spanning rows top to bottom has bottom − top + 2 states. A board is one level per tank:

  • waterOf(puzzle, levels): the grid of water a set of levels makes.
  • levelsOf(puzzle, water): each tank’s level from a grid, or null if some tank’s water isn’t level.

A U-shaped tank fills both arms to the same level, because the level is a row. And wellFormed refuses a tank in two pieces, so a tank has cells in every row from its top to its bottom.

Checking an answer#

An answer is the whole grid as one string, row by row, 1 for water and 0 for none ("000011…"). Any answer that keeps the rules is accepted, though there’s only one:

games/static/games/aquarium/engine/board.jscheck · lines 117–130
/**
 * Whether `answer` ("0110…") solves the puzzle: every tank's water level,
 * and every printed number the count of water in its row or column. Any
 * answer that does counts.
 */
export function check(puzzle, answer) {
  if (!wellFormed(puzzle)) return false;
  const { n } = puzzle;
  const water = waterFrom(n, answer);
  if (!water) return false;
  if (!levelsOf(puzzle, water)) return false;
  const got = countsOf(n, water);
  return puzzle.rows.every((k, r) => k === null || k === got.rows[r]) && puzzle.cols.every((k, c) => k === null || k === got.cols[c]);
}

wellFormed checks the puzzle itself: square, 3 to 16 a side, every tank one connected piece numbered 0 … count−1, counts between 0 and n or null.

Making a puzzle#

Everything comes from the kit’s seeded stream (a random-number generator that repeats for the same seed), here aquarium|<ENGINE_VERSION>|<size>|<difficulty>|<code> (The seeded stream). So the browser, the worker and the referee under Node all make the same puzzle. The engine is at version 1.

games/static/games/aquarium/engine/generate.jsSIZES · lines 24–31
// Each size has one difficulty: the bigger the grid, the deeper the
// reasoning (docs/aquarium.md). `tanks` is how many tanks the grid is cut
// into: more, smaller tanks leave more ways for the water to stand.
export const SIZES = {
  6: { n: 6, difficulty: 'easy', tanks: 9 },
  8: { n: 8, difficulty: 'medium', tanks: 14 },
  10: { n: 10, difficulty: 'hard', tanks: 24 },
};

1. Grow the tanks#

games/static/games/aquarium/engine/generate.jsgrowTanks · lines 62–104
// Tanks: `count` seeds, no two touching (corners included) where that's
// possible, then grown a cell at a time. Each turn a tank is picked, the
// smaller the likelier, and takes a free cell beside it, sideways twice as
// readily as up or down. Returns each cell's tank, numbered from the top left.
function growTanks(r, n, count) {
  const cells = n * n;
  const owner = new Int16Array(cells).fill(-1);
  const seeds = [];
  for (const i of r.shuffle(Array.from({ length: cells }, (_, k) => k))) {
    if (seeds.length === count) break;
    const ri = Math.floor(i / n), ci = i % n;
    if (seeds.some(j => Math.abs(Math.floor(j / n) - ri) <= 1 && Math.abs((j % n) - ci) <= 1)) continue;
    seeds.push(i);
  }
  seeds.forEach((i, g) => { owner[i] = g; });
  const size = seeds.map(() => 1);
  let free = cells - seeds.length;
  while (free > 0) {
    // Each tank's free neighbors, weighted: 2 sideways, 1 up or down.
    const options = seeds.map(() => []);
    for (let i = 0; i < cells; i++) {
      if (owner[i] < 0) continue;
      for (const [j, side] of sidesOf(n, i)) if (owner[j] < 0) options[owner[i]].push([j, side ? 2 : 1]);
    }
    const growing = options.map((o, g) => (o.length ? 36 / (size[g] * size[g]) : 0));
    const sum = growing.reduce((a, b) => a + b, 0);
    let x = r.next() * sum, g = 0;
    while (g < growing.length - 1 && (x -= growing[g]) >= 0) g++;
    while (!growing[g]) g--;
    const opts = options[g];
    let y = r.next() * opts.reduce((a, [, w]) => a + w, 0), k = 0;
    while (k < opts.length - 1 && (y -= opts[k][1]) >= 0) k++;
    owner[opts[k][0]] = g;
    size[g]++;
    free--;
  }
  // Number the tanks in reading order of their first cell.
  const id = new Map();
  return Array.from(owner, g => {
    if (!id.has(g)) id.set(g, id.size);
    return id.get(g);
  });
}
  1. Seeds. Visit the cells in shuffled order. A cell becomes a seed unless it touches an earlier seed, corners included. Stop at tanks seeds. On a small grid the no-touching rule runs out of room first, which is why the measured tank counts below sit a little under SIZES’ 9, 14 and 24.
  2. Growth. Each turn, pick a tank with weight 36 / size² (a one-cell tank is 36 times as likely to grow as a six-cell one), then one of its free neighbors, weighted 2 sideways and 1 up or down. Repeat until every cell has a tank.
  3. Numbering. Renumber tanks in reading order of their first cell.

The result looks printed: irregular, mostly wide tanks of four or five cells, rarely more than ten. Single-cell tanks are 0.1–0.4%.

2. Pour the water#

games/static/games/aquarium/engine/generate.jspour · lines 106–109
// The water: every tank a random level, from full to dry.
function pour(r, sh) {
  return sh.top.map((top, t) => top + r.int(sh.bottom[t] - top + 2));
}

Each tank gets a uniformly random level, from full to dry. The water in each row and column is counted, and the counts are the clues. One grid of tanks gets 4 pours before new tanks are grown, and a code tries at most 400 grids. No sampled code has run out.

3. Keep it only if it rates exactly#

games/static/games/aquarium/engine/generate.jsgenerate · lines 111–150
/**
 * The puzzle for a code:
 *   { engine, slug, code, size, difficulty, n, tanks: [n × n], rows: [n], cols: [n] }
 * Throws if the code or size isn't one the game makes, or the difficulty
 * isn't the size's. `stats`, if given, is told how the making went (for
 * tools/puzzles/): attempts, how each grid tried rated, and how many the
 * plain search refused (`ambiguous`, none so far).
 */
export function generate(code, size, difficulty = SIZES[size]?.difficulty, stats = {}) {
  size = Number(size);
  if (!Object.hasOwn(SIZES, size) || SIZES[size].difficulty !== difficulty || !CODE_RE.test(code)) throw new Error('Not a puzzle the game makes.');
  const { n, tanks: count } = SIZES[size];
  const tier = TIERS[difficulty];
  const r = stream(`${SLUG}|${ENGINE_VERSION}|${size}|${difficulty}|${code}`);
  Object.assign(stats, { attempts: 0, ambiguous: 0, rated: {} });
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
    const tanks = growTanks(r, n, count);
    const sh = shape({ n, tanks });
    for (let p = 0; p < POURS; p++) {
      stats.attempts++;
      const levels = pour(r, sh);
      const rows = new Array(n).fill(0), cols = new Array(n).fill(0);
      sh.tankOf.forEach((t, i) => { if (Math.floor(i / n) >= levels[t]) { rows[Math.floor(i / n)]++; cols[i % n]++; } });
      const puzzle = { engine: ENGINE_VERSION, slug: SLUG, code, size, difficulty, n, tanks, rows, cols };
      const got = solve(puzzle, 3, { sh });
      const rated = got.solved ? DIFFICULTIES[got.tier - 1] : null;
      stats.rated[rated] = (stats.rated[rated] || 0) + 1;
      if (rated !== difficulty) continue;
      if (deep(got.used, tier) < DEPTH[difficulty]) { stats.shallow = (stats.shallow || 0) + 1; continue; }
      // The kit's contract (docs/puzzles.md): kept only if a plain search,
      // sharing no code with the solver, finds this answer and no other, so a
      // solver step that wasn't sound can't ship a puzzle with two answers.
      if (!unique(puzzle, answerOf(got.answer))) { stats.ambiguous++; continue; }
      stats.steps = got.steps;
      stats.used = got.used;
      return puzzle;
    }
  }
  throw new Error(`No ${n}×${n} puzzle came of ${code}.`);
}

A candidate is kept only if:

  • the solver, allowed tiers 1–3, settles every tank;
  • the hardest tier it needed is the size’s tier;
  • it needed that tier at least DEPTH times (0 for easy, 3 for medium and hard; deep() counts them), so a medium puzzle isn’t an easy one with a single medium step.

Every solver step follows from the rules and counts, so if it settles every tank there’s no other answer. As the kit’s engine contract asks (The engine pieces), the candidate is also checked by a separate plain search (The independent count). It’s kept only if the search finds exactly one answer, the solver’s, within its budget:

games/static/games/aquarium/engine/generate.jsunique · lines 46–51
// Whether the plain search (count.js) finds exactly one answer, the one given
// (in its written form), within its budget.
export function unique(puzzle, answer) {
  const found = count(puzzle, 2);
  return found.count === 1 && !found.gaveUp && JSON.stringify(found.answers[0]) === JSON.stringify(answer);
}

A refused candidate is counted in stats.ambiguous and the next is tried, so a wrong solver step costs a candidate instead of shipping a puzzle with two answers. None has been refused in 1,500 codes a size, so adding the gate changed no puzzle and kept ENGINE_VERSION at 1. The search adds a ninth to a twentieth to the time to make a puzzle, and the hardest 10×10 of 100 used under 1% of its budget.

The solver#

games/static/games/aquarium/engine/solver.js reasons like a person with a pencil. For each tank it keeps a range [lo, hi] for the level: cells below hi are certainly water, cells above lo certainly dry, the rows between open. That’s exactly what a player can mark. A cell found wet narrows the tank to hi ≤ row, one found dry to lo ≥ row + 1, and an empty range is a contradiction.

Each step uses the gentlest technique that makes progress, then starts again from the top:

games/static/games/aquarium/engine/solver.jsLADDER · lines 285–289
const LADDER = [
  [1, counting], [1, tooBig],
  [2, rowSums], [2, colSums],
  [3, twoRows], [3, twoCols], [3, lookAhead],
];
Tier Technique What it does
1 · Easy Full A line already holding all its water: every open cell in it is dry.
1 Fill up A line whose open cells are exactly the water it still needs: all of them have water.
1 Too big Rows only. A tank’s open cells in a row fill together, so a stretch wider than the water the row still needs is dry. A column’s cells of one tank are in different rows, so they fill one at a time.
2 · Medium Row sums Try every way of choosing, for each open tank stretch in a row, all water or none, that adds up to the count. A stretch with water in every way is water; one with water in none is dry.
2 Column sums Each tank can give a column anything from none to all of its open cells there, bottom up. What the others can’t make up, this one must; it can’t give more than the others leave room for.
3 · Hard Two rows, two columns Add two neighboring lines’ counts and treat each tank’s water across both as one choice (a tank spanning both fills the lower first). Sees what neither line shows alone.
3 Look ahead Suppose a cell has water (or hasn’t). Count only the lines through its tank, once each (tier 1, no chains). If any line through a changed tank then can’t add up (tier 2’s sums), the cell is the other way. One step deep.
4 Trial Suppose, then follow tiers 1–2 to a contradiction. Never used for rating: it exists so the checks can prove no rated puzzle needs it.

Row sums, column sums and the two-line techniques are one function, sums. Given some lines’ cells grouped by tank and the water they must hold, it finds which levels of each tank the others can complete, with a bitset of reachable totals built forwards and backwards. It narrows each tank to its first and last fitting level, because a pencil can’t mark a gap in a range:

games/static/games/aquarium/engine/solver.jssums · lines 145–182
// The levels each tank may have, given that the water in a set of cells
// adds up to `total`. `parts` is [[tank, rows]]: the rows of each tank's
// cells in the set (a tank's water there is its cells at or below its
// level). Narrows each tank to its first and last level that some choice of
// the others' levels makes add up (a pencil can't mark a gap in between).
// Returns whether anything changed.
function sums(s, parts, total) {
  // Each part's choices: [level, water] for every level in its range that
  // gives a different amount of water here.
  const choices = parts.map(([t, rows]) => {
    const out = [];
    for (let L = s.lo[t]; L <= s.hi[t]; L++) out.push([L, rows.reduce((k, r) => k + (r >= L ? 1 : 0), 0)]);
    return out;
  });
  const max = total + 1;
  // reach[k]: the sums the first k parts can make (a bit per sum, up to total).
  const add = (set, opts) => {
    const out = new Uint8Array(max);
    for (let x = 0; x < max; x++) if (set[x]) for (const [, w] of opts) if (x + w < max) out[x + w] = 1;
    return out;
  };
  const before = [Uint8Array.from({ length: max }, (_, x) => (x === 0 ? 1 : 0))];
  for (const opts of choices) before.push(add(before[before.length - 1], opts));
  if (!before[choices.length][total]) throw new Contradiction();
  const after = new Array(choices.length + 1);
  after[choices.length] = before[0];
  for (let k = choices.length - 1; k >= 0; k--) after[k] = add(after[k + 1], choices[k]);
  let changed = false;
  parts.forEach(([t], k) => {
    // Sums the other parts can make together.
    const others = new Uint8Array(max);
    for (let a = 0; a < max; a++) if (before[k][a]) for (let b = 0; a + b < max; b++) if (after[k + 1][b]) others[a + b] = 1;
    const fits = choices[k].filter(([, w]) => w <= total && others[total - w]).map(([L]) => L);
    if (!fits.length) throw new Contradiction();
    changed = narrow(s, t, fits[0], fits[fits.length - 1]) || changed;
  });
  return changed;
}

solve(puzzle, tier, { grid }) starts from nothing or from a player’s marks (grid: 1 water, 2 dry, 0 open, trusted as given). It returns { solved, answer, levels, tier, used, steps, broken }. The engine’s solution() is solve at tier 3, written as an answer string.

The independent count#

games/static/games/aquarium/engine/count.js shares no code with the solver. It tries every level for each tank (ordered by top row, then bottom row), dropping a branch once any line’s count is overrun or out of reach. It stops at limit answers (2 by default, enough to prove “exactly one”) or after five million levels. A search that runs out of budget proves nothing, so generate refuses that candidate too.

Sizes and difficulty#

Each size has one difficulty, as in Star Battle (ADR 0027). Kind.size_difficulty maps them, and boards and dailies are labeled just “8×8”:

SizeDifficulty
6easy
8medium
10hard
Live from puzzles/aquarium.py, KIND.size_difficulty

The sizes differ in the hardest technique needed, how often it’s needed, and how much there is to do. Measured 2026-10-19 under Node 24: 300 fixed codes per size, each made, solved, and re-counted with count:

6×6 (easy) 8×8 (medium) 10×10 (hard)
Tanks (mean) 8.3 13.7 21.9
Cells per tank (mean; range) 4.3 (1–10) 4.7 (1–10) 4.6 (1–9)
Water cells (mean) 18.2 of 36 33.4 of 64 49.8 of 100
Lines at 0 or full (mean) 2.4 0.7 0.6
Solver steps (mean) 8.6 16.9 32.6
Steps of the size’s own tier (mean) 8.6 4.5 5.5
Candidates tried (mean; max) 1.2; 4 5.7; 36 16.1; 72
Time to make (median; 90th pct; max), before the search was added to generate 0.3; 1.1; 4.8 ms 1.3; 4.1; 18 ms 14; 42; 169 ms
Exactly one answer, by count 300 of 300 300 of 300 300 of 300

Mean uses per puzzle of each technique:

Technique 6×6 8×8 10×10
Full 4.13 5.37 6.99
Fill up 4.13 5.43 7.52
Too big 0.33 1.59 2.57
Row sums 3.29 5.27
Column sums 1.21 4.77
Two rows 0.11
Two columns 0.90
Look ahead 4.50

A 6×6 is counting alone, a line at a time, starting from two or three empty or full lines. An 8×8 adds a handful of row and column sums. A 10×10’s hard steps are mostly look-aheads, with a two-column step in most puzzles.

The 10×10 takes longest because most candidates miss: of 4,820 grids tried for 300 hard puzzles, 749 rated easy, 2,729 medium, 689 hard (under half with the three hard steps required), and 653 needed trial. At 8×8, 945 of 1,704 candidates were too easy.

The one daily. Since 2026-10-20 each game has one daily, the lineup’s middle slot (One daily a game). For Aquarium that’s the 8×8, with the code an HMAC (a keyed hash) of aquarium|<date>|8|medium (Each game’s daily). From 2026-10-26 the size follows the week instead (ADR 0041, the week climbs): 6×6 on Monday and Tuesday, 8×8 Wednesday to Saturday, 10×10 on Sunday, each with the code that size always had. Earlier days keep their three dailies. Endless puzzles use a fresh random code at any size. The archive starts at 2026-10-15.

What the browser holds#

The browser makes the puzzle, so it can also make the answer (solution()). That’s fine for a solo puzzle: the referee decides what counts, and minimum times stop an instant post. A daily’s code isn’t secret once served, but the key that picks it is (Codes from the key).

Your move#

firstMove gives the kit’s first-move prompt (Your first move) its words: Pour in some water (“Tap a cell to fill its tank up to that row”), shown once per browser.

Posting and the referee#

A solve posts { code, size, difficulty, engine, answer, seconds } to the kit’s solve API. Aquarium adds a shape check that rewrites the answer in solution()’s form, or refuses it with a 400:

puzzles/aquarium.pyclean · lines 15–21
def clean(answer, size):
    """An answer is the whole grid, row by row, as one string: 1 for a cell
    with water, 0 for one without. "0011…", n × n of them."""
    n = GRIDS[size]
    if not isinstance(answer, str) or len(answer) != n * n or not set(answer) <= CELLS:
        raise ValueError("not water or none in every cell")
    return answer

The kit’s referee then remakes the puzzle under Node and accepts only an exact match (The pencil-puzzle kit: the one answer). A wrong answer gets That water doesn’t solve this puzzle. (422). Minimum believable times are about one tap per tank:

SizeSeconds
66
810
1016
Live from puzzles/aquarium.py, KIND.min_seconds

The slowest accepted time is the kit’s 86,400 seconds. Only a player’s first solve of a puzzle stands, and it counts as the day’s only if it arrives on the daily’s own day by the server’s clock (When a solve counts as the day’s).

Rating#

Aquarium is rated like every timed puzzle (Timed puzzles), by the source Kit("aquarium"). Par is per size: the median of the last 99 verified solves at that size (Par).

  • Today’s daily is rated at midnight as a field of everyone who solved it that day, ranked by time.
  • Anything else (endless or archived) is rated when posted, against par, at half weight. A size needs 5 earlier solves before it has a par.
  • Aquarium’s codes are random, so no one can pick a quick one, and speed_on_dailies is off.

Worked: yesterday’s 8×8. Three players solved it on its day. At midnight they’re rated as one field by the site’s own code (guide/worked/aquarium.py):

PlayerBeforeTimeAfterChange
Ana1600 ± 902:311595−4.8
Ben1450 ± 1502:091522+71.8
Cy1500 ± 1104:201468−31.5
Live from guide/worked/aquarium.py, DAILY.rows

Ben was fastest and least certain (σ 150), so he moved most. Ana beat Cy but lost to Ben. At 1600 she was expected to do better than one in two, so she drops a little. Cy lost both.

Worked: from the archive. The next day Ana solves an older 8×8 in 2:31, on a board whose par is 3:20. Her time scores 0.637 against par, where she was expected 0.562. She gains +0.76, half the +1.52 it would be worth at full weight.

The interface#

The board (games/static/games/aquarium/js/board.js) is a kit grid surface (The board’s surface) in six layers: water, cell lines, marks, walls, the hover preview and the numbers.

What the board keeps#

A board is each tank’s level plus the cells crossed out, saved as { l: [levels], x: "0010…" }. On load, a level outside its tank’s range is ignored, and so is a cross under water:

games/static/games/aquarium/js/art.jsstateFrom · lines 17–33
/**
 * What a board keeps: each tank's water level (its top row with water; the
 * tank's bottom row plus one when dry) and the cells the player has crossed
 * out, one per cell. Saved as { l: [levels], x: "0010…" }.
 */
export function stateFrom(saved, puzzle, sh = shape(puzzle)) {
  const dry = sh.bottom.map(b => b + 1);
  const levels = Int8Array.from(dry);
  const crosses = new Uint8Array(sh.cells);
  if (saved && Array.isArray(saved.l) && saved.l.length === sh.count) {
    saved.l.forEach((L, t) => { if (Number.isInteger(L) && L >= sh.top[t] && L <= dry[t]) levels[t] = L; });
  }
  if (saved && typeof saved.x === 'string' && saved.x.length === sh.cells) {
    for (let i = 0; i < sh.cells; i++) crosses[i] = saved.x[i] === '1' && Math.floor(i / sh.n) < levels[sh.tankOf[i]] ? 1 : 0;
  }
  return { levels, crosses };
}

A move is { l: [[tank, from, to], …], x: [[cell, from, to], …] }, replayed either way by apply. So a tap, a whole drag and Restart are each one Undo. Water rising over a cross washes it away in the same move.

Filling and crossing out#

Input Water first (the default) × first (Options: Tap order)
Tap or click a cell Its tank fills or drains so the water stands at that row. On the surface already, it drops a row, so tapping one cell toggles it. On an open cell, crosses it out. On a cross or on water, pours as water-first does.
Press and hold (420ms), right-click, Shift- or Alt-click Crosses the cell out (or takes its cross away). On water, the tank first drains to just below the cell. Pours.
Drag Begun on water: fills each tank it passes to the row it passes through, never lower. Begun on a cross: rubs crosses out. Otherwise: crosses out every dry cell it passes. One move. The same.

With a mouse, the water a click would make is outlined as you point (dashed; hollow when the click would drain).

Keys, when the board has focus: arrows move; Space or Enter taps (with Shift, presses); W fills the tank to the cursor’s row; X crosses; Backspace, Delete or 0 clears (takes a cross away, or drains the tank to just below the cell). Z, Y, P and ? are the kit’s. The Shortcut keys option turns letters and digits off.

How to play describes all of these and follows Tap order, because the kit passes a puzzle’s rules the player’s options (The pencil-puzzle kit).

The helpers#

Three helpers are on by default, each with its own opt-out in Options, plus one playing setting. All are in settings in games/static/games/aquarium/js/puzzle.js; those flagged assist are listed under Helpers.

  • Cross off what can’t fill (auto), recomputed on every draw and never saved. A row or column already holding its count gets a faint × on every open cell. Then, in every tank, every dry cell at or above the lowest cross gets one too, because water can’t stand above a dry cell. These crosses count as dry for the next two helpers.
games/static/games/aquarium/js/board.jsmount.helperCrosses · lines 217–237
// ── drawing ──────────────────────────────────────────────────────────
// What the helper crosses out: every open cell of a row or column with all
// its water, and every cell of a tank level with or above a cross in it.
function helperCrosses(on) {
  const out = new Uint8Array(N);
  if (!on) return out;
  const full = linesOf(puzzle, water, crosses);
  for (let i = 0; i < N; i++) {
    if (water[i]) continue;
    const r = rowOf(i), c = i % n;
    if (full.rows[r].clue !== null && full.rows[r].water === full.rows[r].clue) out[i] = 1;
    if (full.cols[c].clue !== null && full.cols[c].water === full.cols[c].clue) out[i] = 1;
  }
  sh.tanks.forEach(cells => {
    let low = -1;
    for (const i of cells) if (crosses[i] || out[i]) low = Math.max(low, rowOf(i));
    for (const i of cells) if (rowOf(i) <= low && !water[i]) out[i] = 1;
  });
  for (let i = 0; i < N; i++) if (crosses[i]) out[i] = 0;
  return out;
}
  • Light finished numbers (counts): a number whose line holds exactly its water gets a ring, with a small pop when it becomes met (not on load).
  • Flag numbers gone wrong (flags): a number turns red in a dashed ring when its line has too much water (over) or can’t reach its count (short), from the engine’s linesOf.
  • Tap order (order): swaps tap and press, as in the table above.

The status line names the first broken line (“Row 3 has too much water.”), else says “7 of 16 numbers have their water”. With Light finished numbers off it counts tanks instead, so it never does the helper’s work.

Solved#

answer() writes out the water and asks the engine’s check. The first pass stops the clock and posts. The frame turns green and the water glints outward from the last tank changed, 60ms per cell (celebrate returns the total), before the kit’s stamp and result sheet.

Accessibility#

The grid is one focusable SVG whose label (the rules and keys) follows Tap order. Each cell’s label gives its position, state, tank size and level, and both lines’ counts. Every move is spoken (“Tank full to row 3.”), then the status line. Met and wrong numbers differ in shape as well as color. Under reduced motion the water jumps and the shine is skipped.

The look#

Its colors and drawing are in Its look. A level change slides the water over 1.4 × the house’s glide, and there are no region tints: the heavy lines are the tanks.

Stored in the browser#

Everything lives under the kit’s uo:aquarium: keys (Sheets, saving and the hub), including each opened puzzle (p:<size>/<difficulty>/<code>, at engine version 1). Each puzzle’s address (#/p/8/medium/<CODE>, #/daily/8, #/archive/<date>/8) opens the same puzzle anywhere.

Leaderboards and stats#

The kit’s boards (puzzles/views.py), one set per size:

  • Daily: a day’s solvers, fastest first, ties to whoever posted first, top 100. Since the one-daily switch there’s one size a day; earlier days have a tab per size.
  • All time, by size: each player’s best time on any verified puzzle of that size, with how many they’ve solved. Ties go to whoever got there first.
  • Ratings: one rating for Aquarium, whatever the size.

The account page and the Dailies page show Aquarium’s counts, streak and best times beside the other puzzles.

Its look#

Aquarium’s personality, in the design system’s personality sheet. Everything else is the puzzle book’s (Puzzles).

Family Puzzles: the pencil-puzzle kit’s look, on the paper
Palette Its accent, sea-glass green #2c705a: the one fill, the ribbon, the kicker and the stamp, and the dashed outline of where a click would put the water. Water runs from #bfe6dc at the surface to #6db8a6 at the bottom of a tall tank (#93cfc0 in a small picture). A number with exactly its water sits in a ring of water, its digits #256050. No region tints: the water is the color. Red is a mistake and green is solved, as on the paper.
Faces The paper’s: Gloock and Figtree
Corners and edges The kit’s
Board The tanks are the heavy lines. Water is drawn tank by tank, lighter at the surface and deeper below, with a bright line where the surface catches the light; the cell lines show through, so rows can still be counted. A level change slides the water up or down. Your crosses are graphite, the helper’s fainter and smaller. Numbers are graphite: met, in a ring of water; gone wrong, red in a dashed red ring.
Emblem and art Two tanks side by side, the water in each at its own height. Its box: a real 6×6 (AQUA44) part-way through, the same puzzle How to play draws.
Voice A puzzle everywhere (“New puzzle”, “Solved”); tanks, water and crosses are its own words.
Payoff The frame turns green, every number is ringed, the crosses step back, and the water bobs and glints tank after tank outward from the last one filled. A “Solved 1:09” stamp in the accent presses onto the folio.
The glance Now: the status line, which names a broken line, else how many numbers have their water. The countdown: the clock.
Exceptions None

Testing#

Check What it proves
npm run check:puzzles (tools/puzzles/check.mjs) 20 random codes per size: the same code makes the same puzzle twice; it rates as asked with no trial; count finds exactly one answer, the engine’s solution(); check accepts it and refuses every tampered copy (tools/puzzles/tamper.mjs); the referee, run as the server runs it, agrees. Fixed fingerprints (known-good outputs) for ABC234 at each size catch a change that should have bumped ENGINE_VERSION.
tools/puzzles/aquarium.test.mjs (in check:puzzles) The board under a fake DOM, played from the keyboard: filling, crosses, W, X and Backspace, × first, the helpers, the answer, reload, Restart, the status line, and How to play following Tap order.
tools/puzzles/unique.test.mjs (in check:puzzles) The uniqueness gate in generate: it takes the one answer, and refuses a puzzle with two and an answer the search didn’t find.
tests/test_aquarium.py Its Kind, the shape check, boards by size, the account line, its page. Against the real referee: today’s daily lands on its board; eight tampered answers, another code’s answer, a fake time and yesterday’s daily posted as today’s are all 422; a 10×10 needs a hard technique and never trial; a 6×6 needs only counting.

Changing it#

  • Anything that changes which puzzle a code makes: growth weights, the seed rule, the pour, ATTEMPTS, POURS, DEPTH, the solver’s ladder or any technique, or count.js and its budget if it would now refuse a candidate it used to pass. Bump ENGINE_VERSION in generate.js, update FIXED in tools/puzzles/check.mjs, redraw the box art if AQUA44 changed, and run npm run check:puzzles. Solves already posted keep the puzzle they were checked against (Engine versions).
  • A new size: SIZES in the engine, and GRIDS, sizes, size_difficulty, min_seconds in puzzles/aquarium.py. Touch daily only if the size should be a daily: the lineup’s middle slot is the one daily, so adding a slot changes which size that is.
  • A helper or the board: games/static/games/aquarium/js/board.js and the settings in puzzle.js, with no version bump. Run node --test tools/puzzles/aquarium.test.mjs and npm run check:details.
  • Minimum times: min_seconds in the Kind; no migration.

Known limitations#

  • Every number is printed. The engine and solver support unprinted counts (null), but nothing generates them, so there’s no sparse-clue variant.
  • Look ahead is one step deep, and trial is never rated, so the hard tier is a ceiling. A harder Aquarium needs a bigger grid or a deeper technique.
  • The solver narrows ranges, not sets. It can’t mark a gap in a tank’s possible levels, as a pencil can’t. A puzzle needing that is refused rather than rated.
  • Times are the browser’s, as for every kit puzzle (Times).

Known issues#

None open. The eight found while writing this chapter were fixed on 2026-10-19.