1. The Problem
Build the classic Hangman game: pick a secret word, show blanks for each letter, let the player guess one letter at a time, track wrong guesses, and end when they win or run out of attempts.
Why this project? Set for tracking guessed letters (automatic deduplication), string masking with map(), game state as a plain object, and the win/lose conditions expressed as clean boolean checks.
2. How to Think About This
Store guessed letters in a Set — no duplicates, O(1) lookup. The display is word.split("").map(l => guessed.has(l) ? l : "_").join(" "). Win = every letter in the word is in the Set. Lose = wrong guess count reaches max (e.g. 6).
3. The Build
hangman.mjs
import * as readline from "readline/promises";
import { stdin, stdout } from "process";
export const WORDS = ["javascript","function","closure","promise","iterator","generator","prototype","destructuring"];
export function createGame(word = WORDS[Math.floor(Math.random() * WORDS.length)]) {
return { word: word.toLowerCase(), guessed: new Set(), maxWrong: 6 };
}
export function getMasked(game) {
return game.word.split("").map(l => game.guessed.has(l) ? l : "_").join(" ");
}
export function getWrongGuesses(game) {
return [...game.guessed].filter(l => !game.word.includes(l));
}
export function isWon(game) { return game.word.split("").every(l => game.guessed.has(l)); }
export function isLost(game) { return getWrongGuesses(game).length >= game.maxWrong; }
export function isDone(game) { return isWon(game) || isLost(game); }
export function guess(game, letter) {
const l = letter.toLowerCase().trim();
if (!/^[a-z]$/.test(l)) return { valid: false, reason: "Enter a single letter a–z" };
if (game.guessed.has(l)) return { valid: false, reason: `Already guessed: ${l}` };
game.guessed.add(l);
return { valid: true, hit: game.word.includes(l) };
}
// CLI
const rl = readline.createInterface({ input: stdin, output: stdout });
const game = createGame();
while (!isDone(game)) {
const wrong = getWrongGuesses(game);
console.log(`
${getMasked(game)}`);
console.log(` Wrong (${wrong.length}/${game.maxWrong}): ${wrong.join(" ") || "none"}`);
const input = await rl.question(" Guess a letter: ");
const result = guess(game, input);
if (!result.valid) { console.log(` ${result.reason}`); continue; }
console.log(result.hit ? ` ✓ Yes! "${input}" is in the word.` : ` ✗ Nope.`);
}
rl.close();
console.log(isWon(game) ? `
🎉 You won! The word was: ${game.word}` : `
💀 Game over! The word was: ${game.word}`);
4. Test & Prove
import { createGame, getMasked, getWrongGuesses, isWon, isLost, guess } from "./hangman.mjs";
let p=0,f=0;
function test(d,fn){try{fn();console.log(" ✓",d);p++;}catch(e){console.log(" ✗",d,"—",e.message);f++;}}
const g = createGame("cat");
test("masked at start", () => { if(getMasked(g)!=="_ _ _") throw new Error(getMasked(g)); });
test("correct guess reveals", () => { guess(g,"c"); if(!getMasked(g).startsWith("c")) throw new Error(); });
test("wrong guess tracked", () => { guess(g,"z"); if(getWrongGuesses(g).length!==1) throw new Error(); });
test("duplicate ignored", () => { guess(g,"z"); if(getWrongGuesses(g).length!==1) throw new Error(); });
test("win condition", () => { guess(g,"a"); guess(g,"t"); if(!isWon(g)) throw new Error(); });
const g2 = createGame("hi");
test("lose condition", () => { ["a","b","c","d","e","f"].forEach(l=>guess(g2,l)); if(!isLost(g2)) throw new Error(); });
test("invalid char rejected", () => { const r=guess(g2,"1"); if(r.valid) throw new Error(); });
console.log(`
${p} passed, ${f} failed`);5. The Interface
_ _ _ _ _ _ _ _ _ _ _ Wrong (0/6): none Guess a letter: j ✓ Yes! "j" is in the word. j _ _ _ _ _ _ _ _ _ _ Wrong (0/6): none Guess a letter: q ✗ Nope.
6. Run It
node hangman.mjs
node hangman.test.mjs🎯 Try this next
- Add ASCII art for the hangman that grows with each wrong guess
- Load words from a text file, one per line — pick a random one
- Add a hint system — reveal a letter for free once per game
- Build a browser version with click-to-guess letter buttons