<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: slasht</title>
    <description>The latest articles on DEV Community by slasht (@slasht).</description>
    <link>https://dev.to/slasht</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4234288%2Fe682cb1f-11a7-4dfa-912e-9c5d11cd2772.png</url>
      <title>DEV Community: slasht</title>
      <link>https://dev.to/slasht</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/slasht"/>
    <language>en</language>
    <item>
      <title>I built an Elo rating system for cat photos, and the matchmaking was harder than the maths</title>
      <dc:creator>slasht</dc:creator>
      <pubDate>Mon, 09 Nov 2026 01:43:15 +0000</pubDate>
      <link>https://dev.to/slasht/i-built-an-elo-rating-system-for-cat-photos-and-the-matchmaking-was-harder-than-the-maths-130m</link>
      <guid>https://dev.to/slasht/i-built-an-elo-rating-system-for-cat-photos-and-the-matchmaking-was-harder-than-the-maths-130m</guid>
      <description>&lt;p&gt;Elo — the rating system behind chess rankings — is about fifteen lines of code.&lt;br&gt;
I know, because I wrote those fifteen lines early one evening and then spent the&lt;br&gt;
next several weeks on everything around them.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://katwars.com" rel="noopener noreferrer"&gt;Katwars&lt;/a&gt;: two cat photos appear, you tap the&lt;br&gt;
cuter one, and that single tap updates a global rating ladder. No accounts, no&lt;br&gt;
sign-up, no app. It runs on Cloudflare Workers.&lt;/p&gt;

&lt;p&gt;Here is what turned out to be interesting.&lt;/p&gt;
&lt;h2&gt;
  
  
  The easy part: the rating maths
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ELO_DIVISOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;expectedScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opponent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opponent&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;ELO_DIVISOR&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;applyElo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winnerRating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loserRating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedWinner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;expectedScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;winnerRating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loserRating&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;expectedWinner&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;newWinner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;winnerRating&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;newLoser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;loserRating&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rating is conserved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;applyElo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newWinner&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newLoser&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;kFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;battles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;battles&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// calibrating&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;battles&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// veteran&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The hard part: deciding who fights whom
&lt;/h2&gt;

&lt;p&gt;This is where the real design problem lives, and it took several rewrites.&lt;/p&gt;

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

&lt;p&gt;There is also a human constraint: voters get bored. Show the same popular cats&lt;br&gt;
repeatedly and the ladder becomes a popularity contest between whatever was&lt;br&gt;
uploaded first.&lt;/p&gt;

&lt;p&gt;What I settled on is a weighted mix of two strategies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;P_BUCKET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// rating-bucketed pair&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;P_EXPOSURE_INJECT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// deliberately show a newcomer&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BUCKET_WIDTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// rating points&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;EXPOSURE_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// battles before "established"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;That test has caught more regressions than any unit test in the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anonymous voting that isn't trivially gameable
&lt;/h2&gt;

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

&lt;p&gt;The compromise that worked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A signed device cookie.&lt;/strong&gt; Not an identity, just a random id with an HMAC, used
for deduplication and rate limiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed battle tokens.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An idempotency key per vote&lt;/strong&gt;, so a retry on a flaky mobile connection can
never double-count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never show a voter their own cat.&lt;/strong&gt; The matchmaking query excludes them, which
removes the most obvious abuse before it starts.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Running it on Cloudflare Workers
&lt;/h2&gt;

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

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

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

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

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

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

&lt;p&gt;The maths was the fun part. Everything that decides whether the thing actually&lt;br&gt;
works — fair exposure, abuse resistance, honest content — lives outside it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Code notes and the site itself: &lt;a href="https://katwars.com" rel="noopener noreferrer"&gt;katwars.com&lt;/a&gt;. The&lt;br&gt;
&lt;a href="https://katwars.com/guides/how-katwars-rankings-work" rel="noopener noreferrer"&gt;rankings explainer&lt;/a&gt; covers&lt;br&gt;
the same maths for non-programmers, in case you want to send it to someone who&lt;br&gt;
just wants to know why their cat lost.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>cloudflare</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
