The games hub has had coins since the avatar system shipped, and they had almost nowhere to go. Two outfits and a gacha pull, three sinks in total, against a full clear of the daily games that pays about 300 coins a day. A currency that accumulates faster than it can be spent is not a currency. It is a number on a screen.
This is the layer that gives them a job, and the one paid product that sits above them.
It is on staging, not on production. The engines are built and tested, both shop pages exist, every unlock and most actions are wired, and the whole layer is deployed and verified at a staging URL with checkout deliberately inert. The honest state is in What Is Not Built, which is the section to read first if you only read one.
The catalogue is smaller than it was a week ago, and that is the part worth reading. Seven priced rows were deleted after opening each game and checking whether the thing being sold was already on screen and free. Four of them were. A design like this is judged by what it refuses to sell, so the count going down is the work going right rather than scope being cut.
The Problem With Selling Anything Here
Every design in this document is downstream of one constraint, and it is worth stating before the features: almost everything a game can sell makes the game worse.
Hints sell the puzzle you came for. Extra lives sell the tension. Unlock-all-levels sells the progression, which is the strange one, because it converts something the player was going to earn into something they can pay to skip, and the moment it is purchasable the earning starts to feel like the punishment for not paying.
The hub also has no accounts, no backend and no ads, which are the three things a normal free-to-play economy is built out of. So the question was never “how do we monetize twenty games”. It was “what can we sell that a player is not worse off for buying”, and the answer turned out to be fewer things than the first draft assumed.
Four Tiers, One Of Which Costs Money
| Tier | What it is | Count | Price |
|---|---|---|---|
| Per-use action | Bought at the moment it is useful, consumed | 19 registered, 18 live | 0 to 25 coins |
| Persistent unlock | Buy once, stays open on this device | 10 registered, 7 live | 250 or 400 coins |
| Pass | A whole category of action, free forever | 9 | 400 to 900 coins |
| Supporter Pack | All of the above at once, plus every outfit and the archive | 1 | $5, one time |
Thirteen games have a priced action. Fourteen are touched once unlocks are counted.
Every coin number derives from one anchor: a Poker Friday buy-in is 10 coins, and 10 coins is about 80 seconds of play. So every price in the catalog converts to minutes, and the ceiling is 3.3 minutes. Three actions sit on that ceiling and all three hand over a whole puzzle’s worth of work: the crossword grid, a thirteen-card arrangement, an opponent’s dice. Nothing costs more, because a price past that point stops reading as a shortcut and starts reading as a toll.
The Rule That Cost The Most Features
A purchase may never make a scored run easier, and where a game gates content, that gate stays fully earnable by playing.
Applied honestly, this deletes most of the obvious catalog. Two examples of what it cost:
Ski Rash could not sell a better weapon. The game has three tools and their balance is measured rather than guessed. The broom banked 6,200 points against roughly 3,500 before it was retuned. Selling reach or radius would have converted a balanced choice into pay-to-win in a game with a score. What is designed instead is a fourth weapon as a side-grade with its own trade-off, and it is currently unbuilt because the trade-off has not been measured yet. Inventing balance numbers to fill a price table is how a false claim ships.
Dishwasher could not sell “unlock all levels”, which was the original plan and is the most obviously saleable thing in the hub. What it sells instead is the same chapters at 250 coins, roughly five days of play for all six, with the story still fully earnable and everyone who already paid grandfathered permanently.
The reframing that rescued the whole tier: sell variety, not advantage. Instead of access to locked content, sell free play, which is the daily game replayable on fresh seeds, forever. The daily seed does not change. The daily result does not change. Nothing about today’s run gets easier. It reuses the seeding machinery that already existed, so the version that respects the player was also cheaper to build than the paywall it replaced.
The Pop-Up Contract
This is where a coin economy either respects people or does not, so it is not a UI convention. It is the shape of a function’s return type.
resolveAction() returns exactly three things, and they map one-to-one onto what a player sees:
| Return | Who gets it | What they see |
|---|---|---|
free | Supporter, pass holder, or a zero-priced action | A plain button. No price, no sheet, ever |
priced, affordable | A player with coins | The same button, carrying a price |
priced, not affordable | A player without coins | An offer sheet, once, explaining the action and how to earn it |
An offer appears only when the action is useful, is one line, is dismissible in one tap, never blocks play, never returns in the same session after dismissal, and carries no sound, badge, timer, counter or animation. There is no fourth return value, so there is no state in which a nag is reachable.
Two of the actions are priced at zero and stay registered anyway. Poker Friday’s “run it out” and “see your hand” both explain why a fold was wrong, and putting a price on the feature that teaches you something is precisely the move the contract exists to prevent.
The Supporter Pack’s “no more pop-ups” is a side effect, not a feature. Supporters see silence because everything is already unlocked, not because a nag tax was lifted. Building a nag in order to sell its removal is the specific thing this refuses to do.
What It Will Not Do
- Not gambling. Coins have no cash value and cannot be cashed out, exchanged or transferred. Every card and dice game is against bots for play-money chips. Nothing pays out. The gacha that predates this work stays exactly as it is and is not extended, and no real-money purchase ever rolls a random outcome.
- No dark patterns. No streaks tied to money, no countdown pricing, no “your coins are running low”, no daily login reward, no notification that exists to bring someone back. Archives never expire and no date is more urgent than another.
- The kids surface is excluded, normatively. No action, pass or unlock touches the Math Trail or the kids tab. That is not a policy note: the progress module has a spec that reads its own source text and fails if
purchase,entitleorpaymentappear in the code. - Every price is shown beside its free-earn path, at equal visual weight. A committed free player owns the entire catalog in about three weeks without spending anything.
Four Engineering Decisions
One registry, no ad-hoc spends. Every action lives in a single file with a price, a pass, an availability predicate and a live/deferred status. No game charges coins on its own. This sounds like bookkeeping and is actually the enforcement mechanism: a rule that lives in a design document gets quietly broken by the eleventh game, and a rule that lives in a type does not.
Determinism survives the purchase. Two actions modify a run rather than reveal something, and both had to be defined against the seeded random number generator’s draw order, so that a replay carrying the same purchases reproduces identically. Critter Blocks’ 3x3 clear became a first-class move inside the replay function: it scores nothing, leaves the tray alone, and consumes time. One action could not be made to fit and is therefore unbuilt rather than approximated.
Assisted is excluded where the score is created, never filtered where it is shown. An assisted dice turn cannot become your best round. An assisted cube solve never enters your times. The reason is narrow: a leaderboard that stores an assisted number and hides it in the view is one refactor, one new surface or one CSV export away from showing it. Excluding it at entry means there is nothing to leak.
Hidden information gets its own channel. One game sells a peek at an opponent’s dice. The tempting implementation is to widen the function that decides what a seat may see, and it is wrong, because that would sell the peek to every bot and every serialiser at the same time. The peek is a separate export with its own preconditions, and the test proving information independence was left untouched.
The Bug That Would Have Given Away Free Purchases
Worth its own section, because it is the failure mode nothing catches.
The wallet ledger indexes each spend under a unique reference, and when two spends collide it treats that as the idempotency guarantee working: it returns success, deliberately, so that a retried purchase is not told it lost something it already owns.
The consequence is that a reference which can repeat does not throw. It succeeds, charges nothing, and hands out a free purchase with no error, no failing test, and nothing on screen.
Three actions needed three different keys, and the wrong one is never obvious from the call site. A revealed crossword cell can never be revealed twice, so the cell itself works. A Sudoku hint keyed on date plus difficulty plus a counter collided twice: starting a new game on the same day at the same difficulty reuses the tuple, and reloading the page resets the counter while the puzzle does not. It ended up keyed on a fingerprint hashed from the puzzle’s given numbers, because a restored game carries a grid and no seed. An undo position genuinely recurs, so a counter is unavoidable, and a counter needs an identity that does not restart along with it.
Both collisions were found by asking “what happens on reload”, not by the test suite.
What A Currency Has To Look Like
The owner asked whether one game’s coin-priced undo had ever been built. It had, six days earlier, and it had been on screen the whole time as grey twelve-pixel text under the board.
Both shop components took their entire appearance from props supplied by the caller. Twelve games had each chosen one, and nine different looks were in the tree. Most had landed on the same muted grey the hub uses for de-emphasised captions, so the thing you spend money with looked like a footnote. The generalisable version: a shared component that accepts a look does not have one. The forty-four pixel tap floor lived inside the component and held everywhere. The appearance lived at the call sites and held nowhere.
The fix moved the look into the component, where a call site cannot override it, and encoded four rules:
The price badge is the loud part, not the button. The body is a soft tint one rung below the primary action. A game whose brightest object is its spend button is a monetised game, which is the thing this whole design exists to avoid. Prominence belongs to this costs coins, never to buy this.
The reward colour could not be the spend colour, and that had to be measured to know. The obvious move was to reuse the existing coin amber. Against its own tint it scores 3.62:1, well under the accessibility floor, so the price would have been the least readable element on the control. The spend colour is a second token per theme, measured in a real browser at 6.0:1 in light and 9.6:1 in dark.
Supporters see no coin skin at all. It is removed rather than recoloured. An amber chip advertising a shop to somebody who has already bought the shop out is the no-nag promise broken in paint instead of in markup.
Colour is never the only carrier. Every priced state also shows a glyph, a number and the word.
Two verification traps produced confident wrong answers first, and both are worth naming because neither looks like a mistake. Browsers return computed colours in the same colour space you authored them in, so a parser expecting the old format scored every control at 1.11:1 and a passing design read as a total failure. And a fresh browser profile has no coins, so every control rendered in its cannot-afford state and the affordable state was never measured at all.
The Shop Needed A Way Out
The price list showed every action grouped by game. Every one of them is bought inside a game, and the page linked to none of them. A price list you cannot leave is a dead end.
Each group now carries the game’s own card art and a link into it. The art comes from a different list than the prices do, and nothing had ever required the two to agree, so a test now asserts that every game selling something resolves to an entry with art and a route. That matters because the fallback is a tinted initial rather than a broken image, which means a miss would look deliberate. The two lists disagree on exactly one name, and that disagreement is now pinned in a test so a second one is a decision rather than a drift.
What Is Not Built
| Layer | Built | Wired to a screen |
|---|---|---|
| Per-use actions | 18 live, 1 deferred | 13, across 9 games |
| Persistent unlocks | 7 live, 3 deferred | 7, all of them |
| Passes | 9 | Honoured everywhere, no purchase screen |
| Shop pages | 2 of 2 | Both |
| Checkout | Designed, specified | Not wired |
Nothing here is on the production hub. The staging deploy has checkout deliberately inert: the environment variables are unset, so every buy button self-disables rather than half-working.
The five unwired actions are unwired for different reasons, and only one of them is laziness. Two hint actions in one game have no hint mechanic behind them yet, and building the button before the engine would be selling a thing that does not exist. One needs a targeting interface. One is blocked on a product question below. One is a boss-start that simply has not been done.
Two features are blocked on a decision, not on code. One game’s “buy a card” has not shipped because the phrase hides several different games, and the answer changes whether it is a retry or a rules change. A fourth weapon in another game is blocked on measurement. Both are registered at a settled price and switched off, which is the honest place to leave something undecided.
One game already spends coins and has never heard of any of this. A mahjong game shipped from a parallel branch while this was being built, and it is the only game in the hub with real coin stakes, won and lost hand by hand. Its own design is careful. It also writes to the balance the old way, which this layer is in the middle of moving. Two systems, two stores, and nothing that fails when they disagree, because neither one asserts against the other. That is a merge problem with a name and an owner rather than a surprise, but it is unresolved, and a “single registry” is only single until a branch ships something that never imported it.
The tests do not prove what the count implies. There are 3,917 of them, and the two things they have missed here are worth more than the number.
They did not catch that three coin buttons rendered sixteen pixels tall, because the test environment has no layout engine and cannot fail a tap target. That fix moved the floor inside the shared component, where it holds for call sites nobody has written yet.
Then the same class of bug came back one level up. After the visual change, a browser pass measured every control at forty-four pixels and inside the viewport, and reported no problems. The screenshot showed one game’s cards crushed to a single word per line with a price pushed off the right edge. Measuring controls never measures the arrangement they sit in, and the only thing that catches it is looking.
Stack
| Tool | Role |
|---|---|
| TypeScript | One action registry, one unlock registry, pure resolver functions |
| Dexie / IndexedDB | Append-only wallet ledger, entitlements, unlocks, and an O(1) balance rollup |
| React | One shared offer sheet and two shared spend buttons that own their own appearance, so no game can invent a different one |
| Stripe Checkout | The one real-money product, with our own signed grants |
| Cloudflare KV | Activation counting only. Hashed keys, no accounts, no personally identifying data |