The README is rebuilt from the working tree and nothing else, by a reader with no memory of how any of it got there — no history, no branches, no earlier draft to be loyal to. That is the whole method: documentation drifts because whoever writes it already knows what it says, and so cannot tell which parts are still true from the outside. 189 lines against 843, and none of them describe a directory that is not there. It covers what the thing is, the dev server and the two ways it differs from a real build, the tests, the bundler and why it is not `lustre/dev build`, Pages, a map of the modules, and the handful of decisions that will bite somebody who changes one thing without knowing about the other.
- commit
- 0c29377f001337d49c64f04831abb93387bd840a
- parent
- 9b4ae6aba89882ee9d56453b75a874297e725f74
- author
- Dami <dami.etoile@gmail.com>
| A | README.md | 189 | ++++++++++++++++++++++++++++++ |
@@ -0,0 +1,189 @@
1+# klot.ski2+3+A sliding-block puzzle game, written in Gleam and Lustre, compiled to4+JavaScript, and drawn one bit at a time onto a canvas. It is offline-first: a5+service worker precaches the whole app, and nothing it does needs a network6+after the first load.7+8+The generator is the point. Sliding-block moves are reversible, so a breadth-9+first search *outward from a solved board* labels every reachable configuration10+with its exact distance home, and a configuration sitting at depth D is by11+construction a puzzle whose optimal solution is exactly D unit steps. One sweep12+per family yields that family's entire difficulty-stratified catalogue at once,13+which is what makes an endless ladder possible from a single pass. There are14+eight families — CLASSIC, NARROW, COMPACT, SPAN, WIDE, YARD, CORNER, MIXED —15+and each is swept the first time it is opened, in a web worker, because the16+classic board alone is about 26k states.17+18+Which of the boards at a depth gets served is not the first one found. Optimal19+length is a poor predictor of how hard a puzzle feels, so the worker also runs20+an eps-greedy walker over the distance field and reports its expected hitting21+time, the number of moves on the optimal path that carry the goal block *away*22+from its exit, and the mean branching along it. `difficulty.gleam` weighs those23+four into a score and cuts it into five marks. Everything else that varies —24+which candidate wins, which backdrop the desktop wears, which screen the blocks25+are shaded with — is a pure function of the family name and the rung, so a26+board looks today the way it looked yesterday and the way it looks on someone27+else's phone.28+29+## Running it30+31+```sh32+gleam run -m lustre/dev start33+```34+35+Serves on <http://localhost:1234>. Everything in `assets/` is served at the36+root, and `src/` and `assets/` are both watched.37+38+Note that `lustre_dev_tools` is an Erlang program — it carries a whole HTTP39+server so it can be a dev server — so the dev server needs an Erlang install40+even though the app itself never touches one.41+42+Two things are different in dev and both will confuse you once:43+44+- 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.51+52+`window.klotski.solve()` walks the current board down the distance field to the53+goal, one move at a time, through the game's own input path — the sounds, the54+trace and the record all happen exactly as they would under a thumb. It is how55+a screenshot of a real run gets taken without solving a hundred-step board by56+hand. Arrow keys move the goal block; Escape closes a menu, then the settings57+sheet.58+59+## Tests60+61+```sh62+gleam test63+```64+65+52 tests at the time of writing, on the JavaScript target, via gleeunit. They66+cover the parts that are decisions rather than pixels: piece extraction and67+legality (`domain`), the layout search against six real screen shapes68+(`layout`), the generators against the reference values (`prng`), the rung69+schedule (`progress`), the threshold tables (`shading`), the save round trip70+(`store`), the theme pools (`theme`), and the arithmetic of a finished run71+(`walk`).72+73+Nothing in `test/` needs a canvas or a DOM. `typeface_ffi.mjs` falls back to74+each face's own fixed advance when there is no canvas to measure with, which is75+what makes the headless run possible at all.76+77+## Building78+79+```sh80+./build.sh81+```82+83+Runs `gleam test`, then `gleam build --target javascript`, then84+`node tools/bundle.mjs`, and writes `dist/`.85+86+It does not run `lustre/dev build`, which is the obvious thing and does not87+work here: building with `lustre_dev_tools` needs `escript`, and the deploy88+image carries Node and not Erlang. Gleam is pinned to 1.18.1 and fetched as a89+static binary with its published sha256 checked, unless a `gleam` is already on90+the PATH, in which case that one is used. `tools/bundle.mjs` pins esbuild to91+0.28.1 and runs it through `npx`.92+93+The one idea in the bundler is that a file's name carries a digest of its94+contents, so two builds never contend for one URL and a mixed cache is95+unrepresentable rather than merely unlikely. Four things deliberately keep96+their names: `index.html` is the door, `sw.js` is how an update is noticed at97+all (a hashed service worker would have to be updated in order to be updated),98+and `manifest.webmanifest` and `icons/` are how an installed app is recognised.99+The order of the steps is the dependency order and is load bearing — a font's100+name has to be settled before the stylesheet that points at it is hashed, and101+the solver worker's before the bundle that names it.102+103+`sw.js` gets its version from a sha256 over the shell and every precached file,104+so the cache key changes exactly when the contents do. It has no105+`skipWaiting`: a new version is applied at the one moment a reload costs106+nothing, which is between one puzzle and the next.107+108+## Deploying109+110+Cloudflare Pages. `assets/_headers` is copied into `dist/` and is what tells111+Pages to answer `/assets/*` from disk for a year without asking, and to112+revalidate everything that kept its name. The rules are written so that no two113+of them can match one file, because Cloudflare merges matching rules and joins114+duplicate header names with a comma rather than letting the narrower one win.115+116+There is no `wrangler.toml` or Pages configuration file in the repository. The117+build command and output directory live in the Pages project settings.118+119+## The code120+121+The dividing line is that Gleam decides and FFI executes. Every `_ffi.mjs` file122+is named for the one platform gap it fills, and none of them knows what a123+theme, a family or a rung is.124+125+| | |126+| --- | --- |127+| `src/klotski.gleam` | Flow, input, chrome. The Lustre app: model, update, view. |128+| `src/klotski/domain.gleam` | Boards, pieces, moves, legality, encoding. |129+| `src/klotski/families.gleam` | The eight packings. A family is a board, a piece multiset and a goal. |130+| `src/klotski/progress.gleam` | The ladder: which depth a rung asks for, and the level curve. |131+| `src/klotski/difficulty.gleam` | How the four measurements are weighed into a score and five marks. |132+| `src/klotski/walk.gleam` | What a finished run was made of: setbacks, idle moves, detours. |133+| `src/klotski/layout.gleam` | Where everything goes. A 200-unit column, and a search over about a dozen candidate arrangements. |134+| `src/klotski/paint.gleam` | The screens, painted. Pure: layout and state in, scene ops out. |135+| `src/klotski/scene.gleam` | What a frame is made of. The boundary between decision and pixel. |136+| `src/klotski/shading.gleam` | How a tone becomes marks: Atkinson diffusion, and the threshold tables. |137+| `src/klotski/ground.gleam` | The backdrop stencils, drawn into whatever nobody painted over. |138+| `src/klotski/theme.gleam` | The only place a colour is written down. Four themes, each carrying pools. |139+| `src/klotski/typeface.gleam` | How wide a line of type is, and how tall. |140+| `src/klotski/prng.gleam` | FNV-1a and mulberry. Seeded randomness as a design system. |141+| `src/klotski/store.gleam` | The save: a versioned envelope in `localStorage` under `klotski.save`. |142+| `src/klotski/prefs.gleam` | The two questions the system has already been asked: dark, and reduced motion. |143+| `src/klotski/browser.gleam` | Storage, tones, haptics, the clock, the install prompt, the worker. |144+| `src/klotski/render.gleam` | Hands a frame, and the pointer, to the pixel pipeline. |145+| `assets/solver-worker.js` | The sweep, and everything that has to stand next to a distance field. |146+| `assets/sw.js` | The offline copy. Templated by the build. |147+| `assets/bootstrap.js` | Four ink/paper pairs, applied before first paint. |148+| `tools/bundle.mjs` | Everything between `gleam build` and a directory that can be served. |149+150+Two faces ship with the app, Tiny5 for display and DM Mono for everything read151+rather than announced. Both are under the SIL Open Font License and both152+licences travel into `dist/` beside the `.woff2` files themselves.153+154+## Things to know before changing something155+156+**The art is not the DOM.** Lustre owns the elements, the state and the157+buttons; `render_ffi.mjs` owns the pixels inside two canvases. The picture is158+composed in greyscale into a low-resolution buffer, thresholded whole, the goal159+block's mark reversed, the tinted regions reprinted in colour, the ground160+painted into every pixel nobody drew over, and the sprites and type blitted on161+top at full device resolution. Error diffusion depends on the entire image, so162+this cannot be expressed declaratively and no attempt is made to.163+164+**Motion is quantised.** Every tile is 8x8 because the period is 8, and slides165+are quantised to it, so a block never lands out of phase with its own shading.166+Most ticks of the clock therefore produce the same picture as the last one, and167+the update loop asks for a frame when a *stepped* value moves rather than when168+time passes. Composing a frame is expensive and cannot be made cheap; drawing169+the same one five times is what a phone feels as a stutter.170+171+**`assets/bootstrap.js` duplicates four colour pairs from `theme.gleam`, by172+hand.** It has to run before first paint, and the alternative is a full white173+frame before the palette arrives — survivable on tinted paper, a flashbulb on174+the black theme. Change a theme's ink or paper and change it in both places.175+176+**The bundle and the solver worker speak a private protocol.** The worker's177+fingerprinted URL is substituted into the bundle before the bundle is hashed,178+so a bundle can only ever reach the worker it was built against. An old bundle179+meeting a new worker would not be an error, it would be a conversation at cross180+purposes.181+182+**A configuration key is base-5 over 22 cells**, packed into a plain number183+that is both an exact key and its own hash. `5^22` fits under `2^53` and `5^23`184+does not, which is what bounds the board sizes on offer.185+186+**Reduced motion is not a setting.** It is read live from the system and there187+is no override, because "reduce motion" is a fact about a person rather than a188+preference about this app. What stops is everything that moves to be nice; what189+does not stop is anything carrying information, including the sweep's counter.