Back to Freecodecamp

Build a Tic-Tac-Toe Game

curriculum/challenges/english/blocks/lab-tic-tac-toe/67e3a6b7f60b4085588189e6.md

latest14.9 KB
Original Source

--description--

Objective: Fulfill the user stories below and get all the tests to pass to complete the lab.

User Stories:

  1. You should create a Board component that renders nine button elements each with a class of square in a 3x3 grid.
  2. Clicking button.square elements should alternate between displaying an X then O within the element.
  3. Once a player has won the game, clicking on any button.square should not cause any further changes.
  4. You should create a button#reset element that resets the game when clicked.
  5. A message should be displayed indicating either X or O as the winner, or neither if the result is a draw.

--before-all--

js
let clock = __FakeTimers.install();

async function reset(assert) {
  const reset = document.querySelector("#reset");
  assert.exists(reset, "button#reset should exist");
  reset.click();
  clock.tick(50);
}

// Gets the text of the document excluding that of the matching selector.
function getInnerTextExcept(removingSelector) {
  const body = document.body.cloneNode(true);

  const squareElements = body.querySelectorAll(`${removingSelector},script`);
  squareElements.forEach(element => {
    element.parentNode.removeChild(element)
  });

  return body.innerText;
}

--after-all--

js
clock.uninstall();

--hints--

You should export a Board component.

js
  const script = [...document.querySelectorAll("script")].find((s) => s.dataset.src ===  "index.jsx").innerText;

  const exports = {};
  const a = eval(script);

  assert.property(exports, "Board");

You should have nine button.square elements.

js
const els = document.querySelectorAll("button.square");
assert.equal(els.length, 9);

The button.square elements should be in a 3x3 grid.

js
// TODO: Maybe enforce buttons to be same size?
const els = document.querySelectorAll("button.square");
const squares = Array.from(els);

const coords = squares.map((square) => {
  const rect = square.getBoundingClientRect();
  return rect;
});

const xTolerance = coords[0].width / 10;
const yTolerance = coords[0].height / 10;

try {
  assert.isBelow(coords[0].x, coords[1].x, "First square should be to the left of the second square");
  assert.isBelow(coords[1].x, coords[2].x, "Second square should be to the left of the third square");
  assert.approximately(coords[0].y, coords[1].y, yTolerance, "First square should be at the same height as the second square");
  assert.approximately(coords[1].y, coords[2].y, yTolerance, "Second square should be at the same height as the third square");
  
  assert.isBelow(coords[3].x, coords[4].x, "Fourth square should be to the left of the fifth square");
  assert.isBelow(coords[4].x, coords[5].x, "Fifth square should be to the left of the sixth square");
  assert.approximately(coords[3].y, coords[4].y, yTolerance, "Fourth square should be at the same height as the fifth square");
  assert.approximately(coords[4].y, coords[5].y, yTolerance, "Fifth square should be at the same height as the sixth square");
  
  assert.isBelow(coords[6].x, coords[7].x, "Seventh square should be to the left of the eighth square");
  assert.isBelow(coords[7].x, coords[8].x, "Eighth square should be to the left of the ninth square");
  assert.approximately(coords[6].y, coords[7].y, yTolerance, "Seventh square should be at the same height as the eighth square");
  assert.approximately(coords[7].y, coords[8].y, yTolerance, "Eighth square should be at the same height as the ninth square");
  
  
  assert.isBelow(coords[0].y, coords[3].y, "First square should be above the fourth square");
  assert.isBelow(coords[3].y, coords[6].y, "Fourth square should be above the seventh square");
  assert.approximately(coords[0].x, coords[3].x, xTolerance, "First square should be at the same width as the fourth square");
  assert.approximately(coords[3].x, coords[6].x, xTolerance, "Fourth square should be at the same width as the seventh square");
  
  assert.isBelow(coords[1].y, coords[4].y, "Second square should be above the fifth square");
  assert.isBelow(coords[4].y, coords[7].y, "Fifth square should be above the eighth square");
  assert.approximately(coords[1].x, coords[4].x, xTolerance, "Second square should be at the same width as the fifth square");
  assert.approximately(coords[4].x, coords[7].x, xTolerance, "Fifth square should be at the same width as the eighth square");
  
  assert.isBelow(coords[2].y, coords[5].y, "Third square should be above the sixth square");
  assert.isBelow(coords[5].y, coords[8].y, "Sixth square should be above the ninth square");
  assert.approximately(coords[2].x, coords[5].x, xTolerance, "Third square should be at the same width as the sixth square");
  assert.approximately(coords[5].x, coords[8].x, xTolerance, "Sixth square should be at the same width as the ninth square");
} catch (e) {
  console.error(e)
  throw e;
}

The first click of a button.square element should result in X being displayed within the element.

js
  const el = document.querySelector("button.square");
  el.click();

  clock.tick(50);

  try {
    assert.include(el.textContent, "X");
  } catch(e) {
    console.error(e);
    throw e;
  }

Clicking on the button#reset element should reset the game.

js
  // NOTE: This test is intentionally high-up, because the latter tests rely on the functionality.
  const el = document.querySelector("button.square");
  el.click();
  clock.tick(50);

  try {
    await reset(assert);
    assert.notInclude(el.textContent, "X");
  } catch(e) {
    console.error(e);
    throw e;
  }

The second click of a button.square element should result in O being displayed within the element.

js
  await reset(assert);
  const els = document.querySelectorAll("button.square");
  els[0].click();
  clock.tick(50);
  els[1].click();

  clock.tick(50);

  try {
    assert.include(els[1].textContent, "O");
  } catch(e) {
    console.error(e);
    throw e;
  }

All subsequent clicks of a button.square element should alternate between displaying X and O within the element.

js
  await reset(assert);
  const els = document.querySelectorAll("button.square");
  try {
    for (let i = 3; i < els.length + 3; i++) {
      const wrappedI = i % els.length;
      els[wrappedI].click();
      clock.tick(50);
      assert.include(els[wrappedI].textContent, (i - 3) % 2 === 0 ? "X" : "O");
    }
  } catch(e) {
    console.error(e);
    throw e;
  }

Clicking on an already used button.square element should result in no change.

js
  await reset(assert);
  const els = document.querySelectorAll("button.square");
  try {
    for (let i = 3; i < els.length + 3; i++) {
      const wrappedI = i % els.length;
      // Click button twice to ensure it does not change
      els[wrappedI].click();
      clock.tick(50);
      els[wrappedI].click();
      clock.tick(50);
      assert.include(els[wrappedI].textContent, (i - 3) % 2 === 0 ? "X" : "O");
    }
  } catch(e) {
    console.error(e);
    throw e;
  }

Clicking on a button.square element after the game has ended should result in no change.

js
  await reset(assert);
  const els = document.querySelectorAll("button.square");
  try {
    // Win game, then click empty square and ensure no change
    for (let i = 0; i < 3; i++) {
      const x = els[i];
      x.click();
      clock.tick(50);
      assert.include(x.textContent, "X");

      const o = els[i + 3];
      o.click();
      clock.tick(50);
      if (i === 2) {
        assert.notInclude(o.textContent, "O");
      } else {
        assert.include(o.textContent, "O");
      }
    }
  } catch(e) {
    console.error(e);
    throw e;
  }

The game should display a message indicating the winner to be X or O.

js
  // Get to almost winning state
  // Check dom
  // Click winning square
  // Check dom for change
  await reset(assert);
  const els = document.querySelectorAll("button.square");
  try {
    for (let i = 0; i < 2; i++) {
      const x = els[i];
      x.click();
      clock.tick(50);
      const o = els[i + 3];
      o.click();
      clock.tick(50);
    }

    const preXWin = getInnerTextExcept("button.square");
    // Click winning button for X
    els[2].click();
    clock.tick(50);

    const postXWin = getInnerTextExcept("button.square");
    assert.notEqual(preXWin, postXWin);

    await reset(assert);
    
    for (let i = 0; i < 2; i++) {
      const x = els[i];
      x.click();
      clock.tick(50);
      const o = els[i + 3];
      o.click();
      clock.tick(50);
    }
    els[6].click();
    clock.tick(50);
    
    const preOWin = getInnerTextExcept("button.square");
    // Click winning button for O
    els[5].click();
    clock.tick(50);
    
    const postOWin = getInnerTextExcept("button.square");
    assert.notEqual(preOWin, postOWin);
    // There should be a difference between `O` and `X` winning.
    assert.notEqual(postXWin, postOWin);

    // Test diagonal win for X
    await reset(assert);
    
    // X moves: top-left
    els[0].click(); 
    clock.tick(50);
    
    // O moves: top-center
    els[1].click();
    clock.tick(50);
    
    // X moves: center
    els[4].click();
    clock.tick(50);
    
    // O moves: top-right
    els[2].click();
    clock.tick(50);
    
    const preDiagonalWin = getInnerTextExcept("button.square");
    // X moves: bottom-right (completes diagonal win)
    els[8].click();
    clock.tick(50);
    
    const postDiagonalWin = getInnerTextExcept("button.square");
    assert.notEqual(preDiagonalWin, postDiagonalWin);
    assert.include(postDiagonalWin, "Winner: X");
  } catch(e) {
    console.error(e);
    throw e;
  }

The game should display a message indicating a draw.

js
  // Get to almost draw state
  // Check dom
  // Click final square
  // Check dom for change
  await reset(assert);
  const els = document.querySelectorAll("button.square");
  try {
    //use already known draw states
    const drawMoves = [0, 1, 2, 4, 3, 5, 7, 6, 8]; 

    const snapshot1 = getInnerTextExcept("button.square");
    els[drawMoves[0]].click();
    clock.tick(50);

    const snapshot2 = getInnerTextExcept("button.square");
    for (let i = 1; i < drawMoves.length - 1; i++) {
      els[drawMoves[i]].click();
     clock.tick(50);
    }

    const snapshot3 = getInnerTextExcept("button.square");
    els[drawMoves[drawMoves.length - 1]].click();
    clock.tick(50);

    const snapshot4 = getInnerTextExcept("button.square");
    assert.notEqual(snapshot4, snapshot1);
    assert.notEqual(snapshot4, snapshot2);
    assert.notEqual(snapshot4, snapshot3);

  } catch(e) {
    console.error(e);
    throw e;
  }

Uninstall the clock, ignore this test

js
clock.uninstall()

--seed--

--seed-contents--

html
<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8" />
    <title>Tic-Tac-Toe</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
    <script
      data-plugins="transform-modules-umd"
      type="text/babel"
      src="index.jsx"
    ></script>
    <link rel="stylesheet" href="styles.css" />
</head>

<body>
    <div id="root"></div>
    <script
      data-plugins="transform-modules-umd"
      type="text/babel"
      data-presets="react"
      data-type="module"
    >
      import { Board } from './index.jsx';
      ReactDOM.createRoot(document.getElementById('root')).render(<Board />);
    </script>
</body>

</html>
css
jsx
const { useState } = React;

export function Board() {

}

--solutions--

html
<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8" />
    <title>Tic-Tac-Toe</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.development.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.development.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.26.5/babel.min.js"></script>
    <script
      data-plugins="transform-modules-umd"
      type="text/babel"
      src="index.jsx"
    ></script>
    <link rel="stylesheet" href="styles.css" />
</head>

<body>
    <div id="root"></div>
    <script
      data-plugins="transform-modules-umd"
      type="text/babel"
      data-presets="react"
      data-type="module"
    >
      import { Board } from './index.jsx';
      ReactDOM.createRoot(document.getElementById('root')).render(<Board />);
    </script>
</body>

</html>
css
* {
    font-family: "Secular One", sans-serif;
    font-weight: 400;
    font-style: normal;
}

.board {
    display: flex;
    flex-direction: column;
    align-items: center;
}

.status {
    margin: 10px;
    font-size: 1.2em;
}

.board-row {
    display: flex;
}

.square {
    width: 60px;
    height: 60px;
    font-size: 1.5em;
    margin: 5px;
    background: #fff;
    border: 1px solid #999;
    cursor: pointer;
    border-radius: 5px;
}

.square:focus {
    outline: none;
}

#reset {
    margin-top: 20px;
    padding: 10px 20px;
    font-size: 1em;
    cursor: pointer;
}
jsx
export function Board() {
  const [squares, setSquares] = React.useState(Array(9).fill(null));
  const [isXNext, setIsXNext] = React.useState(true);
  const winner = calculateWinner(squares);
  const isDraw = squares.every(square => square !== null) && !winner;

  function handleClick(index) {
    if (squares[index] || winner || isDraw) return;
    const nextSquares = squares.slice();
    nextSquares[index] = isXNext ? 'X' : 'O';
    setSquares(nextSquares);
    setIsXNext(!isXNext);
  };

  function resetGame() {
    setSquares(Array(9).fill(null));
    setIsXNext(true);
  };

  function renderSquare(index) {
    return <Square value={squares[index]} onClick={() => handleClick(index)} />;
  };

  return (
    <div className="board">
      <h1>Tic-Tac-Toe</h1>
      <div className="status">
        {winner ? `Winner: ${winner}` : isDraw ? "It's a Draw!" : `Next Player: ${isXNext ? 'X' : 'O'}`}
      </div>
      <div className="board-row">
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div className="board-row">
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div className="board-row">
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
      <button id="reset" onClick={resetGame}>Reset Game</button>
    </div>
  );
};

function Square({ value, onClick }) {
  return (
    <button className="square" onClick={onClick}>
      {value}
    </button>
  );
};

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2], [3, 4, 5], [6, 7, 8],
    [0, 3, 6], [1, 4, 7], [2, 5, 8],
    [0, 4, 8], [2, 4, 6]
  ];
  for (let line of lines) {
    const [a, b, c] = line;
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
};