npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@johnmorrisdotca/tsunagi

v1.4.0

Published

Tsunagi, a line-joining logic puzzle (Flow-style number link) for JavaScript and TypeScript: layouts and answers as short codes, the rules a line keeps, a solver that counts answers, a seeded generator, walls, bridges, waypoints and hexagon boards, a diff

Readme

Tsunagi is the puzzle sometimes called Number Link, Arukone or Flow. It is played at itsutsu.com, which this package was taken out of, and in the demo, with nothing to install.

In 30 seconds

npm install @johnmorrisdotca/tsunagi
import { allJoined, answerOf, checkTsunagiAnswer, decodeLayout, dragThrough, noLines, pressAt } from "@johnmorrisdotca/tsunagi";
import { TSUNAGI_5 } from "@johnmorrisdotca/tsunagi/levels-5";

const [givens, answer] = TSUNAGI_5[0];         // level 1 at 5×5: its layout and its one answer
const layout = decodeLayout(givens, 5)!;        // the marbles, walls, bridges and waypoints, as numbers

let lines = noLines(layout);                     // nothing drawn yet
({ lines } = pressAt(layout, lines, layout.ends[0][0]));   // a finger down on the first marble…
lines = dragThrough(layout, lines, 0, 5);        // …and drawn down a row to cell 5, as a line may go

allJoined(layout, lines);                        // has every pair been joined?
checkTsunagiAnswer(5, givens, answerOf(layout, lines));   // { ok: true } or { ok: false, reason }

And in a page, a level to play, by touch and mouse, with nothing else to set up:

<script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/tsunagi@1/dist/element-define.js"></script>
<tsunagi-board size="6" level="3" marks="numbers" fill="lines" board="wood"></tsunagi-board>

Who it is for

  • Puzzle sites and apps that want Tsunagi with the rules already right: fixed levels everybody plays alike, a check a server can trust in O(cells), and the drawing a finger does (press, drag, let go) as pure functions.
  • Anyone making line puzzles of their own, who wants a solver that counts answers, a generator that makes boards with exactly one, and the twists (walls, bridges, waypoints, a board that wraps, hexagons) to vary them.
  • Pages that just want the board: it draws itself as SVG text (colours or numbers on the marbles, dots or lines, four colour sets, six boards), and plays itself in an element or one function call, with Undo, Restart, Check, Cheat, the zoom pad a big board needs, and its words in English and Japanese.

Features

  • Levels everybody plays alike. Thousands of fixed levels from 4×4 to 15×15 (see Levels), each proved on every build to have exactly one answer, in blocks of sixteen that open one after another. A level keeps its number, so a time on it can be compared with anybody's.
  • A level of the day, the same for everybody, from the date alone: dailyTsunagiLevel(size, date). No server, no seed.
  • A check a server can trust. checkTsunagiAnswer reads a finished answer in O(cells), with no search, and says the first thing wrong.
  • A solver that counts answers, and a seeded generator that makes boards with exactly one, with the twists: walls, bridges, waypoints, wrap, hexagons, few lines, explosions and a stroke limit.
  • A difficulty measure, so a level has a mark from 1 to 5 and the levels of a size run easiest first.
  • Drawn as SVG text, in an entry of its own: colours or numbers on the marbles, dots or lines, four colour sets, six boards, bridges drawn as bridges. A server that only checks answers never loads it.
  • Played in any page by touch and mouse, with Undo, Restart, Check, Cheat and the zoom pad a big board needs, as one function call (mountTsunagi) or one tag (<tsunagi-board>).
  • Games as short strings: a layout, an answer, a game half drawn, each a code a database column can keep.
  • English and Japanese, in the board's words and the demo.
  • No dependencies, no network requests, no sound, and nothing stored outside the page it is in.

Use it in your project

Tsunagi is three things, each usable without the others: the puzzle (rules, solver, generator and levels, as plain functions over strings), the drawing (SVG text), and the page (a mounted board or a tag). The table under Levels says which entry holds which. The examples are at 6×6.

1. The API alone, on a server

import { checkTsunagiAnswer, dailyTsunagiLevel } from "@johnmorrisdotca/tsunagi";
import { TSUNAGI_6 } from "@johnmorrisdotca/tsunagi/levels-6";

const today = dailyTsunagiLevel(6, new Date());   // the level of the day at 6×6: 1 to 256
const [givens, answer] = TSUNAGI_6[today! - 1];     // send `givens` to the browser; keep `answer`
checkTsunagiAnswer(6, givens, answerFromThePlayer); // { ok: true } or { ok: false, reason }, in O(cells)

Importing the main entry on a server is safe: it touches no page.

2. One tag, no bundler

<script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/tsunagi@1/dist/element-define.js"></script>
<tsunagi-board size="6" level="3"></tsunagi-board>
<script>
  document.querySelector("tsunagi-board").addEventListener("tsunagi-solve", (event) => console.log(event.detail.answer));
</script>

3. A bundler, and a framework

import "@johnmorrisdotca/tsunagi/element/define" once, in code that runs in the browser, and <tsunagi-board> is a tag like any other. The tag draws itself in the page's own DOM, so the page's CSS reaches it. Its attributes are read again when they change, and it speaks through DOM events (tsunagi-change, tsunagi-stroke, tsunagi-explosion, tsunagi-solve) that carry a detail.

// React 19
import { useEffect, useRef } from "react";
import "@johnmorrisdotca/tsunagi/element/define";

export function Level({ size, level, onSolved }) {
  const board = useRef(null);
  useEffect(() => {
    const listen = (event) => onSolved(event.detail.answer, event.detail.helped);
    board.current?.addEventListener("tsunagi-solve", listen);
    return () => board.current?.removeEventListener("tsunagi-solve", listen);
  }, [onSolved]);
  return <tsunagi-board ref={board} size={String(size)} level={String(level)} />;
}
<!-- Vue 3: tell the compiler the tag is not a Vue component -->
<script setup>
import "@johnmorrisdotca/tsunagi/element/define";
defineProps({ size: Number, level: Number });
</script>
<template>
  <tsunagi-board :size="size" :level="level" @tsunagi-solve="(event) => console.log(event.detail.answer)" />
</template>
<!-- in vite.config: vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("tsunagi-") } } }) -->
<!-- Svelte 5 -->
<script>
  import "@johnmorrisdotca/tsunagi/element/define";
  let { size, level } = $props();
  let board;
  $effect(() => {
    const listen = (event) => console.log(event.detail.answer);
    board.addEventListener("tsunagi-solve", listen);
    return () => board.removeEventListener("tsunagi-solve", listen);
  });
</script>
<tsunagi-board bind:this={board} size={size} level={level}></tsunagi-board>
// Angular: a standalone component with CUSTOM_ELEMENTS_SCHEMA
import { Component, CUSTOM_ELEMENTS_SCHEMA } from "@angular/core";
import "@johnmorrisdotca/tsunagi/element/define";

@Component({
  selector: "app-level",
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<tsunagi-board size="6" level="3" (tsunagi-solve)="solved($event)"></tsunagi-board>`,
})
export class Level {
  solved(event: Event) { console.log((event as CustomEvent).detail.answer); }
}

In Next.js or any server-rendering framework, import the define entry from a client component, so the tag is defined in the browser. Or skip the tag and call mountTsunagi(element, options) from @johnmorrisdotca/tsunagi/play in an effect: the handle it returns has destroy().

pnpm test:frameworks builds these recipes from the packed tarball in a scratch project for each of the five and plays a level to its end in Chromium and WebKit; it needs the network and a few minutes, so it is run before a release and in CI rather than with pnpm check.

What a developer gets

  • Typed results, with a doc comment on every export. Every function is pure and returns new values.
  • No dependencies. ES modules, an entry per concern, and sideEffects set so that only the define entry has an effect.
  • Where it runs. See Browser support.

The puzzle

Each letter in a layout is a marble, and each marble has one partner. A line joins the two, stepping from cell to cell, never crossing another line, and when every pair is joined every cell is filled. A level has exactly one answer.

  • Walls between two cells, and blocked cells, that no line may pass.
  • Bridges, crossed straight across by one line and straight down by another.
  • Waypoints, a cell a pair's own line must pass through.
  • Wrap, a board whose edges join, so a line leaving one side comes back on the other.
  • Hexagons, a board of six-sided cells, six ways round.
  • Sparse boards, with fewer marbles and more room, and explosions and strokes, a limit on how a board may be drawn.

Drawing a board

import { decodeLayout, linesOfAnswer } from "@johnmorrisdotca/tsunagi";
import { drawTsunagi, TSUNAGI_STYLE } from "@johnmorrisdotca/tsunagi/draw";

const layout = decodeLayout(givens, 7)!;
const svg = drawTsunagi(layout, { lines: linesOfAnswer(layout, answer)!, marks: "numbers", fill: "lines", board: "wood" });

drawTsunagi returns SVG text: put it in a page, a file or an image, with nothing to load. It draws what itsutsu.com draws. Marbles on a board; each line a thick rounded stroke through its cells' middles; every cell a line runs through washed faintly in its colour and, with fill: "marbles", holding a small marble too, so a finished board is a board of marbles joined by their lines. Walls are bars on the edge between two cells and blocked cells are dark squares. 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 faded ghost of the far edge all round it and a dashed rim, and a line across the join leaves by one edge and comes in by the other. A hexagon is a honeycomb of hexagons, in the same square box as every board.

| Option | Values | What it does | | --- | --- | --- | | lines | Lines | the lines drawn so far; none, if left out | | marks | colours (default), numbers | tell the pairs apart by colour, or by the pair's number on a plain shell marble with every line in a soft tint | | fill | marbles (default), lines | a small marble (the dots) in every cell a line runs through, or the line alone | | colours | marble (default), bright, colour-blind, soft, or your own [hue, saturation, lightness][] | each pair's colour; a set of your own is used round and round | | board | paper (default), wood, green, blue, red, black, or a TsunagiBoardLook | the paper, the frame, the rules and the ink of walls and bridges; paper takes its colours from the page's light or dark | | coordinates | boolean | row numbers and column letters down the sides (not on a board that wraps or a hexagon) | | ghosts | boolean, default true | the ghost of the far edge round a board that wraps | | flagged | pairs | the marbles of these pairs flash: what Check found not joined | | blasted | cells | each bursts: where an explosion took a line out | | done | boolean | a faint wash of green, and data-solved="true" | | language | en (default), ja | what a screen reader hears | | label, style, id | | a description instead of the size; style: true puts TSUNAGI_STYLE inside so the drawing stands alone as an image; the prefix of the ids in the drawing, made from the drawing itself if left out so two never share one |

A custom board is a look of colours: { paper: "#fbf8f1" | ["#f0cf95", "#d3a662"], frame, grid, ink, coordinate }. Every colour is also 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 sets only what it wants different. The parts carry classes and data attributes to style or find them: tsu-marble (data-pair, data-cell), tsu-bead, tsu-line (data-pair, data-cells), tsu-bridge, tsu-over-bridge, tsu-wall (data-edge), tsu-waypoint, tsu-flag, tsu-blast, tsu-hex-cell. Nothing in the drawing can be selected, dragged or double-tapped into a selection, and with reduced motion asked for nothing moves. drawTsunagiCode(givens, size, options) draws a level from its code, and drawTsunagiMarble(pair, options) one marble for a legend. tsunagiGeometry(layout) and cellAtPoint(geometry, x, y) say where every cell is in the drawing and which cell a point is over, so a page of your own can play it.

Playing it in a page

import { mountTsunagi } from "@johnmorrisdotca/tsunagi/play";

const board = mountTsunagi(document.getElementById("here")!, {
  size: 7, givens, answer, level: 12,         // a level; `level` shows its difficulty and its place in its block
  marks: "numbers", fill: "lines", colours: "colour-blind", board: "wood",
  cheats: true, explosions: "soft", chips: true,
  onSolve: ({ answer, helped }) => send(answer, helped),   // `answer` is what checkTsunagiAnswer takes
});
board?.undo(); board?.check(); board?.load({ size: 7, givens: other, answer: otherAnswer });

It plays the way the site does. Press a marble (or the end of a line) and drag to its partner; drag back over a line to shorten it, cell by cell; tap a marble to clear its line; a line dragged into another cuts the other back. Pointer events, captured on the press so a drag that leaves the board still ends, with touch-action: none so a finger drawing a line never scrolls the page. A big board (10×10 up) is looked at through a box with a zoom and move pad, the wheel, and the box's edge, which moves the view while a line is dragged near it. The box keeps one steady square, and the lines of words under it keep the room they need, so nothing moves as lines are drawn or messages come and go.

Under the board, unless controls: false: Undo, Restart, Check (the marbles of pairs not joined flash, and it says how many) and, if cheats is on and the level has an answer, Cheat, which draws one unfinished line and marks the solve helped; a line of progress; and the lines a level's twists ask for: strokes left of a limit, the count to the next explosion, what the last explosion did. chips adds a row with the level's difficulty (1 to 5) and a chip for each challenge on it, pressed to say what it means. Everything a button does is also a method on the handle (undo, restart, check, cheat, fit, load, set, destroy).

| Option | What it does | | --- | --- | | size, givens, answer, level | the level; answer is needed for Cheat and makes a solve the stored answer | | lines | lines to start from: a kept game (decodeLines) or a solved board to show | | marks, fill, colours, board, coordinates | the look, as for drawTsunagi; change them with set and they take effect at once | | explosions | on (default), soft (a boom for a blast, half as often) or off; a solve with either help is helped | | cheats | offer Cheat | | controls, chips, zoom | the buttons and words (default on), the chips (default off), and the pad: auto from 10×10, on, off | | language | en or ja; left out, the host's lang or the page's, and it follows the page's | | onChange, onStroke, onExplosion, onSolve | callbacks, and the same four as DOM events on the host: tsunagi-change, tsunagi-stroke, tsunagi-explosion, tsunagi-solve. Each detail has lines, code (to keep the game), progress, answer and helped |

Ctrl or Cmd with Z undoes. The rules are game.ts's, which are pure and need no page, so a server can replay a game's strokes.

The element

<script type="module" src="https://cdn.jsdelivr.net/npm/@johnmorrisdotca/tsunagi@1/dist/element-define.js"></script>
<tsunagi-board size="6" level="3"></tsunagi-board>
<tsunagi-board size="5" givens=".A..." answer="..." marks="numbers" fill="lines" colour-set="soft" board="green" coordinates cheats chips></tsunagi-board>

Or import "@johnmorrisdotca/tsunagi/element/define" in a bundle. Attributes, each read again when it changes: size with level (the package's own levels, fetched when asked) or with givens and answer; marks (colours or numbers); fill (marbles or lines); colour-set (marble, bright, colour-blind, soft); board (paper, wood, green, blue, red, black); coordinates; explosions (on, soft, off); cheats; controls="off"; chips; zoom; progress (a code from an event, to carry on a kept game); lang. A look changes at once without starting a new game; the level, explosions and cheats start it again. It fires the four events above and has the methods undo(), restart(), check(), cheat() and fit(). Importing either entry on a server is safe.

Levels

import { loadTsunagiLevels, openTsunagiLevels, TSUNAGI_LEVEL_COUNTS } from "@johnmorrisdotca/tsunagi/levels";

const sevens = await loadTsunagiLevels(7);       // 256 levels, easiest first; only 7×7 is fetched
openTsunagiLevels(7, new Set([1, 2, 3]));         // 16: the first block of sixteen is open from the start

| Size | Levels | Size | Levels | Size | Levels | | --- | --- | --- | --- | --- | --- | | 4×4 | 192 | 7×7 | 256 | 10×10 | 128 | | 5×5 | 256 | 8×8 | 256 | 11×11 | 64 | | 6×6 | 256 | 9×9 | 256 | 12×12 | 128 | | 13×13 | 128 | 14×14 | 128 | 15×15 | 128 |

Every level was made once, by scripts/tsunagi-levels.ts (4×4 to 12×12) or scripts/tsunagi-levels-big.ts (13×13 to 15×15), and is proved again on every build: solved from scratch, it must have exactly one answer, the one stored, filling every cell, with no two levels the same board turned or mirrored. Levels come in blocks of sixteen, each block no easier on average than the one before, the fifteenth and sixteenth its twist; a block opens once every level of the one before is solved. TSUNAGI_MARKS (@johnmorrisdotca/tsunagi/marks) rates every level 1 to 5 from its measured difficulty.

| Import | What it holds | | --- | --- | | @johnmorrisdotca/tsunagi | the rules, the solver, the generator, the levels' counts, and game.ts's pure play functions: everything but the boards, the drawing and the page | | @johnmorrisdotca/tsunagi/draw | drawTsunagi and the rest of the drawing as SVG text, the colour sets, the boards, the style, and where everything sits in the drawing; no page needed | | @johnmorrisdotca/tsunagi/play | mountTsunagi: a level played in any element by touch and mouse, with its buttons, words, zoom pad and events | | @johnmorrisdotca/tsunagi/element | the TsunagiBoard class behind <tsunagi-board>, to extend or to define under another name | | @johnmorrisdotca/tsunagi/element/define | defines <tsunagi-board> on the page, for its effect | | @johnmorrisdotca/tsunagi/levels | loadTsunagiLevels(size), loadEveryTsunagiLevel(), tsunagiLevelsOf(size), tsunagiLevelOf(size, layout), each size fetched only when loaded | | @johnmorrisdotca/tsunagi/levels-4 … /levels-15 | one size's levels, TSUNAGI_4 … TSUNAGI_15, as [layout, answer] pairs | | @johnmorrisdotca/tsunagi/marks | TSUNAGI_MARKS (each level's 1 to 5) and TSUNAGI_ROLES (each twist level's part in its block) | | @johnmorrisdotca/tsunagi/renumbered | where each old level went when the levels were renumbered on 2026-09-26, for anyone who stored solves by number |

The level of the day

import { dailyTsunagiLevel, tsunagiDay } from "@johnmorrisdotca/tsunagi";

dailyTsunagiLevel(7, new Date());      // a level number, 1 to 256: today's at 7×7
dailyTsunagiLevel(7, "2026-10-01");    // the same level for that day, from its text
tsunagiDay(new Date());                // "2026-10-01": the day, counted in UTC

The levels are fixed, so the level of the day needs no seed and no server: it is a pure function of the date and the size, the same for everybody on every machine, which is what lets two people compare a time on it. A day is counted in UTC, so it turns over at the same moment worldwide. Each size has a level of its own, and every level of a size comes up once before any comes up again (the size's count of levels, in days). It ignores which blocks a player has opened: today's level is open to everybody. The demo's Today button opens it.

API

The API reference lists every export of every entry point with its signature and its doc comment. It is made from the source by pnpm site, so it cannot fall behind the code.

| Export | What it does | | --- | --- | | decodeLayout(code, size), encodeLayout(cells, walls, more) | a layout's code and the LinkLayout it stands for: marbles (ends), cells, walls, waypoints, wrap, hexagon | | encodeAnswer(owners), answerOf(layout, lines), linesOfAnswer(layout, answer) | an answer as a code: a letter for each cell's line, # blocked, + a bridge | | checkTsunagiAnswer(size, layout, answer) | whether an answer joins every pair as the rules allow, in O(cells); { ok: true } or the first reason it does not | | noLines, pressAt, dragTo, dragThrough, letGo | drawing, as a finger does it: each takes the lines drawn so far and returns new ones | | joined, allJoined, unjoinedPairs, filled, ownersOf | what the lines drawn so far amount to | | encodeLines, decodeLines | lines half drawn, as a code, to keep a game and come back to it | | countSolutions(layout, limit, budget) | counts answers up to limit, within a budget of search steps (dead ends, from 13×13), and returns one | | countSolutionsSat(layout, limit, budget, guide) | the same count made by SAT, a solver that learns from its dead ends: what proves every 13×13 to 15×15 level, and what countSolutions is from 13×13 | | candidate, repairedCandidate, sparseCandidate, layoutOf | a new board from a seeded Random: a random filling of lines, cut back to its ends | | bridgeCandidate, wallCandidate, waypointCandidate, wrapCandidate, hexCandidate, bridgeAndWallCandidate | a board with a twist | | measureLevel, difficultyScores, orderByDifficulty | how hard a board is: corners, guessing, cells not forced, its longest line | | challengesOf, isTwist, twistRole, tsunagiMarks | what a layout asks of a player, and a level's mark | | transformed, relettered, symmetryKey | a board turned, mirrored and relettered, and one key for all eight | | cheatLine(layout, lines, answer) | one line of the answer drawn in, for a player who asks for help | | newTsunagiGame, pressGame, dragGame, liftGame, undoGame, restartGame, checkGame, cheatGame | a game in play as pure functions: lines, strokes, Undo, explosions, a stroke limit, Check and Cheat; each returns a new game | | tsunagiProgress, helpOf, helpOpensNext, strongestTsunagiHelp | what a game stands at, and which help (Cheat, softened or no explosions) a solve used and what that costs | | seededRandom(seed) | the mulberry32 stream every generator draws from | | openTsunagiLevels, nextTsunagiLevel, firstUnsolvedTsunagiLevel, tsunagiBand | which levels a player may open, which comes next, and which third of a size a level is in | | dailyTsunagiLevel(size, date), tsunagiDay(date), isTsunagiDay(text) | the level of the day at a size, from the date alone; a date as YYYY-MM-DD in UTC; whether a text is a real one |

Every function is pure: it returns new values and never changes what it was given.

Theming

Nothing here is branded. The drawing and the playable board are coloured by custom properties, and a page sets only the ones it wants different. The plain paper board follows the device's light or dark setting; data-theme="light" or "dark" on <html> forces one. The other boards (wood, green, blue, red, black) carry their own colours in both. The marbles' colours are not properties: they are a colour set, or one of your own.

The drawing (drawTsunagi), custom properties on .tsunagi; the dark values are those of the paper board:

| Property | What it colours | Light | Dark | | --- | --- | --- | --- | | --tsu-paper | the top of the board's paper | #fbf8f1 | #262a27 | | --tsu-paper-deep | the foot of the paper (the same, unless the board shades) | #fbf8f1 | #262a27 | | --tsu-frame | the frame round the board | #a98954 | #6b5632 | | --tsu-grid | the thin lines between cells | #cfc6b2 | #3f443f | | --tsu-ink | walls, blocked cells, bridges and the board's rim | #1f2320 | #ece8dc | | --tsu-coordinate | the row numbers and column letters | #5b3d1c | #e8d3b6 | | --tsu-shu | the burst where an explosion took a line out | #d9381e | the same | | --tsu-good | a solved board's wash | #2f7a4f | the same | | --tsu-font | the numbers' type | the system's own | the same |

The playable board (mountTsunagi and <tsunagi-board>) wears the drawing's properties, and six of its own on .tsunagi-play:

| Property | What it colours | Light | Dark | | --- | --- | --- | --- | | --tsp-ink | text and a pressed button | #1f2320 | #ece8dc | | --tsp-muted | the lines of words under the board | #6b6f68 | #a09d93 | | --tsp-rule | borders | #ddd6c6 | #3a3d38 | | --tsp-surface | the buttons and chips | #fbf8f1 | #1d201e | | --tsp-accent | a warning in the words under the board | #b5452c | #ff8a6b | | --tsp-good | the progress line once the level is solved | #2f7a4f | #6fcf97 |

tsunagi-board, .tsunagi, .tsunagi-play { --tsu-ink: #2b2118; --tsp-accent: #8a1c1c; }

The demo's own page is the worked example: its green felt and its cloth patches are the family's stylesheet, demo/family.css, which is the same file byte for byte in every sibling's demo, and a test holds it to its hash. The drawing's parts carry classes and data attributes for anything a property cannot reach: see Drawing a board.

Limits

All of these are held by tests, and the ones with a name are exported.

| Limit | Value | Where | | --- | --- | --- | | Sizes | 4×4 to 15×15, one side of a square | TSUNAGI_SIZES | | Levels | each size's own, in blocks of sixteen | TSUNAGI_LEVEL_COUNTS, TSUNAGI_BLOCK | | Pairs on a board | sixteen, one letter each, A to P | PAIR_LETTERS | | Boards with a few lines | at most two thirds of the side | sparseMost(size) | | Answers counted | two, so that "many" costs no more than "two" | the limit argument of countSolutions | | The solver's work | none unless you give a budget; it is dead ends from 13×13 and search steps below | the budget argument of countSolutions and countSolutionsSat | | Which solver | the search below 13×13, SAT from 13×13 | SAT_FROM_SIZE | | The zoom pad | from 10×10, up to 3 times | TSUNAGI_ZOOM_FROM, TSUNAGI_MOST_ZOOM | | A day | YYYY-MM-DD, counted in UTC | isTsunagiDay |

A generator never runs on a server unless you ask it to. The check never searches: it is linear in the size of the board.

Browser support

Any browser with ES2020 modules, custom elements, pointer events and CSS aspect-ratio: Chrome and Edge 88, Safari 15, Firefox 89, all from 2021 on. The element draws in the page's own DOM, with no shadow DOM and no CSS the page cannot reach. The demo is played in a real Chromium at a phone's width (with touch) and a desk's, and in WebKit, Safari's engine, at a phone's width; Firefox is not in that run. The package itself (everything but the drawing and the page) needs no DOM: it runs in Node 22 or later (CI tests 22 and 24). Deno and Bun are not tested.

Languages

English and Japanese, chosen by the language option, the host's lang or the page's, and followed when the page's lang changes. The demo has a chooser of its own and takes the browser's language on a first visit. The board's words (TSUNAGI_STRINGS, read with tsunagiSay) are in both. Japanese: included; not yet reviewed by a native reader. Corrections welcome. Every string is listed beside its English in docs/strings-ja.md, and there is an issue template for fixing one. Any other language is a table of your own, passed beside these two.

Roadmap

Not here yet, and each welcome as an issue:

  • Drawing a line from the keyboard. A line is drawn by pointer today; Ctrl or Cmd with Z undoes, and the drawing is described to a screen reader, but the cells cannot be walked with keys.
  • A command line: check an answer, count a board's answers, and print a level as text.

Left out on purpose: levels made from a seed when the page opens, because a fixed level is what lets times be compared; and any account, ranking or storage. A page keeps its own games: the events hand them over.

Making levels

node scripts/tsunagi-levels.ts 7              # 7×7 again: its twists placed, its marks measured, every board kept at its number
node scripts/tsunagi-levels.ts --grow 10      # 10×10 grown to whole blocks of sixteen and reordered, easiest first

The script finds boards with a seeded generator, keeps those the solver proves have one answer, drops any that is another turned or mirrored, measures and orders them, and writes the size's file, its marks and its twists. Seeded, so the same run writes the same files. A board already published keeps its number unless a size is grown, and then /renumbered says where each old level went.

13×13 to 15×15 are made in two steps, because their boards are found by the thousand on every core of a desk: node scripts/tsunagi-pool.ts 15 plain 300 runs seeded jobs in parallel (plain, or a twist: bridge, walls, wrap …) and keeps each board proved to have one answer and measured, and node scripts/tsunagi-levels-big.ts takes the jobs recorded in it and writes the files. A job's boards depend only on its number, never on the machine. The plain boards of 13×13 and 14×14 take seconds and 15×15's about six minutes on twenty cores; the twist boards take longer, about an hour in all. Boards from 13×13 up have at most sixteen lines, the most colours there are, and are proved by countSolutionsSat: 12×12 was the ceiling until a solver that learns from its dead ends replaced the one that walks into them again.

Architecture

The rules, the solver, the generator and the game in play are plain functions over short codes, with no DOM. The drawing is SVG text in an entry of its own, so a server that only checks an answer never loads it, and the page's part (the mount and the element) is another. Each size's levels is an entry of its own, so a page loads only the size it shows.

src/
├── index.ts          the main entry: everything but the levels, the drawing and the page
├── code.ts           layouts and answers as short codes, and the board each stands for
├── steps.ts          where a line may go next on a board: walls, bridges, wrap, hexagons
├── lines.ts          the lines a player has drawn, and what a press and a drag do to them
├── check.ts          whether an answer joins every pair as the rules allow
├── solve.ts          the solver, which counts a board's answers up to a limit (4×4 to 12×12)
├── solveSat.ts       the same count by SAT, for 13×13 and above: the board written as clauses
├── sat.ts            a small SAT solver: clause learning, restarts, clauses added between solves
├── generate.ts       new boards from a seed: lines laid at random, cut back to their ends
├── twists.ts         boards with a twist: walls, bridges, waypoints, wrap, hexagons
├── sparse.ts         sparse boards: few marbles and long lines
├── explosions.ts     explosions that break a line, and a limit on strokes
├── difficulty.ts     how hard a level is, measured from its board and its answer
├── ladder.ts         what a level asks of a player, read from its board
├── ladder.types.ts   the challenges a board can have
├── cheat.ts          one line of the answer drawn in, for a player who asks for help
├── game.ts           a game in play as pure functions: strokes, Undo, explosions, Check, Cheat, help
├── levels.ts         the "/levels" entry: each size's levels, loaded when asked
├── levelCounts.ts    how many levels each size has
├── daily.ts          the level of the day at a size, from the date alone
├── levelBlocks.ts    levels in blocks of sixteen, and which a player may open
├── renumber.ts       a record kept by level number, moved to the numbers levels have now
├── levels.suite.ts   the proof each size's levels test runs: one answer, the one stored
├── random.ts         the seeded random numbers every board is made from
├── draw-entry.ts     the "/draw" entry: the drawing, its colours and boards, and where everything sits
├── draw.ts           a board as SVG text: marbles, lines, walls, bridges under and over, wrap, hexagons
├── geometry.ts       where every cell is in the drawing, and which cell a point is over
├── colours.ts        the colour sets, and how a colour is shaded for a marble, a line and a wash
├── boards.ts         the boards a drawing sits on: paper, wood and four felts, or a look of your own
├── style.ts          the drawing's style: its colours as custom properties, a flash and a burst
├── strings.ts        the words, in English and Japanese, for a screen reader and for the board's buttons
├── play-entry.ts     the "/play" entry: a level played in any element
├── mount.ts          mountTsunagi: draws a level into an element and plays it by touch and mouse
├── viewport.ts       the arithmetic of zooming and moving a big board through its box
├── playStyle.ts      the style of a playable board: its box, buttons, words and zoom pad
├── element.ts        the "/element" entry: the <tsunagi-board> class
├── element-define.ts the "/element/define" entry: defines the tag on the page
├── version.ts        the package's version
└── levels/
    ├── size4.data.ts       the 4×4 levels, each a layout and its one answer
    ├── size5.data.ts       5×5
    ├── size6.data.ts       6×6
    ├── size7.data.ts       7×7
    ├── size8.data.ts       8×8
    ├── size9.data.ts       9×9
    ├── size10.data.ts      10×10
    ├── size11.data.ts      11×11
    ├── size12.data.ts      12×12
    ├── size13.data.ts      13×13
    ├── size14.data.ts      14×14
    ├── size15.data.ts      15×15
    ├── marks.data.ts       every level's difficulty, 1 to 5, and each twist's part in its block
    └── renumbered.data.ts  where each old level went when the levels were renumbered

Tests sit beside the code they test (*.test.ts, one levels.<size>.test.ts a size). scripts/ makes the levels and the twists, builds the demo and its API reference page and checks the package as npm packs it; demo/ is the playable page, and e2e/ its browser tests.

The name

Tsunagi (繋ぎ) is Japanese for "joining", "a link": what holds two things together. It comes from the verb tsunagu (繋ぐ), to tie, to connect, to hold hands, and is said in three beats, tsu-na-gi. In the puzzle every pair of marbles is joined by its own line, and the lines together fill the board.

Where it comes from, and where it is used

Tsunagi was built for Itsutsu, a site for board games, puzzles, card games and dice games played at your own pace. Itsutsu (五つ) is Japanese for "five", after five in a row, the game the site began with. The line-joining puzzle was made there, level by level, each proved to have one answer and each checked on a server in O(cells); once it stood alone it seemed worth sharing.

Used by

  • Itsutsu, for its Tsunagi puzzle, every level and the check.

Using Tsunagi in something? Open an Add my project issue and we will add you.

The family

Tsunagi is one of nineteen packages, each made for the same site, each at github.com/johnmorrisdotca. The code of every one is MIT.

  • Korokoro (コロコロ): dice, with notation, exact odds, real sounds and the dice of many games. Demo.
  • Kyuubu (キューブ): a turning cube for the browser, 2×2 to 7×7, with record solves to replay. Demo.
  • Hitotsu (一つ): a colour-card shedding game for two to eight, with the house rules people play. Demo.
  • Toranpu (トランプ): a deck of playing cards, card games with computer players, and solitaires. Demo.
  • Tane (種): seeded random numbers and daily seeds, the same in every browser and on every server. Demo.
  • Narabe (並べ): one rules engine for abstract board games, from gomoku and Reversi to Go and checkers. Demo.
  • Tenka (天下): world conquest for two to six, on a map of the real world. Demo.
  • Kumimoji (組み文字): a crossword tile race, in English and Japanese kana. Demo.
  • Tsunagi (繋ぎ): a line-joining logic puzzle whose every level has exactly one answer. Demo.
  • Jarajara (ジャラジャラ): mahjong tiles drawn as SVG, stacked layouts, and the matching solitaire Awase. Demo.
  • Suido (水道): a pipe puzzle: turn the pieces until the water reaches every drain. Demo.
  • Domino (ドミノ): dominoes and Mexican Train. Demo.
  • Kotoba (言葉): word lists and word-game rules in English, French, German and Japanese. Demo.
  • Sugoroku (双六): backgammon and its variants, with the doubling cube and match play. Demo.
  • Kazu (数): grid number puzzles: Sudoku and its variants, Futoshiki and Skyscrapers. Demo.
  • Meikyuu (迷宮): mazes on squares, hexagons, triangles and circles, made from a seed and drawn through with a finger or the mouse. Demo.
  • Hikidashi (引き出し): a drawer of small Japanese text tools: era dates, kanji numerals, readings and sentence difficulty. Demo.
  • Chizu (地図): maps of the world and of countries' regions, in English and Japanese, with a quiz and callouts. Demo.
  • Bushu (部首): find a kanji by the parts it is made of. Demo.

This package is Tsunagi. The demos of all nineteen share one header and footer, so each links the rest.

Development

pnpm install
pnpm check          # lint, types and every test, every level proved again
pnpm test:package   # pack, install and import it as somebody who installed it would
pnpm test:demo      # build the demo and play it in a real browser, at a phone's width and a desk's
pnpm site           # build the demo into site/, as the Pages workflow publishes it
pnpm test:frameworks  # the README's React, Vue, Svelte, Angular and plain-page examples, built from the tarball and played (needs the network)
pnpm docs:make      # rewrite docs/strings-ja.md after changing a word of the board
pnpm pictures       # take the README's two pictures from the built demo

Contributing

See CONTRIBUTING.md. The commands are under Development.

Please follow the code of conduct. A way to make the check or the solver run for long, or markup that gets out of the drawing, is for the security policy, not a public issue.

Changes

See CHANGELOG.md.

Licence

MIT, © John Morris. The levels are part of the package and under the same licence.