Skip to content

Repository files navigation

Title Fight

Same name. Different song. Who does it better?

Two songs called Baby have nothing in common except the word. Title Fight takes the eight most popular tracks sharing a title, seeds them 1–8, and runs a single-elimination bracket. You get 15 seconds of each, pick a winner, and crown a champion in about four minutes.

Then you send the link — your friends get the same eight songs and their own opinions.

Quarterfinals    1 v 8    4 v 5    2 v 7    3 v 6
Semifinals         winners advance
Final              one champion

No accounts, no database of users, no music library. Everything is drawn live from Deezer's public API the moment you open a title.

Modes

Mode Route What changes
Daily / · /daily The default. One title a day, the same for everyone, with a day streak.
Free play /play Endless. A title you haven't met, 15-second clips, no clock.
Blind /blind/<title> Covers and artist names hidden until you've picked.
Speed /speed/<title> 5-second clips and 90 seconds to crown a champion.

Opening the app gives you today's daily. Once you've finished it, / falls through to free play rather than dead-ending on a completed bracket — there'd otherwise be nothing to do until midnight. /daily always goes to the daily, finished or not, so you can revisit it.

Every screen carries a mode badge, because the bracket looks identical in all four and "am I on today's title?" needs answering without hunting through the URL.

Each mode keeps its own progress, so finishing Money in free play doesn't hand you a half-finished bracket when Money comes up as the Daily.


Quick start

pnpm install
pnpm dev          # http://localhost:3000

That's it — no API keys, no configuration. Votes are stored in memory and reset when the server restarts, so crowd percentages will look thin until you connect Redis (below).

Requires Node 22 (see .nvmrc) and pnpm.

How it works

Browser ──── /t/baby ───────► Next.js ──── search ────► Deezer public API
   │                             │                      (no key required)
   │  album art + 15s preview    │
   ├──────── direct ─────────────┼───────────────────►  Deezer CDN
   │                             │
   └──── POST /api/vote ────────►│ ──── HINCRBY ─────►  Upstash Redis
                                                        (anonymous counters)

A title is resolved server-side. Opening /t/baby searches Deezer, filters to exact title matches, dedupes by artist, and keeps the top eight by popularity. The field ships inside the HTML — no client round trip, and a title with fewer than eight versions returns a real 404.

Matching is deliberately strict (lib/normalize.ts). Baby (feat. Ludacris) counts as Baby; Baby (Acoustic Version), remixes, live cuts and Baby Blue do not. Otherwise a bracket fills with eight versions of the same recording.

Previews are never stored. Deezer's URLs are signed and expire, so only track IDs are kept and the URLs are re-resolved per request (cached 20 minutes). Audio streams straight from Deezer's CDN to the browser — this app never proxies or caches it, and never plays more than 15 seconds.

A song is only new once. Heard track IDs are remembered per tournament, so the semifinal doesn't make you re-listen to something you judged in the quarters.

The Daily resets at your midnight, not UTC. The server can't know your timezone on a cold request, so /daily works out the local date on the client and hands off to /daily/<date>, which server-renders as normal. That route only accepts dates within a day of the server's own — real offsets span UTC−12…UTC+14, so that covers every genuine visitor while stopping anyone walking the URL back through the archive.

Which title a day gets is a pure function of the date, so every serverless instance agrees without coordinating, and it's pinned in Redis for the day on first request. Without the pin, adding a title to the pool would re-deal the whole schedule mid-day and hand the afternoon a different daily than the morning. The pool is dealt as a shuffled deck rather than hash(day) % length, so no title repeats until every other one has had a turn.

Project layout

app/
  t/[slug]/          the tournament (server-resolves the field, generates the OG card)
  daily/[date]/      the day's bracket; /daily resolves the local date and redirects
  blind/[slug]/      covers and names hidden until you pick
  speed/[slug]/      5s clips against a 90s clock
  api/title          resolve the eight seeds for a title
  api/matchup        the current pair + a signed vote token
  api/vote           record a vote, return the crowd split
  api/hot            leaderboard of most-played titles
  api/random         pick a title to start
components/arena/    the game: cards, waveform, bracket rail, champion reveal, speed clock
components/ui/       shadcn primitives (button, card, badge, skeleton)
lib/
  live-title.ts      Deezer search → eight seeds
  bracket.ts         seeding and bout progression (pure, unit-tested)
  normalize.ts       title matching rules (pure, unit-tested)
  modes.ts           per-mode clip length, clock, blindness, storage key
  daily.ts           local-date maths, the pool, the daily pin (pure, unit-tested)
  daily-stats.ts     day-streak transitions (pure, unit-tested)
  audio.ts           single audio element, clipped playback, cross-fade
  redis.ts           vote counters, with an in-memory fallback
  vote-token.ts      HMAC signing of matchups

What the modes don't do

Two honest limits, both consequences of having no accounts:

  • The daily streak is local and unverified. It lives in localStorage, so clearing site data loses it and moving the system clock forward will bend it. It's a personal record, not a leaderboard position, and it isn't defended as one.
  • Blind mode hides names from the interface, not from devtools. The reveal has to be instant when you pick, so both artists are already in the page when the bout loads. It removes the bias for anyone playing in good faith, which is the whole point; it is not a tamper-proof blind test.

Deployment

Deploys to Vercel's free tier as-is. Add a free Upstash Redis database for global vote counts, then set:

Variable Required Purpose
UPSTASH_REDIS_REST_URL prod Vote counters over HTTPS
UPSTASH_REDIS_REST_TOKEN prod
VOTE_SECRET prod HMAC key for vote tokens — build fails without it
IP_HASH_SALT prod Salts hashed IPs for rate limiting
NEXT_PUBLIC_SITE_URL prod Share links, OG images, and the domain printed on result cards

See .env.example. Generate secrets with openssl rand -base64 32.

Changing domain is a one-line move: update NEXT_PUBLIC_SITE_URL and redeploy. Every share link, OG tag and the branding baked into result cards reads from it. On Vercel it falls back to the project's production domain, so a forgotten variable can't publish cards pointing at localhost.

A full bracket costs roughly 30 Redis commands, so Upstash's 10k/day free tier covers about 300 brackets a day.

Vote storage

Three keys, no schema, no migrations:

Key Type Purpose
votes:{slug}:{loId}-{hiId} hash {a, b} The tallies
voted:{voterId}:{pairId} string, 180d TTL One vote per person per pairing
hot:titles sorted set /hot leaderboard

Pair IDs are ID-sorted, so Bieber vs Prospa is the same key regardless of which side it rendered on — votes accumulate globally across everyone who plays that matchup.

Security

  • Votes are signed. /api/matchup issues an HMAC token naming the only two track IDs that may be voted for, and verifies both IDs are real seeds of that title. A crafted request can't invent a pairing or vote for a track that isn't in the bracket.
  • Production refuses weak secrets. VOTE_SECRET and IP_HASH_SALT have development fallbacks for zero-config local runs; because those fallbacks are public (they're in this repo), the app throws on startup if they're used in production.
  • Rate limited. 80 votes/hour and 30 title lookups/minute per hashed IP, so nobody can stuff the ballot or burn the Deezer and Redis quotas.
  • Inputs are bounded. Title slugs must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ and be ≤40 characters before they reach a Deezer query or a Redis key.
  • No PII. An anonymous tf_vid cookie (httpOnly, SameSite=Lax) and a salted IP hash. No accounts, no emails, no tracking. Streaks and brackets live in localStorage.
  • Security headers in next.config.ts: an enforced Content-Security-Policy, HSTS, nosniff, X-Frame-Options: DENY, and a restrictive Permissions-Policy. The browser only ever talks to this origin plus Deezer's CDNs — everything server-side is proxied through our own routes. 'unsafe-eval' and websocket origins are development-only.

One honest limitation: clearing cookies lets you vote on a pairing again. This is a toy, not an election — the tallies are directional, not authoritative.

Development

pnpm dev                  # Turbopack, service worker disabled
pnpm test                 # node:test — bracket, matching, tokens, slugs, seeds
pnpm lint
pnpm exec tsc --noEmit
pnpm build                # next build --webpack (emits public/sw.js)
pnpm verify:seeds         # check every seed still has a full bracket on Deezer

Adding titles

lib/seeds.ts holds the words the random picker draws from. A word only works if Deezer returns eight different artists with that exact title — otherwise the page 404s. Check candidates before committing them:

pnpm verify:seeds Karma Moonlight Shallow

It runs the real lookup, prints the top artists for each hit, and emits a block ready to paste into the list.

The production build must run under webpack because Serwist's plugin hooks that pipeline; pnpm build already does this. See AGENTS.md for environment gotchas (notably: Node's fetch fails behind TLS-inspecting corporate proxies, so lib/http.ts falls back to curl).

License

Source code is MIT.

Song metadata, artwork and audio previews come from the Deezer API, belong to their rights holders, and are not covered by that license — nothing is stored, and every card links back to the track on Deezer. Title Fight is a fan project, not affiliated with Deezer.

Typeset in Instrument Serif, Inter and JetBrains Mono, all under the SIL Open Font License. Full attribution in NOTICE.md.

About

What's the best song with the same name?

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages