Full design · Games
Number merge
A sliding tile puzzle in glazed ceramic.
Live preview. Click around, it works.
About this template
A sliding tile puzzle in glazed ceramic. Every value is a named glaze with its own finish and weight; merges squash and ripple, streaks fire the kiln, and reaching the target plays a lustre firing. Swipe and keyboard controls, limited undo, three modes (Classic 4 × 4, Big board 5 × 5, Speed run against the clock), stats and history, four glaze themes, optional sound and vibration. Games survive a reload, and a schema for a verified global leaderboard is included.
What the agents are asked
The first message sent when you use this template. You can edit the plan before anything is built.
Adapt this sliding tile puzzle to the customer's brief: keep the five screens (Play, Modes, Stats, How to play, Settings) and the move rules exactly as they are, and rename the brand, glaze names, themes, copy and targets to fit their audience. Keep the self test at #/selftest passing after any change to the engine. Where they want accounts or a global leaderboard, add sign in and the tables in schema.sql, and accept scores only through a server function that replays the game from its seed and move log, as the README describes.
A sliding tile puzzle in glazed ceramic. Slide every tile at once, merge matching tiles into their sum, and fire your way from plain bisque to gold lustre. Three modes, limited undo, stats and history, four glaze themes, optional sound and vibration. "Twofold" is a fictional brand; rename it freely.
- Plain HTML, CSS and one ES module. No dependencies, no build step, no external JavaScript.
- Mobile first: bottom tab bar and swipe on phones, sidebar navigation and keyboard play from 900px wide.
- Four glaze themes, every colour a CSS custom property at the top of
styles.css: Zellige (light, default), Tenmoku (dark), Majolica (light, tin glaze) and Raku (dark, smoke). "Match device" picks Zellige or Tenmoku fromprefers-color-scheme. Theme ids saved by the first release (kiln, tidepool, riso, nocturne) are mapped to the new ones on load, so nobody loses their choice. - Everything is saved in the browser (
localStorage, keytwofold:v1): each mode's game in progress, history and settings. A reload puts you back on the same board, score, undo count and timer. Every access is wrapped intry/catch; when storage is blocked the game still runs and lasts for the visit. - Sample rows carry their own
sampleVersion, separate from the saved state shape. On load, if the stored version is missing or stale, only the rows taggedsample: true(the seeded history rows and the classic in-progress game) are swapped for freshly generated ones; every row the visitor made themselves is kept, and a visitor who already removed the samples stays sample free. This is how a returning visitor never gets stuck looking at stale sample content.
Art direction
Idea. A glaze studio. Every tile value is a named glaze with its own finish and weight, fired on a slate board set in a bottle green glazed surround. The hue family of each glaze is the same in every theme; the theme changes the treatment (bright tin glaze in Majolica, smoke in Raku).
Palette (Zellige). Glaze Green #0E4D40 (surround, primary buttons), Porcelain #F2F1EE (ground, a cool white, not cream), Honey Glaze #E3A534 (streaks, board focus, the win). Slate #2A302E for the board. Tenmoku swaps to Kiln Black #0F1311 with honey as the primary. The only gradient is the Gold lustre tile, which is a metal.
| Value | Glaze | Personality |
|---|---|---|
| 2 | Bisque | Matte unglazed clay, squashes the most |
| 4 | Slip | Satin white |
| 8, 16 | Honey, Amber | Warm gloss |
| 32, 64 | Oxblood, Copper green | Deep gloss |
| 128 | Bottle green | Pooled glaze rim |
| 256 | Cobalt | Starts to glint now and then |
| 512 | Tenmoku | Rust rim, like iron glaze breaking on an edge |
| 1024 | Black glaze | Gold numerals, honey hairline |
| 2048 | Gold lustre | The one gradient, barely squashes |
| 4096+ | Pearl lustre | Porcelain with a gold rim |
Type. Young Serif (display, scores and tile numerals: a warm, heavy old style that reads like a maker's stamp) and Hanken Grotesk (text). Both from Google Fonts. Scale about 1.33, tight tracking on large headings, body line height 1.6.
Photography. Two images, one shoot: late afternoon raking window light from the left, warm 4500K, 90mm macro feel, shallow depth of field. Generated from media.json into public/template-media/number-merge/.
| Name | Size | Where |
|---|---|---|
cover.webp | 1600 × 893 | Gallery cover, the Modes hero (clip reveal, fetchpriority high), the sticky photograph on How to play, og:image |
glaze-surround.webp | 1000 × 1000 | The board surround in Zellige, Tenmoku and Raku, tinted per theme (Majolica paints a solid cobalt surround) |
Every image has a fallback colour, width and height, and alt text. The generated surround shows tile edges and grout rather than a seamless glaze; the tint keeps it quiet behind the board.
Motion. Each piece has one job, and all of it stops under reduce motion (device setting or the in app switch):
- Slides: transforms only, 120 ms, ease out. No layout properties animate.
- Merges: squash along the line of travel from the impact side, a small stretch the other way, then settle; how far depends on the glaze (light clay gives, heavy glaze barely moves). No oscillation.
- Impact ripple: one ring leaves each merge; heavier glazes ring wider, and 128 and up ring in honey.
- Glint: from 256 up, a slow sheen crosses the tile every nine seconds, each tile on its own clock.
- Streak: merging moves in a row fill five notches. Five in a row, or three merges in one move, fires a honey edge round the surround with a chord and a vibration. For feel only; it never changes the score, so server replay is unaffected.
- New glaze: the first time a game reaches a glaze from 64 up, its swatch stamps into the ladder with a label.
- The orchestrated moment, "Lustre firing" (reaching the target, or clearing Speed run): the tile lifts, the rest of the board answers in a wave spreading out from it, one sheen crosses it, then the card rises (about 1.2 s).
- Game over, "The kiln cools": colour drains from the outer ring of tiles inwards, then the score counts up.
- First load: a staggered entrance, once, with the starting tiles dropping in diagonally.
- Other screens:
animation-timeline: view()reveals inside@supports, an IntersectionObserver fallback at threshold 0.2, aclip-pathreveal with a slight scale the first time each photograph shows, a sticky photograph column on How to play, anddocument.startViewTransitioncross fades between routes where supported. - Buttons press down like keys (3 px). Hovers are 150 to 200 ms.
Screens
The hash router serves #/play, #/modes, #/stats, #/how, #/settings, plus #/selftest (not in the menu).
Play
- Board of 4 × 4 or 5 × 5 cells in a glazed surround. Tiles slide (120 ms), merges squash and ripple, new tiles set down.
- Beside the board on wide screens (below it on phones): the score, a streak meter and the "glazes fired this game" ladder.
- Arrow keys or W A S D, swipe anywhere on the board,
Uto undo,Escto close the new game question. - Score with a "+points" float, best score for the mode, and a timer in Speed run.
- Undo steps back one move. You cannot undo twice in a row, and each game has a fixed number (3, or 1 in Speed run).
- New game asks in the page before ending a game with moves on it ("Keep playing" is focused). The ended game goes into history as "Ended early".
- Reaching the target plays "Lustre firing" and shows "Fired to 2048" with Keep going and New game. Game over plays "The kiln cools" and shows the score counting up, a "New best" tag when it is one, the best glaze, and Undo if any are left. Finishing a Speed run fires the 512 tile and shows the time and your best.
- A move that changes nothing gives a small nudge and does not count.
Modes
A photographic hero, then Classic as the large card with Big board and Speed run stacked beside it. Each mode keeps its own game, so switching never loses your place. Cards show a live mini board, the rules, the status of the game in progress and the best score (and fastest clear for Speed run).
| Mode | Board | Target | Undos | Notes |
|---|---|---|---|---|
| Classic | 4 × 4 | 2048 | 3 | Keep going after the target |
| Big board | 5 × 5 | 4096 | 3 | Keep going after the target |
| Speed run | 4 × 4 | 512 | 1 | Timer starts on the first move, pauses when you leave the board or hide the tab; reaching 512 ends the run |
Stats and history
"Kiln log": the best score as a large figure beside its glaze, a three column ledger, then games played, best score, best tile ever, how many games reached their target, a "best tile reached" distribution (one bar per tile, each bar focusable with a full text label), best score, best tile and fastest time per mode, the twelve most recent games, a "Clear history" action with an in page confirmation, and a card explaining the global leaderboard is not connected yet.
How to play
A sticky photograph on wide screens while numbered steps scroll beside it, including the glaze list. The rules with small illustrated boards: slide and merge, one merge per tile per move (2 2 2 2 becomes 4 4, 2 2 4 becomes 4 4, not 8), the new tile rule, game over, controls, undo and the three modes.
Settings
Glaze theme picker (five choices including Match device, each a tile sample), sound switch (off by default), vibration switch (on by default where navigator.vibrate exists; disabled with an explanation where it does not), reduce motion (Device, On, Off), player name for a future leaderboard (validated), and "Erase data" with an in page confirmation.
The rules, exactly
All in the "Game engine" section of script.js, pure functions with no DOM:
mergeLine(line)slides one line towards index 0. A tile merges at most once per move and a tile made by a merge cannot merge again in that move. Pairs nearest the wall merge first.moveTiles(tiles, size, dir)runs every row or column and reports the new tiles, the tiles swallowed by merges (for the animation), the points gained, and whether anything moved.step(game, dir)spawns exactly one tile after a move that changed the board: a 2 with probability 0.9, else a 4 (spawnValue). No change, no spawn, no counted move. It then checks the target and game over (canMove: an empty cell, or two equal neighbours).- Randomness is a seeded generator (
rngNext) whose state lives in the game. Undo restores it, so the same new tile comes back after an undo, andreplayGame(mode, seed, log)rebuilds any game from its seed and its move log.
Self test
Open index.html#/selftest. It runs 23 checks against known boards (merge rules, all four directions, spawning only after a real move, the 90/10 split over 20,000 draws, game over, target, Speed run clear, undo limits, and exact replay) and prints the results on the page. Keep it passing after any engine change.
Data model
store in script.js is the only code that touches storage. The rest of the app calls its methods.
state = {
v: 1,
sampleVersion: 2, // bumps when seeded sample rows change
player: { id, displayName }, // players row
settings: { theme: 'auto', sound: false, haptics: true, motion: 'system' },
mode: 'classic', // last mode played
games: { classic, big, speed }, // one game in progress per mode, or null
history: [ { id, mode, score, bestTile, moves, durationMs,
result: 'over' | 'abandoned' | 'cleared',
reachedTarget, endedAt, sample } ], // scores rows
}
game = { id, mode, size, seed, rng, nextId, tiles: [{ id, v, r, c }], score, moves, log: 'LURD...',
undosLeft, prev, won, keepGoing, over, cleared, bestTile, elapsedMs, recorded, sample }schema.sql holds the matching tables: players and scores (one row per finished game, append only), two leaderboard views, row level security, explicit grants, and the same twelve sample games as demo mode.
What is sample or simulated
| Part | Status |
|---|---|
| Twelve past games in Stats | Sample, tagged "Sample", dated relative to today, removed in one click on Stats |
| Classic game on first open | A sample board in progress, tagged "Sample game", never recorded in history; New game replaces it |
| Global leaderboard | Not connected. The Stats card says so; nothing pretends to rank you |
| Sound | Real, generated with WebAudio oscillators (a porcelain tap pitched up a pentatonic scale as values climb); no audio files |
| Vibration | Real navigator.vibrate patterns, feature detected; desktop browsers usually accept the call and do nothing |
| Photography | Generated for this template, served from NovaBuild at /template-media/number-merge/ |
| Storage | This browser only |
Adding a global leaderboard
The rule that shapes everything: a browser must never be able to write a score. Anyone can open the console and post 999,999. So scores are written by a server function that replays the game.
- Sign in. Add Supabase Auth (or similar). On first sign in, insert the player's row:
insert into players (user_id, display_name) values (auth.uid(), name). RLS allows only your own row. - Run `schema.sql`. It is additive and safe to run twice. Rehearse the commented VERIFY block as the
authenticatedrole inside a transaction and roll back before applying it to a live database. Check thatanonandauthenticatedcannot insert intoscores. - Write the `submit-score` server function (for example a Supabase Edge Function or
POST /api/scores). It: - authenticates the caller and finds their
players.id; - receives
{ clientGameId, mode, seed, log, result, durationMs }, never a score; - copies the engine functions (
rngNext,spawnValue,mergeLine,linePositions,gridOf,moveTiles,canMove,maxTile,spawnTile,createGame,step,replayGame) fromscript.jsunchanged, and callsreplayGame(mode, seed, log). Anullresult means an impossible move log: reject it; - takes
score,best_tile,movesandreached_targetfrom the replay, and for Speed run requires the replay to have cleared 512; - rate limits per player, sanity checks
durationMsagainst the move count (a time cannot be proven, only made implausible to fake), and inserts with the service role key, which lives only on the server, withverified = true. - Swap the store. In
script.js,store.recordScore(rec)keeps its local write and also calls your function with{ clientGameId: game.id, mode, seed, log, result, durationMs }. That meansrecordGame()passesgame.seedandgame.logalong too. Only send games whoseresultisoverorcleared, and skip sample games.store.listScores()can stay local for personal history. - Show the board. Read
leaderboard_scores(best score per player per mode) andleaderboard_speed(fastest clear per player) with the public client; RLS only returns verified rows. Replace the "Global leaderboard" card inrenderStats()with the top 20 for the chosen mode, and tag rows whereis_sampleis true as "Sample". Remove the seeded rows withdelete from players where is_sample.
Scores are append only: a new best is a new row, never an update, and there is no update or delete policy.
Files
| File | What it holds |
|---|---|
index.html | Shell: brand rail, navigation, the Play screen (board, streak, glaze ladder), confirm panel, containers for the other screens |
styles.css | Shared tokens and glaze ramp, four theme token sets, layout, tile personality, game feel animation, reduced motion rules |
script.js | Engine, sample data, store, theme and motion, WebAudio sound, haptics, router and view transitions, each screen, the self test |
media.json | The two photographs to generate (node tools/generate-imagery.mjs --template number-merge) |
schema.sql | players, scores, leaderboard views, RLS, grants, sample rows, a VERIFY block to rehearse |
template.json | Catalogue metadata for NovaBuild |
Accessibility and motion
Skip link; aria-current on the active tab; each screen's heading takes focus on navigation; the board is focusable with an accessible name and every move is announced through a polite live region; overlays move focus to their first button; confirmations are in the page with the safe choice focused; all inputs are labelled; visible focus rings in every theme; the win, game over, new glaze and streak moments are also announced in words. Motion is short and settles without bouncing. Reduce motion (from the device or the setting) makes tiles jump into place, removes squash, ripple, glint, reveals and route transitions, and shows the end screens at once.