Tsunagi繋ぎ

@johnmorrisdotca/tsunagi 1.1.0 · 17 entry points · 216 exports

@johnmorrisdotca/tsunagi

allJoined answerOf blockOf blockRange blocksIn bridgeAndWallCandidate bridgeCandidate bridgesOf candidate CELL_BLOCKED CELL_BRIDGE CELL_EMPTY Challenge CHALLENGES challengesOf cheatGame cheatLine checkGame checkTsunagiAnswer compareEdges countSolutions decodeLayout decodeLines DIFFICULTY_WEIGHTS difficultyScores dragGame dragThrough dragTo edgeKey edgeOpen encodeAnswer encodeLayout encodeLines encodeWalls Explosion explosionAfter explosionsAsChosen filled firstUnsolvedTsunagiLevel forcedShare gameOver helpOf helpOpensNext hexCandidate hexNeighboursOf hexNeighbourTable hexRadius inHex isTsunagiLevel isTwist joined layoutCells layoutNeighbours layoutOf layoutStep letGo LevelMeasure LevelRow liftGame Lines linesCodeFits linesOfAnswer LINK_BLOCKED LINK_BRIDGE LINK_EMPTY LINK_HEX LINK_SPARSE LINK_WALLS LINK_WRAP LinkCandidate LinkExtras LinkLayout measureLevel measureSolved neighboursOf neighbourTable newTsunagiGame nextTsunagiLevel noLines openTsunagiLevels orderByDifficulty outOfStrokes overBridge ownersOf PAIR_LETTERS pressAt pressGame Random randomFilling relettered renumberedRecord repairedCandidate restartGame seededRandom shuffled SolveCount sparseCandidate sparseMost Step stepBetween stepTable strokesToExplosion strongestTsunagiHelp symmetryKey tailWord transformed TSUNAGI_BLOCK TSUNAGI_LEVEL_COUNTS TSUNAGI_SIZES tsunagiBand TsunagiBand TsunagiCheck TsunagiExplosionChoice TsunagiGame TsunagiGameOptions TsunagiHelp tsunagiMarks tsunagiProgress TsunagiProgress tsunagiRole turnsIn TwistCandidate twistRole TwistRole undoGame unjoinedPairs VERSION wallCandidate waypointCandidate wrapCandidate wrappedStep

function allJoined

allJoined(layout: LinkLayout, lines: Lines): boolean

Solved: every pair joined, every open cell on a line, and every bridge gone over both ways.

function answerOf

answerOf(layout: LinkLayout, lines: Lines): string

The answer the lines make, in the answer's spelling (encodeAnswer).

function blockOf

blockOf(level: number): number

The block a level is in, from 1.

function blockRange

blockRange(block: number, count: number): { first: number; last: number; }

A block's first and last level, the last no further than the size has.

function blocksIn

blocksIn(count: number): number

How many blocks a size of count levels has.

function bridgeAndWallCandidate

bridgeAndWallCandidate(size: number, random: Random, longest: number, budget: number, bridgesWanted: number, mostWalls: number): TwistCandidate | null

A board with bridges and the walls it needs besides: both twists at once, for the blocks after both are taught.

function bridgeCandidate

bridgeCandidate(size: number, random: Random, longest: number, budget: number, want: number): TwistCandidate | null

A board with want bridges, or null where this filling offers no place for them or the board has more than one answer.

function bridgesOf

bridgesOf(layout: LinkLayout): number[]

The bridges of a board.

function candidate

candidate(size: number, random: Random, longest: number, budget: number): LinkCandidate | null

One candidate level, or null: a filling, its layout, and the solver's proof that the layout has exactly one answer — the filling's own. A layout the solver cannot settle inside budget positions is dropped rather than trusted.

const CELL_BLOCKED

CELL_BLOCKED: -2

const CELL_BRIDGE

CELL_BRIDGE: -3

const CELL_EMPTY

CELL_EMPTY: -1

A cell in a decoded layout: an empty cell, a blocked one, or a stone of pair n (0 for A).

type Challenge

type Challenge = "bridges" | "walls" | "waypoints" | "wrap" | "explosions" | "strokes" | "hexagon" | "sparse";

A challenge a board can have. Blocked cells count as walls: both are places a line cannot go. Explosions break a drawn line every so many strokes; a hexagon gives every cell six neighbours; a stroke limit counts the lifts; a sparse board has few, long lines.

const CHALLENGES

CHALLENGES: readonly Challenge[]

WHAT A TSUNAGI LEVEL ASKS OF A PLAYER, read from its board: the challenges on it, where it sits in its block's lesson, and how hard it measured. John, 2026-09-26: "at the bottom of every map, show the obstacles or difficulty level in one row… so the user can see that the level they are on has certain challenges", and of the block's last two: "users should know that the ninth [now 15th] would be a sort of experience and the 10th [16th] is the hardest".

Imports carry their .ts so the level script can read the same rules.

function challengesOf

challengesOf(layout: string): Challenge[]

The challenges on a board, in the order the ladder teaches them.

function cheatGame

cheatGame(game: TsunagiGame): TsunagiGame

Cheat: one unfinished line drawn as the answer has it, anything in its way cut back. It spends no stroke, and the game is helped ever after. Nothing unless the game was made with cheats and an answer.

function cheatLine

cheatLine(layout: LinkLayout, lines: Lines, answer: Lines): { lines: Lines; pair: number; } | null

CHEAT: one unfinished line drawn for the player, correctly. John, 2026-09-26: beyond Check, which only flashes the unjoined pairs, "a Cheat button that draws one unfinished line correctly", offered only where "Allow cheating" was chosen at set-up. Flow Free's hint does the same — it draws one whole flow.

The line is the lowest-numbered pair not yet joined; where every pair is joined but the board is wrong, the lowest whose line is not the answer's. It is drawn as the answer has it, and any other line in its way is cut back to before the first cell it would share — on a bridge only where both go the same way over it, since two lines may cross there. Null when there is nothing to draw: every line already the answer's. Pure: new lines, the old untouched.

function checkGame

checkGame(game: TsunagiGame): TsunagiGame

Check: the pairs not joined yet are flagged, and how many cells are still empty is said.

function checkTsunagiAnswer

checkTsunagiAnswer(size: number, givens: string, answer: string): TsunagiCheck

function compareEdges

compareEdges(x: string, y: string): number

Edges in the one order a layout writes them: by their first cell, then their second.

function countSolutions

countSolutions(layout: LinkLayout, limit?: number, budget?: number): SolveCount

function decodeLayout

decodeLayout(code: string, size: number): LinkLayout | null

A layout read from its code, or null for a string that is not one: the wrong length, a stray character, a letter used other than twice, or letters not named in reading order. Null rather than a best guess — see AGENTS.md "Nothing Answers What It Cannot Answer".

function decodeLines

decodeLines(layout: LinkLayout, code: string): Lines | null

Lines read back from a progress code against the layout they were drawn on, or null for a code that does not describe lines on it: a start that is not a stone, a step from nowhere, a line through another pair's stone, a cell two lines claim.

const DIFFICULTY_WEIGHTS

DIFFICULTY_WEIGHTS: { readonly corners: 0.35; readonly guessing: 0.3; readonly notForced: 0.25; readonly longest: 0.1; }

The weights of the four measures in the score.

function difficultyScores

difficultyScores(measures: readonly LevelMeasure[], size: number): number[]

Each level's score, 0 (easiest) to 100 (hardest), as the blend of its four percentiles among measures — one size's levels. A percentile is the share of the other levels strictly below it, so ties share a place and the score of a set of one is 0.

function dragGame

dragGame(game: TsunagiGame, cell: number): TsunagiGame

The finger carried into a cell: the line grows, shortens or cuts another back, as the rules say.

function dragThrough

dragThrough(layout: LinkLayout, lines: Lines, pair: number, cell: number): Lines

A drag that jumped several cells between two pointer events (a quick flick): walked one cell at a time along the row, then the column, so a fast finger draws what a slow one would. Stops at the first cell a step refuses.

function dragTo

dragTo(layout: LinkLayout, lines: Lines, pair: number, cell: number): Lines

A drag of the pair being drawn into cell: one step, as the finger enters a cell.

function edgeKey

edgeKey(a: number, b: number): string

An edge between two neighbouring cells, the same whichever way round they are named.

function edgeOpen

edgeOpen(layout: LinkLayout, a: number, b: number): boolean

Whether a line may step from cell a to its neighbour b: no wall between them.

function encodeAnswer

encodeAnswer(owners: readonly number[]): string

A finished grid's code: the letter of the line through each cell, # where blocked, + on a bridge.

function encodeLayout

encodeLayout(cells: readonly number[], walls?: Iterable<string>, more?: { waypoints?: ReadonlyMap<number, number>; wrap?: boolean; hex?: boolean; sparse?: boolean; strokes?: number | null; explosions?: LinkLayout["explosions"]; }): string

A layout's code, from its cells, walls, waypoints and whether it wraps: the inverse of decodeLayout.

function encodeLines

encodeLines(layout: LinkLayout, lines: Lines): string

function encodeWalls

encodeWalls(walls: Iterable<string>): string

The walls as a layout writes them, after the cells: nothing where there are none.

type Explosion

type Explosion = { lines: Lines; hit: number[]; cells: number[] };

EXPLOSIONS: every so many strokes, a drawn line is broken. John's row: "a drawn line broken or removed every so many moves", with a warning beat before it and "which line is deterministic from the level seed and the moves made", so two players making the same strokes on the same level meet the same explosions, and nothing is left to a coin in the browser.

A stroke is a line let go having changed the board (TsunagiSolve's lift). After the every-th stroke, and every every after it, one line with something drawn is chosen by a hash of the board and the stroke's number:

- boom<N>: that line is cut back to half its length. - blast<N>: that line is wiped, and one line touching it is cut back to half as well.

A stroke that solves the level sets nothing off: the level is done first. Pure, like the rest of the drawing rules: new lines, the old ones untouched.

function explosionAfter

explosionAfter(layout: LinkLayout, givens: string, lines: Lines, stroke: number): Explosion | null

What the stroke-th stroke sets off, from the lines as it left them; null when it sets nothing off: a board without explosions, a stroke between them, or no line drawn to break. givens is the level's layout code, the "seed".

function explosionsAsChosen

explosionsAsChosen(rule: LinkLayout["explosions"], choice: "on" | "soft" | "off"): LinkLayout["explosions"]

A level's explosions as the player chose to play them at set-up: as made, SOFTENED — a boom in place of a blast, and half as often — or OFF. John's row: "a set-up option to soften or switch them off". Either way the solve is kept as helped (solveHelp.ts).

function filled

filled(layout: LinkLayout, lines: Lines): { done: number; of: number; }

How many open cells have a line through them or a stone on them, and how many there are: a bridge counts twice, once each way over it.

function firstUnsolvedTsunagiLevel

firstUnsolvedTsunagiLevel(size: number, solved: ReadonlySet<number>): number | null

The lowest level not yet solved, or null when every level of the size is. It is always open: a block opens only once the block before is all solved, so the first gap is in the open blocks. Nobody is sent past a level they have not finished.

function forcedShare

forcedShare(code: string, size: number): number

The share of a layout's empty cells filled by forced moves alone: each line's two ends are grown while one of them has exactly one empty neighbour to go to, until a pair's ends meet or no end has only one way on. What a person does before they have to think.

function gameOver

gameOver(game: TsunagiGame): boolean

Whether the game takes no more strokes: solved, or out of strokes.

function helpOf

helpOf(game: TsunagiGame): TsunagiHelp | null

The help a game has used so far, the one that costs more where it used two; null for none.

function helpOpensNext

helpOpensNext(help: TsunagiHelp | null): boolean

Whether a solve with this help (or none) opens what a solve opens: every help but explosions off, whose block's lesson was not played.

function hexCandidate

hexCandidate(size: number, random: Random, longest: number, budget: number): TwistCandidate | null

A HEXAGON: the board as Hexversi's honeycomb, the square's corners off the board, filled with lines that may step along either slant as well as the four ways a square allows (hexNeighboursOf). Only an odd side has a hexagon.

function hexNeighboursOf

hexNeighboursOf(size: number, at: number): number[]

The six neighbours of a cell on the hexagon lattice, as indexes; fewer at an edge of the square (the hexagon's own edge is its # cells).

function hexNeighbourTable

hexNeighbourTable(size: number): number[][]

Every cell's six neighbours on the hexagon lattice, worked out once for a size.

function hexRadius

hexRadius(size: number): number

The hexagon's radius on a board of this side: R, for a side of 2R + 1.

function inHex

inHex(size: number, at: number): boolean

Whether a cell of the square is inside the hexagon: at most R lattice steps from the middle.

function isTsunagiLevel

isTsunagiLevel(size: number, level: number): boolean

Whether a number is a level this size has.

function isTwist

isTwist(layout: string): boolean

Whether a board is a twist: any challenge on it.

function joined

joined(layout: LinkLayout, lines: Lines, pair: number): boolean

Whether a pair's line runs from one of its stones to the other.

function layoutCells

layoutCells(code: string): string

A layout's cells without its walls or wrap: the part of the code one character a cell.

function layoutNeighbours

layoutNeighbours(layout: LinkLayout): number[][]

Each cell's neighbours a line may step to on this board: the four around it, less any across a wall. A bridge is a neighbour like any cell; what a line does on one (go straight over) is the caller's to know.

function layoutOf

layoutOf(size: number, paths: readonly number[][], extras?: LinkExtras): { layout: string; answer: string; }

The layout and answer the lines leave: their ends as stones, lettered in reading order, with whatever else the board has — blocked cells, bridges (in two lines' paths, drawn +) and walls.

function layoutStep

layoutStep(layout: LinkLayout, a: number, b: number): number

A layout's step from a to its neighbour b (stepBetween on its own kind of board), or 0.

function letGo

letGo(lines: Lines, layout?: LinkLayout): Lines

Letting go: a line of only its stone is no line (a tap on a stone clears it), and a line ends before a bridge it stopped on.

type LevelMeasure

type LevelMeasure = { pairs: number; /** Turns over every line of the answer. */ turns: number; /** The longest line, in cells. */ longest: number; /** Cells with no marble on them. */ empties: number; /** The share of empty cells the forced moves alone fill, 0 to 1. */ forcedShare: number; /** Positions the solver looked at to prove the one answer. */ nodes: number; /** Positions where it had to try more than one w…

What is measured of one level.

type LevelRow

type LevelRow = readonly [string, string];

One level: its layout and its one answer, each a code (code.ts).

function liftGame

liftGame(game: TsunagiGame): TsunagiGame

The finger lifted: the stroke is counted if it changed the board, the board may be solved, and an explosion may go off.

type Lines

type Lines = readonly (readonly number[])[];

THE LINES A PLAYER HAS DRAWN, and what a press and a drag do to them.

Pure, and the only place the drawing rules live: the grid reports where a finger went, and this says what the lines are now. Each pair has one line, an ordered list of cells starting at one of its stones; an empty list is no line. Every function returns new lines and leaves the ones it was given alone, the way the engine does.

- Press on a stone: a fresh line starts there, and the pair's old one goes. A press let go without moving is a tap, and clears the line. - Press on a line: it is cut back to that cell, and drawing goes on from it. - Drag into the next cell: the line grows. Back over itself: it shortens, cell by cell, as the finger goes. Into another pair's line: that line is cut back to before the cell, and this one goes through. Into another pair's stone, a blocked cell, across a wall, or past its own far stone: nothing. - Onto a bridge: only to go straight on (steps.ts); another line already going the same way over it is cut back, and a line never crosses itself. A bridge is in a line's list between the cells either side of it, and a line let go with its tip on a bridge ends before it. Pressing on a bridge draws nothing: two lines may be there.

function linesCodeFits

linesCodeFits(code: string, size: number): boolean

Whether a progress code has the shape of one: a size's cells in the progress alphabet.

function linesOfAnswer

linesOfAnswer(layout: LinkLayout, answer: string): Lines | null

A level's answer drawn back as lines: each pair's cells, in order, from its first marble to its second. How a solved level opens on its solved board. Null for an answer that does not draw every pair as one unbroken line.

type LinkCandidate

type LinkCandidate = { layout: string; answer: string; pairs: number; /** Positions the solver looked at to prove the answer is the only one. */ nodes: number; /** Positions where it had to try more than one way: how much guessing the level asks for. */ branches: number; /** Turns in the answer's lines, over all of them: how much the lines wind. */ turns: number; /** The layout in its one spelling over all eight tur…

One candidate: its layout and answer codes, and what the solver said about it.

type LinkExtras

type LinkExtras = { blocked?: ReadonlySet<number>; bridges?: ReadonlySet<number>; walls?: ReadonlySet<string>; waypoints?: ReadonlySet<number>; wrap?: boolean; hex?: boolean; sparse?: boolean };

What a board has besides its lines: the cells no line enters, the bridges two lines cross, and the walls between cells.

type LinkLayout

type LinkLayout = { size: number; /** One per cell: `CELL_EMPTY` (a waypoint too), `CELL_BLOCKED`, `CELL_BRIDGE`, or the pair a stone belongs to. */ cells: number[]; /** Each pair's two stones, as cell indexes, the first met in reading order first. */ ends: [number, number][]; /** The edges no line may cross, each as `edgeKey(a, b)`. Empty for a board with none. */ walls: ReadonlySet<string>; /** Empty cells only on…

function measureLevel

measureLevel(layout: string, answer: string, size: number): LevelMeasure | null

Every measure of one level, from its layout and answer; null for a layout that does not read as one of this size.

function measureSolved

measureSolved(layout: string, answer: string, size: number, solved: { nodes: number; branches: number; }): LevelMeasure | null

The same measures from a solve already made — how many positions and branches the solver took — so a caller that has just proved a level (the level tests) does not solve it a second time to measure it.

function neighboursOf

neighboursOf(size: number, at: number): number[]

The four neighbours of a cell on a square grid, as indexes; fewer at an edge.

function neighbourTable

neighbourTable(size: number): number[][]

Every cell's neighbours, worked out once for a size.

function newTsunagiGame

newTsunagiGame(givens: string, size: number, options?: TsunagiGameOptions): TsunagiGame | null

A game on a layout code, or null for a code that is no layout at that size.

function nextTsunagiLevel

nextTsunagiLevel(size: number, solved: ReadonlySet<number>): number

The level to open on: the first open one not yet solved, or the last open one when every open level is solved.

function noLines

noLines(layout: LinkLayout): Lines

function openTsunagiLevels

openTsunagiLevels(size: number, solved: ReadonlySet<number>): number

The levels that are open, given the ones solved: the first block of sixteen always, and each block after it once every level of the block before is solved.

function orderByDifficulty

orderByDifficulty(levels: readonly (readonly [string, string])[], size: number): number[]

The order to play a size's levels in: easiest first, by score, and among equal scores by their order in levels so the ranking is the same every time it is made. Returns indexes into levels.

function outOfStrokes

outOfStrokes(game: TsunagiGame): boolean

Whether the game is out of strokes before it is solved: it takes nothing more until restartGame.

function overBridge

overBridge(lines: Lines, bridge: number): { across: number; down: number; }

The pair going over a bridge each way, or -1: across (left to right) and down.

function ownersOf

ownersOf(layout: LinkLayout, lines: Lines): number[]

The pair whose line (or stone) holds each cell, CELL_EMPTY for none, CELL_BLOCKED where blocked, CELL_BRIDGE on a bridge (two lines may be there).

const PAIR_LETTERS

PAIR_LETTERS: "ABCDEFGHIJKLMNOP"

The letters a pair may be named by, in order: sixteen, more than any level uses.

function pressAt

pressAt(layout: LinkLayout, lines: Lines, cell: number): { lines: Lines; drawing: number | null; }

A press: the lines after it, and the pair now being drawn, or null where the press draws nothing.

function pressGame

pressGame(game: TsunagiGame, cell: number): TsunagiGame

A finger down on a cell: starts a line from a marble, or carries on from a line's cell. Nothing where there is nothing to draw.

type Random

type Random = () => number;

A number in [0, 1), like Math.random, from a stream a seed fixes.

function randomFilling

randomFilling(size: number, random: Random, longest: number, blocked?: ReadonlySet<number>, wrap?: boolean, hex?: boolean): number[][] | null

Grid lines that fill every cell, as lists of cells; null when this attempt painted itself into a corner.

function relettered

relettered(code: string): string

A code's letters renamed in reading order of first appearance: the one spelling of a layout or answer.

function renumberedRecord

renumberedRecord(size: number, old: Readonly<Record<number, number>>, moves?: Readonly<Record<number, readonly number[]>>): Record<number, number>

A RECORD KEPT BY LEVEL NUMBER, MOVED TO THE NUMBERS THE LEVELS HAVE NOW. Tsunagi's levels were renumbered on 2026-09-26 (renumbered.data.ts): the same boards, more of them, easiest first. Anything that says "level 10" and was written before then means the board that was level 10, so it is moved to where that board is now — never left on the number, where it would be somebody's time on a different board. A number the old order never had is dropped: it names no board.

The server's rows are moved by a migration; this is for what a browser kept of its own (tsunagiKept.ts).

function repairedCandidate

repairedCandidate(size: number, random: Random, longest: number, budget: number, most: number): LinkCandidate | null

One candidate level made by MENDING rather than by luck, for boards too big for luck (12×12): a filling whose layout has a second answer is mended where the two answers differ — the line through a cell they disagree on is cut in two there, which adds a pair of stones the second answer cannot honour — and solved again, until it has exactly one answer, its own. The classic Numberlink generator's move; in the 2026-09-26 spike it took 0.4 rounds on average at 12×12 and doubled what luck alone made. Null when the solver gives up, the lines pass most, or no cut can be made.

function restartGame

restartGame(game: TsunagiGame): TsunagiGame

Restart: every line cleared and the strokes counted again (Undo can bring the lines back). A game that is solved or out of strokes restarts too.

function seededRandom

seededRandom(seed: number): Random

A stream of numbers in [0, 1) fixed by a seed.

function shuffled

shuffled<T>(items: readonly T[], random: Random): T[]

A copy of the list in a random order (Fisher–Yates); the list given is left alone.

type SolveCount

type SolveCount = { /** Answers found, never more than the limit asked for. */ count: number; /** Positions looked at: the measure of how much trying the layout takes. */ nodes: number; /** Positions where more than one way on had to be tried: the measure of guessing. */ branches: number; /** The first answer found, as the pair through each cell; null for none. */ solution: number[] | null; /** Every answer found, i…

THE TSUNAGI SOLVER: how many ways a layout can be joined, up to a limit, and how much trying it took.

Run where the levels are made (scripts/tsunagi-levels.ts) and in the unit test that proves every shipped level has exactly one answer — never in a browser and never on a request. A level is a line of data by the time anybody plays it.

The rules it counts under are the published ones, and no more: a line runs cell to cell across and down, never through a wall (steps.ts), lines never cross or share a cell except on a bridge — where one goes straight across and a different one straight down — a waypoint is taken by its own pair's line and no other, on a board that wraps a line may leave one edge and come back at the other, and every open cell and both ways over every bridge are used. A line MAY run beside itself; the solver counts those answers too, so "exactly one" is a claim about every answer the rules allow, not only the tidy ones.

Search: each pair's line grows from its first stone towards its second. At every step the pair with the fewest ways on is grown (a pair with one is forced), after four checks that throw a hopeless grid away early:

- every empty cell still has two ways in and out (an empty cell or an unfinished line's end beside it), or no line could pass through it; - every unfinished line's growing end and its far stone each have a way on; - the growing end and the far stone touch one common region of empty cells (or each other), or the line can never close; - every region of empty cells is touched at both ends by some unfinished line, or nothing could ever fill it.

function sparseCandidate

sparseCandidate(size: number, random: Random, budget: number): LinkCandidate | null

One sparse candidate level, or null. From a filling proved to have one answer, lines are joined one join at a time, in a random order, and a join is kept only while the board still has exactly one answer — so what is left is as few lines as this filling allows without a second answer creeping in. A board that does not get down to sparseMost is dropped.

function sparseMost

sparseMost(size: number): number

The most lines a sparse board of this side has: two thirds of the side — six on a 9×9, where nine is usual.

type Step

type Step = { /** The cell the line reaches. */ to: number; /** The bridge gone over on the way, or -1 for a plain step. */ over: number; /** Over a bridge: whether across (left or right) rather than down (up or down). */ across: boolean; };

WHERE A LINE MAY GO NEXT, on one board: the rules of a single step, which the solver, the check, the drawing and the answer's lines all read, so a bridge or a wall means the same thing everywhere.

- Across an open edge (no wall) to the next cell. - Onto a BRIDGE only to go straight over it: into the cell beyond, the same way on, never turning. Going across uses the bridge's across slot, going down its down slot; the two are crossed by two different lines.

Imports carry their .ts so the level script can run this under plain node.

function stepBetween

stepBetween(size: number, a: number, b: number, wrap: boolean, hex?: boolean): number

The way from a to its neighbour b as a step (-1, +1, -size, +size), counting a step across a wrapped edge as the step it is — right off the right edge is +1, though the cells' numbers differ by size - 1. On a hexagon, the two slanting steps too: up-right (1 - size) and down-left (size - 1). Zero for cells that are not neighbours.

function stepTable

stepTable(layout: LinkLayout): Step[][]

Every cell's steps on a board, worked out once.

function strokesToExplosion

strokesToExplosion(layout: LinkLayout, strokes: number): number | null

Strokes left before the next explosion: 1 means the next stroke sets one off. Null on a board without them.

function strongestTsunagiHelp

strongestTsunagiHelp(helps: readonly (TsunagiHelp | null)[]): TsunagiHelp | null

Of several helps, the one a solve is kept with: explosions off, then Cheat, then explosions softened.

function symmetryKey

symmetryKey(code: string, size: number): string

The least spelling of a layout over its eight turns and mirrors: equal keys are the same board. A hexagon is only turned the ways that keep its lattice — half round, and the two diagonal mirrors (a quarter turn then a mirror) — since a quarter turn alone would make its slanting neighbours the wrong pair.

function tailWord

tailWord(segment: string): { word: (typeof TAIL_ORDER)[number]; every?: number; blast?: boolean; } | null

Which of the tail's words a segment is, and what it says; null for none of them (the walls list).

function transformed

transformed(code: string, size: number, turn: number, mirror: boolean): string

A layout code turned or mirrored: turn quarter turns, then a mirror across the vertical when mirror. Its walls turn with it.

const TSUNAGI_BLOCK

TSUNAGI_BLOCK: 16

TSUNAGI'S BLOCKS: its levels come sixteen at a time. John, 2026-09-26: "Does it make sense to make it 16 levels per bump… 16 levels of 16? Does that equal 256?" A block opens once every level of the block before it is solved, the set-up shows one block at a time, and within a block levels rise, the 15th and 16th its two hardest — the places a block's twist takes once twists exist.

Its own module, with nothing imported, so the level-making script on a desk reads the same number the site does.

const TSUNAGI_LEVEL_COUNTS

TSUNAGI_LEVEL_COUNTS: Record<number, number>

How many levels each size has, read without loading the size. Sixteen blocks of sixteen (levelBlocks.ts); twelve at 4×4, where the generator runs out of distinct boards with one answer before two hundred; eight at 10×10 and 12×12 and four at 11×11, whose boards are slow to find and to prove on every build.

const TSUNAGI_SIZES

TSUNAGI_SIZES: readonly [4, 5, 6, 7, 8, 9, 10, 11, 12]

TSUNAGI'S LEVELS, COUNTED: which sizes there are, how many levels each has, and which of them a player has opened. The levels themselves are data, each size its own import (@johnmorrisdotca/tsunagi/levels-7, or every size through @johnmorrisdotca/tsunagi/levels), so nothing here carries a board.

A level is not made from a seed: level 12 at 7×7 is one board for every player on every day, so a time on it can be compared with anybody's.

function tsunagiBand

tsunagiBand(size: number, level: number): TsunagiBand

Which third of a size a level sits in: its first third easy, its middle medium, its last hard.

type TsunagiBand

type TsunagiBand = "easy" | "medium" | "hard";

The third of a size a level sits in.

type TsunagiCheck

type TsunagiCheck = { ok: true } | { ok: false; reason: string };

What a check says: joined, or the first reason it is not.

type TsunagiExplosionChoice

type TsunagiExplosionChoice = "on" | "soft" | "off";

How a board's explosions are played: as made, softened (a boom for a blast, half as often) or not at all.

type TsunagiGame

type TsunagiGame = { /** The layout as it is played: its explosions as chosen. */ layout: LinkLayout; /** The layout code the game was made from, which decides which line an explosion breaks. */ givens: string; answer: string | null; lines: Lines; /** What Undo would give back, most recent last: up to 200. */ undo: readonly Lines[]; /** Strokes taken since the game started or was restarted. */ strokes: number; /** T…

type TsunagiGameOptions

type TsunagiGameOptions = { /** The board's one answer, as a code. With it, a solve must be that answer and Cheat is possible; without it, any full, joined board is a solve. */ answer?: string; /** Explosions as made (the default), softened or off. */ explosions?: TsunagiExplosionChoice; /** Whether Cheat is offered. Default false. */ cheats?: boolean; /** Lines to start from, to carry on a game kept half-played (`d…

What a game is told when it starts.

type TsunagiHelp

type TsunagiHelp = "cheated" | "explosions-soft" | "explosions-off";

The help a solve used: Cheat drew a line, or explosions were softened or turned off.

function tsunagiMarks

tsunagiMarks(size: number, level: number): number | null

A level's measured difficulty, 1 (easiest) to 5, or null for a level the marks do not have.

function tsunagiProgress

tsunagiProgress(game: TsunagiGame): TsunagiProgress

type TsunagiProgress

type TsunagiProgress = { pairs: number; joined: number; /** Cells with a line through them, and cells there are to fill. */ filled: number; cells: number; /** Strokes left of a limit, or null for no limit. */ strokesLeft: number | null; strokeLimit: number | null; /** Strokes to the next explosion (1 means the next stroke), or null for a board without them. */ boomIn: number | null; strokes: number; solved: boolean;…

What a game stands at: for a status line under the board.

function tsunagiRole

tsunagiRole(size: number, level: number): TwistRole | null

A level's part in its block's lesson, from the data the level script wrote (marks.data.ts), without its size's boards; null for none.

function turnsIn

turnsIn(answer: string, layout: LinkLayout): number

How many times an answer's lines turn a corner, over all of them: a line going straight over a bridge turns nowhere on it.

type TwistCandidate

type TwistCandidate = LinkCandidate & { bridges: number; walls: number; blocked: number; };

MAKING TSUNAGI'S TWISTS, on a desk: boards with bridges, and boards with walls and blocked cells (John, 2026-09-26, both chosen from the brainstorm; bridges first). Like generate.ts, only the level script calls this, and a board is kept only when the solver proves it has exactly one answer — the one it was made from.

BRIDGES. A grid is filled with lines as for a plain board. Then, where a line runs straight through a cell away from the edge and two other lines each end on the cells either side of it the other way, those two are joined into one line over the cell, which becomes a bridge: the first line goes over it one way, the joined one the other. Repeated for as many bridges as asked, never two side by side.

WALLS AND BLOCKED CELLS. A few cells are blocked before the grid is filled, so the lines go round them. Where the board then has more than one answer, walls are put on edges between two different lines — never across an edge the answer uses, so its answer stands — until it has one, and then every wall that is not needed for that is taken away again. What is left is walls a player has to use to find the answer.

function twistRole

twistRole(layouts: readonly string[], level: number): TwistRole | null

A level's part in its block's lesson, or null for a level that has none (1 to 14, or a 15th or 16th left plain because somebody had played it). newOnes are the challenges no earlier level of the size has: what a 15th introduces.

type TwistRole

type TwistRole = { role: "teaches" | "tests"; challenges: Challenge[]; newOnes: Challenge[] };

Where a level sits in its block's lesson: its 15th teaches the block's twist, its 16th tests it. newOnes are what no earlier level of the size had.

function undoGame

undoGame(game: TsunagiGame): TsunagiGame

Undo: the lines as they were before the last stroke. Nothing once the game is over.

function unjoinedPairs

unjoinedPairs(layout: LinkLayout, lines: Lines): number[]

The pairs not joined yet, for Check: every pair whose line does not run from one of its marbles to the other — a line stopped beside its partner looks joined and is not. Nothing about where any line should go.

const VERSION

VERSION: "1.1.0"

The package's version.

function wallCandidate

wallCandidate(size: number, random: Random, longest: number, budget: number, blockedWanted: number, mostWalls: number): TwistCandidate | null

A board with blockedWanted blocked cells and as many walls as it needs (at most mostWalls) to have one answer, or null.

function waypointCandidate

waypointCandidate(size: number, random: Random, longest: number, budget: number, most: number): TwistCandidate | null

WAYPOINTS. A plain filling whose layout has more than one answer, given waypoints — cells in the middle of the answer's lines, each kept for the line through it — until it has one, and then each taken away again where it is not needed: every waypoint left is one a player has to use. At most most.

function wrapCandidate

wrapCandidate(size: number, random: Random, longest: number, budget: number): TwistCandidate | null

WRAP. A grid filled on a torus — a line may run off one edge and on at the other — kept only where at least one line of the answer does, so the wrap is a thing the board asks for, and only with exactly one answer under the wrap rules.

function wrappedStep

wrappedStep(size: number, at: number, by: number): number

The cell beside at one step by (-1, +1, -size, +size) on a board that wraps: off one edge and in at the other.

@johnmorrisdotca/tsunagi/draw

beadShades CELL cellAtPoint colourOfPair COORDINATE_ROOM drawTsunagi drawTsunagiCode drawTsunagiMarble FRAME GeometryOptions HEX_RADIUS hexagonPoints hsl lineColour lineRuns marbleShades round TSUNAGI_BOARD_NAMES TSUNAGI_BOARDS TSUNAGI_COLOUR_SET_NAMES TSUNAGI_COLOUR_SETS TSUNAGI_DEFAULT_BOARD TSUNAGI_DEFAULT_COLOUR_SET TSUNAGI_SHELL TSUNAGI_STRINGS TSUNAGI_STYLE tsunagiBoardLook TsunagiBoardLook TsunagiBoardName TsunagiColour tsunagiColourSet TsunagiColourSetName TsunagiDrawOptions TsunagiFill tsunagiGeometry TsunagiGeometry TsunagiLanguage tsunagiLanguageOf TsunagiMarks TsunagiPoint tsunagiSay washColour

function beadShades

beadShades(colour: TsunagiColour, marks: TsunagiMarks): { light: string; body: string; rim: string; }

A marble on a line's way between its ends: the pair's colour, or for numbers the line's own soft tint, with nothing written on it.

const CELL

CELL: 100

How wide a cell is in the drawing's units.

function cellAtPoint

cellAtPoint(geometry: TsunagiGeometry, x: number, y: number): number | null

The cell under a point of the drawing, or null where there is none: off the board, in the frame, on a hexagon's missing corner. On a board that wraps the ghost cells are the real ones they show. A point is in the nearest cell's hexagon on a honeycomb, which is the one it is in.

function colourOfPair

colourOfPair(set: readonly TsunagiColour[], pair: number): TsunagiColour

The colour of a pair in a set, round and round.

const COORDINATE_ROOM

COORDINATE_ROOM: 34

The room the row numbers and column letters take on every side, where they are drawn.

function drawTsunagi

drawTsunagi(layout: LinkLayout, options?: TsunagiDrawOptions): string

A board as SVG text, with the lines in options.lines drawn on it. The drawing is class="tsunagi"; its colours and its two movements are TSUNAGI_STYLE. Its parts carry classes and data attributes a page can style or find: tsu-marble, tsu-bead, tsu-line (data-pair, data-cells), tsu-bridge (data-cell, data-across), tsu-over-bridge, tsu-wall (data-edge), tsu-waypoint, tsu-flag, tsu-blast, tsu-hex-cell.

function drawTsunagiCode

drawTsunagiCode(givens: string, size: number, options?: TsunagiDrawOptions): string

A level's layout code drawn, or an empty string for a code that is no layout at that size.

function drawTsunagiMarble

drawTsunagiMarble(pair: number, options?: { marks?: TsunagiMarks; colours?: TsunagiColourSetName | readonly TsunagiColour[]; label?: string; style?: boolean; id?: string; }): string

One marble on its own, as SVG text: for a legend or an icon. pair is which of the board's pairs it is, from 0.

const FRAME

FRAME: 14

The frame round the playing surface, in the drawing's units.

type GeometryOptions

type GeometryOptions = { coordinates?: boolean; ghosts?: boolean };

const HEX_RADIUS

HEX_RADIUS: number

A hexagon's centre to its corner, in cells: a pointy-top hexagon one cell wide.

function hexagonPoints

hexagonPoints(x: number, y: number, radius: number): string

A hexagon's corners about a centre, pointy at the top, as an SVG points list.

function hsl

hsl([hue, saturation, lightness]: TsunagiColour, shift?: number, alpha?: number): string

A colour as a CSS string, lightened or darkened by shift percentage points, at an opacity.

function lineColour

lineColour(colour: TsunagiColour, marks: TsunagiMarks): string

The stroke a pair's line is drawn in: its colour, or a soft tint of it for numbers.

function lineRuns

lineRuns(layout: LinkLayout, geometry: TsunagiGeometry, line: readonly number[]): TsunagiPoint[][]

A line as the runs it is drawn in, each a list of points through its cells' middles. On a board that wraps, a step across the join ends one run a cell out beyond the edge (in the ghost) and starts the next a cell out beyond the other edge, so the line is seen to leave and come back.

function marbleShades

marbleShades(colour: TsunagiColour, marks: TsunagiMarks): { light: string; body: string; rim: string; ink: string; }

A marble's shading: the highlight at its upper left, its body, and its rim, and the ink its number is written in.

function round

round(value: number): number

A number as short as it can be written for a drawing: two places.

const TSUNAGI_BOARD_NAMES

TSUNAGI_BOARD_NAMES: readonly ["paper", "wood", "green", "blue", "red", "black"]

The ready-made boards.

const TSUNAGI_BOARDS

TSUNAGI_BOARDS: Record<"paper" | "wood" | "green" | "blue" | "red" | "black", TsunagiBoardLook>

Every ready-made board's look. paper is the page's own light look, written out; drawn by name it is left to the style, so a page can change it.

const TSUNAGI_COLOUR_SET_NAMES

TSUNAGI_COLOUR_SET_NAMES: readonly ["marble", "bright", "colour-blind", "soft"]

The colour sets there are.

const TSUNAGI_COLOUR_SETS

TSUNAGI_COLOUR_SETS: Record<"soft" | "marble" | "bright" | "colour-blind", readonly TsunagiColour[]>

Sixteen colours each, as many pairs as the biggest board has. marble: twelve of them mostly from Okabe and Ito's palette for colour-blind readers, ordered so the five pairs of a small board are the most unlike (no two blues, and no green, which a green board would swallow), then four for the big boards. bright: a plainer, louder set. colour-blind: Okabe and Ito's eight, then each again a shade lighter or darker; past eight, numbers are the better help. soft: pastels, for a board that should look quiet.

const TSUNAGI_DEFAULT_BOARD

TSUNAGI_DEFAULT_BOARD: "paper" | "wood" | "green" | "blue" | "red" | "black"

The default: paper, which takes its colours from the page (--tsu-paper and the rest of TSUNAGI_STYLE), light or dark.

const TSUNAGI_DEFAULT_COLOUR_SET

TSUNAGI_DEFAULT_COLOUR_SET: "soft" | "marble" | "bright" | "colour-blind"

The sets' default: the one itsutsu.com plays in.

const TSUNAGI_SHELL

TSUNAGI_SHELL: { readonly light: "#ffffff"; readonly body: "#ececec"; readonly rim: "#bfbfbf"; readonly ink: "#1a1a1a"; }

The plain shell marble a number is written on: highlight, body, rim, and the ink.

const TSUNAGI_STRINGS

TSUNAGI_STRINGS: Record<TsunagiLanguage, Record<string, string>>

const TSUNAGI_STYLE

TSUNAGI_STYLE: "\n.tsunagi {\n --tsu-paper: #fbf8f1; --tsu-paper-deep: #fbf8f1; --tsu-frame: #a98954; --tsu-grid: #cfc6b2; --tsu-ink: #1f2320;\n --tsu-coordinate: #5b3d1c; --tsu-shu: #d9381e; --tsu-good: #2f7a4f;\n --tsu-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n display: block; width: 100%; height: auto;\n user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action: none…

THE STYLE a Tsunagi drawing wears: the colours of its board as custom properties, the two things that move (a flashing ring round a marble Check found unjoined, and the burst where an explosion took a line out), and the one rule that matters for a puzzle played with fingers: nothing in the drawing can be selected, dragged or double-tapped.

drawTsunagi only writes classes, data attributes and, for a board other than the plain paper, the custom properties below; this is what gives them a look. Every colour is a custom property on .tsunagi (--tsu-paper, --tsu-paper-deep, --tsu-frame, --tsu-grid, --tsu-ink, --tsu-coordinate, --tsu-shu, --tsu-good), so a page's own style needs to set only the ones it wants different. Paper follows the page's light or dark. With reduced motion asked for, nothing moves.

function tsunagiBoardLook

tsunagiBoardLook(board: TsunagiBoardName | TsunagiBoardLook | undefined): TsunagiBoardLook

A board as asked: a ready-made one by name, or a look of your own.

type TsunagiBoardLook

type TsunagiBoardLook = { /** The playing surface: one colour, or two, a light and a deep one, laid as a soft gradient. */ paper: string | readonly [string, string]; /** The frame round the surface. */ frame: string; /** The rules between cells, and the dashed rim of a board that wraps. */ grid: string; /** Walls, blocked cells and a bridge's rails. */ ink: string; /** The letters and numbers down the sides, where t…

What a board is made of: every part a CSS colour.

type TsunagiBoardName

type TsunagiBoardName = (typeof TSUNAGI_BOARD_NAMES)[number];

type TsunagiColour

type TsunagiColour = readonly [number, number, number];

A colour as hue (0 to 360), saturation and lightness (each 0 to 100).

function tsunagiColourSet

tsunagiColourSet(set: TsunagiColourSetName | readonly TsunagiColour[] | undefined): readonly TsunagiColour[]

A colour set as asked: one of the named sets, or colours of your own (used round and round when a board has more pairs than you gave).

type TsunagiColourSetName

type TsunagiColourSetName = (typeof TSUNAGI_COLOUR_SET_NAMES)[number];

type TsunagiDrawOptions

type TsunagiDrawOptions = { /** The lines drawn so far, one list of cells for each pair (`Lines`); none, if left out. */ lines?: Lines; /** Tell the pairs apart by colour (the default) or by the number on their marbles. */ marks?: TsunagiMarks; /** A small marble in every cell a line runs through (`marbles`, the default: the dots), or the line alone (`lines`). */ fill?: TsunagiFill; /** The colour of each pair: a na…

DRAWING a board as SVG text: a string, to put in a page, a file or an image, with nothing to load and nothing run. Every picture here is made in code.

What is drawn is what the site that grew this package draws: marbles on a board, the lines between them as thick rounded strokes through the cells' middles, every cell a line runs through washed faintly in its colour and (as fill: "marbles") holding a small marble of that colour too, so a finished board is a board of marbles joined by their lines. Walls are thick bars on the edge between two cells. A BRIDGE is drawn as a bridge: the line going down passes UNDER its deck and is lost beneath it, and the line going across is drawn over the deck. A waypoint is a ring in its line's colour. A board that wraps has a ghost of the far edge all round it, faded, with a dashed rim round the real board, and a line across the join is drawn out through one edge and in through the other. A hexagon is a honeycomb of hexagons.

The drawing holds still: the same board is the same box whatever is drawn on it, and a page redraws it as lines change. Colours and numbers on the marbles, dots or only lines, a colour set and a board are all options.

type TsunagiFill

type TsunagiFill = "marbles" | "lines";

Marbles along every line (the dots), or the line alone.

function tsunagiGeometry

tsunagiGeometry(layout: LinkLayout, options?: GeometryOptions): TsunagiGeometry

The geometry of a layout's drawing. coordinates leaves room for row numbers and column letters (not on a board that wraps or a hexagon); ghosts false leaves out the ring of a board that wraps.

type TsunagiGeometry

type TsunagiGeometry = { size: number; hex: boolean; /** One ghost cell all round, on a board that wraps. */ ring: number; /** Whether the drawing has the board's coordinates down its sides. */ coordinates: boolean; /** The drawing's width and height, the same, in its units. */ side: number; /** Where the playing surface starts and how far it goes, inside the frame and the coordinates. */ paper: { x: number; y: numb…

type TsunagiLanguage

type TsunagiLanguage = "en" | "ja";

THE WORDS A TSUNAGI BOARD SAYS, in English and Japanese: what a screen reader hears of the drawing, the buttons and the lines under a playable board, and what each challenge on a level means. Plain data, so a page can read them, replace a few or add a language of its own beside these two.

{name} in a line is a value filled in; a line foo that has a fooOne beside it is said as fooOne when its {n} is 1.

function tsunagiLanguageOf

tsunagiLanguageOf(tag: string | null | undefined): TsunagiLanguage

The language a piece of text is in: Japanese for anything starting ja, English for everything else.

type TsunagiMarks

type TsunagiMarks = "colours" | "numbers";

How the pairs are told apart: by colour, or by the number on each pair's marbles.

type TsunagiPoint

type TsunagiPoint = { x: number; y: number };

A point in the drawing.

function tsunagiSay

tsunagiSay(language: TsunagiLanguage, key: string, values?: Record<string, string | number>): string

A line in a language, with its values filled in; the line itself if there is none by that name.

function washColour

washColour(colour: TsunagiColour, marks: TsunagiMarks): string

The faint wash a line leaves on the cells it runs through.

@johnmorrisdotca/tsunagi/play

edgeNudge ensureTsunagiPlayStyle keptView mountTsunagi TSUNAGI_EDGE TSUNAGI_EDGE_STEP TSUNAGI_FITTED TSUNAGI_MOST_ZOOM TSUNAGI_PLAY_STYLE TSUNAGI_ZOOM_FROM TsunagiEventDetail TsunagiLook TsunagiMount TsunagiMountOptions TsunagiView zoomedAbout

function edgeNudge

edgeNudge(x: number, y: number, box: { left: number; top: number; right: number; bottom: number; }): { dx: number; dy: number; }

How far to move a zoomed view in a frame, for a finger at (x, y) in a box: toward the edge it is near, or not at all.

function ensureTsunagiPlayStyle

ensureTsunagiPlayStyle(host: Element): void

Put the style in the page once: in the document's head, or in the shadow root the host is in.

function keptView

keptView(view: TsunagiView, box: number): TsunagiView

A view kept inside the board: never a gap between the board's edge and the box's. box is the box's width in pixels.

function mountTsunagi

mountTsunagi(host: HTMLElement, options: TsunagiMountOptions): TsunagiMount | null

Draw a level into host and play it. Returns the handle that drives it, or null for a layout code that is no layout at that size.

const TSUNAGI_EDGE

TSUNAGI_EDGE: 36

How near an edge of the box a line's end must be dragged to move the view, and how far each frame moves it, in pixels.

const TSUNAGI_EDGE_STEP

TSUNAGI_EDGE_STEP: 6

const TSUNAGI_FITTED

TSUNAGI_FITTED: TsunagiView

The whole board, fitted.

const TSUNAGI_MOST_ZOOM

TSUNAGI_MOST_ZOOM: 3

How far a board may be zoomed in, as a multiple of the whole board fitted to its box.

const TSUNAGI_PLAY_STYLE

TSUNAGI_PLAY_STYLE: "\n.tsunagi {\n --tsu-paper: #fbf8f1; --tsu-paper-deep: #fbf8f1; --tsu-frame: #a98954; --tsu-grid: #cfc6b2; --tsu-ink: #1f2320;\n --tsu-coordinate: #5b3d1c; --tsu-shu: #d9381e; --tsu-good: #2f7a4f;\n --tsu-font: system-ui, -apple-system, \"Segoe UI\", sans-serif;\n display: block; width: 100%; height: auto;\n user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; touch-action:…

THE STYLE a playable Tsunagi board wears (mountTsunagi, <tsunagi-board>): the drawing's own (TSUNAGI_STYLE) and the board's box, its buttons, its lines of words and its zoom pad. Colours are custom properties on .tsunagi-play (--tsp-ink, --tsp-muted, --tsp-rule, --tsp-surface, --tsp-accent, --tsp-good) so a page sets only what it wants different.

Nothing moves when something is chosen: the board is one square box, the lines of words keep the room their longest wording takes, and the buttons are one size. Nothing the player touches can be selected.

const TSUNAGI_ZOOM_FROM

TSUNAGI_ZOOM_FROM: 10

The smallest board a player is given the pad for.

type TsunagiEventDetail

type TsunagiEventDetail = { /** The lines drawn so far. */ lines: Lines; /** The lines as a short code, to keep a game half played (`decodeLines` brings them back). */ code: string; progress: TsunagiProgress; /** The answer the lines make, in the answer's spelling: what `checkTsunagiAnswer` takes. */ answer: string; helped: TsunagiHelp | null; /** What happened, for `tsunagi-explosion`: `boom` or `blast`, and the ce…

What a mounted board tells of itself in every event.

type TsunagiLook

type TsunagiLook = { marks?: TsunagiMarks; fill?: TsunagiFill; colours?: TsunagiColourSetName | readonly TsunagiColour[]; board?: TsunagiBoardName | TsunagiBoardLook; coordinates?: boolean; };

What a board looks like: every option drawTsunagi takes that does not depend on the lines.

type TsunagiMount

type TsunagiMount = { readonly host: HTMLElement; /** The game as it stands. */ game: () => TsunagiGame; progress: () => TsunagiProgress; /** Play another level (or the same one again, fresh). `lines` carries on a kept game. */ load: (level: { size: number; givens: string; answer?: string; level?: number; lines?: Lines }) => void; /** Change how the board looks, or how it is played: marks, fill, colours, board, coor…

type TsunagiMountOptions

type TsunagiMountOptions = TsunagiLook & { /** The board's size: how many cells across. */ size: number; /** The level's layout code. */ givens: string; /** The level's one answer, as a code. With it a solve must be that answer, and Cheat can be offered. */ answer?: string; /** Which level of its size this is, to show its difficulty and its place in its block. */ level?: number; /** Lines to start from, to carry on …

type TsunagiView

type TsunagiView = { zoom: number; x: number; y: number };

A BIG BOARD LOOKED AT THROUGH A BOX: from 10×10 up, a phone's cells are smaller than a thumb, so the board is drawn up to three times the box's width and looked at through it, zoomed and moved by a pad of buttons, the wheel and (while a line is dragged) the box's edge, never by scrolling the page. This is only the arithmetic of the view; mountTsunagi does the rest.

A view is a zoom (1 is the whole board fitted to the box) and where the board's top left sits in the box, which is never past the box's own edges.

function zoomedAbout

zoomedAbout(view: TsunagiView, factor: number, px: number, py: number, box: number): TsunagiView

A view zoomed by factor about the point (px, py) of the box, which stays over the same spot of the board.

@johnmorrisdotca/tsunagi/element

TsunagiBoard

const TsunagiBoard

TsunagiBoard: typeof TsunagiBoard

@johnmorrisdotca/tsunagi/element/define

@johnmorrisdotca/tsunagi/levels

firstUnsolvedTsunagiLevel isTsunagiLevel LevelRow loadEveryTsunagiLevel loadTsunagiLevels nextTsunagiLevel openTsunagiLevels TSUNAGI_LEVEL_COUNTS TSUNAGI_SIZES tsunagiBand TsunagiBand tsunagiLevelOf tsunagiLevelsOf

function firstUnsolvedTsunagiLevel

firstUnsolvedTsunagiLevel(size: number, solved: ReadonlySet<number>): number | null

The lowest level not yet solved, or null when every level of the size is. It is always open: a block opens only once the block before is all solved, so the first gap is in the open blocks. Nobody is sent past a level they have not finished.

function isTsunagiLevel

isTsunagiLevel(size: number, level: number): boolean

Whether a number is a level this size has.

type LevelRow

type LevelRow = readonly [string, string];

One level: its layout and its one answer, each a code (code.ts).

function loadEveryTsunagiLevel

loadEveryTsunagiLevel(): Promise<void>

Every size's levels, loaded.

function loadTsunagiLevels

loadTsunagiLevels(size: number): Promise<readonly LevelRow[]>

A size's levels, loaded once and kept.

function nextTsunagiLevel

nextTsunagiLevel(size: number, solved: ReadonlySet<number>): number

The level to open on: the first open one not yet solved, or the last open one when every open level is solved.

function openTsunagiLevels

openTsunagiLevels(size: number, solved: ReadonlySet<number>): number

The levels that are open, given the ones solved: the first block of sixteen always, and each block after it once every level of the block before is solved.

const TSUNAGI_LEVEL_COUNTS

TSUNAGI_LEVEL_COUNTS: Record<number, number>

How many levels each size has, read without loading the size. Sixteen blocks of sixteen (levelBlocks.ts); twelve at 4×4, where the generator runs out of distinct boards with one answer before two hundred; eight at 10×10 and 12×12 and four at 11×11, whose boards are slow to find and to prove on every build.

const TSUNAGI_SIZES

TSUNAGI_SIZES: readonly [4, 5, 6, 7, 8, 9, 10, 11, 12]

TSUNAGI'S LEVELS, COUNTED: which sizes there are, how many levels each has, and which of them a player has opened. The levels themselves are data, each size its own import (@johnmorrisdotca/tsunagi/levels-7, or every size through @johnmorrisdotca/tsunagi/levels), so nothing here carries a board.

A level is not made from a seed: level 12 at 7×7 is one board for every player on every day, so a time on it can be compared with anybody's.

function tsunagiBand

tsunagiBand(size: number, level: number): TsunagiBand

Which third of a size a level sits in: its first third easy, its middle medium, its last hard.

type TsunagiBand

type TsunagiBand = "easy" | "medium" | "hard";

The third of a size a level sits in.

function tsunagiLevelOf

tsunagiLevelOf(size: number, givens: string): number | null

The level a layout is, at a loaded size, or null for a layout no level has.

function tsunagiLevelsOf

tsunagiLevelsOf(size: number): readonly LevelRow[]

A size already loaded, or a refusal: nothing answers for a list it does not have.

@johnmorrisdotca/tsunagi/levels-4

TSUNAGI_4

const TSUNAGI_4

TSUNAGI_4: readonly (readonly [string, string])[]

TSUNAGI AT 4×4: 192 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-5

TSUNAGI_5

const TSUNAGI_5

TSUNAGI_5: readonly (readonly [string, string])[]

TSUNAGI AT 5×5: 256 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-6

TSUNAGI_6

const TSUNAGI_6

TSUNAGI_6: readonly (readonly [string, string])[]

TSUNAGI AT 6×6: 256 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-7

TSUNAGI_7

const TSUNAGI_7

TSUNAGI_7: readonly (readonly [string, string])[]

TSUNAGI AT 7×7: 256 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-8

TSUNAGI_8

const TSUNAGI_8

TSUNAGI_8: readonly (readonly [string, string])[]

TSUNAGI AT 8×8: 256 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-9

TSUNAGI_9

const TSUNAGI_9

TSUNAGI_9: readonly (readonly [string, string])[]

TSUNAGI AT 9×9: 256 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-10

TSUNAGI_10

const TSUNAGI_10

TSUNAGI_10: readonly (readonly [string, string])[]

TSUNAGI AT 10×10: 128 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-11

TSUNAGI_11

const TSUNAGI_11

TSUNAGI_11: readonly (readonly [string, string])[]

TSUNAGI AT 11×11: 64 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/levels-12

TSUNAGI_12

const TSUNAGI_12

TSUNAGI_12: readonly (readonly [string, string])[]

TSUNAGI AT 12×12: 128 levels in blocks of 16, easiest first by the measured difficulty (difficulty.ts), each block's 15th and 16th its twist where one could be placed.

WRITTEN BY node scripts/tsunagi-levels.ts, NEVER BY HAND. Each line is one level: its layout (a letter for each pair's two stones, . for an empty cell) and its one answer (the letter of the line through every cell). Every level is proved to have exactly one answer by levels.test.ts, no two are the same board under a turn or a mirror, and difficulty.test.ts holds the order to the measure.

@johnmorrisdotca/tsunagi/marks

TSUNAGI_MARKS TSUNAGI_ROLES

const TSUNAGI_MARKS

TSUNAGI_MARKS: Readonly<Record<number, string>>

const TSUNAGI_ROLES

TSUNAGI_ROLES: Readonly<Record<number, Readonly<Record<number, TwistRole>>>>

@johnmorrisdotca/tsunagi/renumbered

TSUNAGI_RENUMBERED TSUNAGI_RENUMBERED_AT

const TSUNAGI_RENUMBERED

TSUNAGI_RENUMBERED: Readonly<Record<number, readonly number[]>>

const TSUNAGI_RENUMBERED_AT

TSUNAGI_RENUMBERED_AT: "2026-09-26"

WHERE EACH TSUNAGI LEVEL WENT when the levels were last renumbered: for each size, the new number of every old level, old level 1 first. Written by node scripts/tsunagi-levels.ts beside the migration that moves the stored runs, races and attempts the same way; a browser reads it to move its own record of solves and attempts (tsunagiKept.ts).

2026-09-26: a hundred a size ordered by solver effort became 256 (192 at 4×4) ordered by the measured difficulty.