--- id: 67e3a6b7f60b4085588189e6 title: Build a Tic-Tac-Toe Game challengeType: 25 dashedName: build-a-tic-tac-toe-game demoType: onClick saveSubmissionToDB: true --- # --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; } ``` # --seed-- ## --seed-contents-- ```html Tic-Tac-Toe
``` ```css ``` ```jsx const { useState } = React; export function Board() { } ``` # --solutions-- ```html Tic-Tac-Toe
``` ```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 handleClick(index)} />; }; return (

Tic-Tac-Toe

{winner ? `Winner: ${winner}` : isDraw ? "It's a Draw!" : `Next Player: ${isXNext ? 'X' : 'O'}`}
{renderSquare(0)} {renderSquare(1)} {renderSquare(2)}
{renderSquare(3)} {renderSquare(4)} {renderSquare(5)}
{renderSquare(6)} {renderSquare(7)} {renderSquare(8)}
); }; function Square({ value, onClick }) { return ( ); }; 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; }; ```