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 rendered
1# klot.ski23A sliding-block puzzle game, written in Gleam and Lustre, compiled to4JavaScript, and drawn one bit at a time onto a canvas. It is offline-first: a5service worker precaches the whole app, and nothing it does needs a network6after the first load.78The generator is the point. Sliding-block moves are reversible, so a breadth-9first search *outward from a solved board* labels every reachable configuration10with its exact distance home, and a configuration sitting at depth D is by11construction a puzzle whose optimal solution is exactly D unit steps. One sweep12per family yields that family's entire difficulty-stratified catalogue at once,13which is what makes an endless ladder possible from a single pass. There are14eight families — CLASSIC, NARROW, COMPACT, SPAN, WIDE, YARD, CORNER, MIXED —15and each is swept the first time it is opened, in a web worker, because the16classic board alone is about 26k states.1718Which of the boards at a depth gets served is not the first one found. Optimal19length is a poor predictor of how hard a puzzle feels, so the worker also runs20an eps-greedy walker over the distance field and reports its expected hitting21time, the number of moves on the optimal path that carry the goal block *away*22from its exit, and the mean branching along it. `difficulty.gleam` weighs those23four into a score and cuts it into five marks. Everything else that varies —24which candidate wins, which backdrop the desktop wears, which screen the blocks25are shaded with — is a pure function of the family name and the rung, so a26board looks today the way it looked yesterday and the way it looks on someone27else's phone.2829## Running it3031```sh32gleam run -m lustre/dev start33```3435Serves on <http://localhost:1234>. Everything in `assets/` is served at the36root, and `src/` and `assets/` are both watched.3738Note that `lustre_dev_tools` is an Erlang program — it carries a whole HTTP39server so it can be a dev server — so the dev server needs an Erlang install40even though the app itself never touches one.4142Two things are different in dev and both will confuse you once:4344- The shell is **generated** from `[tools.lustre.html]` in `gleam.toml`.45 `assets/index.html` is the production shell and is not served here. The two46 are meant to say the same thing; if you add a link to one, add it to the47 other.48- `/sw.js` is served with its build placeholders still in it, so49 `const CORE = __CORE__;` does not parse, registration fails, and the failure50 is swallowed. Offline behaviour can only be exercised against a real build.5152`window.klotski.solve()` walks the current board down the distance field to the53goal, one move at a time, through the game's own input path — the sounds, the54trace and the record all happen exactly as they would under a thumb. It is how55a screenshot of a real run gets taken without solving a hundred-step board by56hand. Arrow keys move the goal block; Escape closes a menu, then the settings57sheet.5859## Tests6061```sh62gleam test63```646552 tests at the time of writing, on the JavaScript target, via gleeunit. They66cover the parts that are decisions rather than pixels: piece extraction and67legality (`domain`), the layout search against six real screen shapes68(`layout`), the generators against the reference values (`prng`), the rung69schedule (`progress`), the threshold tables (`shading`), the save round trip70(`store`), the theme pools (`theme`), and the arithmetic of a finished run71(`walk`).7273Nothing in `test/` needs a canvas or a DOM. `typeface_ffi.mjs` falls back to74each face's own fixed advance when there is no canvas to measure with, which is75what makes the headless run possible at all.7677```sh78gleam format --check src test79```8081Passes, and is meant to keep passing. See `docs/style.md` for what that costs82and why the code is written to want what the formatter does.8384`gleam check` is the closest thing Gleam has to a linter — there is no `gleam85lint`, and the language server is the same compiler, so it reports exactly what86`check` prints and nothing more. The project's own modules are clean. The87warnings you will see are all from `build/packages/`, and they are all in the88Erlang server stack that `lustre_dev_tools` brings with it — mist, glisten,89wisp and friends. Nothing here imports them, and they are compiled on the90production path too, because dev dependencies are in the manifest regardless of91target.9293## Building9495```sh96./build.sh97```9899Runs `gleam test`, then `gleam build --target javascript`, then100`node tools/bundle.mjs`, and writes `dist/`.101102It does not run `lustre/dev build`, which is the obvious thing and does not103work here: building with `lustre_dev_tools` needs `escript`, and the deploy104image carries Node and not Erlang. Gleam is pinned to 1.18.1 and fetched as a105static binary with its published sha256 checked, unless a `gleam` is already on106the PATH, in which case that one is used. `tools/bundle.mjs` pins esbuild to1070.28.1 and runs it through `npx`.108109The one idea in the bundler is that a file's name carries a digest of its110contents, so two builds never contend for one URL and a mixed cache is111unrepresentable rather than merely unlikely. Four things deliberately keep112their names: `index.html` is the door, `sw.js` is how an update is noticed at113all (a hashed service worker would have to be updated in order to be updated),114and `manifest.webmanifest` and `icons/` are how an installed app is recognised.115The order of the steps is the dependency order and is load bearing — a font's116name has to be settled before the stylesheet that points at it is hashed, and117the solver worker's before the bundle that names it.118119`sw.js` gets its version from a sha256 over the shell and every precached file,120so the cache key changes exactly when the contents do. It has no121`skipWaiting`: a new version is applied at the one moment a reload costs122nothing, which is between one puzzle and the next.123124## Deploying125126Cloudflare Pages. `assets/_headers` is copied into `dist/` and is what tells127Pages to answer `/assets/*` from disk for a year without asking, and to128revalidate everything that kept its name. The rules are written so that no two129of them can match one file, because Cloudflare merges matching rules and joins130duplicate header names with a comma rather than letting the narrower one win.131132There is no `wrangler.toml` or Pages configuration file in the repository. The133build command and output directory live in the Pages project settings.134135## The code136137The dividing line is that Gleam decides and FFI executes. Every `_ffi.mjs` file138is named for the one platform gap it fills, and none of them knows what a139theme, a family or a rung is.140141| | |142| --- | --- |143| `src/klotski.gleam` | Flow, input, chrome. The Lustre app: model, update, view. |144| `src/klotski/domain.gleam` | Boards, pieces, moves, legality, encoding. |145| `src/klotski/families.gleam` | The eight packings. A family is a board, a piece multiset and a goal. |146| `src/klotski/progress.gleam` | The ladder: which depth a rung asks for, and the level curve. |147| `src/klotski/difficulty.gleam` | How the four measurements are weighed into a score and five marks. |148| `src/klotski/walk.gleam` | What a finished run was made of: setbacks, idle moves, detours. |149| `src/klotski/layout.gleam` | Where everything goes. A 200-unit column, and a search over about a dozen candidate arrangements. |150| `src/klotski/paint.gleam` | The screens, painted. Pure: layout and state in, scene ops out. |151| `src/klotski/scene.gleam` | What a frame is made of. The boundary between decision and pixel. |152| `src/klotski/shading.gleam` | How a tone becomes marks: Atkinson diffusion, and the threshold tables. |153| `src/klotski/ground.gleam` | The backdrop stencils, drawn into whatever nobody painted over. |154| `src/klotski/theme.gleam` | The only place a colour is written down. Four themes, each carrying pools. |155| `src/klotski/typeface.gleam` | How wide a line of type is, and how tall. |156| `src/klotski/prng.gleam` | FNV-1a and mulberry. Seeded randomness as a design system. |157| `src/klotski/store.gleam` | The save: a versioned envelope in `localStorage` under `klotski.save`. |158| `src/klotski/prefs.gleam` | The two questions the system has already been asked: dark, and reduced motion. |159| `src/klotski/browser.gleam` | Storage, tones, haptics, the clock, the install prompt, the worker. |160| `src/klotski/render.gleam` | Hands a frame, and the pointer, to the pixel pipeline. |161| `assets/solver-worker.js` | The sweep, and everything that has to stand next to a distance field. |162| `assets/sw.js` | The offline copy. Templated by the build. |163| `assets/bootstrap.js` | Four ink/paper pairs, applied before first paint. |164| `tools/bundle.mjs` | Everything between `gleam build` and a directory that can be served. |165166Two faces ship with the app, Tiny5 for display and DM Mono for everything read167rather than announced. Both are under the SIL Open Font License and both168licences travel into `dist/` beside the `.woff2` files themselves.169170## Things to know before changing something171172**The art is not the DOM.** Lustre owns the elements, the state and the173buttons; `render_ffi.mjs` owns the pixels inside two canvases. The picture is174composed in greyscale into a low-resolution buffer, thresholded whole, the goal175block's mark reversed, the tinted regions reprinted in colour, the ground176painted into every pixel nobody drew over, and the sprites and type blitted on177top at full device resolution. Error diffusion depends on the entire image, so178this cannot be expressed declaratively and no attempt is made to.179180**Motion is quantised.** Every tile is 8x8 because the period is 8, and slides181are quantised to it, so a block never lands out of phase with its own shading.182Most ticks of the clock therefore produce the same picture as the last one, and183the update loop asks for a frame when a *stepped* value moves rather than when184time passes. Composing a frame is expensive and cannot be made cheap; drawing185the same one five times is what a phone feels as a stutter.186187**`assets/bootstrap.js` duplicates four colour pairs from `theme.gleam`, by188hand.** It has to run before first paint, and the alternative is a full white189frame before the palette arrives — survivable on tinted paper, a flashbulb on190the black theme. Change a theme's ink or paper and change it in both places.191192**The bundle and the solver worker speak a private protocol.** The worker's193fingerprinted URL is substituted into the bundle before the bundle is hashed,194so a bundle can only ever reach the worker it was built against. An old bundle195meeting a new worker would not be an error, it would be a conversation at cross196purposes.197198**A configuration key is base-5 over 22 cells**, packed into a plain number199that is both an exact key and its own hash. `5^22` fits under `2^53` and `5^23`200does not, which is what bounds the board sizes on offer.201202**Reduced motion is not a setting.** It is read live from the system and there203is no override, because "reduce motion" is a fact about a person rather than a204preference about this app. What stops is everything that moves to be nice; what205does not stop is anything carrying information, including the sweep's counter.