@uniview/2048-ai
v0.1.0
Published
2048 in the terminal, authored in Solid, with the real n-tuple + expectimax AI
Maintainers
Readme
@uniview/2048
2048 in the terminal, authored in Solid — with the real trained AI.
npx @uniview/2048The AI here is not a heuristic. It is an n-tuple network (5 patterns, 8-way dihedral symmetry, ~11 M trained weights) driving an expectimax search over afterstates — the same agent as the web app it was ported from. At depth 2 it reaches the 4096 tile. It ships inside the package: no download, no setup, works offline.
| key | |
| --------------------- | ------------------- |
| ↑ ↓ ← → / h j k l | move |
| a | toggle AI auto-play |
| s | one AI move |
| + / - | search depth (1–4) |
| n | new game |
| q / Ctrl-C | quit |
Two model layouts
The published package carries one file, model/model.pack (33 MB on disk,
14 MB over the wire). The upstream export it is built from is 9 files and 88 MB:
one sparse uint32 idx ++ float32 val list per pattern table, sharded to stay
under Cloudflare's 25 MiB asset cap. Packing rewrites that as
- indices — globally ascending with a mean gap of ~3, so delta + LEB128 turns 4 bytes into (nearly always) 1;
- values — int16 with one scale per table.
Quantization costs a relative error of 7.3e-5 on V(board), measured against the
Python golden — far below the gap between candidate moves, and tests/ai.test.ts
pins it (the raw float32 export is still held to its original exact tolerance).
Developing
model/ is gitignored, so a fresh clone has no weights. The game runs without
them — fully playable by hand, the AI panel just reads no model — human play,
and the AI suites skip.
pnpm --filter @uniview/2048 dev # vite-node, reads model/ in either layoutTo enable AI mode, drop the sharded export (manifest.json, golden.json,
lut{0..4}_*.bin) into model/, or point elsewhere:
UNIVIEW_2048_MODEL_DIR=/path/to/model pnpm --filter @uniview/2048 devPublishing
pnpm --filter @uniview/2048 pack:model # model/*.bin -> model/model.pack
pnpm --filter @uniview/2048 build # tsdown: src/ -> dist/cli.mjsdist/cli.mjs is one self-contained file: Solid, @uniview/solid-renderer,
@uniview/tui-solid and @uniview/tui-core are all inlined, so the published
package has zero runtime dependencies — the bin plus the weights. (Both
steps run automatically on npm publish via prepublishOnly, which means the
sharded model has to be present on the publishing machine.)
Why the toolchain looks like this
The JSX here compiles with babel-preset-solid in generate: "universal" mode —
Solid's universal renderer has its own calling convention, and esbuild, rolldown
and Bun's transpiler all only know React's. So:
- dev/test need a runner that can host a babel plugin — hence
vite-node, nottsxand notbun run(both fail withReact is not defined). - the bundle is
tsdown+rolldown-plugin-solid(that same preset, packaged). It is a rolldown plugin, so Vite will not pick it up —vite.config.tskeeps its own copy of the transform for the runner.
Layout
src/
vendor/ engine + AI, vendored verbatim from the training repo (pure, no DOM)
board.ts · patterns.ts · universal.ts · model.ts · expectimax.ts
ai/
loader.ts reads model.pack or the sharded LUTs; returns null when absent
pack.ts the single-file packed model format (npm ships this one)
controller.ts auto-play state (available / running / depth / step)
game.ts the game controller — engine + signals, no UI, injectable RNG
board.tsx the grid, in the classic 2048 palette
app.tsx board + score + score-curve sparkline + AI panel
keys.ts input mapping (kept separate so it is testable without a terminal)src/vendor/ currently sits close to upstream (only an import path changed), which
makes it cheap to re-sync a fix from the training repo. That is a convenience, not
a rule — change it freely if the game needs it. The golden tests below are what
actually keeps it honest.
Why it is trustworthy
The port is verified against the reference implementation, not eyeballed:
- Engine — replays all 204 golden boards × 4 moves (816 cases) from the
Python reference;
after,rewardandchangedall match. - Value function — reproduces the reference
V(board)on every golden board across all six grid shapes (4×4, 5×5, 4×5, 5×4, 3×4, 6×6). One model really does serve every shape. The float32 export matches to 1e-7 relative; the packed model to 2e-4, which is the quantization budget. - It reaches 2048. Not a claim — a test: at the depth the app ships with, a
seeded game is played out through the real game controller and must end with
game.won() === true. Depth 2 goes on to 4096.
The value-parity golden lives in model/, not in this repo, because it
describes the exact weights sitting beside it.
pnpm --filter @uniview/2048 testWhat it demonstrates for uniview
Everything on screen is a @uniview/tui-solid component: Panel, Box, Text,
StatusBar, and the score curve is the same Sparkline the charts demo uses —
no bespoke rendering. The tiles are plain colored Boxes, so no new render
primitive was needed for a game.
State is signals; the app mounts once and never re-renders by hand.
