A Scrandle-style voting game built from food photos posted in a private Discord channel. Roughly 15 players. One Cloudflare Worker polls the channel hourly, ingests new photos, and posts a matchup with two buttons. Votes are private, the result reveals at close, and the chefs find out who won in the channel.
Cloudflare WorkersD1R2Discord botnext/ogElo
Players
~15
Catalog
1,001 dishes
Frontend
None
Hosting cost
$0
02
Design decisions already settled
Read this before changing anything below. Each one was argued through and cost something to land on.
D01
The game lives in Discord, not on a web page
No voting page, no OAuth, no session cookies, no frontend. Discord already knows who everyone is and everyone is already in the channel. The web app was v1 for about a day; it turned out every part of it except the leaderboards was friction.
D02
Buttons, not a native Discord poll
Polls are zero code, but they show a live tally and anyone can expand an answer to see who voted. That kills anonymity and invites bandwagoning. Buttons cost about 50 lines more and give both back — each click gets a private ephemeral reply and nobody sees a thing until close.
D03
One pair at a time, not a slate of ten
A slate of ten is twenty messages and a chore. One matchup a day is a ritual. It also deletes the entire slate-construction algorithm — pick two dishes inside an Elo band, exclude recent pairs, prefer the least-played.
D04
Render images on Vercel, not in the Worker
Workers Free gives 10ms of CPU per invocation, which cannot rasterize anything. The render endpoints live in the Next app as ImageResponse routes — satori to SVG, resvg to PNG. The Worker builds a signed URL and Discord fetches the PNG itself.
D05
Re-host every image in R2
Discord CDN URLs are signed with ?ex= and ?hm= and expire in about 24 hours. They are refreshable, so expiry alone is not the argument — durability is. A chef deleting one message would otherwise punch a permanent hole in the back catalog, and the render endpoint wants stable, cacheable URLs.
D06
Chefs hidden during voting, revealed at close
Vote on the photos alone. The closed message attributes everything and publishes the self-vote tally. Anonymous voting keeps it honest, and the argument afterwards in the channel is the actual product.
D07
No tells about which dish is new
Sides are randomized and matchups never ping the Tasters role, because a ping would correlate with new dishes entering the pool. Only the weekly standings post pings. Small things, but the whole game rests on not knowing whose plate you are looking at.
03
Accounts and registrations
The code is written; this is what it needs before it can run. Everything here is free. Progress is kept in this browser.
Discord application
One bot, no OAuth client needed.
0/6
Discord server setup
IDs, a role to ping weekly, a place to log failures.
0/4
Cloudflare
One Worker, one D1 database, one public R2 bucket.
0/5
Vercel
Only one variable — the render routes already exist.
0/2
Nothing else. No auth provider, no Neon, no Supabase, no Workers Assets, no Hono. The Worker has two routes and hand-rolls them.
04
Stack
Cloudflare Workers
the whole game, one Worker
D1
dishes, matchups, votes, players, cursor state
R2
images, bound to the Worker so writes are a binding call rather than an S3 client
Next.js on Vercel
three ImageResponse routes that render the cards Discord displays
Discord HTTP interactions
button clicks POST straight to the Worker, no gateway connection
Cron trigger
one, hourly, doing ingest, close, and post in the same tick
Workers Free allows 5 cron triggers per account. Using one leaves room.
05
Free-tier constraints to design around
LimitValueWhat it means here
CPU time10ms per invocationPer invocation, not per day — rendering rarely does not help. This is why images render on Vercel.
Subrequests50 per invocationThe real ceiling, and D1 and R2 binding calls count towards it — confirmed by a backfill dying on it. Storing one image costs three, so ingest caps at 10 per run.
Simultaneous outgoing connections6Batch image downloads 5 at a time, no Promise.all over 20.
Interaction response3 secondsA vote is one D1 upsert and an ephemeral reply. Nowhere near it.
D1 writes100,000 rows/dayNot a factor.
R210 GB, 1M writes/moYears of photos.
Cron failures do not retry and do not alert. Two mitigations, both required:
—Make the tick idempotent. Advance the stored last_message_id cursor only after a successful commit, so a failed run replays cleanly next hour.
—Wrap each stage in try/catch and POST any error to the logs webhook. Cron failures do not retry and do not alert.
06
Schema
sql
CREATE TABLE dishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
discord_message_id TEXT NOT NULL UNIQUE,
attachment_id TEXT NOT NULL,
poster_discord_id TEXT NOT NULL,
r2_key TEXT NOT NULL,
sha256 TEXT NOT NULL UNIQUE,
caption TEXT,
posted_at INTEGER NOT NULL,
ingested_at INTEGER NOT NULL,
elo REAL NOT NULL DEFAULT 1500,
matches_played INTEGER NOT NULL DEFAULT 0,
first_matchup_id INTEGER
);
CREATE TABLE matchups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dish_a_id INTEGER NOT NULL REFERENCES dishes (id),
dish_b_id INTEGER NOT NULL REFERENCES dishes (id),
status TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed'
message_id TEXT,
created_at INTEGER NOT NULL,
closes_at INTEGER NOT NULL,
closed_at INTEGER,
votes_a INTEGER NOT NULL DEFAULT 0,
votes_b INTEGER NOT NULL DEFAULT 0,
elo_a_before REAL,
elo_b_before REAL,
elo_a_after REAL,
elo_b_after REAL
);
CREATE TABLE votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
matchup_id INTEGER NOT NULL REFERENCES matchups (id),
voter_discord_id TEXT NOT NULL,
picked_dish_id INTEGER NOT NULL,
voted_at INTEGER NOT NULL,
UNIQUE (matchup_id, voter_discord_id)
);
CREATE TABLE players (
discord_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
first_seen INTEGER NOT NULL
);
CREATE TABLE state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- keys: last_message_id, last_matchup_slot, last_standings_at,
-- standings_snapshot, backfill_cursor
The UNIQUE on votes gives you one-vote-per-person enforcement in the database rather than in application logic, and the upsert on top of it is what lets people change their pick until close. The UNIQUE on sha256 handles reposts. Query matchups directly for pair history rather than keeping a separate table. There is no drops table — one pair at a time made it unnecessary.
07
Build phases
Seven phases built, one left. Mark them off as you verify them against your own deploy.
0
0
Scaffold
Built
Wrangler, D1, R2, secrets, a cron that fires.
Worker project with no framework — two routes hand-rolled in fetch(), so no Hono. D1 migrations in migrations/, R2 bound as BUCKET, secrets via wrangler secret put.
1
1
Ingest
Built
Hourly poll, hash, store the bytes in R2.
Hourly scheduled() handler:
01Read last_message_id from state.
02GET /channels/{id}/messages?after={cursor}&limit=100, reversed to oldest-first.
03Filter to attachments with an image content type, skipping bot messages.
04Take at most 15. Leftovers wait for next hour, which drains on its own at hourly cadence.
05For each, in batches of 5: fetch bytes, hash sha256, skip if the hash exists, PUT to R2, insert a dishes row.
06Advance the cursor only after the batch commits.
Only JPEG and PNG are ingested. satori rasterizes those two, so a WebP would ingest cleanly and then fail to render mid-matchup. Skips are counted and reported to the logs webhook rather than swallowed.
/backfill?secret=...&pages=5 walks history backwards for the one-time import. Run it by hand.
2
2
Matchup posting
Built
Pick two, post one message, two buttons.
Same hourly tick. Posts only when nothing is open and the current UTC hour is one of POST_HOURS_UTC, at most once per hour. A matchup closes when the next one is due rather than a fixed span after it went up, so a post made off-schedule still hands its slot back on time. Pair selection:
01Any dish with first_matchup_id IS NULL jumps the queue, oldest first, so new dishes are guaranteed a slot.
02Otherwise take the least-played dish, ties broken randomly.
03Opponent comes from a 150-point Elo band, excluding any pair seen in the last 20 matchups. Close matchups are tense matchups.
04Every fifth matchup is a deliberate wide-gap pair. Upsets make the best results.
05Fall back to the nearest rating, then to any dish, rather than skipping a day.
The row is inserted first so the matchup id can go in the image URL path — that is what makes Discord's proxy treat each card as a new image instead of serving a stale one.
Sides are randomized, and the post never pings. A ping would correlate with new dishes entering the pool, and position 1 would otherwise always be the newer plate.
3
3
Voting
Built
Ed25519 verify, upsert, ephemeral reply.
POST /interactions is the only route that matters. Verify the X-Signature-Ed25519 header over timestamp + body, answer PING with PONG, then handle the button.
custom_id carries v:<matchupId>:<a|b>. The handler checks the matchup is still open, upserts the vote, and replies with flag 64 so only the voter sees it. Changing your pick is the same upsert.
Every response is ephemeral. That is the entire reason this uses buttons instead of a native poll — nobody sees who voted, and there is no running tally to bandwagon onto.
4
4
Close and reveal
Built
One Elo update per matchup, then edit the message.
Elo is applied once per matchup rather than once per vote. Sequential per-voter updates are order-dependent and jumpy with a pool this small. Vote share becomes a fractional score: 6 of 8 voters pick A, so A scored 0.75. K=24.
The original message is then edited in place — result card, chefs named, buttons removed, self-vote tally appended. The reveal happens where the argument will happen.
5
5
Weekly standings
Built
Chef ratings, movement since last week.
A chef rating is the mean Elo of their dishes, so there is no second rating system to maintain. Movement is computed against a snapshot kept in state, which avoids a history table.
Posted on STANDINGS_WEEKDAY after STANDINGS_HOUR_UTC, at most once every six days. This is the only post that pings @Tasters.
6
6
Render endpoints
Built
Three signed ImageResponse routes on Vercel.
/api/scrandle/matchup/[id], /result/[id], and /standings/[stamp]. Each takes ?d=<base64url json>&s=<hmac> and renders a PNG. Discord's proxy fetches them; the Worker never touches image bytes.
The HMAC is not decoration. Without it these routes are an open image proxy that will render any URL anyone hands them.
7
7
Leaderboards on the web
Not yet
The one part that genuinely wants HTML.
Dish and chef leaderboards, plus taste compatibility between players — agreement rate between any two voters across shared matchups, which falls out of the raw votes table for free. Two people who agree 80% of the time are taste twins, and that is a better social feature than any ranking.
Read-only, so it needs no auth. It can read D1 over the REST API or run as a second Worker route. Everything else stays in Discord.
08
Version announcements
Separate from the game loop and trivial. Add a post-deploy step: