A one-finger puzzle game for mobile. Installable PWA, no runtime dependencies, ~15 kB gzipped, works offline.
Play it: https://smashinggoodtime.github.io/numberweave/ — every push to main
tests, builds and redeploys it via GitHub Actions.
Connect same or next-numbered tiles. Bigger weaves clear more. Don't let the stack reach the top.
npm installnpm run devThen open the printed URL on your phone (same Wi-Fi) or in a browser's device mode. Other scripts:
| Script | Does |
|---|---|
npm run dev |
Dev server with HMR |
npm run build |
Typecheck, then build to dist/ |
npm run preview |
Serve the production build |
npm test |
Vitest over the logic core |
npm run icons |
Regenerate every icon, splash and store graphic |
npm run build:native |
Build to dist-native/ with no service worker |
npm run sync |
Native build, then cap sync |
To install it on a phone, open the built site and use "Add to Home Screen" — it runs standalone, portrait, and offline.
Weave. Drag across touching tiles showing the same number or the next number up. Dragging back onto the previous tile undoes that step. Release with two or more tiles to commit.
Grow. The whole path folds into the tile you finished on, which gains
floor(log2(pathLength)) ranks — double the tiles, gain another rank.
| Path | Gain |
|---|---|
| 2–3 tiles | +1 |
| 4–7 tiles | +2 |
| 8–15 tiles | +3 |
| 16+ tiles | +4 |
Resonate. If the new tile matches an orthogonal neighbour, it absorbs it and ranks up — then checks again, recursively. Each link raises the score multiplier. Because you choose where the path ends, you choose where the result lands, and therefore what it resonates with. That is the skill ceiling: a beginner sees "connect matching numbers", an expert is deciding where to detonate a chain they set up two moves ago.
Survive. A new row rises from the bottom every few weaves — one tick per weave, whether that weave was two tiles or twelve. Small weaves cost the same as big ones, so hunt the big ones. If the stack crosses the top, the run ends.
Scoring is rank² × tiles × chain × 10.
- Endless — the main mode; escalating difficulty, persistent best.
- Daily — seeded from the UTC date, so everyone gets the same board. Best result per day is kept, with a shareable summary.
- Zen — rows still arrive, but gently, and you cannot lose.
Twenty-eight achievements unlock six alternate colour palettes, and the daily tracks a streak.
src/
game/ pure logic — no DOM, no canvas, fully deterministic
rules.ts every tunable constant
rng.ts mulberry32; seeds the Daily and the tests
board.ts grid, gravity, rises, deadlock detection
path.ts step legality, backtracking, the ghost preview
resolve.ts merge + recursive resonance -> animation steps
engine.ts run state machine, level curve, playability guarantees
render/ canvas 2D — sprite cache, particles, rings, shake
input/ pointer events, with coalesced samples so fast swipes don't skip
audio/ procedural WebAudio; no audio files
ui/ DOM overlays (menus, HUD, coach marks)
platform.ts storage backend — native Preferences, or localStorage
haptics.ts game events -> Taptic Engine, or navigator.vibrate
leaderboards.ts Game Center / Play Games, absent unless signed in
Those last three all follow the same rule: __NATIVE__ is a compile-time
constant, so every Capacitor import sits inside a branch that is statically
dead on the web and shakes out entirely. The deployed PWA contains no
Capacitor code at all.
The engine resolves an entire turn synchronously and returns an ordered list of animation steps. The renderer plays that queue while input is locked, so the logic stays turn-based and testable while the presentation stays fluid. When the queue drains, the view reconciles against the board, so visual drift cannot accumulate.
npm test covers step legality, backtracking, the growth formula, recursive
resonance, gravity, rises, game over, deadlock recovery, snapshot
round-tripping, and seed determinism.
npm run build produces the PWA. npm run build:native produces the same game
without a service worker, into dist-native/, which is what Capacitor wraps —
separate output directories so cap sync can never pick up a worker that would
serve stale HTML after a store update.
- docs/release-checklist.md — ordered by lead time
- docs/store-listing.md — listing copy,
npm run check:listing - native/README.md — the leaderboard plugin contract
Every balance number lives in src/game/rules.ts — grid
size, opening rows, level pacing, the rise clock, the spawn spread, the growth
formula and scoring. Retuning after playtesting is a one-file edit.
Two findings from simulating full runs against bots of varying strength, worth knowing before you change them:
- The rise clock must not reach one weave per row. At that rate the board
demands a 7-tile path every move, which nobody can sustain, so every run
ended at the same point regardless of skill. It floors at 2; past that,
difficulty comes from
spawnWeightswidening the spread of ranks dealt, which makes long paths genuinely harder to find. - The board can drain faster than it refills. Strong play clears more tiles
per weave than a rise supplies, so
MIN_TILESpulls a row in early rather than letting the board dwindle to a few scattered tiles.
Current curve, 16 simulated runs per skill tier:
| Player | Weaves (min/med/max) | Score (min/med/max) | Top rank |
|---|---|---|---|
| Weak (≤3-tile paths) | 74 / 101 / 131 | 49k / 88k / 167k | 8–10 |
| Mid (≤6) | 84 / 124 / 171 | 82k / 194k / 340k | 9–13 |
| Strong (≤16) | 99 / 124 / 202 | 142k / 195k / 457k | 10–13 |
Skill shows up mostly as a raised score floor — a strong player's worst run still beats a mid player's median.
- The live run is written back to storage after every weave, so when a mobile OS evicts the backgrounded app the menu offers Continue and the run picks up exactly where it was — board, score and RNG sequence included. A daily interrupted on an earlier day is banked into your stats instead.
- Colour never carries rank alone: every tile shows its numeral, and the ramp steps ~40° of hue per rank so neighbours stay distinguishable. The Signal palette goes further and is built for colour-blind play — it alternates blue against amber rather than walking the hue wheel, holding ΔE 39+ between adjacent ranks under all three common kinds of colour blindness, where the decorative palettes drop as low as 0.4. It is never locked.
- Respects
prefers-reduced-motion, with a manual toggle in Settings that disables shake and particles. - Sound and haptics can both be turned off. On the web haptics use
navigator.vibrate, which iOS Safari does not implement; a native build routes them to the Taptic Engine instead — selection ticks while dragging, weighted impacts on a commit. - The frame loop only draws when something moved: nothing at all behind a full-screen overlay, half rate while you are thinking, full rate while dragging or animating.
- Icons are generated procedurally by
scripts/gen-icons.mjs, which writes real PNGs using onlyzlib. No binary art assets in the repo. One run produces three sets:public/icons/for the PWA (committed, the web build needs it),resources/as source art fornpx @capacitor/assets generate, andstore/for listing artwork. The last two are gitignored — they are only needed locally, and regenerate in about a second. - Anything bound for iOS is written as a 3-channel PNG. App Store Connect rejects an app icon that has an alpha channel, even a fully opaque one, so the encoder drops the channel rather than filling it.
- Android's adaptive icon is emitted as separate foreground and background layers, with the artwork inside the 0.62 safe zone — the only part of the square guaranteed to survive whatever mask a launcher applies.