| assets | ||
| docs | ||
| src | ||
| test | ||
| tools | ||
| .gitignore | 210B | |
| build.sh | 2.5K | |
| dev-logo-seed.svg | 625B | |
| gleam.toml | 1.0K | |
| manifest.toml | 9.4K | |
| README.md | 10.9K |
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]ingleam.toml.assets/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.index.html /is served with its build placeholders still in it, sosw.js 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/ 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/, 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/, and writes dist/.
It does not run lustre/, 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/ 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/ is copied into dist/ and is what tells
Pages to answer / 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/ |
Flow, input, chrome. The Lustre app: model, update, view. |
src/ |
Boards, pieces, moves, legality, encoding. |
src/ |
The eight packings. A family is a board, a piece multiset and a goal. |
src/ |
The ladder: which depth a rung asks for, and the level curve. |
src/ |
How the four measurements are weighed into a score and five marks. |
src/ |
What a finished run was made of: setbacks, idle moves, detours. |
src/ |
Where everything goes. A 200-unit column, and a search over about a dozen candidate arrangements. |
src/ |
The screens, painted. Pure: layout and state in, scene ops out. |
src/ |
What a frame is made of. The boundary between decision and pixel. |
src/ |
How a tone becomes marks: Atkinson diffusion, and the threshold tables. |
src/ |
The backdrop stencils, drawn into whatever nobody painted over. |
src/ |
The only place a colour is written down. Four themes, each carrying pools. |
src/ |
How wide a line of type is, and how tall. |
src/ |
FNV-1a and mulberry. Seeded randomness as a design system. |
src/ |
The save: a versioned envelope in localStorage under klotski.save. |
src/ |
The two questions the system has already been asked: dark, and reduced motion. |
src/ |
Storage, tones, haptics, the clock, the install prompt, the worker. |
src/ |
Hands a frame, and the pointer, to the pixel pipeline. |
assets/ |
The sweep, and everything that has to stand next to a distance field. |
assets/ |
The offline copy. Templated by the build. |
assets/ |
Four ink/paper pairs, applied before first paint. |
tools/ |
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/ 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.