Before the first line of Octupuz existed, its spec was 849 KB of JSON: 72 screens, 23 components, every animation researched from Instagram, and every decision written down with its reason. The first build worked from a summary of that spec instead of the spec. It looked like a prototype, contradicted decisions that were already settled, and was thrown away.
I'm Panth. I lead the software team at Oizom, and Octupuz is my own project, built in about a month. This post is about the parts of it you can't see in a screenshot: why the backend knows nothing about puzzles, why a board has to come out the same on every device, and how the server checks a solve without trusting the phone.
What Octupuz is
A social feed that looks and moves like Instagram, except every post is a puzzle you play right there. 41 kinds are live: Queens, Nonogram, Pipes, Water Sort, Suguru, Skyscrapers, a whodunit called Case File, and more. New ones every day. When you solve one, your time lands on the post.
It runs on the web and installs as a PWA at octupuz.in. The Play Store is next. The stack: Postgres on Supabase with row-level security, a Svelte client, Capacitor for the Android shell, and games in plain JavaScript.
The spec came first, and the summary failed
The spec is a set of JSON files. screens.json holds the 72 screens: per screen its route, what it reads, what it calls, and every transition with its gesture and motion. decisions.json holds every settled decision with the reasoning behind it. motion.json is 157 KB of animations researched from Instagram's own app.
The build prompt for the second attempt opens with one instruction, in bold: read the specification first, all of it, before writing any code. The sections after it are, in its own words, "a map to the requirements", not the requirements. Where the prompt and the spec disagree, the spec wins.
The same prompt lists two holes the first attempt had, both invisible when you read the SQL and both found only by sending the request an attacker would send to the live API:
-
revoke execute ... from anondoes nothing, because Postgres grants EXECUTE toPUBLICandanoninherits it. - A table-level
revoke select ... from authenticatedsilently disables every RLS policy on that table.
So the working rule became: verify from the outside, with only the public key. Reading a policy is not the same as testing it.
The backend is Instagram, the frontend is the game
This is the decision everything else hangs on. The backend stores social data: players, follows, likes, comments, messages, plays. For each game it stores one string, bundle_path. It does not know what Queens is.
A post is a row with a puzzle column the backend never looks inside:
create table if not exists public.posts (
post_id uuid primary key default gen_random_uuid(),
game_id uuid not null references public.games(game_id) on delete cascade,
puzzle jsonb not null, -- opaque here; only the game reads it
post_sequence int not null, -- this game's nth puzzle
...
);
Today puzzle is usually a seed. The board it draws does not exist until a phone imports the game's bundle and asks for it.
A game is written once, in games/src/<game>/index.js, and each difficulty is a two-line entry file:
const diff = 'hard'
export default (await import('../../src/queens/index.js')).make(diff)
The database stores the relative path (/games/queens/hard.js) and the app loads it with a dynamic import() when a post needs it. Adding a game adds a row. No app update, no store review.
On Android, the JavaScript updates itself over the air: a signed manifest, a staged rollout by percentage, and never mid-session. A new bundle applies the next time the app goes to the background, not while someone is playing. That created one edge case. Posts come from the database the moment a game goes live, but a phone can still be on last week's bundle. So the app tries its own copy of the game first, and if it has none, fetches the same file from the server.
A board has to come out the same everywhere
If a post is a seed, two friends only play the same puzzle if the seed draws the same board on an iPhone, an Android phone, a laptop and the server. The shared random package opens with a list of what is banned in anything that decides a board:
-
Math.random,Date.now,performance.now. Time goes into the move log and nowhere else. -
Math.sin,cos,exp,log,pow. Their precision is implementation-defined and differs between V8, JavaScriptCore and SpiderMonkey. Integers,Math.imul,|0and>>>0are exact everywhere. -
sort(() => rng() - 0.5). It is not a real shuffle, and it is not stable across engines. -
localeCompare,Intl,toLocaleStringin game logic.
Randomness comes from one place: the seed string is hashed with cyrb128 into four 32-bit integers, which seed sfc32. Both use integer operations only.
A golden file keeps it honest. It records the boards a fixed set of seeds draw, and it is committed. If someone changes a generator, the test fails, because every past puzzle of that game would now redraw differently.
Queens is the exception. A Queens board has to be solvable without guessing, which means generating candidates and rejecting the ones a person could only guess at. The rejection test is a backtracking solution count, run thousands of times. That takes about a second: fine once on a server, not fine in a feed scrolling past thirty previews. So Queens boards are generated once, offline, and the post carries the finished grid, with its seed alongside so any board can be traced back to what made it. A few other games are published the same way.
A time on a post means the board was solved
Here is the whole path:
posts row: game_id + puzzle (seed or baked board)
|
+--> phone: import(bundle_path) -> build(puzzle) -> play -> move log
| |
+--> verifier: same rules.js -> replay(puzzle, moves) <----------+
|
+--> server's own stats -> time on the post
Every tap goes into a move log with a timestamp. For Queens it looks like this:
q34@2100;d05@2600;D4043@3100
A crown at row 3, column 4 at 2.1 s, a dot at row 0, column 5, then one painted run from (4,0) to (4,3). A painted run is one entry, not four. Written as four dots sharing a millisecond, an honest player would look like a script to the timing check.
When you finish, the phone sends the post id and the move log. The verifier loads the puzzle, picks the game's rules module, and replays the log from an empty board:
const run = game.rules.replay(ctx.puzzle, moves, game.difficulty);
if (run.illegal || !run.completed) {
return json({ error: 'the moves do not replay to an end', illegal: !!run.illegal }, 422);
}
// the client's numbers are ignored
const elapsed = Date.now() - Date.parse(ctx.started_at);
const plausible =
run.stats.time <= elapsed + 2000
&& run.minMoveGapMs >= game.rules.humanFloorMs
&& (ctx.attempts < 50 || !ctx.p10_ms || run.stats.time >= ctx.p10_ms * 0.25);
The rules.js it runs is the same file the game imports in the browser. No DOM, no clock, no Math.random. One copy of the rules, run by both sides.
A few details make it hold:
- The phone's numbers are thrown away. Time, moves, mistakes and hints all come from the replay. A modified app can lie about its time, and the lie never reaches the board.
- The server's clock starts the game. The play row is created by the first move, with the server's timestamp. A claimed time can't be longer than the time that has actually passed, plus 2 seconds.
- Nobody taps forty times a second. Each game sets a floor for the gap between moves. For Queens it is 90 ms.
-
Implausible is kept, not shown. A finish that fails those checks is recorded with
flagged = true. The player sees their own result. Nobody else sees a number the server did not believe: no story, no "beat you" notification, no place on the post. -
The server is the only way to finish. The
playstable's update policy refusesis_completed, and the function that records a finish is granted to the service role alone. A browser can save where it got to. It cannot say it arrived.
The verifier runs as a Cloudflare Pages Function and talks to Postgres through Hyperdrive, so the database credential never leaves Cloudflare. A Worker can't evaluate code it downloads, so the rules for all 41 games are imported statically and picked by bundle_path, the same path the phone used to choose its copy.
That static list failed once. Nineteen games were carried over from an older project of mine and nobody added them to the verifier's list. The app played those puzzles all the way to the last move, then the server answered 501 and refused to record the finish. The only test that noticed was the one that plays a real board to its end. Now a test reads the verifier's imports and fails the build when any folder under games/src is missing.
There was one more bug, and it was in the database write. Passing ${JSON.stringify(stats)}::jsonb to the Postgres driver looks right. The driver already serialises a string as a JSON string when the target is jsonb, so Postgres stored a string that contained JSON, not an object. ->> 'time' on that is null, and every recorded finish had no time. The fix is two casts, ::text::jsonb, which makes Postgres parse the text instead of wrapping it.
What replay buys is simple to say. A time on a post means a solved board. And nobody ever needs to see another person's board to trust it, which matters, because the board and the move log are the answer.
Decisions that are product, not tech
Some rules in the spec look technical and aren't:
- No winning and no losing. A puzzle is completed or it is not. A wrong move counts a mistake and the board carries on. The UI never says "won", "lost", "score" or "game over".
- Play is a button. Tapping a board in the feed does nothing. Play opens a full-screen player. Scrolling past a puzzle should never start one.
- Nobody sees another person's board, during play or after.
- Sign-in is required. No anonymous access, so every request is attributable to an account that can be suspended. Email sign-in is a code, not a magic link, because a magic link signs in whichever browser opened it.
-
Two tables, never one.
playersare people.gamesare publishers run by the back office. A game cannot like, play, follow, be messaged or have a story. There is nouser_idcolumn anywhere, onlyplayer_idandgame_id.
Each of those is written in decisions.json with its reason, so the build didn't have to decide them again.
How it was built
The first commit is dated 2026-09-18. I aimed at three things: the best UI and UX I could get, the best board generation, and an architecture that ships a new game without an app update. The last one is where most of this post came from. A game is a bundle and a row, the phone draws the board from the post, and the server checks the result with the same code.
If you've built something where the client reports a result, what do you replay or re-check on the server, and what do you simply trust?
Top comments (1)
tr.ee/dev-to