Technical guide

Puzzles

Low Poly

Join numbered dots with lines, and a hidden picture fills in, one triangle at a time.

Checked against the code on 2026-10-30. Play Low Poly

On this page
  1. At a glance
  2. The rules in code
    1. A puzzle
    2. Drawing a line
    3. Solved
    4. Fairness: audit
  3. Making the pictures
    1. Sources and license
    2. 1. Pixels (raster.mjs)
    3. 2. Light (mesh.mjs, lit)
    4. 3. Greedy insertion (mesh.mjs, build)
    5. 4. Background, holes, colors (mesh.mjs, build.mjs)
    6. 5. Sizes are densities
    7. 6. Recognizability
    8. Codes and the manifest
    9. Determinism and versions
  4. Sizes and difficulty
  5. The daily pick
  6. What the browser holds
  7. Your move
  8. Posting and the referee
  9. Rating, and times on dailies only
  10. The interface
    1. Drawing and rubbing out
    2. The helper
    3. Filling and the reveal
    4. Accessibility
    5. Stored in the browser
  11. Its look
  12. Testing
  13. Changing it
  14. Known limitations
  15. Known issues

Low Poly is a picture puzzle made of dots. Each dot shows how many lines it needs. Join two dots with a line, and when all three sides of one of the hidden picture’s triangles are drawn, the triangle fills with color. Draw every line and the picture is whole, and you learn its name.

It plays like a game the New York Times ran from 2019 to 2024 (David’s sister’s idea), without its name, branding or art; docs/low-poly.md has the research. Its pictures are made offline and shipped as files, and a code names a picture, as in Griddles. Low Poly’s pictures has the pipeline’s overview and The pencil-puzzle kit what the kit does for every puzzle.

At a glance#

Part Where What it is
Engine games/static/games/low-poly/engine/ index.js (sizes, codes, loading, check, the kit’s contract), picture.js (geometry, audit)
Pictures games/static/games/low-poly/pictures/ One <code>.json per picture, index.json (manifest and daily order), codes.js (every code, and WITHOUT)
Interface games/static/games/low-poly/js/ board.js (dots, lines, the band, hit-testing), puzzle.js (words, helper, How to play, endless picks, share line), art.js (emblem, thumbnails, the picture)
Look games/static/games/low-poly/low-poly.css Raspberry, #a8235a
Pipeline tools/low-poly/ subjects.mjs, raster.mjs, mesh.mjs, delaunay.mjs, build.mjs, play.mjs (the play check)
Server puzzles/low_poly.py Its Kind: sizes, the daily pick, the answer’s shape, times ranked on dailies only
Pages games/templates/games/low-poly.html, games/templates/games/thumbs/low-poly.html The kit’s page; the box art (the fox, 76XLFK at Easy, part drawn)
Tests tests/test_low_poly.py Server, manifest license, referee
Design notes docs/low-poly.md The original, how this one plays, open questions

Decisions: ADR 0023 (the kit), ADR 0032 (the archive), ADR 0034 (one daily a game), ADR 0030 (ratings).

The rules in code#

A puzzle#

games/static/games/low-poly/engine/index.jsgenerate · lines 60–73
/**
 * The puzzle a code names at a size: { engine, slug, code, size, difficulty,
 * title, w, h, dots, tris, colors }. Async (the picture is a file); throws
 * for a code, size or difficulty it doesn't make.
 */
export async function generate(code, size, difficulty = SIZES[size]?.difficulty) {
  code = String(code).toUpperCase();
  if (!CODE_RE.test(code) || !CODES.includes(code)) throw new Error(`No picture ${code}.`);
  if (!Object.hasOwn(SIZES, size) || SIZES[size].difficulty !== difficulty) throw new Error(`No ${difficulty} ${size} Low Poly.`);
  if (WITHOUT[size]?.includes(code)) throw new Error(`No ${size} picture ${code}.`);
  const picture = await load(code);
  const { w, h, dots, tris, colors } = picture.tiers[size];
  return { engine: ENGINE_VERSION, slug: SLUG, code, size, difficulty, title: picture.title, w, h, dots, tris, colors };
}

generate is async because the picture is a file: read from disk under Node, fetched in a browser, cached for the visit. A picture at one size is { w, h, dots: [[x, y]], tris: [[a, b, c]], colors: ['#rrggbb'] }. Coordinates are whole hundredths of a board unit, so every browser and Node get exactly the same geometry. The puzzle adds code, size (easy, medium or hard, also its difficulty) and title.

games/static/games/low-poly/engine/index.jsSIZES · lines 24–29
// A size is how many dots a picture is drawn with, and that's its difficulty.
export const SIZES = {
  easy: { difficulty: 'easy', dots: 25 },
  medium: { difficulty: 'medium', dots: 55 },
  hard: { difficulty: 'hard', dots: 100 },
};

The picture’s lines are the sides of its triangles, each once, as [a, b] with a < b, sorted. A dot’s number is how many lines meet it (degreesOf). The lines are the answer:

games/static/games/low-poly/engine/index.jscheck · lines 80–83
/** Whether an answer (lines as [a, b]) is exactly the picture's lines. */
export function check(puzzle, answer) {
  return Array.isArray(answer) && JSON.stringify(answer) === JSON.stringify(linesOf(puzzle));
}

Nothing needs reasoning out, so solve, rate and count just return the lines, the size’s difficulty and one answer.

Drawing a line#

The board keeps the set of lines drawn, each "a-b". Any two dots may be joined, unless:

games/static/games/low-poly/js/board.jsmount.refusal · lines 162–170
/** Why the line a–b can't be drawn, or null if it can. */
function refusal(a, b) {
  const k = lineKey(a, b);
  if (drawn.has(k)) return `Those two are joined already. ${ctx.touch ? 'Tap' : 'Click'} the line to rub it out.`;
  if (left[a] === 0 || left[b] === 0) return `That dot has all ${want[left[a] === 0 ? a : b]} of its lines. ${ctx.touch ? 'Tap' : 'Click'} one of them to rub it out.`;
  if (dotOnLine(puzzle, a, b) >= 0) return 'A line can’t pass through a dot.';
  for (let i = 0; i < tris.length; i++) if (filled[i] && cutsTriangle(puzzle, a, b, tris[i])) return 'A line can’t cross a filled triangle.';
  return null;
}

A dot can’t take more lines than its number, which makes the numbers the real constraint. A wrong line (not in the picture) is allowed and stays. It uses up both dots’ counts, and the triangles beside it never fill. Nothing counts against you; a puzzle can’t be lost.

A line may cross another line that isn’t part of a filled triangle (the research found no rule either way; the design notes ask David). It can’t run through a dot, meaning within THROUGH (30 hundredths of a unit) of a dot it doesn’t end at. It can’t cross a filled triangle (cutsTriangle).

A triangle fills once its three sides are drawn, and its lines become fixed: a tap or a dot’s rub-out leaves them. Undo still takes them back.

Solved#

games/static/games/low-poly/js/board.jsmount.answer · lines 440–444
answer() {
  if (drawn.size !== target.length) return null;
  for (const k of target) if (!drawn.has(k)) return null;
  return linesOf(puzzle);
},

Solved means exactly the picture’s lines, which leaves every triangle filled and every count at zero. If every count is zero but some triangles are blank, the status line says some lines aren’t in the picture.

Fairness: audit#

Every picture at every size must pass audit (the engine’s problems()), in the pipeline and in npm run check:puzzles:

games/static/games/low-poly/engine/picture.jsaudit · lines 73–109
/** What's wrong with a picture, as sentences: [] when it's fair to play. */
export function audit(p) {
  const out = [];
  const n = p.dots.length;
  if (!n || p.tris.length !== p.colors.length) out.push('triangles and colors don’t match');
  if (p.colors.some(c => !/^#[0-9a-f]{6}$/.test(c))) out.push('a color isn’t #rrggbb');
  if (p.dots.some(([x, y]) => !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x > p.w || y > p.h)) out.push('a dot is off the board');
  const used = new Set(p.tris.flat());
  if (used.size !== n || p.tris.flat().some(k => !Number.isInteger(k) || k < 0 || k >= n)) out.push('a dot is in no triangle');
  for (const t of p.tris) {
    const [a, b, c] = t.map(k => p.dots[k]);
    if (orient(a, b, c) === 0) out.push(`triangle ${t} is flat`);
  }
  const lines = linesOf(p);
  const count = new Map();
  for (const t of p.tris) for (const k of sidesOf(t)) count.set(k, (count.get(k) || 0) + 1);
  if ([...count.values()].some(v => v > 2)) out.push('a line has more than two triangles');
  for (let i = 0; i < lines.length; i++) {
    const [a, b] = lines[i];
    if (dotOnLine(p, a, b) >= 0) out.push(`line ${a}–${b} passes through dot ${dotOnLine(p, a, b)}`);
    for (let j = i + 1; j < lines.length; j++) {
      const [c, d] = lines[j];
      if (crosses(p.dots[a], p.dots[b], p.dots[c], p.dots[d])) out.push(`lines ${a}–${b} and ${c}–${d} cross`);
    }
  }
  for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
    if (Math.hypot(p.dots[i][0] - p.dots[j][0], p.dots[i][1] - p.dots[j][1]) < 2 * THROUGH) out.push(`dots ${i} and ${j} touch`);
  }
  // Two triangles that are the same three dots, or one inside another, would fill twice.
  const seen = new Set();
  for (const t of p.tris) {
    const k = [...t].sort((x, y) => x - y).join(',');
    if (seen.has(k)) out.push(`triangle ${k} twice`);
    seen.add(k);
  }
  return out;
}

No picture line passes through a dot or crosses another. No two dots are closer than two THROUGHs. No triangle is flat or doubled, no line borders more than two triangles, and every dot is in a triangle. So every picture can be drawn without hitting a refusal.

Making the pictures#

Sources and license#

The pictures are low-poly renderings of Microsoft’s Fluent Emoji, “Flat” style, under the MIT License. The notice is SOURCE in tools/low-poly/subjects.mjs and travels in the manifest (index.json) with each picture’s source URL. Flat art, one subject in a few strong colors, reads far better than a photo: even 25 dots make a fox. The SVGs stay outside the repository and are only drawn as images in headless Chrome, so nothing in them runs.

SUBJECTS lists each of the 236 as [folder, title, kind, sizes?]. The kind (creature, plant, food, machine, thing, place, land) shapes the daily order.

1. Pixels (raster.mjs)#

rasterize(paths, 256) draws each SVG onto a 256 × 256 transparent canvas in headless Chrome (tools/house/chrome.mjs) and reads back RGBA pixels (alpha is opacity).

2. Light (mesh.mjs, lit)#

Flat art has no shading, so the subject is lit from the top left (up to 24% brighter, toward white so whites still shade) and darkened toward the bottom right. That gives flat regions a slope for triangles to follow.

tools/low-poly/mesh.mjslit · lines 200–224
/**
 * The picture lit from the top left: brighter there, darker at the bottom
 * right, across the subject's own box. Flat art has no shading of its own,
 * and a low-poly picture is its facets: this gives every flat part a slope
 * for the triangles to follow.
 */
function lit(rgba, S, light) {
  let x0 = S, y0 = S, x1 = 0, y1 = 0;
  for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) if (rgba[(y * S + x) * 4 + 3] > 8) { x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
  const out = new Uint8ClampedArray(rgba);
  const w = Math.max(1, x1 - x0), h = Math.max(1, y1 - y0);
  for (let y = 0; y < S; y++) {
    for (let x = 0; x < S; x++) {
      const i = (y * S + x) * 4;
      const t = ((x - x0) / w + (y - y0) / h) / 2;
      const f = 1 + light * (0.5 - t) * 2;
      for (let c = 0; c < 3; c++) {
        const v = rgba[i + c];
        // Lighter toward white, darker toward black, so whites still shade.
        out[i + c] = f >= 1 ? v + (255 - v) * (f - 1) * 0.8 : v * f;
      }
    }
  }
  return out;
}

3. Greedy insertion (mesh.mjs, build)#

This is Garland and Heckbert’s greedy insertion (1995) with flat-colored triangles. Start with a few triangles, then keep adding the dot that most reduces the picture’s error:

  1. Features. Each pixel is its color premultiplied by alpha, plus alpha weighted by 1.6 so outlines count more than shading. Per-row prefix sums (running totals) give a triangle’s error with one subtraction per row.
  2. Candidates. On a 2-pixel lattice, a non-background point that differs from a neighbor two pixels away (an outline, a shadow, an eye) is a candidate. Flat areas get every 8th point. Candidates start one step in from the frame.
  3. Start with the frame’s four corners, Delaunay-triangulated (tools/low-poly/delaunay.mjs, Bowyer–Watson), which avoids thin slivers where it can. The corners go with the background.
  4. Insert. A candidate’s gain is the error of the triangles it would replace minus the error of the ones it would make. The best 40 are tried in order and the first to pass fine is added. A candidate within the size’s gap of a dot is dropped; one that fails fine is skipped from then on.
  5. Stop at the size’s dot count in the kept part (triangles at least half opaque), when candidates run out, or after 2,000 rounds.

fine refuses a dot that would leave a sliver (a corner under 14°) in a triangle at least 0.3 opaque, or a line within clear of a dot it doesn’t end at:

tools/low-poly/mesh.mjsfine · lines 239–257
/** No dot too close to a line of a triangle it isn't a corner of, and no sliver in a triangle likely to be kept (0.6 × keepAlpha opaque). */
function fine(points, tris, clear, angle, img, keepAlpha) {
  for (const v of tris) {
    const P = v.map(k => points[k]);
    if (minAngle(...P) >= angle) continue;
    const st = stats(img, ...P);
    if (st.n && st.s[3] / st.n / (255 * ALPHA_WEIGHT) >= keepAlpha * 0.6) return false;
  }
  for (const e of edgesOf(tris)) {
    const a = points[e[0]], b = points[e[1]];
    for (let k = 0; k < points.length; k++) {
      if (k === e[0] || k === e[1]) continue;
      const p = points[k];
      if (p[0] < Math.min(a[0], b[0]) - clear || p[0] > Math.max(a[0], b[0]) + clear || p[1] < Math.min(a[1], b[1]) - clear || p[1] > Math.max(a[1], b[1]) + clear) continue;
      if (segDist(p, a, b) < clear) return false;
    }
  }
  return true;
}

4. Background, holes, colors (mesh.mjs, build.mjs)#

Triangles under half opaque are dropped, with any dot left unused. A dropped triangle with all three sides on kept ones would look like a triangle that never fills, so fillHoles puts it back in its neighbors’ mean color, until there are none:

tools/low-poly/build.mjsfillHoles · lines 54–79
/**
 * A triangle left out (mostly transparent) whose three sides all belong to
 * kept triangles would look, once drawn, like a wrong triangle that never
 * fills: it's a hole in the picture, so it's filled with its neighbors' color.
 */
function fillHoles(mesh, all) {
  const { tris, colors } = mesh;
  let grew = true;
  while (grew) {
    grew = false;
    const edges = new Map();
    tris.forEach((t, i) => { for (const [a, b] of [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]]) { const k = a < b ? `${a},${b}` : `${b},${a}`; (edges.get(k) || edges.set(k, []).get(k)).push(i); } });
    const have = new Set(tris.map(t => [...t].sort((a, b) => a - b).join(',')));
    for (const t of all) {
      if (have.has([...t].sort((a, b) => a - b).join(','))) continue;
      const sides = [[t[0], t[1]], [t[1], t[2]], [t[2], t[0]]].map(([a, b]) => edges.get(a < b ? `${a},${b}` : `${b},${a}`));
      if (sides.every(Boolean)) {
        const near = sides.flat().map(i => colors[i]);
        tris.push(t);
        colors.push([0, 1, 2].map(c => Math.round(near.reduce((s, x) => s + x[c], 0) / near.length)));
        grew = true;
        break;
      }
    }
  }
}

A triangle’s color is the mean of its pixels, un-premultiplied, so edge pixels count by color weighted by alpha.

5. Sizes are densities#

tools/low-poly/build.mjsTIERS · lines 29–37
// Per size: how many dots, and the least gap between two (px of the S × S
// source). A board unit is the gap / 0.7, so at the kit's zoom a dot's
// nearest neighbor is at least 0.7 of a cell away; a line keeps 0.42 of a
// unit off any dot it doesn't end at, clear of the dot's 0.3-unit disc.
export const TIERS = {
  easy: { vertices: 25, gap: 22 },
  medium: { vertices: 55, gap: 14 },
  hard: { vertices: 100, gap: 10 },
};

gap is the least distance between dots, in source pixels. make sets the board unit to gap ÷ 0.7, so dots are at least 0.7 units apart at every size, and lines keep 0.42 units off other dots. Coordinates are whole hundredths of a unit, and triangles are written in a fixed order. The kit zooms in units, so Hard is the board that needs zooming on a phone.

6. Recognizability#

Every subject was checked by eye at all three sizes on a contact sheet (build.mjs <assets> --sheet=out.html) and kept only if it reads. Easy’s 25 dots are where pictures fail, so a subject’s fourth entry can change Easy. { easy: 30 } or { easy: 35 } gives 44 pictures, mostly faces, more dots. { easy: 0 } drops Easy for 17 that stayed a blob (Elephant, Seal, Sloth…), so they’re never a daily; WITHOUT in games/static/games/low-poly/pictures/codes.js tells the engine (codesAt(size)). That was engine version 2.

Codes and the manifest#

tools/low-poly/build.mjscodeOf · lines 41–50
/** A picture's code: six letters from its folder name (FNV-1a), the same every time. */
export function codeOf(name, salt = 0) {
  let h = 0x811c9dc5 ^ salt;
  let out = '';
  for (let round = 0; out.length < 6; round++) {
    for (const ch of `${name}#${round}`) h = Math.imul(h ^ ch.charCodeAt(0), 0x01000193) >>> 0;
    out += ALPHABET[h % 32] + ALPHABET[(h >>> 5) % 32] + ALPHABET[(h >>> 10) % 32];
  }
  return out.slice(0, 6);
}

A code is six letters from an FNV-1a hash (a fixed recipe that turns text into a number) of the folder name, salted on a collision, so adding pictures never changes another’s code. Fox is 76XLFK. build.mjs writes each picture’s file, codes.js, and index.json, whose daily is the dailies’ order:

tools/low-poly/build.mjsdailyOrder · lines 102–120
// The daily order: the kinds taken in turn, each kind's pictures in an order
// of their own (by a hash of their codes), so neighbors are seldom alike (a
// few are, all "thing", the biggest kind). Dailies before one daily a game
// stepped through it; since, the server keeps its kinds in turn but shuffles
// each kind's pictures by the site's key (puzzles/low_poly.py).
function dailyOrder(pictures) {
  const kinds = new Map();
  for (const p of pictures) (kinds.get(p.kind) || kinds.set(p.kind, []).get(p.kind)).push(p);
  for (const list of kinds.values()) list.sort((a, b) => codeOf(a.code, 7).localeCompare(codeOf(b.code, 7)));
  const lists = [...kinds.values()].sort((a, b) => b.length - a.length);
  const total = pictures.length;
  // Each kind spread evenly through the order: its k-th picture lands at (k + ½) / n of the way.
  const slots = [];
  for (const list of lists) list.forEach((p, k) => slots.push([(k + 0.5) / list.length, lists.indexOf(list), p.code]));
  slots.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
  const order = slots.map(s => s[2]);
  if (order.length !== total) throw new Error('daily order lost a picture');
  return order;
}

Each kind’s pictures are shuffled by a hash and spread evenly through the order, so neighbors are usually different kinds. Measured: 4 neighboring pairs match, all “thing”, the biggest kind. This order is public; the daily pick keeps its kinds and hides its pictures.

Determinism and versions#

The pipeline uses only sorting and hashes, so a rebuild gives the same pictures and a code keeps its puzzle. A maker change that moves anything needs an ENGINE_VERSION bump (2 now) and a rebuild. npm run check:puzzles holds three pictures’ fingerprints. --reuse remakes the manifest alone. If the fox’s Easy dots move, redraw the box art.

Sizes and difficulty#

Measured on 2026-10-19 over every picture, through generate under Node 24 (angles by the pipeline’s minAngle, distances in board units):

Easy Medium Hard
Pictures 219 236 236
Dots, median (range) 25 (25–35) 55 (55–56) 100 (92–100)
Triangles, median (range) 32 (17–57) 81 (52–94) 161 (103–181)
Lines to draw, median (range) 56 (42–91) 135 (107–148) 261 (203–280)
Busiest dot’s number, median (range) 8 (5–11) 8 (7–12) 9 (7–12)
Board, median width (range) 8.2 (4.6–9.3) units 12.3 (6.6–14.0) 16.9 (8.9–19.1)
Nearest two dots, median (min) 0.71 (0.70) units 0.70 (0.70) 0.70 (0.70)
Smallest angle in a picture, median (min) 15.2° (11.9°) 14.6° (10.1°) 14.3° (14.0°)
Triangles under 14° 7 1 0
audit problems 0 0 0

Difficulty is the amount: about four times the lines from Easy to Hard, on a board twice as wide. The busiest dot stays at 8 or 9, so more dots look alike. Four Hard pictures fell short of 100 dots when candidates ran out.

The one daily is Medium from 2026-10-20 (One daily a game). From 2026-10-26 the weekday picks the level (ADR 0041, the week climbs): Easy Monday and Tuesday, Medium Wednesday to Saturday, Hard Sunday, each from its own level’s order.

The daily pick#

Low Poly’s dailies are picked (Kind.pick, as in Griddles), not derived from a keyed hash of the day. Each day gets a picture that hasn’t been a daily, days are filled in date order, and each is kept (Each game’s daily). Only pictures in every size are dealt (daily_order: 219 of 236).

puzzles/low_poly.pypick · lines 70–84
def pick(kind, day, size, difficulty, used, anywhere=()):
    """A day's picture: before one daily a game, the public order's
    (`public_order`, as those days were dealt); from then on, the first in
    the secret order not yet a daily at this size, and once all have been,
    the one longest ago. `used` is the codes of every earlier daily at this
    size, oldest first. From the day the week picks the size (docs/adr/0041),
    a picture that's been a daily at any size waits until every other has
    had its turn at this one (`anywhere`, every size's, oldest first)."""
    if not daily.one_daily(day):
        return public_order(kind, day, size)
    order = secret_order(kind)
    last = {c: k for k, c in enumerate(used)}
    taken = set(last) | (set(anywhere) if daily.follows_week(day) else set())
    fresh = [c for c in order if c not in taken] or [c for c in order if c not in last]
    return fresh[0] if fresh else min(order, key=lambda c: last[c])

Days with three dailies (before ADR 34) keep what the public order dealt them. A day’s place was its days since EPOCH (2026-10-16), plus a start the site’s key picked, plus a third of the list per size up (219 // 3 = 73). So a day’s three differed, and a picture returned at another size 73 days later.

puzzles/low_poly.pypublic_order · lines 41–52
def public_order(kind, day, size):
    """A day's picture while there were three dailies a day (before
    settings.ONE_DAILY_FROM): every size went round the daily order, a
    picture a day, from a place the site's key picks, the sizes a third of
    the way round from each other (73 days), so a day's three were
    different pictures. The order itself is public (index.json), so one
    day's picture told the next: from one daily a game, `pick` deals from
    an order only the key knows. Days before keep these pictures."""
    order = daily_order()
    sizes = [s for s, _ in kind.daily]
    start = daily.seed(kind.slug, "daily order")
    return order[((day - EPOCH).days + start + sizes.index(str(size)) * len(order) // len(sizes)) % len(order)]

That order is public and days stepped through it one place at a time, so one day’s picture told you every one after.

From one daily a game, the day’s Medium is the first picture not yet a Medium daily, in an order only the site’s key knows. It takes the public order’s kinds in turn, each kind’s pictures shuffled by the key, so a day’s picture tells you at most the next one’s kind. Every picture comes once before any repeats, counting the three-a-day days’ Medium pictures. After that, the one used longest ago comes first.

puzzles/low_poly.pysecret_order · lines 55–67
def secret_order(kind):
    """The daily order as only the site's key knows it: the manifest's
    order of kinds (index.json's `daily`, a creature, then a food, then a
    thing … spread so neighbors are seldom alike), with each kind's own
    pictures shuffled by the key. A day's picture tells you nothing of the
    next but, at most, its kind."""
    order = daily_order()
    kinds = {p["code"]: p["kind"] for p in manifest()["pictures"]}
    shuffled = {}
    for c in sorted(order, key=lambda c: daily.seed(kind.slug, "secret order", c)):
        shuffled.setdefault(kinds[c], []).append(c)
    deal = {k: iter(v) for k, v in shuffled.items()}
    return [next(deal[kinds[c]]) for c in order]

A daily’s code is the picture’s own, so a picture solved in Endless can come round as the day’s. It opens fresh, since the kit saves a daily under its day (Sheets, saving and the hub), and counts on the day’s board and streak. The server keeps the two solves apart (Posting a solve).

Endless picks a random picture this browser hasn’t solved, else any (newCode in puzzle.js).

What the browser holds#

Every picture’s file, so every answer, and the public order. It can’t know a coming day’s picture, because that order needs the site’s key.

Your move#

firstMove gives the kit’s first-move prompt (Your first move) its words: Join two dots (“Drag from one dot to another to draw a line”), once per browser.

Posting and the referee#

A solve posts the lines. The shape check:

puzzles/low_poly.pyclean · lines 87–99
def clean(answer, size):
    """An answer is the picture's lines: a list of [a, b], two dots' numbers
    with a < b, in order, each line once. [[0, 1], [0, 4], [1, 2], …]"""
    if not isinstance(answer, list) or not 0 < len(answer) <= MOST_LINES:
        raise ValueError("not a list of lines")
    out = []
    for line in answer:
        if not (isinstance(line, list) and len(line) == 2 and all(type(k) is int and 0 <= k < MOST_DOTS for k in line) and line[0] < line[1]):
            raise ValueError("not a line between two dots")
        if out and out[-1] >= line:
            raise ValueError("lines out of order, or one twice")
        out.append(line)
    return out

At most 1,200 lines between dots numbered under 400, each [a, b] with a < b, in order, none twice. The kit’s referee loads the picture under Node and compares the lines exactly (The pencil-puzzle kit: the one answer), else Those lines don’t make this picture. (422). Minimum believable times:

SizeSeconds
easy15
medium35
hard70
Live from puzzles/low_poly.py, KIND.min_seconds

Rating, and times on dailies only#

Endless opens any picture by code, and some are much quicker than others, so ranking endless times would reward hunting small pictures. Low Poly’s Kind sets speed_on_dailies: only dailies rank times. An endless solve is checked and counted but never ranked. The all-time board shows each player’s best daily time at a size, with their solve count.

Ratings are the kit’s (Timed puzzles). A daily is rated as a field of its solvers at midnight; an endless or archive solve against its size’s par, at half weight.

Worked: today’s Medium (guide/worked/low_poly.py):

PlayerBeforeTimeAfterChange
Iris1520 ± 1004:221548+27.8
Sam1580 ± 855:411574−5.9
Lu1450 ± 2006:551372−78.3
Live from guide/worked/low_poly.py, DAILY.rows

Worked: a hunted Easy. Sam opens a small Easy picture in Endless and draws it in 0:50, against a par of 2:40. It scores 0.911 and moves his rating +3.3, with no place on a board. The design notes chose this over a par per picture, which would need many solves of each picture to mean anything.

The interface#

The board (games/static/games/low-poly/js/board.js) isn’t a kit grid, since the dots aren’t on one. It’s an SVG of fills, lines, a rubber band, dots and a cursor ring, in the picture’s units.

Drawing and rubbing out#

Input What it does
Drag from a dot A band follows, snapping to the nearest dot and showing a refused join. Let go on a dot to join.
Tap a dot, then another Picks the first (ringed), then joins. Tap the picked dot to let go.
Tap a line Rubs it out, unless it’s fixed.
Double-tap a dot, or right-, Shift- or Alt-click Rubs out its lines that aren’t fixed.
Keys Arrows go to the nearest dot that way. Space or Enter picks, then joins. X, Backspace or Delete rubs out. Escape lets go.

Hit-testing prefers a dot, then (while a dot is picked) a dot a little off, then a line, then a nearby dot. A press on open paper, or a slide starting on a line, pans a zoomed board (pansAt). A refusal shakes the dot, buzzes and says why.

A move is { a: [lines added], d: [lines rubbed out] }; the save is { lines: ["a-b", …] }. On load a saved line is kept only if it’s real and neither dot goes over its number, since storage could hold anything.

The helper#

One: Fade finished dots (dim, on). A dot with all its lines shrinks to a point with no number. No hints, as in the original.

Filling and the reveal#

A filling triangle pops. After the last line the dots and lines fade, a glint sweeps the picture, and the result sheet opens with the title as heading and the picture as emblem (solvedTitle, solvedArt). Share result adds a row of ▲ (one per 15 triangles, up to 10) and the triangle count, never the subject. In the archive (gallery: true) each solved picture shows on its card.

Accessibility#

The board is one focusable SVG (role="application") labeled with its keys. The cursor announces each dot (“Dot, 3 of 6 lines to go.”), and picks, joins, fills and refusals are spoken. The status line always counts filled triangles, so color isn’t the only sign. Reduced motion skips the pops and glint.

Stored in the browser#

The kit’s uo:low-poly: keys: each picture’s lines, moves and time under engine version 2 (a daily under its day, apart from Endless), recent, options, tally, dailies log, pending solves.

Its look#

Low Poly’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, raspberry #a8235a: the one loud ink on the page, because the picture brings every other color. It’s the one fill, the ribbon, the kicker and the stamp. The emblem’s facets are coral #f0705e, amber #f5b83d, violet #7b5cc2 and teal #2aa79a. The cursor, a picked dot and the line a drag is drawing are signal blue, yours, and a refused line red, as on the paper.
Faces The paper’s: Gloock and Figtree
Corners and edges The kit’s
Board No grid. Dots are paper discs ringed in graphite, each with the number of lines it still needs in Figtree. Lines are graphite, 2px however far it’s zoomed. A drag shows a dashed signal-blue line that goes solid when it would land on a dot, and red where it would be refused. A picked dot fills with signal blue, its number in the paper’s white. A triangle of the picture fills with its own color the moment its three lines are drawn (a short pop), and its lines become faint white facets. With the helper on, a dot with all its lines fades to a small graphite point.
Emblem and art A little gem of four triangles in coral, amber, violet and teal; the result sheet shows the solved picture in its place. Its box: the fox (76XLFK, Easy), part drawn.
Voice A picture on the boards and the account line (“pictures solved”); dots, lines and triangles are its own words, and the result sheet’s heading is the picture’s name (“Whale”).
Payoff The dots and lines lift away, a glint crosses the picture from its top left, the folio names the picture, and the result sheet shows it with its name as the heading. Under reduced motion it’s simply there.
The glance Now: the status line, which counts the filled triangles. The countdown: the clock, a quiet one that “Show the clock” hides.
Exceptions None

Testing#

Check What it proves
npm run check:puzzles Low Poly exports CODES, so every picture at every size is checked: made twice the same, problems() empty, its answer accepted, tampered ones refused (tools/puzzles/tamper.mjs), the referee agreeing. Three fixed fingerprints catch a rebuild without a version bump.
tests/test_low_poly.py The answer’s shape. The daily pick, old and new: no repeats until all are used, kept as dealt, and (one a day) a day doesn’t tell the next. No-Easy pictures are endless only. Endless and daily solves of one picture stay apart, and boards rank daily times only. Every picture names its license. The real referee against right, wrong and faked solves.
npm run check:low-poly:play (tools/low-poly/play.mjs) Headless Chrome, real drags and touches, four screen sizes. A bad save is cut back to what each dot can take. A solve at each size by drag and by taps; wrong lines, refused lines, double-tap, Undo, reload, the reveal.

Changing it#

  • Add pictures. Append to SUBJECTS, run node tools/low-poly/build.mjs <fluent assets> --sheet=out.html, check the sheet, adjust Easy where it doesn’t read, and rebuild. Old codes don’t move. Days not yet dealt pick from the grown order; dealt days keep their DailyPuzzle rows. No bump if no existing picture changed. Run npm run check:puzzles.
  • Change the maker (mesh, tiers, lighting). Every picture moves: bump ENGINE_VERSION, update the fingerprints in tools/puzzles/check.mjs, recheck the contact sheets.
  • Change the board (games/static/games/low-poly/js/board.js). No bump. Run npm run check:low-poly:play and npm run check:details.

Known limitations#

  • A finite set. 219 daily pictures; Medium repeats from mid-2027.
  • A daily you’ve seen. You may have drawn the day’s picture in Endless. It counts in full anyway, since the daily is everyone’s.
  • No hints or reasoning tiers. Difficulty is line count and look-alike dots.
  • Recognizability is judged by eye, once, at build time.

Known issues#

Found while writing this chapter. Fixed since: an endless picture couldn’t later be the day’s (the daily pick); one day’s picture told the next; the play check’s bad save tested nothing; build’s comments overpromised; stale words. Open:

  1. Slivers get through. fine checks slivers only in triangles at least 0.3 opaque, the final keep filters by opacity alone, and fillHoles adds triangles unchecked. Measured: 7 Easy triangles under 14° (the sharpest 11.9°) and 1 Medium (10.1°). audit doesn’t check angles. Enforcing 14° means remaking those pictures, a new engine version.