
Every daily guessing game has the same weak spot: the answer has to live somewhere, and the browser is the easiest place to put it.
The original Costcodle took the simple route. The page downloads its whole puzzle list, games.json, future answers included, and works out today's puzzle number from the device's clock. For a casual game that is a fair trade: no server to run, no accounts, nothing to maintain. It also means anyone who opens DevTools can read the price, and two players in different time zones can be looking at different items at the same moment.
When I rebuilt the game as Costcodle, I gave myself two rules:
- The price of today's item never reaches the browser before the game is over.
- Everyone in the world plays the same item on the same UTC day.
This post covers how the first rule works without a session table, the shortcut I decided to live with, and the small details (integer cents, a UTC schedule) that made both rules hold up.
The game in one paragraph
Every day there is one Costco item and six guesses. You type a dollar amount, and each guess prints a line on a receipt: Higher or Lower, plus Close when you are within 25%. Land within 5% of the price, edges included, and you win. The prices are snapshots collected for the original game, so treat them as a fun reference rather than today's shelf price.
Puzzle #1122 from October 15, 2026 is a past puzzle with a published answer, so it makes a safe example. The round went $4.00 (Higher), $7.00 (Close and Lower), $6.00 (Win), and the price was $5.99. That exact round is the 30-second walkthrough video:
What the browser is allowed to see
The puzzle object the client receives has six fields. For #1122 it looks like this (image hash shortened):
{
"id": 1122,
"gameDate": "2026-10-15",
"name": "Organic Crimini Mushrooms, 24 oz",
"packageLabel": null,
"currency": "USD",
"image": {
"url": "/media/puzzles/1122/dd56b3fc8a46...",
"alt": "Organic Crimini Mushrooms, 24 oz",
"width": 300,
"height": 300,
"version": "dd56b3fc8a46..."
}
}
A test asserts that exact list of keys, so adding a field to the public puzzle is always a deliberate change. The photo is served by the Worker from a private R2 bucket, and its URL carries only the puzzle number and an opaque version, nothing about where the image came from.
The build has a check of its own. If any static file that ships to the browser contains question bank data, such as a product name or a source image URL, the build fails. That catches the boring leaks: a fixture that slipped into a bundle, or a debug JSON file left in public/.
The practice mode follows the same rule. Costcodle Unlimited deals random past puzzles, and it only deals puzzles that are over: never today's, never a future one, and never yesterday's while it can still be finished. Both modes ask one function, isUnlimitedEligible, so they cannot disagree about which puzzles are safe to show.
Judging guesses without a session table
When you start a game, the server writes down a few claims about it and signs them with HMAC-SHA-256. The result is a token with four dot-separated parts:
c1.<key id>.<base64url JSON claims>.<base64url HMAC>
Here are the decoded claims after two guesses on #1122, taken from a test run:
{
"v": 1,
"aud": "costcodle:daily",
"mode": "daily",
"bankVersion": "fixture-v1",
"rulesVersion": "classic-v1",
"sessionId": "lhFV78TNuypCvStxVPi-erBXw2NSBthp",
"puzzleId": 1122,
"gameDate": "2026-10-15",
"startedAt": "2026-10-15T12:00:00.000Z",
"expiresAt": "2026-10-16T00:10:00.000Z",
"revision": 2,
"guesses": [400, 700]
}
Anyone can decode that, and that is fine. It holds what the player already knows: which puzzle, when the game started, and the guesses in cents. There is no price in it, and the signature means nobody can edit the guess list without the server noticing.
Every guess is a POST /api/daily/guess with the token and the typed amount. The Worker verifies the signature, loads the private price, replays every guess in the token through the rules, then appends the new one and signs a fresh token. Simplified, with the version and time window checks left out:
async function guess(token: string, typed: string) {
const { claims, kid } = await verifyProgress(token, signingKeys);
const puzzle = bank.findById(claims.puzzleId); // private, server only
const round = evaluateRound(claims.guesses, puzzle.priceCents);
if (round.status !== "playing") throw new GameError("GAME_FINISHED");
const parsed = parseGuess(typed); // "7.00" -> { ok: true, cents: 700 }
if (!parsed.ok) throw new GameError("INVALID_GUESS");
if (claims.guesses.includes(parsed.cents)) throw new GameError("DUPLICATE_GUESS");
const child = { ...claims, guesses: [...claims.guesses, parsed.cents], revision: claims.revision + 1 };
return session(child, puzzle, kid); // signs the child token
}
The response carries the feedback for every guess so far: the amount, a band (win, close or far) and a direction. While the game is still being played, the response has no answer key at all, not even a null one, and the response schema rejects anything else. Only the response that ends the game, a win or the sixth miss, adds it:
"status": "won",
"answer": { "priceCents": 599, "currency": "USD" }
A few details that took more than one attempt to get right:
- Key ids. The token names the key that signed it, so I can rotate the signing key. Old keys stay around for verification until the last game they signed has closed.
-
Two audiences. Daily and Unlimited tokens come from the same key ring with a different
aud. The daily endpoint rejects an Unlimited token even when its signature is valid. - Safe retries. The same parent token plus the same guess always produces a byte-identical child token, so a retry after a dropped connection gives the player exactly what the first attempt would have.
-
An expiry with a grace window.
expiresAtis 00:10 UTC on the next day. A game started just before midnight can still be finished.
The payoff is that a guess costs a signature check, a replay of at most six guesses and a new signature, with no database write. D1 only hears about a game once it is finished, when the client reports the result for the day's shared stats.
The shortcut I decided to live with
A server that remembers nothing cannot tell an old token from a new one. Take the token from before your first guess, send a different amount, and the server judges it as if the other branch never happened. I checked this against the real service: from the same starting token, $4.00 and $7.00 are both accepted as a first guess. Starting a fresh game works too, since the server hands an empty session to anyone who asks.
So a determined player can binary search the price. Two things keep that in check. Starting and guessing share a rate limit of 300 requests a minute per IP. And the day's shared stats store one row per session: the first finished result a session reports is kept, and later reports from the same session are ignored.
I accepted this because Costcodle has no leaderboard, no prizes and no accounts. What I wanted was a game where curiosity does not spoil it: View Source and the Network tab show nothing useful until the round is over. Closing the rewind would mean a row per game and a write per guess, plus cleanup, all to stop someone from cheating at a free game they play alone. If a leaderboard ever shows up, this is the first thing that changes.
Integer cents, so the 5% edge is exact
Every amount in the game is an integer number of cents, and the win check never divides:
const error = Math.abs(guessCents - targetCents) * 100;
if (error <= targetCents * 5) return { guessCents, band: "win", direction: "none" };
return {
guessCents,
band: error <= targetCents * 25 ? "close" : "far",
direction: guessCents < targetCents ? "higher" : "lower",
};
On #1122 the price is 599 cents, so the limit is 599 × 5 = 2995. A guess of $5.70 is 29 cents off, 2900, a win. $5.69 is 30 cents off, 3000, only Close. The top edge works the same way: $6.28 wins and $6.29 is Close again. No floating point rounding decides who gets the green line.
These rules are open source as costcodle-rules: guess judging, price parsing, the daily schedule and the spoiler-free share text. It is MIT licensed, pure TypeScript with no runtime dependencies, and published on npm under the same name. The package is exported from the site's own repository, so it is the code the live game runs, and every example in its README is covered by a test.
import { evaluateGuess, winningRange } from "costcodle-rules";
evaluateGuess(400, 599); // { guessCents: 400, band: "far", direction: "higher" }
evaluateGuess(700, 599); // { guessCents: 700, band: "close", direction: "lower" }
evaluateGuess(600, 599); // { guessCents: 600, band: "win", direction: "none" }
winningRange(599); // { minCents: 570, maxCents: 628 }
One puzzle per UTC day
The second rule is mostly arithmetic. Puzzle numbers count whole UTC days from one fixed anchor, October 15, 2026, which is #1122. Because the count uses UTC and rounds down, daylight saving time and the player's time zone never move the boundary.
import { canContinueDaily, nextRolloverAt, puzzleIdAt } from "costcodle-rules";
puzzleIdAt("2026-10-15T12:00:00.000Z"); // 1122
nextRolloverAt("2026-10-15T12:00:00.000Z"); // "2026-10-16T00:00:00.000Z"
canContinueDaily("2026-10-15", "2026-10-16T00:09:59.000Z"); // true
canContinueDaily("2026-10-15", "2026-10-16T00:10:00.000Z"); // false
The server picks the puzzle with its own clock, so a wrong clock on the player's device cannot put them on a different item.
The rest of the stack
The site is Astro with React islands on a single Cloudflare Worker, with D1 for data and a private R2 bucket for product photos. Pages are cached at the edge under a key that includes the build version and a publication revision, so publishing a new answer page makes old copies unreachable without purging anything. The same Worker runs three scheduled jobs: one fetches the next product photo at 23:55 UTC, one publishes the answer pages of finished puzzles just after midnight, and an hourly one retries anything that failed.
Costcodle is an independent fan game. It is not affiliated with, endorsed by, or sponsored by Costco Wholesale Corporation.
Your turn
Would you have accepted the rewind, or paid for a session table from day one? And if you have built a daily game yourself, where does your answer live?
Top comments (0)