← benadams.dev
Progress
0/25
Build planDeployed · 1,001 dishes ingested

Scrandle

A food bracket that lives in a Discord channel

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
Contents01Overview02Settled decisions03Accounts and setup04Stack05Free-tier limits06Schema07Build phases08Version announcements09Secrets10Open questions
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, which invites bandwagoning. Buttons cost about 50 lines more and take that away — each click gets a private ephemeral reply and nobody sees a thing until close. Revisited later, when the channel asked to see who voted for what: a poll gives that for free, but a poll message can never be edited, and the whole close flow is an edit that swaps the matchup card for the result card. Publishing the ballot from the buttons was forty lines. Losing the reveal would have been the game.

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, rounds, 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,
  rd REAL NOT NULL DEFAULT 250,        -- Glicko deviation: how sure we are
  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,
  rd_a_before REAL,
  rd_b_before REAL,
  rd_a_after REAL,
  rd_b_after REAL,
  bonus INTEGER NOT NULL DEFAULT 0     -- runs beside the everyday slot
);

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. The weekly ranking round got its own rounds, round_entries and round_votes rather than widening these: a pair is a ranking of two and could have been the n=2 case, but matchups is what the everyday game, the standings and every admin route read, and generalising it would have meant rewriting all of them at once on a bot that was already running.

07

Build phases

Seven phases built, one left. Mark them off as you verify them against your own deploy.

  1. 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.

  2. 1
    1

    Ingest

    Built

    Hourly poll, hash, store the bytes in R2.

    Hourly scheduled() handler:

    1. 01Read last_message_id from state.
    2. 02GET /channels/{id}/messages?after={cursor}&limit=100, reversed to oldest-first.
    3. 03Filter to attachments with an image content type, skipping bot messages.
    4. 04Take at most 15. Leftovers wait for next hour, which drains on its own at hourly cadence.
    5. 05For each, in batches of 5: fetch bytes, hash sha256, skip if the hash exists, PUT to R2, insert a dishes row.
    6. 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?pages=5 (with the secret in an Authorization header) walks history backwards for the one-time import. Run it by hand.

  3. 2
    2

    Matchup posting

    Built

    Pick two, post one message, two buttons.

    Same hourly tick. On a named hour from POST_HOURS_UTC the slot posts MATCHUPS_PER_SLOT matchups together — three, at 9am — and then refuses until they close. A matchup closes when the next posting hour comes round rather than a fixed span after it went up, so with one hour a day that is a full day of voting, and a post made off-schedule still hands its slot back on time. The cap counts what is open rather than what went out, so a batch that failed to close is topped back up rather than stacked on. Pair selection:

    1. 01Any dish with first_matchup_id IS NULL jumps the queue, oldest first, so new dishes are guaranteed a slot.
    2. 02Otherwise take the least-played dish, ties broken randomly.
    3. 03Opponent comes from a 150-point Elo band, excluding any pair seen in the last 20 matchups. Close matchups are tense matchups.
    4. 04Every fifth matchup is a deliberate wide-gap pair. Upsets make the best results.
    5. 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.

    The batch lives in two threads. Five cards in the morning and five results the next morning is ten bot posts a day in a channel that exists for people to post their dinner in, so the batch goes out as one message — "Cooking — Thu 3 Sep", with when voting closes — that opens a thread with the cards inside, and the results open "Results — Thu 3 Sep" the same way a day later. The channel sees two posts a day. Bonuses, ranking rounds and the caption contest are one post each and stay on the channel floor.

    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.

    The morning's posts are cooking and nothing else. Drinks used to share them, and because both halves of a pair have to match category, that meant the 9am post was a food matchup only when the draw happened to land on food — at a rate nobody chose. Drinks, places and people each got a slot of their own instead.

    Three a morning rather than two a day is a decision about the backlog. 577 food photographs, 539 never on the board, refilling at about one a day — and the Wednesday five comes out of the same pool. Two matchups a day spends 4.7 photographs and gains one, so the unplayed pile lasts about five months; three spends 6.7 and makes it three. Worth paying — at two a day a photograph gets 1.9 outings a year and a rating built on that is noise. Nothing runs out either way, because the draw orders by play count and an empty backlog just means a second lap.

    Thursday is the placement round: up to five photographs from the last fortnight that nobody has voted on, ranked on one card. The rotation does put unplayed photographs first, which is not the same as putting new ones first — 539 of them are unplayed and the pick among them is random, so something posted on Tuesday joins the back of a queue three months deep. This does not change how fast the pool is spent, only the order, and the order is the point. Below three new photographs it posts a pair instead; below one it posts nothing and leaves the slot for the next tick.

    How often the drink slot fires is computed rather than configured, because a fixed weekly day is wrong in both directions — six drinks and it shows the same two every month, eighty and it never gets through them. It aims at a constant sweep: the time for every drink to reach the board once. Two drinks a matchup, a four-week sweep, so about one post a week per eight drinks, clamped between weekly and daily.

  4. 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 — while a round is running nobody sees who voted, and there is no tally to bandwagon onto. Once it closes, the result names everyone.

    The weekly place round carries b:<roundId>:<slot> instead, and clicks accumulate rather than replace: the first is your favourite, the second your runner-up, and you can stop whenever. A ballot of one is valid — it says that photo beat the other four and says nothing about how those four compare.

    The caption contest needed the one thing buttons cannot do: take a sentence. That is a modal — type: 9 in the response, a MODAL_SUBMIT interaction coming back — and it is the only way Discord will collect free text from somebody without giving them a message box the rest of the channel can read. Reopening it prefills what they already wrote, so a second click is an edit rather than something that looks like it lost the first one.

  5. 4
    4

    Close and reveal

    Built

    One rating update per matchup, then edit the message.

    The rating 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.

    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.

    A second embed under the card names who voted for what, in the order they voted. It rides on the edit that was happening anyway, so it costs no extra call, no extra permission and no second message — and it renders below the card, which keeps the card the headline.

    How far a result moves a rating depends on how much is already known about it. Every photograph carries a Glicko deviation beside its rating — 250 when nobody has voted on it, narrowing with every result, floored at 60. A fixed K could not work here: at 6.7 photographs a day out of 577, a photograph gets about four outings a year, and at K=24 one that belongs 300 points above the opening rating needs thirty-five games to get there. That is most of a decade, so every rating in the table was 1500 plus a coin toss. The floor is picked so two settled photographs still move about as far as the old K moved them; the change is felt at the new end of the catalog and nowhere else.

    One consequence: ratings are no longer zero-sum. The side with the wider deviation moves further, because a newcomer beating a veteran says far more about the newcomer.

    A ranking round closes as the round-robin it already is: every pair of photos in it is an ordinary matchup scored the same way, where a ballot ranking one above another is a vote for it and anything ranked beats everything left unranked. A photo's whole card is one rating period — the four comparisons it appears in, resolved together — so the prior damps them to about three and a half matchups' worth rather than four, and tightens its deviation on the way out.

    A caption contest closes on Borda points — 3, 2, 1 — and not on Elo. A caption has no rating to carry anywhere: it exists for one photograph and will never appear again, so a rating would need a pool to be rated against and there is not one. A contest has a winner, which is a different thing from a rating, and the result names the authors of every line for the first time.

  6. 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.

  7. 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.

  8. 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:

json
"deploy": "wrangler deploy && node scripts/announce.mjs"

The script reads the changelog and POSTs to the logs webhook. Ping @Tasters for player-facing changes, stay quiet for bugfixes.

09

Secrets

Set with wrangler secret put, never in wrangler.toml:

secrets
DISCORD_BOT_TOKEN
DISCORD_PUBLIC_KEY
DISCORD_LOG_WEBHOOK_URL
SCRANDLE_IMAGE_SECRET
BACKFILL_SECRET

Plain vars in wrangler.toml:

vars
DISCORD_CHANNEL_ID
DISCORD_GUILD_ID
TASTER_ROLE_ID
IMAGE_BASE_URL
R2_PUBLIC_BASE
VOTE_WINDOW_HOURS
POST_HOURS_UTC
MATCHUPS_PER_SLOT
STANDINGS_WEEKDAY
STANDINGS_HOUR_UTC
10

Open questions for the build session

Q1

Vote window: 24 hours or 48? A day keeps it a ritual, two catches people who only check in on weekends.

Q2

Should a dish ever retire from the catalog, or does everything stay eligible forever?

Q3

Reaction-count threshold for qualifying a backfilled photo, so the channel's existing reactions do the curation.

Scrandle build plan0/25 steps done