Dev.to WebDev 🛠 Dev 👁 0 📖 5 min read

I built an Elo rating system for cat photos, and the matchmaking was harder than the maths

Elo — the rating system behind chess rankings — is about fifteen lines of code. I know, because I wrote those fifteen lines early one evening and then spent the next several weeks on everything around them. The project

Elo — the rating system behind chess rankings — is about fifteen lines of code.
I know, because I wrote those fifteen lines early one evening and then spent the
next several weeks on everything around them.

The project is Katwars: two cat photos appear, you tap the
cuter one, and that single tap updates a global rating ladder. No accounts, no
sign-up, no app. It runs on Cloudflare Workers.

Here is what turned out to be interesting.

The easy part: the rating maths

Elo gives every player a rating, predicts each matchup from the difference
between two ratings, and moves points from loser to winner based on how
surprising the result was.

const ELO_DIVISOR = 400;

function expectedScore(self, opponent) {
  return 1 / (1 + Math.pow(10, (opponent - self) / ELO_DIVISOR));
}

function applyElo(winnerRating, loserRating, k) {
  const expectedWinner = expectedScore(winnerRating, loserRating);
  const delta = Math.round(k * (1 - expectedWinner));
  return {
    newWinner: winnerRating + delta,
    newLoser: loserRating - delta,
    delta,
  };
}

Two details matter more than they look.

Compute one delta, apply it symmetrically. If you calculate the winner's gain
and the loser's loss independently, rounding makes them disagree, and total
rating in the system drifts up or down forever. One rounded delta added to one
side and subtracted from the other keeps the sum exactly constant — a property
you can assert in a test:

test("rating is conserved", () => {
  const r = applyElo(1500, 1500, 32);
  expect(r.newWinner + r.newLoser).toBe(3000);
});

The K-factor should decay. K controls how far a single result can move a
rating. New entrants need to move fast to find their true level; established
ones need stability, or one unlucky evening erases a month of results:

function kFor(battles) {
  if (battles <= 20) return 32; // calibrating
  if (battles <= 100) return 24;
  return 16; // veteran
}

The hard part: deciding who fights whom

This is where the real design problem lives, and it took several rewrites.

Elo only converges if ratings are tested against informative opponents. Pick
pairs uniformly at random and most matchups are lopsided — a 1900 against a
1200 tells you almost nothing, because the favourite wins and gains two points.
Pick only near-equal pairs and new entrants never get seen at all, because
nothing sits near an unrated newcomer.

There is also a human constraint: voters get bored. Show the same popular cats
repeatedly and the ladder becomes a popularity contest between whatever was
uploaded first.

What I settled on is a weighted mix of two strategies:

export const P_BUCKET = 0.75; // rating-bucketed pair
export const P_EXPOSURE_INJECT = 0.25; // deliberately show a newcomer
export const BUCKET_WIDTH = 200; // rating points
export const EXPOSURE_THRESHOLD = 10; // battles before "established"

Three quarters of the time, pick a seed at random and find an opponent within
200 rating points — a close matchup, which is where information lives. The other
quarter of the time, force the seed to be a cat with fewer than ten battles, so
newcomers are guaranteed exposure instead of waiting for luck.

The number that told me whether this worked was not the average. Averages hide
starvation. What matters is the 10th percentile of battles per entrant: if
the least-seen ten percent of cats get a fair number of appearances, nobody is
being ignored. That became an integration test that seeds a hundred cats, runs a
thousand picks against a real Postgres instance, and asserts on p10.

That test has caught more regressions than any unit test in the project.

Anonymous voting that isn't trivially gameable

No accounts was a product requirement — the whole thing should work in one tap
from a shared link. That leaves you with the classic problem: how do you stop
someone voting for their own cat five hundred times?

The compromise that worked:

  • A signed device cookie. Not an identity, just a random id with an HMAC, used for deduplication and rate limiting.
  • Signed battle tokens. When the server hands out a pair, it also issues a short-lived signed token describing that exact matchup. A vote without a valid token for the pair it claims to be voting on is rejected — so you cannot fabricate favourable matchups by posting straight to the API.
  • An idempotency key per vote, so a retry on a flaky mobile connection can never double-count.
  • Never show a voter their own cat. The matchmaking query excludes them, which removes the most obvious abuse before it starts.

None of this survives a determined attacker with a script and a proxy pool.
It comfortably handles the realistic case, which is someone reloading a lot,
and that is the right bar for a cat site.

Running it on Cloudflare Workers

The stack is Next.js on Cloudflare Workers via OpenNext, Neon Postgres over HTTP,
and R2 for images. A few things I would tell my past self:

Watch the bundle ceiling. Workers cap compressed bundle size — the free tier
at 3 MB — and a Next app gets there faster than you expect. CI fails the build if
the gzipped worker exceeds the ceiling, which has stopped several careless
imports from shipping.

Prerendering is not caching. This one cost me a production outage of sorts.
Pages built at deploy time are only served if there is an incremental cache
configured. Without one, every request is a cache miss — and a route marked
dynamicParams = false turns that miss into a 404. My build was green, my
local production server was fine, and six live pages returned 404 in production.
The lesson: verify new routes against the deployed worker, not just the build.

Cron triggers are limited. The free tier allows five per Worker, and I had
five. New scheduled work now rides an existing daily job and checks the weekday
itself, which is less elegant but costs nothing.

What I would do differently

I seeded the ladder with a few hundred placeholder cats so the site was not empty
at launch. It made the leaderboard look healthy and taught me nothing about real
users, and search engines took one look at five hundred near-identical pages and
declined to index them. Real content from real users would have been worth more
than a full-looking ladder.

The maths was the fun part. Everything that decides whether the thing actually
works — fair exposure, abuse resistance, honest content — lives outside it.

Code notes and the site itself: katwars.com. The
rankings explainer covers
the same maths for non-programmers, in case you want to send it to someone who
just wants to know why their cat lost.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.