Skip to content

ronyeh/rps

Repository files navigation

Rock Paper Scissors Lizard Spock

A real-time multiplayer mobile game built with Expo (React Native) and a Cloudflare Workers + Durable Objects backend. Players join from their phones, choose a throw each round, and compete on a live leaderboard.

Rules

Each gesture beats two others and loses to two others:

  • Scissors cuts Paper
  • Paper covers Rock
  • Rock crushes Lizard
  • Lizard poisons Spock
  • Spock smashes Scissors
  • Scissors decapitates Lizard
  • Lizard eats Paper
  • Paper disproves Spock
  • Spock vaporizes Rock
  • Rock crushes Scissors

Project Structure

RPS1/
├── app/
│   ├── (tabs)/
│   │   ├── _layout.tsx       # Tab layout (tab bar hidden)
│   │   └── index.tsx         # Join screen
│   ├── game.tsx              # Main game screen
│   └── _layout.tsx           # Root stack navigator
├── components/               # Themed UI primitives
├── lib/
│   └── api.ts                # Typed API client
└── cloudflare-worker/
    ├── worker.js             # Worker + Durable Object
    └── wrangler.toml         # Cloudflare config

Mobile App

Built with Expo Router (file-based navigation).

Start the dev server: bunx expo start --tunnel and connect from your phone by scanning the QR code.

Join screen (app/(tabs)/index.tsx): Enter a name and tap "Join the Game!" to call /join-game, receive a user_id, and navigate to the game screen. A rules modal explains the gesture hierarchy.

Game screen (app/game.tsx): Polls /status every second and renders based on phase:

  • idle — waiting for players
  • starting — 3-second GET READY countdown (purple)
  • throwing — 5 throw buttons; locks in on tap; cancel button shown for 3 seconds after throwing
  • results — winner/tie banner with revealed throws; 3-second display window

The timer interpolates smoothly between polls using a 100ms local interval.

Backend

A single Cloudflare Durable Object (GameRoom) manages all game state. The Worker proxies every request to the one global room via idFromName('global').

Endpoints

Endpoint Description
GET / Lists all endpoints
GET /join-game?user={name} Join the game; returns user_id
GET /leave-game?user_id={id} Leave the game
GET /throw?user_id={id}&choice={rock|paper|scissors|lizard|spock} Submit a throw
GET /cancel-throw?user_id={id} Cancel throw within 3 seconds
GET /status Full game state, leaderboard, timer
GET /reset Clear all players and scores

Round State Machine

idle → starting (3s) → throwing (10s) → results (3s) → idle → …

Tiebreaks loop back into throwing with a shorter timer (10 → 5 → 3s minimum) until one player wins outright. DO Alarms drive all phase transitions server-side.

Win Calculation

Each player's throw is compared head-to-head against every other throw in the round. The player with the most pairwise wins takes the round. If two or more players tie for the top win count, those players re-throw (others sit out) until there is a single winner.

Edge cases:

  • 1 player throws: everyone else gets a loss; the sole thrower gets no win
  • 0 players throw: round is void, no scores change
  • Pure tie (everyone throws the same): treated as a tie; all throwers re-throw

Deploying

cd cloudflare-worker
wrangler deploy

Live backend: https://rpsls-game.ronyeh.workers.dev


Discussion

Design decisions made during development.

Single Durable Object for the whole game

Rather than one DO per "room," we use a single global GameRoom instance via idFromName('global'). For a hackathon with a fixed group of friends this is the right call — no room management, no lobby, everyone is always in the same game. The tradeoff is that the design doesn't scale to multiple concurrent games, but that wasn't a requirement.

DO Alarms instead of external cron or client-driven timers

Cloudflare DO Alarms fire reliably from within the Durable Object itself, making the 10-second round timer authoritative and cheat-proof. The alternative — having the client call an endpoint after 10 seconds — would be unreliable and gameable. Alarms also handle the results window (3s) and the GET READY countdown (3s) without any additional infrastructure.

GET READY phase before each throw window

The original design started the throw timer the moment the 2nd player joined. In practice this meant players navigating to the game screen would arrive mid-round and see only 8–9 seconds on the timer. We added a 3-second starting phase that acts as a buffer, ensuring everyone reaches the game screen before throwing begins. Tiebreaks skip the GET READY phase since the 3-second results window already serves that purpose.

Tie-breaking rules

When multiple players share the highest win count in a round, those players re-throw with a progressively shorter timer (10s → 5s → 3s minimum). Non-tiebreak players sit out until the round concludes. This keeps rounds from dragging indefinitely while still resolving ties fairly. The timer reduction adds urgency to repeated ties.

user_id over session auth

Each player receives a random 8-digit hex user_id on join, which they include in subsequent requests. There is no server-side session or cookie auth. For a hackathon among friends this removes all friction while still giving the server a stable identity per player. Rejoining with the same name reconnects to the existing record.

KV → Durable Objects

The initial scaffold used Cloudflare KV for the click-battle game it was based on. KV is eventually consistent and has no built-in locking, making it unsuitable for a game that needs a shared authoritative round state and timed transitions. Durable Objects provide strong consistency and co-location of compute + storage, which is exactly what a real-time multiplayer round loop requires.

Free plan: new_sqlite_classes migration

Cloudflare's free plan requires Durable Objects to be declared with new_sqlite_classes instead of the paid-tier new_classes. This was discovered at first deploy and fixed in wrangler.toml.

Cancel window tracked on the server

The 3-second throw cancel window stores the throw timestamp in DO persistent storage (throwTimes). This means the window is enforced server-side and survives DO hibernation/reactivation. The client also tracks the time locally to show a live countdown, but the server is the final authority on whether a cancel is valid.

Poll-based updates

The app polls /status every second rather than using WebSockets or Server-Sent Events. For a low-player-count game over a fast local network this is simple and sufficient. The client interpolates the countdown between polls using a 100ms local timer so the display feels smooth even though data arrives at 1Hz.

About

Rock Paper Scissors (Lizard Spock)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors