Tier 1 — Beginner

Rock Paper Scissors

Play RPS against the computer — lookup tables, game loop, async input, running score.

🐍 Python 🟨 JavaScript

1. The Problem

Build a Rock Paper Scissors game where the player types their choice each round, the computer picks randomly, and the game tracks wins, losses, and draws. Play until the player quits. Show running totals after each round.

Why this project? Game loop, lookup tables over nested if/else, input validation, and running state across turns.

2. How to Think About This

The classic mistake is a big if/else if chain: "if player is rock and cpu is scissors then win, if player is rock and cpu is paper then lose..." That's 9 conditions. Instead, store the rules in a lookup table (an object) — each choice knows what it beats. The result logic collapses to three lines.

3. The Build

rps.mjs
import * as readline from "readline/promises";
import { stdin, stdout } from "process";

// ── Game rules as a lookup table ─────────────────────────────────────────────
// Each choice knows what it beats — no nested if/else needed
const RULES = {
  rock:     { beats: "scissors", emoji: "🪨" },
  paper:    { beats: "rock",     emoji: "📄" },
  scissors: { beats: "paper",   emoji: "✂️"  },
};
const CHOICES = Object.keys(RULES);  // ["rock", "paper", "scissors"]

// ── Pure game logic ───────────────────────────────────────────────────────────
export function cpuChoice() {
  return CHOICES[Math.floor(Math.random() * CHOICES.length)];
}

export function play(player, cpu) {
  // validate
  if (!CHOICES.includes(player)) throw new Error(`Unknown choice: "${player}"`);
  if (player === cpu)              return "draw";
  return RULES[player].beats === cpu ? "win" : "lose";
}

// ── CLI game loop ─────────────────────────────────────────────────────────────
const rl = readline.createInterface({ input: stdin, output: stdout });
const score = { win: 0, lose: 0, draw: 0 };
const shortcuts = { r: "rock", p: "paper", s: "scissors" };

console.log("
🪨 📄 ✂️  Rock Paper Scissors");
console.log("Type rock/paper/scissors (or r/p/s). Press Ctrl+C or type 'quit' to exit.
");

while (true) {
  const raw = (await rl.question("Your choice: ")).trim().toLowerCase();

  if (raw === "quit" || raw === "q") break;

  const player = shortcuts[raw] ?? raw;   // expand shortcut if used
  if (!CHOICES.includes(player)) {
    console.log(`  Please enter rock, paper, or scissors (or r/p/s).
`);
    continue;
  }

  const cpu    = cpuChoice();
  const result = play(player, cpu);
  score[result]++;

  const icon = { win: "✓", lose: "✗", draw: "=" }[result];
  const msg  = { win: "You win!", lose: "Computer wins!", draw: "Draw!" }[result];
  console.log(`  ${RULES[player].emoji} vs ${RULES[cpu].emoji}  →  ${msg}`);
  console.log(`  Score: ${score.win}W ${score.lose}L ${score.draw}D
`);
}

rl.close();

const total = score.win + score.lose + score.draw;
if (total > 0) {
  const winPct = Math.round(score.win / total * 100);
  console.log(`
Final score: ${score.win}W ${score.lose}L ${score.draw}D (${winPct}% wins)`);
}

4. Test & Prove

rps.test.mjs
import { play, cpuChoice } from "./rps.mjs";

let pass=0,fail=0;
function test(desc,fn){try{fn();console.log("  ✓",desc);pass++;}catch(e){console.log("  ✗",desc,"—",e.message);fail++;}}

// Deterministic outcome tests
test("rock beats scissors",     () => { if(play("rock","scissors")!=="win")  throw new Error(); });
test("scissors beats paper",    () => { if(play("scissors","paper")!=="win") throw new Error(); });
test("paper beats rock",        () => { if(play("paper","rock")!=="win")     throw new Error(); });
test("rock loses to paper",     () => { if(play("rock","paper")!=="lose")    throw new Error(); });
test("paper loses to scissors", () => { if(play("paper","scissors")!=="lose")throw new Error(); });
test("same choice = draw",      () => { if(play("rock","rock")!=="draw")     throw new Error(); });
test("invalid throws",          () => { try{play("gun","rock");throw new Error("no throw");}catch(e){if(e.message==="no throw")throw e;} });

// Statistical: cpuChoice always in valid set
test("cpuChoice always valid", () => {
  const valid=new Set(["rock","paper","scissors"]);
  for(let i=0;i<500;i++){if(!valid.has(cpuChoice()))throw new Error();}
});

// Statistical: results cover all outcomes over many rounds
test("play produces all three outcomes", () => {
  const outcomes=new Set();
  for(let i=0;i<500;i++) outcomes.add(play("rock",["rock","paper","scissors"][Math.floor(Math.random()*3)]));
  if(outcomes.size!==3) throw new Error("Only got: "+[...outcomes]);
});

console.log(\`
\${pass} passed, \${fail} failed\`);

5. The Interface

Input: rock/paper/scissors (or r/p/s shortcuts). "quit" or Ctrl+C to exit.

Output: emoji for both choices, win/lose/draw result, running score.

🪨 📄 ✂️  Rock Paper Scissors
Type rock/paper/scissors (or r/p/s). Press Ctrl+C or type 'quit' to exit.

Your choice: r
  🪨 vs ✂️  →  You win!
  Score: 1W 0L 0D

Your choice: p
  📄 vs 📄  →  Draw!
  Score: 1W 0L 1D

6. Run It

node rps.mjs          # play the game
node rps.test.mjs     # run tests

🎯 Try this next

  1. Add best-of-N mode: play until someone wins N rounds
  2. Add Rock Paper Scissors Lizard Spock (2 more choices, 10 rules — but the lookup table still handles it cleanly)
  3. Build a browser version with clickable image buttons instead of text input
  4. Track a win streak and display the current streak after each win

What you practised

Lookup tables as an alternative to long if/else chains (a key design pattern), shortcut aliases via object lookup, game loops with async readline, running score tracking, and statistical testing to verify random behaviour covers all expected outcomes.

Reference: Objects · Control Flow · User Input & Output

Try It Yourself