sokoban-tui
v0.1.0
Published
A Sokoban puzzle game for your terminal, with 245 classic levels
Maintainers
Readme
sokoban-tui
A Sokoban puzzle game for your terminal, with 245 classic levels.
Built with Ink — colored block tiles, full-screen alternate buffer, undo/redo, saved progress, a built-in solver for hints, and deadlock warnings.
Microban · level 37 of 155 · #37 overall
╭──────────────────────────────╮
│ ████████████ │
│ ██·· ████ │
│ ██ ▨▨ ██ │
│ ████ ▨▨ ██ │
│ ██ ●● ··██ │
│ ██████████ │
╰──────────────────────────────╯
moves 42 pushes 11 boxes 1/2 best 38/9
←↑↓→ hjkl move u undo r restart i hint esc menu ? helpInstall
Requires Node 22 or newer (Ink 7 is ESM-only and needs it).
npm install -g sokoban-tui
sokobanOr run it without installing:
npx sokoban-tuiThe published package is two bundled files with zero runtime dependencies — 828 KB installed, no dependency tree to resolve.
From source
npm install
npm run dev # play straight from source
npm run build # produce bundle/
npm link # then `sokoban` runs the bundleControls
| Key | |
| --- | --- |
| ← ↑ ↓ → / h j k l | Walk. Walking into a box pushes it. |
| u / U | Undo / redo |
| r / R | Restart level / undo a restart |
| i | Hint — press again for more, again to play it |
| z | Compact mode (half-width tiles) |
| esc | Pause menu |
| ? | Help |
| q | Quit |
Options
sokoban --level 42 Start at level 42 (1-245 overall)
sokoban --pack original microban | original
sokoban --ascii Plain ASCII tiles (# $ * @) instead of blocks
sokoban --no-color Disable colour (also honours NO_COLOR)
sokoban --no-save Play without writing progress
sokoban --config-dir <path> Override where settings and progress live
sokoban --credits Print level attributionProgress and settings are stored in $XDG_CONFIG_HOME/sokoban-cli (which is
~/.config/sokoban-cli on macOS).
The levels
| Pack | Levels | Author | | --- | --- | --- | | Microban | 155 | David W. Skinner | | Original & Extra | 90 | Thinking Rabbit (1982) |
Microban comes first because Skinner designed it as a gentle on-ramp; the Thinking Rabbit classics follow. See NOTICE.md for attribution and licensing — the level data is not covered by this project's MIT licence.
Levels are regenerated from the cached upstream files with:
npm run levels:build # parse, validate, emit src/levels/generated/
npm run solutions:bake # precompute solutions used by the hint systemHow it works
A few decisions worth knowing if you're reading the source.
The engine is pure. src/core/ and src/render/ import no React, no Ink,
and no node:fs. Rules, board painting, and search are functions over plain
data, unit-tested with no terminal involved.
Terrain is split from state. A compiled level holds immutable typed arrays for walls and goals; only the player position and ~30 box indices change per move. The grid is padded with a wall border and everything outside the reachable region is sealed, so the move hot path needs no bounds checks.
Undo is two bits per move. A move is (direction, was-it-a-push), and the
inverse is fully determined by that, so history never stores boards. It also
serializes to standard LURD notation for free, which is what powers replay,
stored best runs, and the baked solutions.
The board is painted to strings, not components. One Ink <Text> per row
carrying pre-built ANSI, rather than a component per tile — a <Box> per tile
would create a Yoga layout node per cell. Ink measures text through
string-width, which strips ANSI first, so embedded colour is zero-width and
this is a supported path rather than a trick.
Effects are data. An effect is a pure function of (elapsed, cell) →
emphasis that writes into a per-cell buffer the painter already consults, so
adding one never touches the React tree. The shared animation timer only runs
while something is actually animating.
One component owns the keyboard. Ink's useInput fires in every mounted
component, which silently breaks overlays. Screens and overlays register with a
focus-layer stack (src/ui/input/) and only the topmost one is active. Raw
useInput is confined to useKeyLayer.ts; there is a regression test that
opens the pause menu, presses an arrow, and asserts the board did not move.
The solver searches push space. Nodes are box configurations with the player normalized to the lowest cell it can reach, which collapses all non-pushing wandering into one node. Two things earn their keep: a greedy box→goal matching heuristic (summing each box's distance to its nearest goal badly underestimates when boxes compete for one goal room — adding matching took Original #1 from "unsolved at 300k nodes" to "solved in 23k"), and pruning any push that freezes a box off-goal.
What the hint system can and can't do
Solutions are precomputed offline (npm run solutions:bake) and shipped in
src/levels/generated/solutions.ts. Current coverage:
| Pack | Solved offline | | --- | --- | | Microban | 152 / 155 | | Original & Extra | 7 / 90 |
Microban is where hints genuinely work — those boards have ≤6 boxes and solve in milliseconds, live as well as baked. The Original set mostly does not: those levels have 10–30 boxes packed into a single goal room, and cracking them needs corral pruning and tunnel macros, which is research-grade work this solver deliberately doesn't attempt. All 245 levels are published, human-verified puzzles and are solvable — the solver just can't prove it for most of the classics.
So the hint UX degrades in a defined order: use the baked line if the player is still on it (instant), otherwise search live for 5s with a cancellable spinner, otherwise say plainly that it found nothing rather than pretending. Pruning soundness is tested — every baked solution is replayed and asserted never to pass through a position the search would have discarded.
Development
npm test # 113 tests
npm run typecheck
npm run build # esbuild -> bundle/cli.js + bundle/worker.js
npm run smoke # exercise the shipped bundle, isolated from node_modulesnpm run build produces the artifact that ships. Three details in
scripts/bundle.mjs were each found by the bundle breaking, and are commented
there: react-devtools-core must be stubbed (it is an uninstalled optional
peer of ink), a createRequire banner is needed because a CJS dependency calls
require() at runtime, and the solver worker must stay a separate file so
new URL('./worker.js', import.meta.url) still resolves. Do not add a shebang
to the banner — esbuild already carries over the one from src/cli.tsx.
The test suite covers the rules (including the object-identity invariant that
an illegal move returns the same state, which is what lets React.memo skip
re-renders), the full 245-level corpus, row widths across every zoom and glyph
set, persistence corruption handling, the solver, and the UI input routing.
Note on imports:
moduleResolutionisnode16, so every relative import needs a.jsextension even in.tssource —import { step } from './core/rules.js'. This trips everyone up once.
Licence
MIT for the code — see LICENSE. The bundled levels are covered separately; see NOTICE.md.
