Skip to the code
cf.

Five-letter word game

The tea shop prints a word puzzle on its receipts, and now it wants one on the website. Build WordGuess, the default export: you have six tries to guess a five-letter word.

It takes answer, the word to guess, in any case. It's optional: without it, the game picks a word at random from its own list when it starts, and keeps that word for the whole game.

You type anywhere on the page, so listen for keydown on window. A letter key (a to z, either case) adds that letter, in capitals, to the guess you're typing, up to five letters. Backspace removes the last letter. Enter submits the guess if it has five letters; with fewer, nothing is submitted and the status says Not enough letters. Other keys do nothing, and so do keys pressed with Ctrl, Alt, or Meta held down.

Show the board as an element with role="grid" holding six role="row" elements, each with five role="gridcell" elements. Submitted guesses fill the rows from the top, the guess you're typing shows in the next row, and each cell holds one letter or nothing.

Each cell of a submitted guess gets a data-status: correct when its letter is in that spot in the answer, present when the letter is in the answer somewhere else, and absent when it isn't. Cells that aren't part of a submitted guess have no data-status.

Repeated letters are the tricky part. A letter can only be marked correct or present as many times as it appears in the answer. Exact matches claim their copies first. Then, from left to right, each other copy is present while the answer still has an unclaimed copy of that letter, and absent after that. With the answer BLEND, the guess GEESE marks the middle E correct and both other Es absent. With the answer MATCH, the guess AROMA marks the first A present and the last one absent.

A <p role="status"> shows messages: Not enough letters as above (until the next key), You got it! when a guess matches the answer, and The word was CRANE. (with the answer in capitals) after six wrong guesses. Once the game is won or lost, keys do nothing.

<WordGuess />                 // a random word from the game's list
<WordGuess answer="crane" />
 
// after guessing TRACE, with the answer CRANE:
<div role="row">
  <div role="gridcell" data-status="absent">T</div>
  <div role="gridcell" data-status="correct">R</div>
  <div role="gridcell" data-status="correct">A</div>
  <div role="gridcell" data-status="present">C</div>
  <div role="gridcell" data-status="correct">E</div>
</div>

What the tests check

  1. shows six empty rows of five cells
    render(<App answer="CRANE" />);
    expect(screen.getByRole("grid")).toBeInTheDocument();
    const rows = screen.getAllByRole("row");
    expect(rows.length).toBe(6);
    for (const row of rows) {
      const cells = within(row).getAllByRole("gridcell");
      expect(cells.length).toBe(5);
      for (const cell of cells) {
        expect(cell.textContent).toBe("");
        expect(cell).not.toHaveAttribute("data-status");
      }
    }
    
  2. types letters into the current row in capitals, ignoring other keys
    const letters = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell").map((cell) => cell.textContent).join("");
    render(<App answer="CRANE" />);
    await user.keyboard("cr");
    expect(letters(0)).toBe("CR");
    await user.keyboard("{Backspace}");
    expect(letters(0)).toBe("C");
    await user.keyboard("1 ");
    expect(letters(0)).toBe("C");
    await user.keyboard("{Control>}a{/Control}{Alt>}b{/Alt}{Meta>}{Backspace}{/Meta}");
    expect(letters(0)).toBe("C");
    await user.keyboard("RANEXY");
    expect(letters(0)).toBe("CRANE");
    await user.keyboard("{Backspace}");
    expect(letters(0)).toBe("CRAN");
    expect(screen.getAllByRole("gridcell")[0]).not.toHaveAttribute("data-status");
    
  3. Enter needs five letters
    const cells = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell");
    render(<App answer="CRANE" />);
    await user.keyboard("cra{Enter}");
    expect(screen.getByRole("status")).toHaveTextContent("Not enough letters");
    expect(cells(0).map((cell) => cell.textContent).join("")).toBe("CRA");
    for (const cell of cells(0)) expect(cell).not.toHaveAttribute("data-status");
    await user.keyboard("n");
    expect(screen.getByRole("status")).not.toHaveTextContent("Not enough letters");
    
  4. marks each letter correct, present, or absent
    const cells = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell");
    render(<App answer="CRANE" />);
    await user.keyboard("trace{Enter}");
    expect(cells(0).map((cell) => cell.textContent).join("")).toBe("TRACE");
    expect(cells(0).map((cell) => cell.getAttribute("data-status"))).toEqual(["absent", "correct", "correct", "present", "correct"]);
    await user.keyboard("s");
    expect(cells(1)[0]).toHaveTextContent("S");
    expect(cells(1)[0]).not.toHaveAttribute("data-status");
    
  5. marks extra copies absent once the answer's copies are used up
    const statuses = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell").map((cell) => cell.getAttribute("data-status"));
    render(<App answer="BLEND" />);
    await user.keyboard("geese{Enter}");
    expect(statuses(0)).toEqual(["absent", "absent", "correct", "absent", "absent"]);
    
  6. marks the first misplaced copy present and later ones absent
    const statuses = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell").map((cell) => cell.getAttribute("data-status"));
    render(<App answer="MATCH" />);
    await user.keyboard("aroma{Enter}");
    expect(statuses(0)).toEqual(["present", "absent", "absent", "present", "absent"]);
    
  7. says you got it, then ignores keys
    const letters = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell").map((cell) => cell.textContent).join("");
    render(<App answer="CRANE" />);
    await user.keyboard("trace{Enter}");
    expect(screen.getByRole("status")).not.toHaveTextContent("You got it!");
    await user.keyboard("crane{Enter}");
    expect(screen.getByRole("status")).toHaveTextContent("You got it!");
    const statuses = within(screen.getAllByRole("row")[1]).getAllByRole("gridcell").map((cell) => cell.getAttribute("data-status"));
    expect(statuses).toEqual(["correct", "correct", "correct", "correct", "correct"]);
    await user.keyboard("steep{Enter}");
    expect(letters(2)).toBe("");
    
  8. shows the answer after six wrong guesses
    const letters = (n) => within(screen.getAllByRole("row")[n]).getAllByRole("gridcell").map((cell) => cell.textContent).join("");
    render(<App answer="CRANE" />);
    for (let i = 0; i < 5; i++) await user.keyboard("trace{Enter}");
    expect(screen.getByRole("status")).not.toHaveTextContent("The word was");
    await user.keyboard("track{Enter}");
    expect(screen.getByRole("status")).toHaveTextContent("The word was CRANE.");
    expect(letters(5)).toBe("TRACK");
    await user.keyboard("{Backspace}");
    expect(letters(5)).toBe("TRACK");
    
  9. takes the answer in any case
    render(<App answer="crane" />);
    await user.keyboard("CRANE{Enter}");
    expect(screen.getByRole("status")).toHaveTextContent("You got it!");
    
  10. picks its own five-letter word when there's no answer
    render(<App />);
    for (let i = 0; i < 6; i++) await user.keyboard("qqqqq{Enter}");
    const status = screen.getByRole("status").textContent;
    expect(status).toMatch(/^The word was [A-Z]{5}[.]$/);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).