Work · 2026
Verbris: how I built a game in a month using AI
828 commits between 4 June and 2 September 2026, none of them written by hand. How Verbris came about, a browser game halfway between Ruzzle and Tetris, and everything I built and then deleted.
The dates here are the commit dates: 828 of them, from 4 June to 2 September 2026. The images aren’t mockups. I ran the code as it stood at each stage and captured the screen.
Where it started
On 4 June 2026 I open an editor and start Verbris, a browser game halfway between Ruzzle and Tetris. From Ruzzle it takes words traced across adjacent letters; from Tetris, gravity: clear a word and the letters above fall, while a hidden reserve grid refills from the top. Two days later it was properly online, deployed on Vercel and Railway.
The very first published version. The game was still called WordCrush and you played by dragging a finger across the letters.
The title says “a month” because June was enough: by the end of it the game had accounts, stats, leaderboards, a puzzle generator in four languages, and a server that, rather than trusting the score it receives, replays the game to check it. What happened from July onwards was a different kind of work, deciding what from that first month deserved to stay. That’s the part I find worth telling.
I didn’t write a single line of code. All 828 commits came out of an AI agent, Claude Code, which I instructed, whose diffs I read, and whose work I sent back when it didn’t convince me. I never asked it to “make me a game”: I asked for one spec at a time. I’m starting with the method because without it the numbers below don’t mean much.
The method: the same loop, forty-five times
The repository holds 45 spec documents, 45 implementation plans and 16 code audits, written before or right after the code they describe. The loop is always this:
- A spec in plain language, what should happen and why, before opening a file. For instance:
2026-06-12-daily-session-anticheat-design.md,2026-07-06-modello-rituale-design.md. - A plan that breaks the spec into steps, listing the files involved.
- The implementation, written by the AI and reviewed by me diff by diff.
- Every so often an audit, a pass dedicated to nothing but hunting dead code, duplication and pointless complication. The last one is dated 30 August.
- Verification, which is not “seems to work”: 463 frontend tests, 16 on the backend, type-check, lint, and for the puzzle generator a regression check (
pnpm gen:golden) that has to stay green at an unchanged fingerprint.
Skip the spec and an AI still writes you the code, quickly too. The problem shows up three weeks later, when that code still works but nobody, you included, can say why a rule is written the way it is. The chat that produced it has long been closed. The spec stays.
June, the 459 founding commits
The first real problem arrives after three days: dragging with a mouse is awkward on a phone screen. On 7 June the game switches to tap-to-play and the home page is redesigned. From there it stops being a desk experiment.
After tap-to-play: a tighter layout, with “How to play” moved into a panel instead of taking up fixed space.
Then the month accelerates. Player stats arrive, global leaderboards, friends, authentication, an increasingly elaborate end-of-game popup, generative sound effects. On 18 June there’s a complete interface redesign: design tokens declared once with Tailwind’s @theme, cells with more visual states, a timer with a bar instead of bare text, contextual onboarding that doesn’t block play.
The 18 June redesign. The name is still WordCrush and there’s still a “Sign in” button top right.
The grid in the same period: timer with a coloured bar, 2×2 blocks, golden cells, onboarding still overlaid.
Below the surface, though, the commit that matters more than any redesign is the one from 20 June, a shared replayRun. From then on the server stops computing the score its own way: it replays the moves it receives with the same engine as the client, and if the result doesn’t match, it discards the game. Client and server run the same code, not two parallel implementations of the same rule. It has governed every feature since.
On 30 June, exactly a month after launch, the game changes its name. WordCrush becomes Verbris, and moves to a new domain. Renaming a product that’s already online with real users costs you something: shared links break, indexing has to be rebuilt, recognition resets. I did it because WordCrush sounded like a clone of something else, while Verbris (from verbum) carried the multilingual identity the game was growing into.
Stuff I built and then deleted
Case studies usually leave this part out, and I think it’s the part that teaches most when you work with an AI writing code at that speed. Skip it and every decision looks right first time, which isn’t how it went.
Login, friends and the global leaderboard: June, then deleted on 15 August
In June the game had accounts, authentication, friend invitations and a worldwide leaderboard.
The sign-in and registration modal. It can’t be reached today: the code behind it was deleted, not just hidden.
On 6 July, a month after building it, I decided to give up the worldwide leaderboard and persistent identity. The reason is technical. A serious global leaderboard needs robust anti-cheat, otherwise changing device or clearing the cache is enough to push in an invented score. That anti-cheat was weeks of work, and it existed to turn a three-minute daily ritual into a competition. I didn’t want Verbris to be that.
Five weeks later, on 15 August, I did the thing people usually postpone: instead of hiding login, friends and the leaderboard behind a flag, I deleted them from the code. Around 1,300 lines, gone. Keeping them “for later, you never know” is the most common way to accumulate dead code nobody dares touch again.
The first-attempt leaderboard, which lasted one evening
Also on 6 July, before landing on the final model (your best score counts, retries unlimited), I had the seemingly fairer alternative implemented: only the first attempt of the day counts, and anyone who wants to replay does so in a separate Practice mode. I watched it work and threw it away the same evening. Without verified identity, “only the first attempt counts” is circumvented like any leaderboard, by changing device. It added friction for people playing honestly without stopping anyone else.
The global leaderboard, five days alive
On 10 June the end-of-game popup gains an Official/Best toggle for comparing yourself against global scores. On 15 June, with the switch to “your best counts”, that comparison no longer meant anything, and mixing official and free attempts in one leaderboard was just confusing. Out went the toggle, and out went the leaderboard store feeding it.
The 10 June end-of-game popup with the global leaderboard toggle: built on the 10th, removed on the 15th.
The exit-confirmation popup, three days
On 12 June I build anti-cheat for the daily game, with a server-side time window and a popup asking for confirmation before leaving mid-game (leaving counted as a partial submission). I remove it on the 15th along with partial submission: by then the model had become “your best counts”, so leaving halfway lost you nothing and that confirmation was pure obstacle. A perfectly sensible piece of interface on Monday becomes an annoyance on Thursday, if you change the model underneath it.
The promo videos with the bot
In August I had a pipeline built that generated promo clips from bot moves in three variants (quiz, community, playout). They stayed in production for a while, then I took them out. The clip that drives the real app, with a running timer and a real move, worked better than a bot pretending to play.
The link challenge, anonymous multiplayer born the same day
6 July wasn’t only about removing things. The same day I throw away the worldwide leaderboard I build what replaces it for the use that actually mattered, challenging friends rather than the whole world. You create a challenge, send a URL, and whoever opens it plays the very same grid and lands in a small leaderboard. Nobody knows who the players are, the server included.
Identity is a token the server generates on first contact and the client keeps in localStorage. It isn’t an account, just a way of recognising that this is the same device as the previous challenge. If the token is missing or no longer valid, the server issues another one without asking anything.
The challenge modal today: nickname, number of grids, and the product limit stated plainly, three players.
Scoring goes through the same engine as the daily: the client sends the moves rather than the total, and the server replays them with replayRun. There’s no more permissive version for challenges. Determinism follows the same road too, with golden cells reborn from a seed computed in game-core with the daily’s formula, so everyone watches the grid evolve identically.
There is an attack here that the daily doesn’t have, though: open the link, study the grid at your leisure with no clock running, and only then start playing, declaring a false time. The defence is a marker. The server consumes the grid, that is, records that this game has started and not yet been submitted, the instant the client asks for it in order to play, not when it displays it. A fixed time window starts from there, and abandoning without submitting leaves you a zero. A review dedicated to the mode, on 16 August, found that in the first version the public request used to show the leaderboard and countdown returned the complete grid anyway, reopening the study window the marker was meant to close. Fixed by moving that part of the response into the one request that actually consumes the marker.
One clarification I owe the reader: each challenge takes up to three players. That’s a product limit, not a technical one, because the score table is one row per player per challenge and has no structural reason to stop there. Challenging a friend was meant for a small group who know each other, not for a public tournament, which would have needed the same anti-cheat that made me drop the worldwide leaderboard a few paragraphs above.
The two rules I never touched again
Two constraints became untouchable enough to have their own section in the guide the AI reads at the start of every session.
The first: client and server share the code. game-core, the package that decides whether a move is valid and what it’s worth, is imported by the frontend and by the backend alike. If the client applied a bonus the server didn’t know about, the replay would discard every legitimate game. The rule lives in one place and two callers use it.
The second: determinism. The same day has to produce the exact same grid for everyone, in the same language, and the server’s replay has to consume the same random sequence the client saw. The seeded RNG lives in a single module, there isn’t one Math.random() on the paths that matter, and not even the offline generator ever looks at the clock.
A third rule arrived later, out of a real bug: in August the state of Cruciverbris survived inside the classic daily, which found itself holding clues and rules that weren’t its own. Hence the choice to treat modes as data, with eight Record<GameMode, …> tables for fields, behaviour, screens, menus, tutorials and end-of-game. Add a mode and forget a row, and the TypeScript compiler refuses to compile. That isn’t elegance: it’s a way of making that bug impossible a second time.
It has more to do with AI than it sounds. An agent respects an invariant the compiler checks far better than an instruction repeated by voice in every chat. One Record<GameMode, …> is worth ten lines of CLAUDE.md.
August, 335 commits and four languages
August is the second most intense month, the one where the game stops being a puzzle and becomes a small set of modes on one engine.
There are five modes now. Cruciverbris and the end-of-day percentile each have their own section below. Verbr-Flip turns letters over instead of clearing them, with no gravity: it’s complete and tested, but it sits outside the menu, hidden by a single line of code (visible: () => false). That isn’t unfinished work, it’s a postponed release, which is a different thing from the removals above.
The home page rebranded Verbris, with modes as menu entries, August 2026.
Cruciverbris, the mode that almost renamed itself
The public name is Cruciverbris, from cruciverba plus verbum, but in the code it stays definition* everywhere: the definitionRun store, the definition_puzzles table, the /puzzle/daily-definitions endpoint. That isn’t an oversight, it’s deliberate. The commercial name can still change, and renaming internal identifiers for a name that might change again in a month is work you do twice.
The mechanic doesn’t resemble any other mode. There’s no running timer and no whole grid to explore at random: it empties as you solve a chain of clues, one word at a time, each the logical consequence of the last. Building those chains is the most unusual piece of engineering in August, because for a themed day the process inverts. You don’t start from the grid to see which words fit: you start from the chain, ten clues about the sea, say, and build the grid backwards so that it holds exactly that sequence, in the right order.
The real cost isn’t in the code, though, it’s in the content: around 2,600 clues written by hand in four languages, organised into 204 themed lists, covering all of August and September 2026. August has ten generic days and twenty-one themed ones; September is themed from day one. Clues can be generated with AI, with a script that asks for one crossword-style definition per word without ever using the word itself or its root, or written by hand in a text file and fed through the same pipeline. Always depending on an API key to produce text you could type directly would be a bottleneck for no reason.
A release choice rather than a code one: the entry appears on the home page by itself, language by language, only once that language has at least one published day. No flags to flip by hand and no “coming soon” to hide afterwards.
The percentile instead of the leaderboard
On 14 August the last piece of no-identity arrives. Not only giving up the worldwide leaderboard, decided on 6 July, but offering a comparison anyway: you beat 83% of today’s games, without anyone knowing who scored what.
The obvious route was a table with one row per game, score and timestamp, and a count of how many rows sit below mine to derive the position. It works, and it grows linearly with games played: on a daily with thousands of runs, every end-of-game would query a table that never stops growing.
What got built is a histogram. Four hundred buckets of a hundred points, one counter per bucket, one row per puzzle, that is one a day per language, about 1.6 kB that never grows with the number of players. Recording a score means incrementing the right bucket with an atomic SQL function; computing a percentile means summing the counters below your own. The database no longer knows that somebody scored 1,200 points at 14:32: it knows that on that day, in that bucket, there are N games. For a daily ritual rather than a competitive ladder that’s a fair trade, because the granularity you lose is granularity no screen would have shown.
A dedicated audit on 16 August found a real defect, not a hypothetical one: the rule mapping score to bucket was written twice, once in SQL and once in TypeScript. It’s the same trap as the client-server invariant, applied to the database-service pair. Changing the number of buckets on one side only would have made the end-of-game screen and the history report different numbers, without turning a test red. The fix moved the computation into SQL as well, called by the writer, the reader and the seeder, so today the rule lives in one place.
And as with the leaderboard, the choice here is stated rather than hidden: a replay counts as a new game in the histogram. I measured it, your percentile rises without you improving, from 50% to 58% after four worse attempts on a freshly seeded day. On the day of the audit I decided to accept it. Fixing it would mean subtracting your own games client-side, that is, trusting the client on a public number, which is exactly the shortcut server-side replay exists to avoid.
Knowing how many play without knowing who
On 24 August the same rule reaches usage counting. Verbris has no Google Analytics, no cookies and no consent banner, and not because nobody looks at the numbers: the way I look at them is built so it can’t identify anyone even if I wanted to.
The principle is the percentile’s, applied to a different question, how many different people played today. Every event, the start and end of every game across all modes with a single hook per side in the store, produces a hash from IP, user agent and a salt that changes every day. The salt isn’t a table to rotate by hand: it’s computed on the fly from a fixed secret plus today’s date, so at midnight it changes by itself. The same visitor on the same day always produces the same hash, which is enough to count distinct visitors; the next day the salt differs and that hash can’t be reconstructed, so following the same person from one day to the next isn’t possible, because the function doesn’t invert.
The only rows carrying anything derived from personal data are discarded after eight days, on every run of the script that prints the report. The rest, how many games per mode and language, is a plain counter: no column can hold an identity.
I’m writing it here rather than in a changelog because it’s the third time the same question, can I have this number without an identity underneath, gets a different technical answer for a different problem. First the score, with the histogram. Then the players, with the short-lived hash. It isn’t a principle written once and copy-pasted: it’s a question I asked myself again every time a collective number was needed, and so far the answer has always been yes.
The end-of-game popup
If one piece of interface tells the whole story of how the project moved, it’s this one. On 16 June, with the exit confirmation gone, a stats redesign arrives with dropdowns for moving between views, charts of the last eight days, and a historical percentile. The direction here is the opposite of the removals: not less friction, but more context. “You scored 120 points” says nothing on its own.
The stats popup today: streak, stars, Legend days and two charts. It’s the same idea from 16 June, refined.
The latest addition is from early September: a visual celebration that grows with your percentile, more festive the higher you place. I chose not to set a minimum number of games before showing it, knowing that in the early hours of the day the sample is small and the number can be generous. I’d rather someone playing at seven in the morning gets the celebration, and the number settles on its own during the day.
The end-of-game summary today, here in Untimed and captured without completing any words, which is why the score is zero. In the Daily Challenge the same component also shows the percentile.
The promo clips, a headless browser that really plays
On 17 August I start from the obvious route for anyone who already has a bot that plays by itself, the same one that computes par for the stars: have it play hundreds of games, pick the best by measurable criteria such as the longest cascade, the highest score or the rarest word, and cut them together with ffmpeg into three formats, with countdowns and reveals drawn over frozen frames. It worked, it shipped, and it’s the pipeline I later threw away: you could see nobody was actually playing.
Its replacement, from the end of August, is easier to describe and harder to achieve. You record a real instance of the game while it plays, with headless Chrome driven by Playwright. What makes that possible without inventing fake backend endpoints or dragging production-crew code into the bundle is intercepting the network request the client uses to load the daily, and answering with a puzzle from the Untimed pool instead of the real one for that day. The app believes it’s playing a normal daily, with a running timer, real moves and a real exit animation, and has no idea it’s on a sound stage. The real backend is never contacted, so the rule “never today’s or tomorrow’s daily”, to avoid spoilers, is guaranteed by the fact that the video can’t see it, not by a checklist to remember.
The timer deserves a note, because it’s the easiest detail to get wrong: it starts at 35 seconds instead of 15 to leave room for taps during recording, and the moment the last move lands it’s forced to zero. The countdown you see in the finished video is backdated deliberately; it was never the real elapsed time.
The least obvious part is how the frames are captured. The technically cleanest route was Chrome’s native function for exactly this, Page.startScreencast from the debug protocol, the one developer tools use to show an emulated phone screen: each frame arrives with its own timestamp, so mapping a move onto a second is immediate. Measuring rather than assuming turned up that this function ignores the simulated device’s pixel density and always captures at the viewport’s CSS resolution, 390 pixels wide, not the real resolution of the phone being imitated, which is three times that. It’s a Chromium limitation, verified by driving the low-level protocol commands by hand too, not an option you can pass better.
On 18 August I tried the obvious alternative: capturing one frame at a time with the ordinary page screenshot, which does respect that density. It worked, but it cost nearly half a second per frame, and on a real recording the frame rate collapsed from 59 to 4. Too far from fluid for a feed people scroll in a second, and I reverted that change two minutes after committing it. It’s the kind of about-turn that would cost you the pride of undoing hours of hand-written work, and that with an AI rewriting in minutes is simply the right move.
The accepted solution, ten days later and once again measured, doesn’t fix the limitation: it compensates for it. Frames stay at the low resolution Chromium imposes, and the upscale to the video’s final resolution, nearly triple, uses Lanczos instead of ffmpeg’s default bilinear filter. Sharper from the same source, without inventing detail the frame doesn’t hold. It isn’t the elegant solution I’d have liked to write up; it’s the real one, with the limitation stated rather than buried in an optimistic comment.
The last trick concerns the variant where three or four words are picked one letter at a time by tapping instead of dragging, followed by a wrong attempt repeated on purpose before time runs out. Those sequences aren’t hand-written: an offline script replays every move with the same engine the server uses for the replay, processMove, so it knows where each letter will be after gravity and the reserve have changed the grid, and it does so with the same seed the client will use during recording. The tap path looks decided on the spot and is in fact precomputed exactly as the real engine will see it, wrong words included: real paths across the grid that the game genuinely rejects, not random key presses dressed up as mistakes.
The videos come out ready for YouTube, Instagram and TikTok, with captions drawn from a pool of variants so the same one never appears twice in a row, and one of four grid exit animations picked at random, the same one players see.
A real bug: the background that slowed everything down
On 28 August a blunt report arrives: the game lags, both while dragging letters and during the cascade after a word. The first hypothesis, React components re-rendering more than necessary, was true but not enough. I fixed four components subscribing to the whole game state instead of the individual fields they actually used, because a timer updating four times a second was re-rendering panels that don’t even display the time, and the game was still slow.
The measurement that found the cause didn’t count renders, it counted frames. Sitting still, touching nothing, the game screen was running at 25 frames per second instead of 60. The bottleneck wasn’t interaction: it was something burning CPU continuously, on every screen, even behind a closed modal.
It was the decorative background, two semi-transparent glows drifting slowly behind every screen, with a 70-pixel blur applied over an animated element. A translation or a zoom the GPU computes almost for free, compositing layers it already has; a blur that size, animated, forces the browser to re-blur the whole area on every frame instead of once. With the blur removed and replaced by a gradient that fades to transparent, the same effect on screen, it’s back to 60 frames when idle, 21 to 52 during a real drag, and 23 to 55 during a cascade after an accepted word.
What taught me something isn’t the fix, it’s where the bug was. I was looking for the slowness in the code that reacts to the player’s gesture, selection, validation, letters falling, and the culprit reacted to nothing: it ran all the time, underneath everything, from the moment the app opens. Since then, when something seems slow during X, the first question I ask isn’t what X is doing wrong, it’s what runs even when I’m not doing X.
Verbris today
Three months and 828 commits after the first, this is where it stands. The dark theme has been the default from the start, and I hadn’t shown it up close since June’s redesign.
The home page today: modes as menu entries, dark theme.
The game grid today.
The light theme
The last visual step, in place since August: a complete light theme. Not a filter over the existing colours, but background, tiles, panels and states redesigned for both themes.
The same home page today, in light theme.
The game grid in light theme.
By the numbers
- 828 commits from 4 June to 2 September 2026, none written by hand.
- 459 in June, 335 in August. July has the fewest commits and the heaviest decisions.
- 45 specs, 45 plans, 16 code audits.
- Around twenty commits that remove or undo something already built.
- 5 game modes on one engine, in Italian, French, Spanish and English.
- Roughly 2,600 hand-written clues for Cruciverbris and 204 themed lists.
- 463 frontend tests, 16 on the backend, plus the generator’s regression net.
Three months on
I didn’t use AI to write code faster than I’d write it myself. I used it to be able to afford changing my mind. Building login, friends and the leaderboard in June and dumping them in August, writing every line by hand, would have been weeks burned on a product bet: in practice I wouldn’t have done it, I’d have kept the lot and called not being able to decide a roadmap. When a spec becomes working code in a few hours that calculation changes, and the constraint moves elsewhere: it’s no longer how fast you write, it’s how disciplined you are about deciding what to keep. The 45 specs and the 16 audits are there for that.
The underlying architecture, code shared between client and server, determinism, modes as data, I never had to rewrite. Everything else, yes: the name, authentication, the scoring model, the interface four times over. Which is why “a month” in the title isn’t a gimmick: it’s how long it took to get an engine solid enough to carry two more months of second thoughts on top.