Puzzles
Star Battle
Place stars so every row, column and region has its share and no two stars touch, even at a corner.
On this page
Star Battle is a grid puzzle. The grid is split into regions, and you place stars so that every row, column and region has the same number of stars. No two stars may touch, not even at a corner.
It was the first puzzle on the pencil-puzzle kit (ADR 0023), which supplies everything around the board. How a puzzle plugs in follows Star Battle’s daily from page to win screen; this chapter covers the rest.
At a glance#
| What | Where |
|---|---|
| Engine | games/static/games/star-battle/engine/: index.js (the kit’s contract), board.js (rules), generate.js, solver.js, count.js |
| Interface | games/static/games/star-battle/js/: puzzle.js (words, settings, rules sheet), board.js, art.js; app.js only calls the kit’s start |
| Look | games/static/games/star-battle/star-battle.css: night indigo and gold stars |
| Page | games/templates/games/star-battle.html, which extends the kit’s puzzles/play.html |
| Server | puzzles/star_battle.py: its Kind (sizes, daily slots, minimum times, answer cleaning); the rest is the kit’s puzzles/ |
| Tests | npm run check:puzzles (tools/puzzles/check.mjs, tools/puzzles/tamper.mjs); the kit’s server tests, tests/test_puzzles.py, are written around Star Battle |
| Design notes | docs/star-battle.md, ADR 0023 (the kit), ADR 0027 (sizes, not difficulties) |
| Sizes | 6×6 ★, 8×8 ★, 10×10 ★★ (one star per row, column and region on the two smaller, two on 10×10) |
| Difficulty | One per size: easy, medium, hard (ADR 0027), so a size is a puzzle’s whole name |
| The daily | 8×8 ★ (the middle slot), one a day from 2026-10-20; all three sizes before |
| Engine version | 2 (it still makes engine 1’s puzzles too: VERSIONS) |
The rules in code#
A puzzle#
A puzzle is { n, stars, regions } (plus engine, slug, code, size, difficulty). regions is n × n region numbers, row by row, numbered 0…n−1 in reading order. A cell is its index, row * n + column, everywhere: the board, the referee and a posted answer.
shape() builds what the rules and solver need: rows, columns and regions as lists of cells (together, the units), each cell’s three units, and its up to eight neighbors.
games/static/games/star-battle/engine/board.js shape · lines 13–40/**
* Everything the rules and the solvers need about a grid:
* n, k (stars per unit), rows, cols, regions (each a list of cells),
* units (rows, then columns, then regions), unitsOf (per cell, its row,
* column and region unit), regionOf (per cell), near (per cell, the up to
* eight cells touching it), kind (per unit: 'row', 'column' or 'region').
*/
export function shape(puzzle) {
const { n, stars: k, regions: regionOf } = puzzle;
const rows = [], cols = [], regions = Array.from({ length: n }, () => []);
for (let r = 0; r < n; r++) {
rows.push(Array.from({ length: n }, (_, c) => r * n + c));
cols.push(Array.from({ length: n }, (_, c) => c * n + r));
}
for (let i = 0; i < n * n; i++) regions[regionOf[i]].push(i);
const units = [...rows, ...cols, ...regions];
const kind = units.map((_, u) => (u < n ? 'row' : u < 2 * n ? 'column' : 'region'));
const unitsOf = Array.from({ length: n * n }, (_, i) => [Math.floor(i / n), n + (i % n), 2 * n + regionOf[i]]);
const near = Array.from({ length: n * n }, (_, i) => {
const r = Math.floor(i / n), c = i % n, out = [];
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
const rr = r + dr, cc = c + dc;
if ((dr || dc) && rr >= 0 && cc >= 0 && rr < n && cc < n) out.push(rr * n + cc);
}
return out;
});
return { n, k, rows, cols, regions, units, kind, unitsOf, regionOf, near };
}
The rules, as check() holds them#
games/static/games/star-battle/engine/board.js check · lines 76–91/**
* Whether `answer` (a list of star cells) solves the puzzle: whole-number
* cells on the grid, none twice, exactly `stars` in every row, column and
* region, and no two touching. Any answer that does counts.
*/
export function check(puzzle, answer) {
if (!wellFormed(puzzle) || !Array.isArray(answer)) return false;
const { n, stars: k, regions } = puzzle;
if (answer.length !== n * k || !answer.every(i => Number.isInteger(i) && i >= 0 && i < n * n)) return false;
if (new Set(answer).size !== answer.length) return false;
const row = new Array(n).fill(0), col = new Array(n).fill(0), reg = new Array(n).fill(0);
for (const i of answer) { row[Math.floor(i / n)]++; col[i % n]++; reg[regions[i]]++; }
if (![row, col, reg].every(counts => counts.every(c => c === k))) return false;
for (const a of answer) for (const b of answer) if (a < b && touching(n, a, b)) return false;
return true;
}
An answer is the list of star cells. It solves the puzzle if it has exactly n × stars distinct cells on the grid, exactly stars in every unit, and no two touching. wellFormed() rejects a puzzle that isn’t square between 4 and 20, doesn’t have n regions each in one piece, or asks for more stars than fit (stars × 4 > n + 1).
Because touching includes corners, a row’s two stars need a gap, which makes two stars a unit a different puzzle. An answer is written as the star cells in order (answerOf()): [4, 7, 15, 23, 24, 32].
Marks and moves#
The board keeps one mark a cell (0 empty, 1 star, 2 dot, meaning not a star), saved as a string of digits. A move is { c: [[cell, from, to], …] }, so a tap, a drag and Restart each take one Undo. The helper’s dots aren’t marks: they’re worked out from the stars on every redraw.
Making a puzzle#
From a code#
A code seeds a random stream, so it always makes the same puzzle. The seed is star-battle|<version>|<size>|<difficulty>|<code> (the engine pieces). generate(code, size) refuses any difficulty but the size’s own, so generate('ABC234', 8, 'hard') throws. generate(code, size, difficulty, stats, version) makes the puzzle as any version in VERSIONS made it (the daily).
games/static/games/star-battle/engine/generate.js generate · lines 237–273/**
* The puzzle for a code:
* { engine, slug, code, size, difficulty, n, stars, regions: [n × n] },
* as engine `version` made it (one of VERSIONS; the latest unless asked).
* Throws if the code, size, difficulty or version isn't one the game makes.
* `stats`, if given, is told how the making went (for tools/puzzles/):
* attempts, the grids that reached one answer, how each of those rated, and
* how many rated right but were too shallow.
*/
export function generate(code, size, difficulty = SIZES[Number(size)]?.difficulty, stats = {}, version = ENGINE_VERSION) {
size = Number(size);
version = Number(version);
if (!Object.hasOwn(SIZES, size) || SIZES[size].difficulty !== difficulty || !CODE_RE.test(code) || !VERSIONS.includes(version)) throw new Error('Not a puzzle the game makes.');
const { n, stars: k } = SIZES[size];
const depth = version >= 2 ? DEPTH[difficulty] : 0;
const r = stream(`${SLUG}|${version}|${size}|${difficulty}|${code}`);
Object.assign(stats, { attempts: 0, failed: { stars: 0, regions: 0, nudge: 0 }, unique: 0, rated: {}, shallow: 0 });
for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
stats.attempts++;
const stars = layStars(r, n, k);
if (!stars) { stats.failed.stars++; continue; }
const regions = growRegions(r, n, k, stars, LEAN[k][difficulty]);
if (!regions || !fair(n, regions)) { stats.failed.regions++; continue; }
const puzzle = { engine: version, slug: SLUG, code, size, difficulty, n, stars: k, regions };
if (!nudge(r, puzzle, stars) || !fair(n, puzzle.regions)) { stats.failed.nudge++; continue; }
puzzle.regions = renumber(puzzle.regions);
// (what solver.js's rate() says, with the steps it took)
const got = solve(puzzle, 3);
const rated = got.solved ? Object.keys(TIERS).find(name => TIERS[name] === got.tier) : null;
stats.unique++;
stats.rated[rated] = (stats.rated[rated] || 0) + 1;
if (rated !== difficulty) continue;
if (deep(got.used, TIERS[difficulty]) < depth) { stats.shallow++; continue; }
return puzzle;
}
throw new Error(`No ${difficulty} ${n}×${n} puzzle came of ${code}.`);
}
Each attempt (up to ATTEMPTS = 1,500, counted, not timed) lays an answer, grows regions around it, nudges the regions until that answer is the only one, and rates it. The first that rates exactly the size’s difficulty, with enough steps of it, is the puzzle.
1. Lay an answer#
layStars() fills rows one at a time from the bit patterns of k stars with none side by side (rowPatterns()). It skips a pattern that touches the row above (m & (prev | prev << 1 | prev >> 1)), overfills a column, or leaves a column short, backtracking within 20,000 steps.
2. Grow regions around it#
games/static/games/star-battle/engine/generate.js growBlobs · lines 87–113// Grow blobs from seed cells until they fill the grid. `lean` (a whole
// number) lets big blobs grow faster, leaving some small regions, which are
// easy to read; 0 grows them evenly. Returns the blob of each cell.
function growBlobs(r, n, seeds, lean) {
const owner = new Int16Array(n * n).fill(-1);
const size = seeds.map(() => 1);
seeds.forEach((cell, b) => { owner[cell] = b; });
let left = n * n - seeds.length;
while (left > 0) {
// Each blob's open edge: unowned cells beside it.
const edge = seeds.map(() => []);
for (let i = 0; i < n * n; i++) {
if (owner[i] >= 0) continue;
for (const j of sides(n, i)) if (owner[j] >= 0) edge[owner[j]].push(i);
}
// Whole-number weights (size to the power `lean`), so every browser
// picks the same blob: no fractional powers, which engines may round apart.
const weights = size.map((s, b) => (edge[b].length ? s ** lean : 0));
let x = r.int(weights.reduce((a, w) => a + w, 0)), b = 0;
while ((x -= weights[b]) >= 0) b++;
const cell = r.pick(edge[b]);
owner[cell] = b;
size[b]++;
left--;
}
return owner;
}
One star a region (6×6, 8×8): a blob grows from each star. Each step gives a random free neighbor to a blob picked with weight size^lean. With lean = 1 big blobs grow faster and leave a few small, easy-to-read ones; with lean = 0 they grow evenly. Whole-number weights keep every browser in step.
Two stars a region (10×10): 2n blobs grow evenly (lopsided growth often traps a blob with no partner), pairBlobs() pairs touching blobs into regions by backtracking, and lopside() then makes them uneven, with lean × n² tries to hand a non-star border cell to a neighbor at least as big without splitting the region.
games/static/games/star-battle/engine/generate.js LEAN · lines 227–235// How lopsided regions grow, by stars and difficulty: lopsided grids leave
// small regions, which give easy starts. For one star it's the power of a
// blob's size its growth is weighted by; for two, how many times over the
// grid's cells to try handing one to a bigger region. Tuned so each
// difficulty's grids rate as asked often (tools/puzzles/check.mjs).
const LEAN = {
1: { easy: 1, medium: 0, hard: 0 },
2: { easy: 2, medium: 1, hard: 1 },
};
A grid with a region over two and a half rows of cells (fair(): max × 2 ≤ 5n) is thrown away. One that big “looks careless and plays as a few small ones”.
3. Make the answer the only one#
games/static/games/star-battle/engine/generate.js nudge · lines 188–208// Nudge region borders until `stars` is the only answer: find another
// answer, and hand one of its stars that isn't one of ours to a neighboring
// region, which breaks that answer and keeps ours. Returns whether it got there.
function nudge(r, puzzle, stars) {
const { n, regions } = puzzle;
const ours = new Set(stars);
for (let k = 0; k < NUDGES; k++) {
const found = count(puzzle, 2);
if (found.count === 1) return true;
const other = found.answers.find(a => a.some(i => !ours.has(i)));
const moves = [];
for (const c of other) {
if (ours.has(c) || !stillWhole(n, regions, c)) continue;
for (const j of sides(n, c)) if (regions[j] !== regions[c] && !moves.some(([cc, g]) => cc === c && g === regions[j])) moves.push([c, regions[j]]);
}
if (!moves.length) return false;
const [c, g] = r.pick(moves);
regions[c] = g;
}
return count(puzzle, 2).count === 1;
}
count() in games/static/games/star-battle/engine/count.js is a plain search, row by row, with no reasoning. If it finds a second answer, nudge() hands a cell where that answer has a star and ours doesn’t to a neighboring region. That breaks the other answer and leaves ours alone. A grid still ambiguous after NUDGES = 60 rounds, or made unfair, is thrown away.
So uniqueness is proved twice: by the search, and by the solver, whose steps are all sound.
4. Rate it: the solver’s ladder#
games/static/games/star-battle/engine/solver.js marks stars and dots, always with the gentlest technique that makes progress, and counts each one’s uses (used). A puzzle’s difficulty is the hardest tier it needed:
| Tier | Technique | What it does |
|---|---|---|
| 1 · one unit at a time (easy) | Touching | A star’s eight neighbors are dots. |
| Full | A unit with all its stars: the rest is dots. | |
| Fill | A unit with as many open cells as stars to place: they’re all stars. | |
| Shape | List the ways a unit can still place its stars (up to MAX_WAYS = 30). A cell every way uses is a star; one no way uses, or every way touches, is a dot. |
|
| 2 · counting across units (medium) | Confined | A region whose open cells lie in one row puts its stars there, so the rest of the row is dots. Likewise a row inside one region, and columns. |
| Bands | The same for m neighboring rows or columns: m regions inside them use up all their stars there; m regions covering them have no stars outside. | |
| 3 · looking around (hard) | Crowding | If a star here would leave some unit too few open cells, or none that don’t touch, the cell is a dot. |
| Room | Shape again, keeping only the ways that don’t crowd out another unit. | |
| 4 · trial (never rates) | Trial | Suppose a cell is a star (or a dot) and run tiers 1–2 to a contradiction. Kept for a possible expert level. |
games/static/games/star-battle/engine/solver.js crowds · lines 203–232// Whether stars in these open cells would crowd out some row, column or
// region: their neighbors ruled out, and any unit they complete finished
// off, would leave a unit without room for the stars it still needs (too
// few open cells, or none that don't touch). One look, no chain of guesses.
function crowds(sh, s, stars) {
const { k } = sh;
const gone = new Set(stars);
const added = new Map(); // unit → stars these add to it
for (const i of stars) {
for (const j of sh.near[i]) if (s.cell[j] === OPEN) gone.add(j);
for (const u of sh.unitsOf[i]) added.set(u, (added.get(u) || 0) + 1);
}
for (const [u, more] of added) {
const cells = sh.units[u];
const have = cells.filter(j => s.cell[j] === STAR).length + more;
if (have > k) return true;
if (have === k) for (const j of cells) if (s.cell[j] === OPEN) gone.add(j);
}
const affected = new Set();
for (const j of gone) for (const u of sh.unitsOf[j]) affected.add(u);
for (const u of affected) {
const cells = sh.units[u];
const need = k - cells.filter(j => s.cell[j] === STAR).length - (added.get(u) || 0);
if (need <= 0) continue;
const open = cells.filter(j => s.cell[j] === OPEN && !gone.has(j));
// ways() returns null past MAX_WAYS: too many to be crowded out.
if (open.length < need || (need > 1 && ways(sh, open, need)?.length === 0)) return true;
}
return false;
}
Both hard techniques look one star, or one unit’s stars, ahead at the immediate effect, never down a chain of guesses. check:puzzles fails any puzzle the solver finishes faster with trial, so no rated puzzle needs it.
The depth floor (from engine 2). A puzzle must also need several steps of its own tier, as the kit’s other puzzles must. So a medium isn’t an easy with one medium step bolted on:
games/static/games/star-battle/engine/generate.js DEPTH · lines 40–43// How many steps of its own tier a puzzle must need (from version 2), so a
// medium isn't an easy puzzle with a single medium step, nor a hard one a
// medium with one look ahead.
export const DEPTH = { easy: 0, medium: 3, hard: 2 };
A grid that falls short is thrown away (stats.shallow), and check:puzzles fails a puzzle under its floor.
What separates the sizes#
Measured for this chapter: 300 codes a size (Node 24, seed guide|stats|2026-10-19|star-battle). Times are rough, from a busy desktop:
| Size | Tier | Make (median, p95, max) | Attempts (mean, max) | Solver steps (mean) | Steps of its tier (mean, min) |
|---|---|---|---|---|---|
| 6×6 ★ | easy | 0.7 ms, 7 ms, 22 ms | 3.4, 24 | 18 | (all of them) |
| 8×8 ★ | medium | 8 ms, 33 ms, 83 ms | 20.3, 164 | 29 | 3.9, 3 |
| 10×10 ★★ | hard | 45 ms, 200 ms, 317 ms | 13.0, 62 | 47 | 6.2, 2 |
Mean uses of each technique per puzzle:
| Size | Touching | Full | Fill | Shape | Confined | Bands | Crowding | Room |
|---|---|---|---|---|---|---|---|---|
| 6×6 ★ | 3.3 | 7.5 | 6.0 | 1.5 | ||||
| 8×8 ★ | 3.0 | 9.2 | 8.0 | 4.7 | 0.8 | 3.1 | ||
| 10×10 ★★ | 4.7 | 8.7 | 10.3 | 15.9 | 0.3 | 1.3 | 3.1 | 3.1 |
How every grid that reached one answer rated (what each size’s filter and floor throw away):
| Size | Easy | Medium | Hard | Beyond (needs trial) |
|---|---|---|---|---|
| 6×6 ★ | 300 kept | 161 | 20 | 5 |
| 8×8 ★ | 58 | 300 kept, 221 too shallow | 122 | 71 |
| 10×10 ★★ | 76 | 154 | 300 kept, 40 too shallow | 287 |
Most attempts fail before they’re rated, mostly grids nudge() couldn’t make unique (5,311 at 8×8 and 2,909 at 10×10). Without the floor, engine 1 made 8×8s with as few as one medium step (54 of these 300 codes). The floor nearly doubled attempts at 8×8 (20.3, from 11.2), still far below ATTEMPTS.
The daily#
puzzles/star_battle.py KIND · lines 26–42KIND = Kind(
slug="star-battle",
title="Star Battle",
engine_file="games/static/games/star-battle/engine/generate.js",
# Boards say the stars a row holds, as the game does: "10×10 ★★".
sizes={"6": "6×6 ★", "8": "8×8 ★", "10": "10×10 ★★"},
# The bigger the grid, the deeper the reasoning: a size is a difficulty,
# and a board is named by its size alone (docs/adr/0027).
size_difficulty={"6": "easy", "8": "medium", "10": "hard"},
daily=(("6", "easy"), ("8", "medium"), ("10", "hard")),
# Roughly the time it takes just to place every star with the answer in
# front of you: 6, 8 and 20 stars.
min_seconds={"6": 6, "8": 10, "10": 20},
clean=clean,
wrong="Those stars don’t solve this puzzle.",
since=date(2026, 10, 12),
)
A day’s code is an HMAC (a hash made with a secret key) of star-battle|<date>|<size>|<difficulty> (the kit’s dailies). The one daily is KIND.normal, 8×8 ★, until 2026-10-26, when the weekday picks it (ADR 0041, the week climbs): 6×6 ★ Monday and Tuesday, 8×8 ★ Wednesday to Saturday, 10×10 ★★ Sunday. Dailies back to since (2026-10-12) are in the archive.
A daily records its engine version (DailyPuzzle.engine). Dailies from before engine 2 are marked 1 and still played at version 1, because the engine still makes it (VERSIONS). So a bump that keeps its predecessor in VERSIONS changes no daily (a day keeps its engine version).
Your move#
firstMove gives the kit’s first-move prompt (Your first move) its words: Mark your first cell (“Tap a cell: a dot where no star can go, or a star”).
Secrets#
None in play. The engine runs in the browser, so solution(puzzle) is a console call away. The server keeps only tomorrow’s code and each puzzle’s answer, never sent to a player (What a cheater could and couldn’t do).
Posting and the referee#
A solve posts { code, size, difficulty, engine, answer, seconds }. The kit’s view (the kit’s referee) then:
- refuses a size and difficulty the kind doesn’t pair;
- cleans the answer with
clean()(shape only: exactlyn × starsdistinct integers on the grid, nottrueor2.0; sorted); - refuses a time under the size’s minimum or over a day;
- compares the answer with the one the referee made under Node, at the version the post names if the engine still makes it.
puzzles/star_battle.py clean · lines 14–23def clean(answer, size):
"""An answer is the cells holding stars (row * n + column): whole
numbers on the grid, none twice, n × stars of them. Written increasing."""
n, stars = GRIDS[size]
if not isinstance(answer, list) or len(answer) != n * stars:
raise ValueError("not the right number of stars")
# Exactly integers: not a bool (an int to Python), a fraction or text.
if not all(type(i) is int and 0 <= i < n * n for i in answer) or len(set(answer)) != len(answer):
raise ValueError("not cells of the grid")
return sorted(answer)
| Size | Fastest believable (seconds) |
|---|---|
| 6 | 6 |
| 8 | 10 |
| 10 | 20 |
puzzles/star_battle.py, KIND.min_secondsEach minimum is roughly the time to place every star with the answer in front of you. The comparison is exact because the answer is unique; a uniqueness bug would refuse a right-but-different answer, never accept a wrong one.
Rating#
Kit("star-battle") rates Star Battle (timed puzzles), with par set by size. An endless solve, or an archive daily, is rated against par when posted, at weight 0.5. The day’s daily waits for midnight and is rated as a field of everyone who posted it that day.
Worked: an endless 10×10 ★★. A settled 1550 ± 80 player solves in 9:30 where par is 12:00. The time scores 0.615, and at weight 0.5 moves them +0.7. Yesterday’s daily from the archive, in 2:30 against a 3:20 par, counts like any endless solve: +0.9.
Worked: today’s 8×8 ★, four solvers. Each meets the other three at 0.50 of a game:
| Player | Before | Time | After | Change |
|---|---|---|---|---|
| Ray | 1620 | 2:51 | 1629 | +8.6 |
| Opal | 1480 | 3:56 | 1479 | −0.9 |
| June | 1500? | 2:28 | 1743? | +242.8 |
| Mo | 1550 | 5:02 | 1535 | −14.7 |
guide/worked/star_battle.py, DAILY.rowsIn a field only the order of times counts: Mo would lose the same had he taken twice as long. guide/worked/star_battle.py computes these through ratings.sources.Kit and ratings.service.compute. The fastest 8×8 the referee believes, 10 seconds, would score 0.9975 against a 3:20 par.
Leaderboards and the account#
The kit’s boards, Daily, All-time (by size) and Ratings, are at /games/star-battle/leaderboard/ (puzzles/views.py), and the account page shows account_summary(). The Stats tab is the site’s (the Stats tab). A solve at a difficulty its size no longer has (from before ADR 0027) counts as solved but is on no board.
The interface#
The board#
games/static/games/star-battle/js/board.js meets the kit’s board contract on gridSurface (the board’s surface), with layers for region tints, the red wash, lines, borders, counters and marks. The kit’s drawRegions() tints regions so neighbors never share a tint.
| Input | Does |
|---|---|
| Tap or click a cell | Next mark: empty → dot → star → empty (the Tap order option puts star first). A cell the helper dotted counts as dotted, so a tap makes it a star. |
| Press and hold, right-click, Shift-click | The order’s second mark at once (a star by default); again clears it. |
| Drag | Paints dots over empty cells; begun on a dot, rubs dots out. Never touches stars. One Undo. |
| Keys | Arrows move, Space cycles, S star, D (or .) dot, X / Backspace / Delete / 0 clears, R reads the region aloud (“Region 5: 7 cells, rows 3 to 5, columns 1 to 4, with 0 of 1 stars.”). |
Helpers (assists)#
Each has its own switch, all on by default (assist: true in games/static/games/star-battle/js/puzzle.js):
| Option | What it computes |
|---|---|
| Dot what stars rule out | blocked(puzzle, stars): every cell touching a star, and the rest of any full unit. Faint dots, never kept as marks. |
| Show broken rules | mistakes(puzzle, stars): touching stars (a dashed ring) and overfull units (hatched red). The status line names the first. |
| Count the stars | A pip per star each unit needs, by each row and column and in each region’s first cell, filling as stars go in and lighting gold when full. Off, the pips still show what’s needed. |
None reads the answer, and there’s no hint or check against the solution. The status line counts stars placed.
Solved, sharing and saving#
When answer() returns the stars, the kit stops the clock and posts the solve (the kit’s step 7), and celebrate() lights the stars outward from the last. Share result sends “Star Battle · Daily 8×8 ★ · Monday, October 19 / Solved in 2:31” with the address; an endless puzzle’s address (#/p/<size>/<difficulty>/<code>) opens the same puzzle. Saves live under uo:star-battle:p:<size>/<difficulty>/<code> (sheets, saving and the hub).
Accessibility#
The grid is one tab stop. A cell reads as “Row 3, column 4, region 5, empty. Row 1 of 2, column 0, region 1.”: its state, then its units’ star counts. Every helper is a shape as well as a color.
Its look#
Star Battle’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, night indigo #33378a: the one fill, the ribbon, the kicker and the stamp. Stars are gold #e0a21a outlined in #86580a, so a star reads on every region tint, with a #fbe6a6 shine. The rest is the paper’s: the kit’s region tints, signal blue for the cursor, red for a mistake. |
| Faces | The paper’s: Gloock and Figtree |
| Corners and edges | The kit’s |
| Board | Cells wash in the region tints, with heavy graphite between regions, a heavy frame and thin graphite between cells. A star is a rounded gold five-point shape with a small shine. A dot is a graphite point, and the helper’s dots are smaller and lighter. Each row, column and region has a counter, a row of small stars: outlined when empty, inked as stars go in, gold when full, red when over. A unit with too many stars is hatched red, touching stars wear a dashed red ring, and the keyboard cursor is a signal-blue ring. |
| Emblem and art | A gold star. Its box: a real 6×6 (ABC234, Easy) drawn from the engine. |
| Voice | A puzzle everywhere (“New puzzle”, “Solved”); stars, dots and regions are its own words. |
| Payoff | Each star lights with a gold halo, one after another outward from the last (about a second; none under reduced motion), the dots fade, and a “Solved 1:09” stamp in the accent presses onto the folio. |
| The glance | Now: the status line, which counts the stars placed. The countdown: the clock. |
| Exceptions | None |
Testing#
| Check | Proves |
|---|---|
npm run check:puzzles (tools/puzzles/check.mjs) |
For 20 codes a size: the same puzzle every time, rated as asked over the floor with no trial, one answer by plain search, tampered copies refused (tools/puzzles/tamper.mjs), the referee agreeing at both engine versions. Fingerprints catch an unbumped change. |
tests/test_puzzles.py |
The kit’s server side, around Star Battle: dailies and their engine versions (DailyEngineTests), posting, boards, the archive, the account, the referee under Node. |
Known issues and open questions#
Found while writing this chapter; the others have been fixed.
- Open (from the notes): whether hard (two looks ahead) is too gentle or too steep; a faster uniqueness search for 12×12 ★★ or 14×14 ★★★; the minimum times; dot-first as the default tap order.
Changing it#
| Change | What it needs |
|---|---|
Anything in engine/ that could change which puzzle a code makes |
Bump ENGINE_VERSION, update FIXED in tools/puzzles/check.mjs (old prints to earlier), run npm run check:puzzles. Keep the old version in VERSIONS if it can still be made (as DEPTH is switched on by version), or its dailies change. The box art (games/templates/games/thumbs/star-battle.html, ABC234 6×6) and the test puzzle in tests/test_puzzles.py are engine 1’s. |
The board or helpers (js/, CSS) |
No bump. check:layout and check:details with the site running. |
| Sizes, daily slots, minimum times | puzzles/star_battle.py; a new size also needs the engine’s SIZES. |
| What’s rated | ratings/sources.py and its VERSION. |
Then run npm run check:guide, and npm run guide:stamp star-battle once you’ve read this chapter against the change.