clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

main
klot.ski / README.md
README.md 10.9K source

klot.ski

A sliding-block puzzle game, written in Gleam and Lustre, compiled to JavaScript, and drawn one bit at a time onto a canvas. It is offline-first: a service worker precaches the whole app, and nothing it does needs a network after the first load.

The generator is the point. Sliding-block moves are reversible, so a breadth- first search outward from a solved board labels every reachable configuration with its exact distance home, and a configuration sitting at depth D is by construction a puzzle whose optimal solution is exactly D unit steps. One sweep per family yields that family's entire difficulty-stratified catalogue at once, which is what makes an endless ladder possible from a single pass. There are eight families — CLASSIC, NARROW, COMPACT, SPAN, WIDE, YARD, CORNER, MIXED — and each is swept the first time it is opened, in a web worker, because the classic board alone is about 26k states.

Which of the boards at a depth gets served is not the first one found. Optimal length is a poor predictor of how hard a puzzle feels, so the worker also runs an eps-greedy walker over the distance field and reports its expected hitting time, the number of moves on the optimal path that carry the goal block away from its exit, and the mean branching along it. difficulty.gleam weighs those four into a score and cuts it into five marks. Everything else that varies — which candidate wins, which backdrop the desktop wears, which screen the blocks are shaded with — is a pure function of the family name and the rung, so a board looks today the way it looked yesterday and the way it looks on someone else's phone.

Running it

gleam run -m lustre/dev start

Serves on http://localhost:1234. Everything in assets/ is served at the root, and src/ and assets/ are both watched.

Note that lustre_dev_tools is an Erlang program — it carries a whole HTTP server so it can be a dev server — so the dev server needs an Erlang install even though the app itself never touches one.

Two things are different in dev and both will confuse you once:

  • The shell is generated from [tools.lustre.html] in gleam.toml. assets/index.html is the production shell and is not served here. The two are meant to say the same thing; if you add a link to one, add it to the other.
  • /sw.js is served with its build placeholders still in it, so const CORE = __CORE__; does not parse, registration fails, and the failure is swallowed. Offline behaviour can only be exercised against a real build.

window.klotski.solve() walks the current board down the distance field to the goal, one move at a time, through the game's own input path — the sounds, the trace and the record all happen exactly as they would under a thumb. It is how a screenshot of a real run gets taken without solving a hundred-step board by hand. Arrow keys move the goal block; Escape closes a menu, then the settings sheet.

Tests

gleam test

52 tests at the time of writing, on the JavaScript target, via gleeunit. They cover the parts that are decisions rather than pixels: piece extraction and legality (domain), the layout search against six real screen shapes (layout), the generators against the reference values (prng), the rung schedule (progress), the threshold tables (shading), the save round trip (store), the theme pools (theme), and the arithmetic of a finished run (walk).

Nothing in test/ needs a canvas or a DOM. typeface_ffi.mjs falls back to each face's own fixed advance when there is no canvas to measure with, which is what makes the headless run possible at all.

gleam format --check src test

Passes, and is meant to keep passing. See docs/style.md for what that costs and why the code is written to want what the formatter does.

gleam check is the closest thing Gleam has to a linter — there is no gleam lint, and the language server is the same compiler, so it reports exactly what check prints and nothing more. The project's own modules are clean. The warnings you will see are all from build/packages/, and they are all in the Erlang server stack that lustre_dev_tools brings with it — mist, glisten, wisp and friends. Nothing here imports them, and they are compiled on the production path too, because dev dependencies are in the manifest regardless of target.

Building

./build.sh

Runs gleam test, then gleam build --target javascript, then node tools/bundle.mjs, and writes dist/.

It does not run lustre/dev build, which is the obvious thing and does not work here: building with lustre_dev_tools needs escript, and the deploy image carries Node and not Erlang. Gleam is pinned to 1.18.1 and fetched as a static binary with its published sha256 checked, unless a gleam is already on the PATH, in which case that one is used. tools/bundle.mjs pins esbuild to 0.28.1 and runs it through npx.

The one idea in the bundler is that a file's name carries a digest of its contents, so two builds never contend for one URL and a mixed cache is unrepresentable rather than merely unlikely. Four things deliberately keep their names: index.html is the door, sw.js is how an update is noticed at all (a hashed service worker would have to be updated in order to be updated), and manifest.webmanifest and icons/ are how an installed app is recognised. The order of the steps is the dependency order and is load bearing — a font's name has to be settled before the stylesheet that points at it is hashed, and the solver worker's before the bundle that names it.

sw.js gets its version from a sha256 over the shell and every precached file, so the cache key changes exactly when the contents do. It has no skipWaiting: a new version is applied at the one moment a reload costs nothing, which is between one puzzle and the next.

Deploying

Cloudflare Pages. assets/_headers is copied into dist/ and is what tells Pages to answer /assets/* from disk for a year without asking, and to revalidate everything that kept its name. The rules are written so that no two of them can match one file, because Cloudflare merges matching rules and joins duplicate header names with a comma rather than letting the narrower one win.

There is no wrangler.toml or Pages configuration file in the repository. The build command and output directory live in the Pages project settings.

The code

The dividing line is that Gleam decides and FFI executes. Every _ffi.mjs file is named for the one platform gap it fills, and none of them knows what a theme, a family or a rung is.

src/klotski.gleam Flow, input, chrome. The Lustre app: model, update, view.
src/klotski/domain.gleam Boards, pieces, moves, legality, encoding.
src/klotski/families.gleam The eight packings. A family is a board, a piece multiset and a goal.
src/klotski/progress.gleam The ladder: which depth a rung asks for, and the level curve.
src/klotski/difficulty.gleam How the four measurements are weighed into a score and five marks.
src/klotski/walk.gleam What a finished run was made of: setbacks, idle moves, detours.
src/klotski/layout.gleam Where everything goes. A 200-unit column, and a search over about a dozen candidate arrangements.
src/klotski/paint.gleam The screens, painted. Pure: layout and state in, scene ops out.
src/klotski/scene.gleam What a frame is made of. The boundary between decision and pixel.
src/klotski/shading.gleam How a tone becomes marks: Atkinson diffusion, and the threshold tables.
src/klotski/ground.gleam The backdrop stencils, drawn into whatever nobody painted over.
src/klotski/theme.gleam The only place a colour is written down. Four themes, each carrying pools.
src/klotski/typeface.gleam How wide a line of type is, and how tall.
src/klotski/prng.gleam FNV-1a and mulberry. Seeded randomness as a design system.
src/klotski/store.gleam The save: a versioned envelope in localStorage under klotski.save.
src/klotski/prefs.gleam The two questions the system has already been asked: dark, and reduced motion.
src/klotski/browser.gleam Storage, tones, haptics, the clock, the install prompt, the worker.
src/klotski/render.gleam Hands a frame, and the pointer, to the pixel pipeline.
assets/solver-worker.js The sweep, and everything that has to stand next to a distance field.
assets/sw.js The offline copy. Templated by the build.
assets/bootstrap.js Four ink/paper pairs, applied before first paint.
tools/bundle.mjs Everything between gleam build and a directory that can be served.

Two faces ship with the app, Tiny5 for display and DM Mono for everything read rather than announced. Both are under the SIL Open Font License and both licences travel into dist/ beside the .woff2 files themselves.

Things to know before changing something

The art is not the DOM. Lustre owns the elements, the state and the buttons; render_ffi.mjs owns the pixels inside two canvases. The picture is composed in greyscale into a low-resolution buffer, thresholded whole, the goal block's mark reversed, the tinted regions reprinted in colour, the ground painted into every pixel nobody drew over, and the sprites and type blitted on top at full device resolution. Error diffusion depends on the entire image, so this cannot be expressed declaratively and no attempt is made to.

Motion is quantised. Every tile is 8x8 because the period is 8, and slides are quantised to it, so a block never lands out of phase with its own shading. Most ticks of the clock therefore produce the same picture as the last one, and the update loop asks for a frame when a stepped value moves rather than when time passes. Composing a frame is expensive and cannot be made cheap; drawing the same one five times is what a phone feels as a stutter.

assets/bootstrap.js duplicates four colour pairs from theme.gleam, by hand. It has to run before first paint, and the alternative is a full white frame before the palette arrives — survivable on tinted paper, a flashbulb on the black theme. Change a theme's ink or paper and change it in both places.

The bundle and the solver worker speak a private protocol. The worker's fingerprinted URL is substituted into the bundle before the bundle is hashed, so a bundle can only ever reach the worker it was built against. An old bundle meeting a new worker would not be an error, it would be a conversation at cross purposes.

A configuration key is base-5 over 22 cells, packed into a plain number that is both an exact key and its own hash. 5^22 fits under 2^53 and 5^23 does not, which is what bounds the board sizes on offer.

Reduced motion is not a setting. It is read live from the system and there is no override, because "reduce motion" is a fact about a person rather than a preference about this app. What stops is everything that moves to be nice; what does not stop is anything carrying information, including the sweep's counter.