A ranking game: drag 2β5 things from most hipster to most mainstream. Real popularity comes from a per-category blend of public signals β English Wikipedia pageviews plus a category-specific source (Spotify, Last.fm, TMDb, IMDb, or Open Library).
npm install
npm run devOpen http://localhost:3000.
src/
lib/
items.ts # Curated item bank β names + Wikipedia article slugs
game.ts # Round generation + Kendall-tau pair scoring
types.ts
popularity/ # Pluggable popularity sources + weighted blending
wikipedia.ts spotify.ts lastfm.ts tmdb.ts imdb.ts openlibrary.ts
blend.ts # min-max normalize per source, then weighted-average
types.ts # SourceName, per-category weights, display labels
data/ # Pre-scraped numbers baked in (spotify / imdb / openlibrary)
app/
page.tsx # Landing: category picker
play/page.tsx # Game shell (reads ?category=&size=)
play/GameClient.tsx, Card.tsx, SortableCard.tsx, Reveal.tsx
api/round/route.ts # GET /api/round?category=music&size=4 -> { items: ScoredItem[] }
Concordant-pair fraction (a Kendall-tau variant, normalized to 0β1). For n items there are C(n,2) pairs; you get credit for every pair you ordered correctly relative to the actual popularity ranking. So in a 4-item round, perfect is 6/6 = 100%; swapping two adjacent items is 5/6 β 83%.
The "actual" ranking is a weighted blend of public popularity signals, chosen per category. Each source returns a raw number on its own scale; within a round we min-max normalize each source to [0, 1] and take a weighted average (see src/lib/popularity/blend.ts). Weights live in src/lib/popularity/types.ts:
| Category | Source (weight) | + Wikipedia |
|---|---|---|
| Music | Spotify monthly listeners (0.5), Last.fm listeners (0.1) | 0.4 |
| Movies | TMDb popularity (0.5) | 0.5 |
| TV | IMDb number of ratings (0.6) | 0.4 |
| Books | Open Library reading-log count (0.6) | 0.4 |
| Everything else | β | 1.0 |
TV β IMDb and Books β Open Library are both free and need no API key. IMDb publishes a daily ratings dataset (numVotes is a great mainstream proxy); we resolve each show's IMDb id from its Wikipedia slug via Wikidata, then look up its vote count. Open Library's search API returns readinglog_count (how many people have shelved a book) β the best free Goodreads-shelf-like signal.
Both are pre-scraped and baked into src/lib/data/ so the runtime makes no extra calls. Refresh them with:
node --experimental-strip-types scripts/scrape-imdb-data.mjs # β data/imdb-votes.json
node --experimental-strip-types scripts/scrape-openlibrary-data.mjs # β data/openlibrary-counts.jsonMusic's Spotify numbers come from scripts/scrape-spotify-data.mjs (kworb). Last.fm and TMDb are live API calls gated on LASTFM_API_KEY / TMDB_API_KEY (see .env.example); a source with no data for an item simply drops out and the remaining weights renormalize.
Edit src/lib/items.ts. Each item needs:
{ id: "unique-slug", name: "Display Name", wiki: "Wikipedia_Article_Title", category: "music" }The wiki field is the article's URL slug. Underscores are fine. URL-encode non-ASCII (Phα» β Pho because the article redirects, but NattΕ β Natt%C5%8D). Quick check: the slug works if https://en.wikipedia.org/wiki/<slug> loads.
See MULTIPLAYER.md β the room-code architecture, transport options (Partykit / Upstash + SSE / Pusher), and the anti-cheat note about hiding pageview counts from the client.
Every main game state has a story.
npm run storybook # dev
npm run build-storybook # static build β ./storybook-staticStories live next to their components:
src/app/Landing.stories.tsxβ landing/category pickersrc/app/play/Card.stories.tsxβ base card, dragging, ghost, long namesrc/app/play/Board.stories.tsxβ full drag-and-drop play state (2/4/5 items, mixed category)src/app/play/Reveal.stories.tsxβ score states: perfect, sharp instincts, mixed signals, inverted, plus 2- and 5-item rounds
Tailwind v4 is wired into the preview via .storybook/preview.tsx, which imports src/app/globals.css.
Snapshots every story and diffs against the last accepted baseline.
npm run chromaticFor CI, .github/workflows/chromatic.yml runs on every push and PR. To activate:
- Create a project at chromatic.com.
- Add the project token as a repo secret named
CHROMATIC_PROJECT_TOKEN. - The first run accepts all snapshots as the baseline; subsequent runs flag pixel diffs in PR checks.
Push to GitHub, import on Vercel β zero config needed. The Wikipedia Pageviews API is free and unauthenticated.