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.
@johnmorrisdotca/tsunagi 1.1.0 · 17 entry points · 216 exports
@johnmorrisdotca/tsunagi 131@johnmorrisdotca/tsunagi/draw 42@johnmorrisdotca/tsunagi/play 16@johnmorrisdotca/tsunagi/element 1@johnmorrisdotca/tsunagi/element/define 0@johnmorrisdotca/tsunagi/levels 13@johnmorrisdotca/tsunagi/levels-4 1@johnmorrisdotca/tsunagi/levels-5 1@johnmorrisdotca/tsunagi/levels-6 1@johnmorrisdotca/tsunagi/levels-7 1@johnmorrisdotca/tsunagi/levels-8 1@johnmorrisdotca/tsunagi/levels-9 1@johnmorrisdotca/tsunagi/levels-10 1@johnmorrisdotca/tsunagi/levels-11 1@johnmorrisdotca/tsunagi/levels-12 1@johnmorrisdotca/tsunagi/marks 2@johnmorrisdotca/tsunagi/renumbered 2@johnmorrisdotca/tsunagiallJoined 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
allJoined(layout: LinkLayout, lines: Lines): boolean
Solved: every pair joined, every open cell on a line, and every bridge gone over both ways.
answerOf(layout: LinkLayout, lines: Lines): string
The answer the lines make, in the answer's spelling (encodeAnswer).
blockOf(level: number): number
The block a level is in, from 1.
blockRange(block: number, count: number): { first: number; last: number; }
A block's first and last level, the last no further than the size has.
blocksIn(count: number): number
How many blocks a size of count levels has.
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.
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.
bridgesOf(layout: LinkLayout): number[]
The bridges of a board.
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.
CELL_BLOCKED: -2
CELL_BRIDGE: -3
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 = "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.
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.
challengesOf(layout: string): Challenge[]
The challenges on a board, in the order the ladder teaches them.
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.
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.
checkGame(game: TsunagiGame): TsunagiGame
Check: the pairs not joined yet are flagged, and how many cells are still empty is said.
checkTsunagiAnswer(size: number, givens: string, answer: string): TsunagiCheck
compareEdges(x: string, y: string): number
Edges in the one order a layout writes them: by their first cell, then their second.
countSolutions(layout: LinkLayout, limit?: number, budget?: number): SolveCount
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".
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.
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.
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.
dragGame(game: TsunagiGame, cell: number): TsunagiGame
The finger carried into a cell: the line grows, shortens or cuts another back, as the rules say.
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.
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.
edgeKey(a: number, b: number): string
An edge between two neighbouring cells, the same whichever way round they are named.
edgeOpen(layout: LinkLayout, a: number, b: number): boolean
Whether a line may step from cell a to its neighbour b: no wall between them.
encodeAnswer(owners: readonly number[]): string
A finished grid's code: the letter of the line through each cell, # where blocked, + on a bridge.
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.
encodeLines(layout: LinkLayout, lines: Lines): string
encodeWalls(walls: Iterable<string>): string
The walls as a layout writes them, after the cells: nothing where there are none.
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.
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".
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).
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.
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.
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.
gameOver(game: TsunagiGame): boolean
Whether the game takes no more strokes: solved, or out of strokes.
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.
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.
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.
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).
hexNeighbourTable(size: number): number[][]
Every cell's six neighbours on the hexagon lattice, worked out once for a size.
hexRadius(size: number): number
The hexagon's radius on a board of this side: R, for a side of 2R + 1.
inHex(size: number, at: number): boolean
Whether a cell of the square is inside the hexagon: at most R lattice steps from the middle.
isTsunagiLevel(size: number, level: number): boolean
Whether a number is a level this size has.
isTwist(layout: string): boolean
Whether a board is a twist: any challenge on it.
joined(layout: LinkLayout, lines: Lines, pair: number): boolean
Whether a pair's line runs from one of its stones to the other.
layoutCells(code: string): string
A layout's cells without its walls or wrap: the part of the code one character a cell.
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.
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.
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.
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 = { 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 = readonly [string, string];
One level: its layout and its one answer, each a code (code.ts).
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 = 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.
linesCodeFits(code: string, size: number): boolean
Whether a progress code has the shape of one: a size's cells in the progress alphabet.
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.
LINK_BLOCKED: "#"
LINK_BRIDGE: "+"
LINK_EMPTY: "."
TSUNAGI, WRITTEN DOWN: a level's layout and a finished grid, as the strings every puzzle travels as (puzzleCode.ts is the spelling of the number grids; this is the spelling of this one).
A LAYOUT is row-major, one character a cell: . for an empty cell, a capital letter for a stone — each letter exactly twice, the two ends of one line — # for a blocked cell no line may enter, and + for a BRIDGE: a cell two different lines cross, one straight across and one straight down, neither turning on it (John, 2026-09-26; Flow Free's bridges). The letters are named in the order their first stone is met reading left to right, top to bottom, so one layout has one spelling.
WALLS come after the cells, behind a |: the edges between two neighbouring cells no line may cross, each as its two cells a-b (a before b), in order, comma-separated — A..A|1-2,5-9. A layout with no walls has no |, so every layout written before walls existed is still the same string, and the same board (solves are kept by it).
A bridge sits away from the edge, never beside another bridge, and no wall touches it: it always has its four ways across.
A WAYPOINT is a pair's letter in lower case on an empty cell: a ring of that colour its line must pass through (John, 2026-09-26). WRAP comes last, as the segment wrap after the walls (or after the cells where there are none): the left and right edges join, and so do the top and bottom, so a line leaving one side comes back in on the other, as the wrapping gomoku does.
A HEXAGON (hex, first of the words after the walls) is Hexversi's honeycomb: an odd square of side 2R + 1 with every row slid half a cell along, so each cell has six neighbours — the four around it and the two diagonals along the slant, up-right and down-left (rules/hexagon.ts). The cells outside the hexagon of radius R are #, off the board, and a hexagon has no walls, bridges or wrap.
sparse says the board has few, long lines — at most sparseMost — and is refused on a board with more; strokes<N> (last of all) gives the player N strokes to solve it in, every lift that changed the board spending one.
AN ANSWER is the grid of cells with every open cell carrying the letter of the line through it, # where the layout has one, and + on a bridge: the two lines over it are the ones either side of it.
Imports carry their .ts so the level script (scripts/tsunagi-levels.ts) can run this under plain node.
LINK_HEX: "hex"
The segment after the cells that makes a board a hexagon of hexagons.
LINK_SPARSE: "sparse"
The segment after the cells that says a board has few, long lines.
LINK_WALLS: "|"
Where the walls start, after the cells.
LINK_WRAP: "wrap"
The segment after the cells that makes a board wrap.
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 = { 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 = { 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…
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.
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.
neighboursOf(size: number, at: number): number[]
The four neighbours of a cell on a square grid, as indexes; fewer at an edge.
neighbourTable(size: number): number[][]
Every cell's neighbours, worked out once for a size.
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.
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.
noLines(layout: LinkLayout): Lines
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.
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.
outOfStrokes(game: TsunagiGame): boolean
Whether the game is out of strokes before it is solved: it takes nothing more until restartGame.
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.
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).
PAIR_LETTERS: "ABCDEFGHIJKLMNOP"
The letters a pair may be named by, in order: sixteen, more than any level uses.
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.
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 = () => number;
A number in [0, 1), like Math.random, from a stream a seed fixes.
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.
relettered(code: string): string
A code's letters renamed in reading order of first appearance: the one spelling of a layout or answer.
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).
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.
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.
seededRandom(seed: number): Random
A stream of numbers in [0, 1) fixed by a seed.
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 = { /** 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.
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.
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 = { /** 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.
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.
stepTable(layout: LinkLayout): Step[][]
Every cell's steps on a board, worked out once.
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.
strongestTsunagiHelp(helps: readonly (TsunagiHelp | null)[]): TsunagiHelp | null
Of several helps, the one a solve is kept with: explosions off, then Cheat, then explosions softened.
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.
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).
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.
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.
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.
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.
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 = "easy" | "medium" | "hard";
The third of a size a level sits in.
type TsunagiCheck = { ok: true } | { ok: false; reason: string };
What a check says: joined, or the first reason it is not.
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 = { /** 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 = { /** 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 = "cheated" | "explosions-soft" | "explosions-off";
The help a solve used: Cheat drew a line, or explosions were softened or turned off.
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.
tsunagiProgress(game: TsunagiGame): 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.
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.
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 = 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.
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 = { 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.
undoGame(game: TsunagiGame): TsunagiGame
Undo: the lines as they were before the last stroke. Nothing once the game is over.
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.
VERSION: "1.1.0"
The package's version.
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.
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.
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.
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/drawbeadShades 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
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.
CELL: 100
How wide a cell is in the drawing's units.
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.
colourOfPair(set: readonly TsunagiColour[], pair: number): TsunagiColour
The colour of a pair in a set, round and round.
COORDINATE_ROOM: 34
The room the row numbers and column letters take on every side, where they are drawn.
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.
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.
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.
FRAME: 14
The frame round the playing surface, in the drawing's units.
type GeometryOptions = { coordinates?: boolean; ghosts?: boolean };
HEX_RADIUS: number
A hexagon's centre to its corner, in cells: a pointy-top hexagon one cell wide.
hexagonPoints(x: number, y: number, radius: number): string
A hexagon's corners about a centre, pointy at the top, as an SVG points list.
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.
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.
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.
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.
round(value: number): number
A number as short as it can be written for a drawing: two places.
TSUNAGI_BOARD_NAMES: readonly ["paper", "wood", "green", "blue", "red", "black"]
The ready-made 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.
TSUNAGI_COLOUR_SET_NAMES: readonly ["marble", "bright", "colour-blind", "soft"]
The colour sets there are.
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.
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.
TSUNAGI_DEFAULT_COLOUR_SET: "soft" | "marble" | "bright" | "colour-blind"
The sets' default: the one itsutsu.com plays in.
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.
TSUNAGI_STRINGS: Record<TsunagiLanguage, Record<string, string>>
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.
tsunagiBoardLook(board: TsunagiBoardName | TsunagiBoardLook | undefined): TsunagiBoardLook
A board as asked: a ready-made one by name, or a look of your own.
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 = (typeof TSUNAGI_BOARD_NAMES)[number];
type TsunagiColour = readonly [number, number, number];
A colour as hue (0 to 360), saturation and lightness (each 0 to 100).
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 = (typeof TSUNAGI_COLOUR_SET_NAMES)[number];
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 = "marbles" | "lines";
Marbles along every line (the dots), or the line alone.
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 = { 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 = "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.
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 = "colours" | "numbers";
How the pairs are told apart: by colour, or by the number on each pair's marbles.
type TsunagiPoint = { x: number; y: number };
A point in the drawing.
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.
washColour(colour: TsunagiColour, marks: TsunagiMarks): string
The faint wash a line leaves on the cells it runs through.
@johnmorrisdotca/tsunagi/playedgeNudge 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
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.
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.
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.
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.
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.
TSUNAGI_EDGE_STEP: 6
TSUNAGI_FITTED: TsunagiView
The whole board, fitted.
TSUNAGI_MOST_ZOOM: 3
How far a board may be zoomed in, as a multiple of the whole board fitted to its box.
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.
TSUNAGI_ZOOM_FROM: 10
The smallest board a player is given the pad for.
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 = { 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 = { 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 = 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 = { 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.
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/elementTsunagiBoard: typeof TsunagiBoard
@johnmorrisdotca/tsunagi/element/define@johnmorrisdotca/tsunagi/levelsfirstUnsolvedTsunagiLevel isTsunagiLevel LevelRow loadEveryTsunagiLevel loadTsunagiLevels nextTsunagiLevel openTsunagiLevels TSUNAGI_LEVEL_COUNTS TSUNAGI_SIZES tsunagiBand TsunagiBand tsunagiLevelOf tsunagiLevelsOf
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.
isTsunagiLevel(size: number, level: number): boolean
Whether a number is a level this size has.
type LevelRow = readonly [string, string];
One level: its layout and its one answer, each a code (code.ts).
loadEveryTsunagiLevel(): Promise<void>
Every size's levels, loaded.
loadTsunagiLevels(size: number): Promise<readonly LevelRow[]>
A size's levels, loaded once and kept.
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.
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.
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.
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.
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 = "easy" | "medium" | "hard";
The third of a size a level sits in.
tsunagiLevelOf(size: number, givens: string): number | null
The level a layout is, at a loaded size, or null for a layout no level has.
tsunagiLevelsOf(size: number): readonly LevelRow[]
A size already loaded, or a refusal: nothing answers for a list it does not have.
@johnmorrisdotca/tsunagi/levels-4TSUNAGI_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-5TSUNAGI_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-6TSUNAGI_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-7TSUNAGI_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-8TSUNAGI_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-9TSUNAGI_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-10TSUNAGI_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-11TSUNAGI_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-12TSUNAGI_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/marksTSUNAGI_MARKS: Readonly<Record<number, string>>
TSUNAGI_ROLES: Readonly<Record<number, Readonly<Record<number, TwistRole>>>>
@johnmorrisdotca/tsunagi/renumberedTSUNAGI_RENUMBERED TSUNAGI_RENUMBERED_AT
TSUNAGI_RENUMBERED: Readonly<Record<number, readonly number[]>>
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.
この日本語は、まだ日本語を母語とする方の確認を受けていません。訂正を歓迎します。