<?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: Özgür ÖZALP</title>
    <description>The latest articles on DEV Community by Özgür ÖZALP (@ozgurozalp).</description>
    <link>https://dev.to/ozgurozalp</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%2F379043%2F7900ab84-81b0-4cae-99ae-e9f8ccf7a6de.jpg</url>
      <title>DEV Community: Özgür ÖZALP</title>
      <link>https://dev.to/ozgurozalp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ozgurozalp"/>
    <language>en</language>
    <item>
      <title>Rock paper scissors is secretly a distributed systems problem</title>
      <dc:creator>Özgür ÖZALP</dc:creator>
      <pubDate>Wed, 29 Jul 2026 19:30:04 +0000</pubDate>
      <link>https://dev.to/ozgurozalp/rock-paper-scissors-is-secretly-a-distributed-systems-problem-21i9</link>
      <guid>https://dev.to/ozgurozalp/rock-paper-scissors-is-secretly-a-distributed-systems-problem-21i9</guid>
      <description>&lt;p&gt;Back in 2023 I built &lt;a href="https://rock.paperscissors.online/" rel="noopener noreferrer"&gt;an online rock paper scissors game&lt;/a&gt; so my friends and I could settle arguments remotely. Open a room, send the link, best of three. I figured it was a weekend project.&lt;/p&gt;

&lt;p&gt;It's been running for about three years now, there's an iOS app, a Turkish sister site, and a matchmaking queue. And along the way this three-rule game kept teaching me lessons I thought only "real" systems taught. A few of them stuck with me.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't leak what you never send
&lt;/h2&gt;

&lt;p&gt;The first design question sounds silly until you sit with it: when player A picks rock, what does the server tell player B?&lt;/p&gt;

&lt;p&gt;Nothing. It has to be nothing.&lt;/p&gt;

&lt;p&gt;In most games a bit of information leaking to the client is a balance problem. Here it's total: seeing the opponent's pick even 100ms early means you win every round, forever. So the server acknowledges your pick, tells the other side only that you &lt;em&gt;have&lt;/em&gt; picked, and reveals both choices in the same message once the round locks. Commit first, reveal later.&lt;/p&gt;

&lt;p&gt;The same reasoning pushed everything else server-side. The client never says "I won" — there is no message type for it. It can only say "my pick is rock." Scores, round winners, even the display names shown on screen are computed by the server. I didn't do this because I expected cheaters on a rock paper scissors site. I did it because the alternative (trusting clients and validating later) is the kind of thing you can never fully retrofit. Turns out people &lt;em&gt;do&lt;/em&gt; poke at the websocket traffic, by the way. I've seen the log lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The last free seat
&lt;/h2&gt;

&lt;p&gt;My favorite bug happened within days of launch.&lt;/p&gt;

&lt;p&gt;Someone dropped their room link into a group chat. Two friends tapped it at the same moment, both clients checked the seat, both saw it empty, both sat down. One of them got a working game. The other one was in a phantom state: their screen said they were in the room, the room had never heard of them.&lt;/p&gt;

&lt;p&gt;Classic check-then-set race. Local testing will never show it to you, because you are one person clicking one button. A group chat will show it to you on day one.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating "is the seat free?" and "take the seat" as two steps. In Redis that's a single atomic operation — &lt;code&gt;SET NX&lt;/code&gt;, or a small Lua script once the rules got more nuanced (rejoin after disconnect, reserved seats for signed-in users, that kind of thing). Exactly one of the two concurrent taps wins, the other gets an honest "room is full."&lt;/p&gt;

&lt;p&gt;Since then I treat every check-then-write in a codebase as a bug that just hasn't scheduled itself yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rooms that clean up after themselves
&lt;/h2&gt;

&lt;p&gt;Nothing about a live match touches PostgreSQL. Rooms, seats, picks, scores, the matchmaking queue — all of it lives in Redis with a TTL of a couple of hours. Walk away mid-match and the room just... evaporates. No cleanup jobs, no orphaned rows, nothing to vacuum.&lt;/p&gt;

&lt;p&gt;Postgres only hears about a match after it ends, when there's a result worth keeping for stats and history.&lt;/p&gt;

&lt;p&gt;I originally did this out of laziness, honestly. It ended up being the best architectural decision in the project: the socket servers share state through Redis, so they scale horizontally without sticky sessions, and the database stays small and boring after three years of matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature that died quietly
&lt;/h2&gt;

&lt;p&gt;One lesson cost me actual users. The iOS app has a Live Activity that shows the score on the lock screen during a match. At some point a token refresh path broke, an error got swallowed by a catch block that logged nothing, and the feature silently stopped working. For weeks. Nobody reported it — players don't file bugs about a lock screen widget, they just assume it's gone.&lt;/p&gt;

&lt;p&gt;I only noticed while testing something unrelated.&lt;/p&gt;

&lt;p&gt;The rule I took from that: a failure either surfaces to the user or leaves a trace somewhere I actually look (an analytics event, an error log with context). &lt;code&gt;catch {}&lt;/code&gt; with nothing inside is not error handling, it's evidence destruction. Sounds obvious written down. It was not obvious at 1 AM when I wrote that catch block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two domains, one app
&lt;/h2&gt;

&lt;p&gt;Small architectural detail I get asked about: the game is &lt;a href="https://rock.paperscissors.online/" rel="noopener noreferrer"&gt;rock.paperscissors.online&lt;/a&gt; in English and &lt;a href="https://taskagitmakas.online/" rel="noopener noreferrer"&gt;taskagitmakas.online&lt;/a&gt; in Turkish. Same Nuxt app, same backend. The domain decides the language — no &lt;code&gt;/en&lt;/code&gt; prefix, no locale switcher redirect dance.&lt;/p&gt;

&lt;p&gt;For a consumer product I'd pick this over path-based i18n again. Each audience sees what looks like a product made for them, and hreflang handles telling Google the two are siblings.&lt;/p&gt;

&lt;p&gt;The stack, since someone always asks: Nuxt 3 on the web, Expo/React Native on iOS, Node + Express + Socket.IO behind them, Redis for live state, Postgres for history.&lt;/p&gt;




&lt;p&gt;That's the strange charm of tiny multiplayer games: the game design is finished before you start, so all that's left is the hard part. If you want to see the result (or need to settle an argument), &lt;a href="https://rock.paperscissors.online/" rel="noopener noreferrer"&gt;grab a friend and play a round&lt;/a&gt;. And if you've hit similar races or silent failures in your own side projects, I'd genuinely like to hear how you found them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>gamedev</category>
      <category>realtime</category>
    </item>
  </channel>
</rss>
