Back in 2023 I built an online rock paper scissors game 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.

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.

You can't leak what you never send

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

Nothing. It has to be nothing.

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 have picked, and reveals both choices in the same message once the round locks. Commit first, reveal later.

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 do poke at the websocket traffic, by the way. I've seen the log lines.

The last free seat

My favorite bug happened within days of launch.

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.

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.

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 — SET NX, 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."

Since then I treat every check-then-write in a codebase as a bug that just hasn't scheduled itself yet.

Rooms that clean up after themselves

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.

Postgres only hears about a match after it ends, when there's a result worth keeping for stats and history.

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.

The feature that died quietly

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.

I only noticed while testing something unrelated.

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). catch {} 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.

Two domains, one app

Small architectural detail I get asked about: the game is rock.paperscissors.online in English and taskagitmakas.online in Turkish. Same Nuxt app, same backend. The domain decides the language — no /en prefix, no locale switcher redirect dance.

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.

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.


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), grab a friend and play a round. 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.