Technical guide

Puzzles

Hashi

How Hashi makes a bridges puzzle from a short code, proves it has one answer, checks your solve and rates your time.

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

On this page
  1. At a glance
  2. The rules in code
    1. A puzzle
    2. A board as it stands
    3. A move, and the answer
  3. Making a puzzle
    1. From a code to a stream
    2. Growing a network
    3. The solver, and what “medium” means
    4. Sizes, and what separates them
    5. The daily
    6. ENGINE_VERSION
  4. Your move
  5. Secrets
  6. Posting and the referee
  7. Rating
  8. Leaderboards, the account and stats
  9. The interface
    1. The modules
    2. Building
    3. Zoom
    4. Helpers (assists)
    5. The clock, the solve and sharing
    6. What the browser keeps
    7. Accessibility
  10. Its look
  11. Testing
  12. Known issues and open questions
  13. Changing it

Hashi is a solo logic puzzle. The grid has numbered islands, and you join them with straight bridges until each island has as many bridges as its number and every island is connected. There’s a daily puzzle and endless play, with a leaderboard and a rating.

Hashi looks like the pencil-puzzle kit’s games, but its code is its own, written before the kit existed. Moving it onto the kit is planned (docs/hashi.md), and the kit’s random stream matches Hashi’s bit for bit, so the move won’t change any puzzle. The shared pieces have their own chapters: Dailies, codes and seeds, Referees and anti-cheat, Ratings and Front-end kits.

At a glance#

What Where
The engine (rules, generator, solver, the seeded stream) games/static/games/hashi/engine/
The interface (routes, home, desk, board, store, hub, sheets) games/static/games/hashi/js/
Its look games/static/games/hashi/hashi.css, on the house’s house/paper.css and the kit’s puzzles.css and kit.css
The page games/templates/games/hashi.html (its own, not the kit’s puzzles/play.html)
Server side: models, dailies, posting, boards hashi/
The referee (Node) and the checker (Python) referee/hashi.mjs, hashi/check.py
Tests and checks tests/test_hashi.py, tools/hashi/check.mjs (npm run check:hashi)
Design notes and decisions docs/hashi.md, ADR 0013 (dailies, boards, referee), ADR 0024 (sizes, not difficulties)

A puzzle is called a chart. There’s one difficulty and four sizes:

Sizes 7×7, 10×10, 15×15, 20×20 (the engine’s SIZES)
Difficulty medium, for every chart (ADR 0024)
The daily One a day. From 2026-10-26 its size follows the week: 7×7 Monday and Tuesday, 10×10 Wednesday to Saturday, 15×15 Sunday (one()). From 2026-10-20 until then, 10×10 (NORMAL). Before that, three: 7×7, 10×10 and 15×15 (DAILY).
Engine version 3. It still makes engine 2’s charts too (VERSIONS).

The rules in code#

A puzzle#

The engine returns { engine, code, size, w, h, islands }. Each island is [x, y, n]: column, row and number. Islands are numbered in reading order, so an island’s index is the same in the browser, the referee, the database and a posted solve. The server stores only w, h and islands (hashi.models.Puzzle), never the answer.

layout() works out where bridges can go. It pairs each island with its nearest island to the right and its nearest below. These pairs are the links, the only places a bridge can be. It also lists which links cross which:

games/static/games/hashi/engine/board.jslayout · lines 6–42
/**
 * Every place a bridge could go: between an island and its nearest island to
 * the right, and to the bottom. Returns
 *   links: [{ a, b, dir: 'h' | 'v', x1, y1, x2, y2 }] (a before b)
 *   at:    per island, the indexes of its links
 *   crosses: per link, the indexes of the links it would cross
 *   index: Map "a,b" → link index
 */
export function layout({ w, h, islands }) {
  const grid = new Int32Array(w * h).fill(-1);
  islands.forEach(([x, y], i) => { grid[y * w + x] = i; });
  const links = [];
  islands.forEach(([x, y], a) => {
    for (let xx = x + 1; xx < w; xx++) {
      const b = grid[y * w + xx];
      if (b >= 0) { links.push({ a, b, dir: 'h', x1: x, y1: y, x2: xx, y2: y }); break; }
    }
    for (let yy = y + 1; yy < h; yy++) {
      const b = grid[yy * w + x];
      if (b >= 0) { links.push({ a, b, dir: 'v', x1: x, y1: y, x2: x, y2: yy }); break; }
    }
  });
  const at = islands.map(() => []);
  const index = new Map();
  links.forEach((l, k) => { at[l.a].push(k); at[l.b].push(k); index.set(`${l.a},${l.b}`, k); });
  const crosses = links.map(() => []);
  links.forEach((e, i) => {
    if (e.dir !== 'h') return;
    links.forEach((f, j) => {
      if (f.dir === 'v' && e.x1 < f.x1 && f.x1 < e.x2 && f.y1 < e.y1 && e.y1 < f.y2) {
        crosses[i].push(j);
        crosses[j].push(i);
      }
    });
  });
  return { links, at, crosses, index };
}

So a bridge only ever joins nearest neighbors, and “a bridge never crosses an island” needs no check. Links cross only at right angles. The rules would allow a bridge between two islands side by side, but the generator never places islands next to each other.

A board as it stands#

The state of play is one number per link, counts[k]: 0, 1 or 2 bridges. status() works out the rest:

games/static/games/hashi/engine/board.jsstatus · lines 50–70
/**
 * How a board stands, from bridge counts per link:
 *   have: bridges at each island; done: islands with exactly their number
 *   over: islands with too many; crossed: links crossing a built link
 *   groups: how many separate pieces the built bridges make
 *   solved: every island exact, nothing crossing, all one piece
 */
export function status(puzzle, shape, counts) {
  const n = puzzle.islands.length;
  const have = new Array(n).fill(0);
  shape.links.forEach((l, k) => { have[l.a] += counts[k]; have[l.b] += counts[k]; });
  const done = have.map((c, i) => c === puzzle.islands[i][2]);
  const over = have.map((c, i) => c > puzzle.islands[i][2]);
  const crossed = shape.links.map((_, k) => counts[k] > 0 && shape.crosses[k].some(j => counts[j] > 0));
  const parent = Array.from({ length: n }, (_, i) => i);
  const find = i => (parent[i] === i ? i : (parent[i] = find(parent[i])));
  shape.links.forEach((l, k) => { if (counts[k] > 0) parent[find(l.a)] = find(l.b); });
  const groups = new Set(parent.map((_, i) => find(i))).size;
  const solved = done.every(Boolean) && !crossed.some(Boolean) && groups === 1;
  return { have, done, over, crossed, groups, solved };
}
  • Every island has exactly its number of bridges (done). More is over, which the helpers show.
  • No built link crosses another built link (crossed).
  • The built bridges form one piece (groups === 1), found by union-find.

solved means all three. If every island is full but the network is in two pieces, the status line says “Every island’s full, but they aren’t all joined up.”

A move, and the answer#

A move changes one link. A tap or drag steps it up, (from + 1) % 3: one, two, none. A removal (Shift, right-click, press and hold, or Alt + arrow) steps it down, stopping at 0. A move is { k, from, to }, kept in moves for Undo and redo for Redo. Restart is { all: counts }, so Undo can bring the old board back.

A move that would cross a built bridge is refused. The bridges in the way flash the error color for 1.2 s (MOTION.pulse), then fade back in 150 ms (flash() in games/static/games/hashi/js/chart.js), which is short enough to keep under reduced motion. The status line says “That would cross a bridge.”, and a phone buzzes twice.

A solve is posted as its built links by island index, [[a, b, count], …] (bridgeList()). countsFrom() reads it back, refusing a pair that isn’t a link, a count that isn’t 1 or 2, or a link named twice.

Making a puzzle#

From a code to a stream#

A code is six characters from 23456789ABCDEFGHJKLMNPQRSTUVWXYZ. An endless code comes from newCode() (crypto.getRandomValues); a daily’s from the server (Dailies, codes and seeds). The engine seeds a random number generator (sfc32) from hashi|<version>|<size>|<code>. A seeded generator gives the same numbers every time, so the browser and the referee make the same chart from the same code (the seeded stream). generate(code, size, version) makes the chart as any version in VERSIONS made it (ENGINE_VERSION).

games/static/games/hashi/engine/generate.jsgenerate · lines 118–146
/**
 * The puzzle for a code: { engine, code, size, w, h, islands: [[x, y, n]] },
 * made as engine `version` made it (one of VERSIONS; the latest unless
 * asked). Throws if the code, size or version isn't one the game makes.
 * `stats`, if given, is told how the making went (for tools/hashi/ and the
 * guide): networks grown, those thrown away unrated, how the rest rated,
 * and the medium ones too shallow to keep.
 */
export function generate(code, size, version = ENGINE_VERSION, stats = {}) {
  size = Number(size);
  version = Number(version);
  if (!SIZES[size] || !CODE_RE.test(code) || !VERSIONS.includes(version)) throw new Error('Not a puzzle the game makes.');
  const depth = version >= 3 ? DEPTH[size] : 0;
  const r = stream(`hashi|${version}|${size}|${code}`);
  Object.assign(stats, { attempts: 0, ungrown: 0, rated: {}, shallow: 0 });
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
    stats.attempts++;
    const grown = grow(r, size, size, SIZES[size].islands, { double: DOUBLE, loop: size <= 10 ? 0.05 : 0 });
    if (!grown) { stats.ungrown++; continue; }
    const puzzle = { engine: version, code, size, w: size, h: size, islands: grown.islands };
    const shape = layout(puzzle);
    const rated = rate(puzzle, shape);
    stats.rated[rated] = (stats.rated[rated] || 0) + 1;
    if (rated !== LEVEL) continue;
    if (depth && solve(puzzle, 2, shape).used.connecting < depth) { stats.shallow++; continue; }
    return puzzle;
  }
  throw new Error(`No ${size}×${size} puzzle came of ${code}.`);
}

The loop grows a network, rates it with the solver, and keeps the first one rated medium that’s deep enough (DEPTH, below). A code gets up to ATTEMPTS = 4,000 tries: a count, not a time limit, so a slow phone makes the same chart as a fast desktop. No code has run out (the most of 1,200 measured needed 142). If one did, the page would say the puzzle couldn’t be drawn and the referee would refuse it. stats, if passed, records how the making went.

Growing a network#

The chart is grown as an answer first, so it always has one:

games/static/games/hashi/engine/generate.jsgrow · lines 61–116
// Grow one network of islands and bridges on a w×h grid.
function grow(r, w, h, target, { double, loop }) {
  const EMPTY = -1, BRIDGE = -2;
  const cell = new Int32Array(w * h).fill(EMPTY);
  const islands = [];
  const bridges = new Map(); // "i,j" → 1 or 2
  const place = (x, y) => { cell[y * w + x] = islands.length; islands.push([x, y]); return islands.length - 1; };
  const lonely = (x, y) => STEPS.every(([dx, dy]) => {
    const xx = x + dx, yy = y + dy;
    return xx < 0 || yy < 0 || xx >= w || yy >= h || cell[yy * w + xx] < 0;
  });
  place(r.int(w), r.int(h));
  const longest = Math.max(2, Math.ceil(Math.min(w, h) * 0.6));
  for (let tries = 0; islands.length < target && tries < target * 60; tries++) {
    const from = r.int(islands.length);
    const [dx, dy] = r.pick(STEPS);
    // Mostly short bridges, sometimes long ones.
    const reach = 2 + Math.floor(r.next() * r.next() * (longest - 1));
    let [x, y] = islands[from];
    let ok = false, to = -1, steps = 0;
    for (let k = 1; k <= reach; k++) {
      x += dx; y += dy; steps = k;
      if (x < 0 || y < 0 || x >= w || y >= h) break;
      const c = cell[y * w + x];
      if (c === BRIDGE) break;
      if (c >= 0) {
        const key = from < c ? `${from},${c}` : `${c},${from}`;
        if (k >= 2 && !bridges.has(key) && r.chance(loop)) { ok = true; to = c; }
        break;
      }
      if (k === reach && lonely(x, y)) ok = true;
    }
    if (!ok) continue;
    if (to < 0) to = place(x, y);
    const [fx, fy] = islands[from];
    for (let k = 1; k < steps; k++) cell[(fy + dy * k) * w + (fx + dx * k)] = BRIDGE;
    const key = from < to ? `${from},${to}` : `${to},${from}`;
    bridges.set(key, r.chance(double) ? 2 : 1);
  }
  if (islands.length < target * 0.85) return null;
  // Use the whole board: islands on every edge, so a 10×10 looks it.
  const xs = islands.map(([x]) => x), ys = islands.map(([, y]) => y);
  if (Math.min(...xs) > 0 || Math.min(...ys) > 0 || Math.max(...xs) < w - 1 || Math.max(...ys) < h - 1) return null;
  // Number the islands in reading order, and count their bridges.
  const order = islands.map((p, i) => i).sort((i, j) => islands[i][1] - islands[j][1] || islands[i][0] - islands[j][0]);
  const rank = new Array(islands.length);
  order.forEach((i, k) => { rank[i] = k; });
  const n = new Array(islands.length).fill(0);
  const answer = [];
  for (const [key, c] of bridges) {
    const [i, j] = key.split(',').map(Number);
    n[rank[i]] += c; n[rank[j]] += c;
    answer.push([Math.min(rank[i], rank[j]), Math.max(rank[i], rank[j]), c]);
  }
  return { w, h, islands: order.map((i, k) => [islands[i][0], islands[i][1], n[k]]), answer };
}

For a w × h grid aiming at target islands:

  1. Place one island at a random cell.
  2. Pick a random island and direction, and walk reach = 2 + ⌊u₁ · u₂ · (longest − 1)⌋ cells, where u₁ and u₂ are random numbers between 0 and 1. Multiplying two makes most bridges short. longest is 60% of the grid’s side, at least 2.
  3. The walk stops at the edge or at a cell under a bridge, making nothing. If it meets an island at least two cells away with no bridge to it yet, it makes a loop with probability loop (5% on 7×7 and 10×10, never on bigger grids). If it walks the full reach to a cell with no island beside it (lonely), it places a new island there.
  4. The cells under the new bridge are marked so later walks can’t cross it. The bridge is double with probability DOUBLE = 0.25.
  5. Stop at target islands, or after target × 60 tries.
  6. Throw it away if it has under 85% of target, or misses an edge.
  7. Number the islands and give each its bridges’ total.

Loops are rare and small-board only because bridges can trade around a loop, which rarely leaves one answer. The puzzle is then just the islands, kept only if the solver can reach that answer, and only that one, by reasoning.

games/static/games/hashi/engine/generate.jsSIZES · lines 23–29
/** Board sizes (square), with how many islands to aim for. */
export const SIZES = {
  7: { islands: 12 },
  10: { islands: 24 },
  15: { islands: 54 },
  20: { islands: 96 },
};

The solver, and what “medium” means#

games/static/games/hashi/engine/solver.js reasons the way a person does. For each link it tracks the fewest and most bridges it could still hold, lo..hi, starting at 0..min(2, n_a, n_b). Three tiers narrow these. A higher tier runs only when the lower ones stop making progress.

Tier 1, counting. At each island with number n, add its links’ bounds into sumLo and sumHi. Each link holds at least n − (sumHi − hi) (what the others can’t supply) and at most n − (sumLo − lo) (what’s left). A link with lo > 0 is built, so every link it crosses gets hi = 0.

games/static/games/hashi/engine/solver.jscounting · lines 45–64
// Tier 1: counting at each island, and crossings.
function counting(puzzle, shape, s) {
  let changed = false;
  const { islands } = puzzle;
  shape.links.forEach((_, k) => {
    if (s.lo[k] > 0) for (const j of shape.crosses[k]) changed = setHi(s, j, 0) || changed;
  });
  islands.forEach(([, , n], i) => {
    const ks = shape.at[i];
    let sumLo = 0, sumHi = 0;
    for (const k of ks) { sumLo += s.lo[k]; sumHi += s.hi[k]; }
    if (sumLo > n || sumHi < n) throw new Contradiction();
    for (const k of ks) {
      const lo = n - (sumHi - s.hi[k]), hi = n - (sumLo - s.lo[k]);
      const a = setLo(s, k, lo), b = setHi(s, k, hi);
      if (a || b) changed = true;
    }
  });
  return changed;
}

Tier 2, staying connected. The sure bridges (lo > 0) form groups:

  • A group that isn’t every island and has nothing left to give is a contradiction.
  • A link can’t take a bridge (hi = lo) that would finish both its ends when those are the only open islands in their two groups and the groups aren’t everything. Two 1s can’t join; a 2 can’t double up with a 2.
  • A group with exactly one link left that could still take a bridge must use it (lo ≥ 1).
games/static/games/hashi/engine/solver.jsconnecting · lines 85–123
// Tier 2: never close off a finished group that isn't the whole puzzle; a
// group with one way out has to use it.
function connecting(puzzle, shape, s) {
  const total = puzzle.islands.length;
  const { islands } = puzzle;
  const g = groups(puzzle, shape, s);
  for (const [gid, size] of g.size) {
    if (size < total && g.open.get(gid) === 0) throw new Contradiction();
  }
  const updates = [];
  shape.links.forEach((l, k) => {
    if (s.lo[k] >= s.hi[k]) return;
    // Raising this link by one: would it finish everything it touches?
    const ga = g.id[l.a], gb = g.id[l.b];
    const closesA = g.sure[l.a] + 1 === islands[l.a][2];
    const closesB = g.sure[l.b] + 1 === islands[l.b][2];
    if (!closesA || !closesB) return;
    const size = ga === gb ? g.size.get(ga) : g.size.get(ga) + g.size.get(gb);
    const open = ga === gb ? g.open.get(ga) : g.open.get(ga) + g.open.get(gb);
    if (open === 2 && size < total) updates.push(['hi', k, s.lo[k]]);
  });
  if (g.size.size > 1) {
    const exits = new Map();
    shape.links.forEach((l, k) => {
      const ga = g.id[l.a], gb = g.id[l.b];
      if (ga === gb || s.hi[k] === 0) return;
      for (const gid of [ga, gb]) exits.set(gid, exits.has(gid) ? -1 : k);
    });
    for (const gid of g.size.keys()) {
      const k = exits.get(gid);
      if (k === undefined) throw new Contradiction();
      if (k >= 0) updates.push(['lo', k, 1]);
    }
  }
  let changed = 0;
  for (const [which, k, v] of updates) if (which === 'hi' ? setHi(s, k, v) : setLo(s, k, v)) changed++;
  if (s.used) s.used.connecting += changed;
  return changed > 0;
}

Tier 3, trying it. For each open link, set it to its least, then its most, and run tiers 1 and 2 on the copy. If that ends in a contradiction, that value is ruled out. It’s one link at a time, never a guess inside a guess.

rate() returns the easiest tier that settles every link. Every step only rules out what no answer could have, so a settled board is the only answer: uniqueness is proved without a search. A chart is kept only if it rates medium, meaning counting alone can’t finish it and trying is never needed.

Medium also has a depth floor (since engine 3). solve() counts what each tier did (used), and a chart is kept only if staying connected settles at least DEPTH[size] links. Without it, a “medium” could be an easy chart with one connecting step; under engine 2, 8–38% of charts a size were.

games/static/games/hashi/engine/generate.jsDEPTH · lines 36–39
// How many links staying connected (the solver's tier 2) must settle, by
// size, so a medium isn't an easy chart with one connecting step bolted
// on. A small chart has few links to settle; a big one, many.
export const DEPTH = { 7: 2, 10: 3, 15: 4, 20: 5 };

npm run check:hashi keeps the solver honest: up to 10×10 it counts answers by plain search (answers() in tools/hashi/check.mjs) and fails unless there’s exactly one. It also fails a chart under its floor, and holds fixed charts (FIXED, code ABC234 at every size) for every version, so any change to a version’s charts gets caught.

Sizes, and what separates them#

With one difficulty, size is the only difference between charts. Measured over 300 codes per size (Node 24, seed guide|stats|2026-10-19|hashi; times are rough CPU times on a busy desktop):

Size Islands (target) Links Double bridges Networks per chart (mean, max) Links staying connected settles (mean, min) Time to make (median, p95, max)
7×7 12 (12) 14 17% 14.2, 91 3.0, 2 0.8 ms, 8 ms, 26 ms
10×10 24 (24) 31 19% 24.3, 142 4.6, 3 4.9 ms, 27 ms, 45 ms
15×15 54 (54) 79 23% 10.9, 93 8.0, 4 12 ms, 68 ms, 196 ms
20×20 94 (96) 149 24% 15.5, 77 12.3, 5 58 ms, 293 ms, 513 ms

The floor costs little: engine 2’s medians were 0.9, 2.9, 9.5 and 46 ms. Most networks are thrown away. Small grids mostly fail to grow, since they often can’t fit their islands touching every edge. Big grids mostly grow beyond logic alone, or with more than one answer. The floor bites hardest on 10×10, where two medium networks in five were too shallow.

The daily#

hashi/daily.pypuzzles_for · lines 34–46
def puzzles_for(date):
    """The day's daily puzzles, created on first ask at the current engine
    and fixed from then on (a new secret key mustn't change a day that's been
    played). A daily made before a bump keeps its engine version while the
    engine can still make it (VERSIONS), so the day's chart doesn't change
    under anyone; one from a version it can't make any more stays as it was,
    for the solves already posted to it, and the current engine's is made
    beside it."""
    out = []
    for size in lineup(date):
        kept = DailyPuzzle.objects.filter(date=date, size=size, engine__in=VERSIONS).order_by("-engine").first()
        out.append(kept or DailyPuzzle.objects.get_or_create(date=date, size=size, engine=ENGINE, defaults={"code": daily_code(date, size)})[0])
    return out

A day’s code is an HMAC of hashi|<date>|<size> under the site’s secret key, so only the server can make it (ADR 0013, ADR 0024; the dailies chapter).

A daily row records its engine version (unique on date, size and engine) and keeps it while the engine still makes that version. So a bump mid-day leaves the day’s chart alone. The daily API gives each daily’s engine, and the browser makes the chart at it. A row from a version no longer made (engine 1’s) stays for its solves, and the current version’s daily sits beside it with its own board.

From 2026-10-20 a day has one daily, the 10×10 (lineup(), one daily a game). Once dailies follow the week, it takes the weekday’s size (the week climbs, ADR 0041). The daily board opens on the day’s size. Earlier days keep their three. There’s no archive of past dailies until Hashi moves onto the kit.

ENGINE_VERSION#

Version 3 added the depth floor; version 2 made one difficulty. Bump it whenever any code would make a different chart. The version is in the seed, so a bump changes every chart.

The engine still makes the versions in VERSIONS (version 2 is version 3 without the floor), so a daily made before a bump stays the same (the daily). A saved game opens at its own version if the engine still makes it, else as new (loadGame(), and open() in games/static/games/hashi/js/app.js). The referee makes any version in VERSIONS, and checks an older version’s solve only if it stored that chart (engine versions).

Your move#

The first puzzle a player opens shows the house’s first-move prompt, Build your first bridge, until the first bridge (Your first move). It shows once per browser (uo:hashi:first-move), and the clock waits for it.

Secrets#

There are none in play. The browser has the engine, so anyone can run solve() in the console. The server keeps only tomorrow’s daily code to itself. As for every solo game, ADR 0013 accepts that someone could post a solver’s answer after the minimum time (what a cheater could and couldn’t do).

Posting and the referee#

A signed-in player’s solve posts { code, size, engine, bridges, seconds } (scoreFor() in games/static/games/hashi/js/hub.js) to /games/hashi/api/solves/. The referees chapter has the view in full. In order, it:

  1. Parses strictly: size, engine and seconds as whole numbers (true, 1.5 and "Infinity" fail), 1 to 1,000 bridges of three integers each. difficulty is read only for engine 1, whose three difficulties shared codes.
  2. Refuses a time outside MIN_SECONDS[size] to 86,400 seconds (422).
  3. Makes the chart if it isn’t stored: node referee/hashi.mjs CODE SIZE [VERSION]. A player can make at most 60 new charts an hour (429), each within 20 seconds (503 and retry). A version no longer made is checked only if its chart is stored.
  4. Checks the bridges with hashi/check.py, a Python port of layout() and status(). Any solution counts, not just the solver’s.
  5. Keeps the first solve per player per chart; another answers already. It counts for today’s daily only if it arrives today with the daily’s engine version.
SizeFastest believable (seconds)
78
1015
1530
2060
Live from hashi/times.py, MIN_SECONDS

The reply has the time, best, rating (below), and for a daily its rank of of and your streak. A guest’s solve, or one that got a 401, 202, 429, 5xx or no answer, waits in uo:hashi:pending for the next load once signed in (flushPending()). A 422 is final.

Rating#

Hashi is a timed puzzle:

ratings/sources.pyHashi · lines 235–253
class Hashi(Timed):
    game = "hashi"

    @property
    def model(self):
        from hashi.models import Solve
        return Solve

    def solves(self):
        return self.model.objects.select_related("puzzle", "daily")

    def board(self, s):
        return (s.puzzle.size,)

    def board_filter(self, s):
        return Q(puzzle__size=s.puzzle.size) & ~Q(puzzle__legacy_difficulty="easy")

    def rated(self, s):
        return s.puzzle.legacy_difficulty != "easy"
  • Par is per size: the median of the last 99 verified solves at the size, once there are 5. Engine 1’s Medium and Hard charts count toward it. Its Easy charts don’t, and aren’t rated (rated()), since they were gentler than any chart now.
  • An endless chart is rated on posting, against par (a fixed 1500 ± 350 opponent), at weight ENDLESS = 0.5.
  • The daily waits for midnight. Then everyone who solved it that day is one field, ranked by time. A daily only you solved is rated against par, at full weight.

Worked: an endless 15×15. A settled 1550 ± 80 player finishes in 7:30, where par is 9:00. The time scores 0.590 (a little better than par is a little better than a draw), at weight 0.5, and moves them +0.5. On a 15×15 made by engine 1 at Easy, the same time isn’t rated at all.

Worked: a 10×10 daily, three solvers. At midnight each meets the other two, at a weight of 0.67 a meeting:

PlayerBeforeTimeAfterChange
Ray16202:311623+3.1
June1500?2:131726?+226.3
Mo15503:421539−11.0
Live from guide/worked/hashi.py, DAILY.rows

June, new and fastest, moves the most. Solved alone, a 10×10 daily in 3:10 against a 4:00 par scores 0.615 at full weight: +1.3 for a 1550 ± 80 player. guide/worked/hashi.py computes all of this through ratings.sources.Hashi and ratings.service.compute, as the site does.

The fastest 10×10 time the referee believes, 15 seconds, would score 0.9961 against a 4:00 par. That’s nearly a win, which is why the minimum times matter.

Leaderboards, the account and stats#

/games/hashi/leaderboard/ (hashi/views.py) has three boards, each the top 100:

Board Shows Ranked by
Daily A day’s daily (?date=, Earlier and Later; never past today, and only what was really played) Seconds, then who posted first
All-time, by size Each player’s best on any verified chart of the size, and how many they’ve solved; engine-1 Easy left out (COUNTS) Best, then who reached it first
Ratings Established players’ Hashi ratings (where ratings show) Rating

On an engine-change day the daily board shows the newer chart (order_by("-engine")). An old difficulty= in a link is ignored. The account page shows account_summary(): puzzles solved, the daily streak, and up to three best times. The Stats tab is the site’s (the Stats tab); engine-1 Easy solves count there with no time.

The interface#

The modules#

File Does
games/static/games/hashi/js/app.js Routes: #/ home, #/daily/10, #/p/<size>/<code>. An old #/p/<size>/<difficulty>/<code> redirects, with a toast that it now draws a new chart. A ticket stops a slow route drawing over a newer one. Picks the engine version: a daily’s own, a saved chart’s, else the current one.
games/static/games/hashi/js/maker.js, games/static/games/hashi/js/worker.js Makes charts in a Web Worker (on the page if there’s none), so “Setting out the islands…” stays animated. A 20×20 takes up to half a second on a desktop, longer on a phone.
games/static/games/hashi/js/home.js Today’s card, Endless (size, New puzzle, tally, up to three unfinished charts), the once-only How to play.
games/static/games/hashi/js/play.js The desk: bar, clock, pause, moves, status line and announcer, zoom, the solve, posting, the result sheet.
games/static/games/hashi/js/chart.js The board’s SVG and its gestures. It only draws and reports; play.js owns the counts.
games/static/games/hashi/js/store.js, games/static/games/hashi/js/hub.js What the browser keeps; everything that talks to the server.
games/static/games/hashi/js/sheets.js, games/static/games/hashi/js/ui.js, games/static/games/hashi/js/art.js How to play and Options, button classes, the emblem and thumbnail.

Building#

Gesture Does
Drag from an island toward a neighbor One more bridge (one, two, none). The direction counts after max(10px, 0.6 × (R + 0.1) cells): the axis moved further along, or the other if there’s no neighbor that way and the drag went at least half as far across.
Tap an island, then a neighbor The same.
Tap the paper between two islands The same, for that link. Where two links cross, a built one wins, else the nearer; on a near tie (within 0.12 of a cell, or 8px for a finger), it asks you to drag.
Shift or right-click with any of those One fewer.
Press and hold a bridge (finger or pen) One fewer, after 450 ms.
Two fingers Pinch to zoom; one finger moves a zoomed board.

A press finds the nearest island within half a cell, or within 12px (mouse) or 22px (finger) when cells are small. The paper between islands is checked first, so a tap on a short link isn’t taken for an island. Islands have radius R = 0.42 of a cell; a double bridge’s lines sit GAP = 0.085 either side of center.

Keys use the house’s shortcuts(). Turning off Shortcut keys turns off the letters; arrows, Enter and Escape always work. Arrows move between islands, starting nearest the middle; with no neighbor that way, they go to the nearest island roughly that way, scored along + 2 × across. Shift + arrow builds and Alt + arrow removes. N reads the island again, U goes to the next unfinished one, Z and Y undo and redo (Ctrl/⌘ Z and Shift Z too), P pauses, + − 0 zoom, ? opens How to play and Escape lets go.

Zoom#

The board opens fitted to the screen, with cells of 8 to 64px (84 on a window 1,600px or wider). Only if cells come out under 28px are − + and Fit offered, zooming to cells of up to 52px. Pinch and Ctrl + wheel zoom about the pointer; buttons and keys about the chosen island. Pausing zooms out.

Helpers (assists)#

All start on, each with a switch in Options (uo:hashi:options):

Option What it computes
Mark finished islands status().done: an island with exactly its number turns green. It doesn’t mean those bridges are right.
Mark islands with too many status().over: a dashed red ring and a bold number. The status line names the first, by row and column.
Show where bridges can go From the chosen island, a dotted line along every empty link that crosses no built bridge.
Show the clock Hides the clock; time is still kept.
Shortcut keys The letters above.

None reads the answer; each is worked out from the board’s counts. There’s no hint, no check against the solution and no auto-fill.

The clock, the solve and sharing#

The clock runs only while the chart is on screen: not solved, not paused, no sheet open, tab visible. A sheet hides the board so it’s never free thinking time, using the house kit’s hideBoard(), which a Feedback screenshot can still see past (the front end). Elapsed time is saved every five seconds and on pagehide. A solve posts max(1, round(seconds)).

On a solve, the size’s tally and best are updated and a daily is logged for streaks (dailyDone). What the solve did to the best is kept with the save as pb ({ first, beat, best }, the puzzle kit’s shape), so the Dailies page’s Full Sweep card and its share name a new Hashi best (Dailies). The islands settle outward from the last bridge built (breadth-first, min(60, 700 / (depth + 1)) ms a step), then the result sheet opens, celebrating a new best (or a rating high, once posted) with the house’s best, else a daily with daily (Celebrations). Its primary button is the next daily of a Dailies-page run, else New puzzle. Share result gives the day (or the chart’s number) and size, the time, today’s place and streak once posted, and a new best; its link opens the same chart (#/p/<size>/<code>), or the game for a daily.

What the browser keeps#

Everything is under uo:hashi: in localStorage. Nothing leaves the browser but a signed-in player’s solve (AGENTS.md rule 6):

Key Holds
p:<size>/<code> A chart’s counts, moves and redo (the last 400 of each; dropped counts the rest so Moves stays right), elapsed time, solved and its seconds, the post’s answer, the engine version
recent The 150 charts opened most recently; older ones are forgotten
tally Solves and best time by size
options, choice, welcomed Helpers and keys; the last size picked; whether How to play has welcomed you (here or on your account: ADR 0039)
pending Solves waiting for sign-in, one per chart
v The store’s shape. upgrade() ran once at version 2 to fold engine 1’s tally into sizes and drop three-part keys.

Anything unreadable falls back to empty. If storage is full, saveGame() forgets the ten oldest charts and tries again. If that fails too, the game plays on without saving and says so once a visit.

Accessibility#

The board is one tab stop, role="application" with a description of the controls; the islands are hidden from the accessibility tree. One quiet announcer (role="status") reads each island’s state and directions (“4, needs 2 more, row 3 column 5. East: 1 bridge to the 3. South: blocked by a bridge. …”), every move and refusal, the solve and the rank. Every helper is a shape as well as a color. Reduced motion drops the ripple and stamp.

Its look#

Hashi’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, with an interface of its own
Palette Its accent, graphite teal #0c6670: the one fill, the ribbon, the kicker and the stamp. It sits between Star Battle’s indigo and Fillomino’s terracotta, and far from --good’s green. A finished island’s numeral is #24603f on a green wash. The rest is the paper’s.
Faces The paper’s: Gloock and Figtree
Corners and edges The kit’s
Board Islands are pencil-puzzle circles, a graphite ring on a white fill with the number in heavy graphite. Bridges are graphite rules, a double one two side by side. A bridge being drawn is dashed in signal blue (dashed red when letting go would clear it, with a × when it would cross another). A finished island is washed green in a green ring, too many is a dashed red ring, and the chosen island wears the signal-blue ring with dotted guides to where its bridges could go.
Emblem and art Two islands joined by a double bridge and a third by a single one, the third filled in the accent. Its box: a real 7×7 part-way through, on a teal desk with a pencil.
Voice A puzzle everywhere (“New puzzle”, “Solved”); islands and bridges are its own words.
Payoff The islands settle one after another outward from the last bridge, and a “Solved 1:09” stamp in the accent presses onto the folio.
The glance Now: the status line. The countdown: the clock.
Exceptions None

The harbor chart Hashi once wore (a dark chart table, brass and navy, “Charted”) was retired by David on 2026-10-13, so it reads as part of the same book as Star Battle and Fillomino.

Testing#

Check Proves
npm run check:hashi (tools/hashi/check.mjs) For 25 codes a size (more with --size=, --seed=): each code makes the same chart twice, rated medium and over its floor; the solver’s answer solves it; up to 10×10, search finds one answer; every version still makes its fixed charts. Then Node tests for the refused bridge’s flash (tools/hashi/chart.test.mjs) and for undo’s cap, full storage and saved versions (tools/hashi/store.test.mjs).
tests/test_hashi.py DailyTests Daily codes and lineups; a daily keeps its version across a bump; engine 1’s dailies stay beside new ones.
CheckTests The Python checker passes a real answer and fails crossings, wrong counts, pieces, non-neighbors, a count of 3 and a repeated link.
SubmitTests, EarlierEngineSubmitTests Posting end to end, referee mocked: sign-in, bad JSON, whole numbers, the hourly limit, times, versions, the day’s slot, best, engine 1’s charts.
BoardTests, AccountSummaryTests The three boards, ties, old links, engine-change days, the account summary in two queries.
RefereeTests, MigrationTests The real Node referee makes current and engine-2 charts (refusing engine 1), its layout matches the checker’s, a real 15×15 daily goes all the way through, and ADR 0024’s migrations run both ways.

Known issues and open questions#

Found while writing this chapter; the others have since been fixed.

  1. Par mixes engines. Engine 1’s Medium and Hard 10×10 and 15×15 times share par with later engines’, by ADR 0024’s choice, and engine 3’s deeper charts share it with engine 2’s. Worth revisiting once old times have left the 99-solve window.
  2. Open: the minimum times (under Posting and the referee) are “how long it takes to draw every bridge”. ADR 0013 says to raise them if boards fill with impossible times.

Changing it#

Change What it needs
Anything in engine/ that could change which chart a code makes Bump ENGINE_VERSION, add its fixed charts to FIXED in tools/hashi/check.mjs, run npm run check:hashi. If the old version can still be made on request (a new filter switched on from the new version, as DEPTH is), keep it in VERSIONS so old dailies and saved games keep their chart. Drop a version only if its dailies never need drawing again. The box art (games/templates/games/thumbs/hashi.html, ABC234 7×7 at engine 2) and the test chart in tests/test_hashi.py (engine 2’s) stay valid while engine 2 is made.
The interface (js/, hashi.css) No bump. Run check:layout and check:details with the site running (Testing).
Minimum times hashi/times.py only.
What’s rated ratings/sources.py and its VERSION (Ratings).
Moving onto the kit A Kind in puzzles/registry.py with no difficulty in its code (puzzles/daily.py already leaves it out, so dailies keep their codes), the solves moved with their created times (which is why the kit’s Solve.created is a default), and this chapter rewritten.

Then run npm run check:guide, and npm run guide:stamp hashi once this chapter has been read against the change.