Recipe · checked November 9, 2026
Casual or daily web game that survives viral spikes
A game that runs in the browser with one shared puzzle or question a day: players vote or answer, see live crowd numbers, keep streaks and share their result. Static pages on Cloudflare Workers, vote counts and streaks in Upstash Redis, every vote stored in Neon Postgres, and live numbers from one cached results endpoint instead of a realtime service.
Fits when
- The game runs in a web browser and a round is short (seconds to a few minutes).
- Everyone plays the same puzzle or question each day, so most reads are the same for every player.
- Players share results, so traffic can jump from hundreds to tens of thousands in a day.
Not for
- Games built in an engine (Unity, Unreal, Godot), console or 3D games, or phone games: Cuely doesn't cover those.
- Fast real-time multiplayer (shooters, racing, anything that needs an authoritative game server and matchmaking).
- Games that sell items or subscriptions inside an iOS or Android app: the stores' in-app purchase rules apply (see the mobile-subscription-app recipe).
The stack
| Category | Pick | Why | Pick something else when |
|---|---|---|---|
| Hosting | Cloudflare Workers | Static assets are free and unlimited, so a viral day of page loads costs nothing, and Workers Paid ($5/month) includes 10 million API requests a month, then $0.30 per million. Move to Paid before you share the game: on Free, requests past 100,000 a day get error 1027. |
|
| Counters and cache | Upstash Redis | Atomic counters for the tally, SET with NX to stop double votes, sorted sets for leaderboards and expiring keys for streaks, at up to 10,000 commands a second, over HTTP from Workers. |
|
| Database | Neon | The durable record: every vote, each day's question and final results, in Postgres you can query and recompute streaks from. Its serverless driver connects from Workers over HTTP, and scale-to-zero keeps quiet days free. |
|
| Realtime | Cached polling (no realtime service) | Every player needs the same numbers, so serve them from one endpoint cached for 5 seconds and poll it while the results screen is open. A spike adds cheap cached requests, not connections. |
|
| Authoptional | Better Auth | Start with no sign-in: an anonymous player id kept in the browser. When players want streaks on more than one device, add Better Auth (its anonymous plugin lets a guest upgrade to an account); users stay in your Neon database with no per-user fees. |
|
| Monitoring | Sentry | Error alerts on the vote and results endpoints, so you hear about a failure on the viral day, not the day after. |
|
| Analyticsoptional | PostHog | Daily players, how many come back the next day, and which shares bring new players. |
|
About $5/month at 1,000 users in tool bills: Cloudflare Workers $5; $0 for Upstash Redis, Neon, Cached polling (no realtime service), Sentry. Workers Paid from launch day ($5/month, 10M requests included, then $0.30 per million): Free stops at 100,000 requests a day. Usage profile: 1,000 monthly active users with light use: about 100,000 requests, 3,000 emails, 200,000 analytics events and 5 GB of stored files a month, and a database under 1 GB. AI apps add 50 questions per user a month at about 4,000 input and 500 output tokens each (200M input and 25M output tokens). Internal tools are priced for 10 staff instead.
Catches that change the plan
- Workers Free has a daily limit of 100,000 requests; past it Cloudflare returns error 1027 (or skips your Worker if the route fails open). Workers Paid is $5/month with 10M requests included, then $0.30 per million. Source
- Requests to static assets on Workers are free and unlimited; only requests that run your Worker script are billed. Source
- Upstash Redis Free is 500K commands a month; pay as you go is $0.20 per 100K with a budget you set, and every plan allows up to 10,000 commands a second. Source
- Cloudflare KV allows 1 write per second to the same key, and changes can take 60 seconds or more to show in other locations, so it can't hold a live tally. Source
- On Vercel Hobby, going over the included usage (1M CDN requests a month) means waiting 30 days to use that feature again. Pro has a flat-rate CDN, and a cached function response doesn't run the function. Source
Setup steps
Decide when the day starts, who a player is and how votes are counted
Make three decisions before you design the tables and keys: when a day starts, how you know a player, and how you stop double votes.
- Pick one day boundary for everyone (midnight UTC is simplest, and Cron Triggers run on UTC) and show the countdown in the player's local time.
- Use an anonymous player id: a random id created in the browser on first visit and kept in localStorage. Don't ask for sign-in to play.
- Make the rule 'one vote per player per day', enforced on the server with SET NX in Redis and a primary key in Postgres, never only in the browser.
- Write the first 30 days of questions before launch; a daily game that skips a day loses its streak players.
Done when: You can say, in one sentence each, when a day starts, what a player id is, and what happens on a second vote.
Create the Worker with static assets
The game page, scripts and images are static assets, which are free and unlimited. Only /api/* runs your code.
- Create a Workers project and put the game's HTML, JS and images in a public folder served as static assets.
- Send only /api/vote and /api/results to the Worker script; everything else is a file.
- Upgrade the account to Workers Paid ($5/month) before you share the game anywhere.
npm create cloudflare@latest -- daily-game cd daily-game && npm i @upstash/redis @neondatabase/serverless npx wrangler deploywrangler.jsonc{ "name": "daily-game", "main": "src/index.ts", "compatibility_date": "2026-11-01", "assets": { "directory": "./public" }, // Roll the day over at midnight UTC (step 6). "triggers": { "crons": ["0 0 * * *"] } }Done when: The deployed URL serves the game page, and the Workers dashboard counts requests only for /api calls, not page loads.
Docs: developers.cloudflare.com, developers.cloudflare.com, developers.cloudflare.com
Set up Upstash Redis and the Postgres tables
Redis holds what changes every second (tallies, who voted today, streaks). Postgres holds what you must never lose (every vote, every question, final results).
- Create an Upstash Redis database in the region closest to most players and switch it to pay as you go with a monthly budget before launch.
- Add UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN and DATABASE_URL as Worker secrets with `npx wrangler secret put`.
- Create a Neon project and run the schema below.
- Key names in Redis: q:{day} (today's question), voted:{day}:{player} (dedupe, expires after 2 days), tally:{day} (a hash of choice to count), streak:{player} (last day and count), rl:{ip} (rate limit, expires after 60 seconds).
db/001_game.sqlcreate table questions ( day date primary key, prompt text not null, choices text[] not null check (array_length(choices, 1) between 2 and 6) ); -- Every vote, once per player per day. create table votes ( day date not null references questions(day), player_id text not null, choice text not null, created_at timestamptz not null default now(), primary key (day, player_id) ); create index votes_by_player on votes (player_id, day desc); -- Copied from Redis when the day ends. create table final_results ( day date primary key references questions(day), tally jsonb not null, total integer not null );Done when: Inserting the same (day, player_id) twice fails on the primary key, and the Worker can read and write a test key in Redis.
Docs: upstash.com, neon.com
Write the vote endpoint
Count in Redis and answer the player at once; write the durable row after the response with waitUntil.
- Rate-limit by IP with a short-lived counter (INCR plus EXPIRE 60), or Upstash's @upstash/ratelimit, so one script can't stuff the tally.
- Validate the player id format and that the choice is one of today's choices.
- SET voted:{day}:{player} with NX; if it already exists, don't count again.
- HINCRBY tally:{day} {choice} 1, then update the streak (a last day of yesterday means +1, anything older resets to 1).
- Insert the vote into Postgres inside ctx.waitUntil, with ON CONFLICT DO NOTHING.
src/index.tsimport { Redis } from "@upstash/redis/cloudflare"; import { neon } from "@neondatabase/serverless"; export interface Env { UPSTASH_REDIS_REST_URL: string; UPSTASH_REDIS_REST_TOKEN: string; DATABASE_URL: string; } const dayOf = (d = new Date()) => d.toISOString().slice(0, 10); // the game day, in UTC const yesterday = () => dayOf(new Date(Date.now() - 86_400_000)); export default { async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { const redis = Redis.fromEnv(env); const { pathname } = new URL(req.url); if (pathname === "/api/vote" && req.method === "POST") return vote(req, env, ctx, redis); if (pathname === "/api/results") return results(req, ctx, redis); return new Response("Not found", { status: 404 }); }, }; async function vote(req: Request, env: Env, ctx: ExecutionContext, redis: Redis) { const ip = req.headers.get("cf-connecting-ip") ?? "unknown"; const hits = await redis.incr(`rl:${ip}`); if (hits === 1) await redis.expire(`rl:${ip}`, 60); if (hits > 30) return new Response("Slow down", { status: 429 }); const { playerId, choice } = (await req.json()) as { playerId: string; choice: string }; const day = dayOf(); const q = await redis.get<{ choices: string[] }>(`q:${day}`); if (!q || !q.choices.includes(choice) || !/^[A-Za-z0-9-]{16,64}$/.test(playerId)) { return new Response("Bad vote", { status: 400 }); } // One vote per player per day. const first = await redis.set(`voted:${day}:${playerId}`, choice, { nx: true, ex: 172_800 }); if (!first) return Response.json({ ok: true, alreadyVoted: true }); await redis.hincrby(`tally:${day}`, choice, 1); const s = await redis.hgetall<{ last: string; count: number }>(`streak:${playerId}`); const count = s?.last === yesterday() ? Number(s.count) + 1 : 1; await redis.hset(`streak:${playerId}`, { last: day, count }); // The durable record, written after the player has their answer. const sql = neon(env.DATABASE_URL); ctx.waitUntil(sql`insert into votes (day, player_id, choice) values (${day}, ${playerId}, ${choice}) on conflict do nothing`); return Response.json({ ok: true, streak: count }); } async function results(req: Request, ctx: ExecutionContext, redis: Redis) { // One cached answer per Cloudflare location, refreshed every 5 seconds. const key = new Request(new URL("/api/results", req.url).toString()); const hit = await caches.default.match(key); if (hit) return hit; const day = dayOf(); const tally = (await redis.hgetall<Record<string, number>>(`tally:${day}`)) ?? {}; const total = Object.values(tally).reduce((a, b) => a + Number(b), 0); const percent = Object.fromEntries( Object.entries(tally).map(([k, v]) => [k, total ? Math.round((Number(v) / total) * 1000) / 10 : 0]), ); const res = Response.json({ day, total, percent }, { headers: { "Cache-Control": "public, max-age=5" } }); ctx.waitUntil(caches.default.put(key, res.clone())); return res; }Done when: Voting twice from the same player id leaves the tally unchanged, and the votes table gets exactly one row.
Show live percentages by polling the cached endpoint
The results endpoint caches one answer per Cloudflare location for 5 seconds (the Cache API doesn't replicate between locations), so heavy polling reaches Redis only a few times a second.
- After a player votes, show the percentages and poll /api/results every 5 seconds.
- Poll only while the tab is visible (document.visibilityState) and stop after a few minutes on the page.
- Never put anything player-specific (their own vote or streak) in the shared results response; return those from the vote call instead.
public/results.jsexport function watchResults(render) { const stopAt = Date.now() + 3 * 60_000; async function tick() { if (document.visibilityState === "visible") { const res = await fetch("/api/results"); if (res.ok) render(await res.json()); } if (Date.now() < stopAt) setTimeout(tick, 5000); } tick(); }Done when: With the results screen open in two browsers, a vote in one shows in the other within about 10 seconds.
Roll the day over on a schedule
A Cron Trigger at midnight UTC loads the new question and saves yesterday's final tally, so the game never depends on someone being awake.
- Add a scheduled() handler that reads the new day's question from Postgres and writes it to q:{day} in Redis.
- In the same run, copy yesterday's tally hash into final_results in Postgres.
- If no question exists for the new day, report it to Sentry and show a friendly 'back tomorrow' page rather than an error.
Done when: Running the scheduled handler locally for a test date loads that day's question and writes the previous day's final_results row.
Make sharing the growth loop
Players share a short text result with a link. Most new players arrive through these shares, and the text costs nothing to generate.
- Build the share text in the browser: the game's name and day number, the player's pick, how much of the crowd agreed, the streak, and the link.
- Call navigator.share() from the Share button where the browser supports it, and fall back to navigator.clipboard.writeText() with a 'Copied' message. Both need HTTPS.
- Give each day its own page with an og:image, made once when you write the question and saved as a static asset, so link previews never run code.
- Keep each past day's results page up, since late clicks on shared links land there.
Done when: Pasting a shared link in a chat app shows the day's preview image, and the Share button works on a phone and on a desktop browser.
Get ready for the viral day
Every free plan in this stack stops or errors at its cap. Set the plans, caps and alerts before the spike, not during it.
- Confirm Workers Paid is on.
- Confirm Upstash is on pay as you go with a budget, and Neon's compute has a maximum size set.
- Add Sentry to the Worker and alert on a jump in errors for /api/vote.
- Send a few thousand test votes from a script against a preview deployment and check that the tally, the votes table and the cached results agree.
Done when: A test of a few thousand votes in a minute finishes with no errors, matching counts in Redis and Postgres, and a results endpoint that still answers from cache.
Best practices
- Count in Redis, store in Postgres: the tally is fast and can be rebuilt; the votes table is the truth.
- Serve everything that's the same for every player from cache, and keep per-player answers out of shared responses.
- Let people play without signing in; offer sign-in only to keep streaks across devices.
- Keep past days' results as permanent pages, since shared links keep arriving for days.
- Write questions weeks ahead and keep a few spares for a day you can't.
Pitfalls
- Counting with SELECT count(*) on every page view: under a spike, the database becomes the bottleneck.
- Keeping the tally in Cloudflare KV or Vercel Global Config: both are built for rare writes (KV allows 1 write per second per key).
- Opening a WebSocket per player for numbers that change every few seconds: free realtime plans allow connections in the hundreds.
- Launching on a free plan that stops at its cap: Workers Free errors after 100,000 requests a day; Vercel Hobby stops for 30 days past its limits.
- Trusting the browser to prevent double votes: enforce it on the server.
- A server-side day boundary in local time that nobody wrote down: players in different time zones see different days.
Costs and obligations
- Steady state at 1,000 daily players: about $5/month, all of it Workers Paid; Upstash, Neon, Sentry and PostHog stay on their free plans.
- A viral day of 100,000 players who each vote once and watch results for 2 minutes (one poll every 5 seconds): page loads are static assets (free), and 100,000 votes + 2.4M polls is about 2.5M Worker requests, a quarter of Workers Paid's 10M a month (then $0.30 per million).
- Upstash on that day: about 6 commands per vote (rate limit, today's question, dedupe, tally, streak read and write) is 600K, and the 5-second cache keeps results reads small. At $0.20 per 100K that's about $1.20 to $1.50 for the day on pay as you go.
- Neon on that day: 100,000 small inserts sent after each response is light work; watch compute hours (Free has 100 CU-hours a month per project).
- If the game is aimed at children under 13 in the US, COPPA applies before you collect any identifier; get advice before launch.
- An anonymous player id and analytics cookies can still be personal data under GDPR for EU players; say what you store in a privacy policy and ask for consent where analytics needs it.
- Don't keep IP addresses longer than the rate limit needs.
These point to obligations to check; they aren't legal or tax advice.