This is an exercise for learning how to manage application state with the classic
Tic Tac Toe game. Complete the instructions below, then check your solution against
the example solution in the gh-pages
branch on this repo. Click Here to
see the working app and target functionality.
Build out your app.js
to fulfill the following user stories. No edits are required in any other file.
- Upon loading, I can immediately click cells to alternately place Xs and Os on each click
- I cannot change a cell after it has a value inside it
- I can see when a winning line has been created
- No further moves can be played from this point
- I can click "New Game" at any time to reset the board
- Fork this repo and clone it down to your local machine
- Start a new branch called
implement-game
- Study the
index.html
. The only dynamic part of the app will be happening inside the<div class="board"></div>
element. - Build a state object that can handle every possible scenario as a user interacts with the app
- Write state modification functions for every action a user can make in the app
- Start new game
- Click a cell
- Write a
renderBoard
function that reads the globalstate
object and renders all the HTML necessary to draw the Tic Tac Toe grid - Write event listeners for the two user actions. Each listener should:
- retrieve DOM info if applicable (e.g. which cell was clicked?)
- invoke a state modification function
- invoke the render function
Try to follow the instructions above on your own. If you get stuck, you can reference the walkthrough below:
- How to Build the State Object
- How to Write the State Modification Functions
- How to Write the Render Functions
- How to Write the Event Listeners
- How to Check For Winning Move
Your state is just a collection of key-value pairs like any other JavaScript object. Its sole purpose is to hold information about what the user sees at any given moment. Take careful note: it holds information and does NOT manage the behavior of the DOM. Think about what information your Tic Tac Toe game would need at every possible moment throughout the game's lifecycle. It can help to write out a bunch of example scenarios:
- I load the app and see a blank grid
- I click the top-left cell and a X appears in the cell
- I click the center-left cell and a O appears in the cell
- I click the center-left cell and nothing happens
- I click to cause a three-X sequence across the top row. The top cells change color.
- Further clicks on empty cells have no effect
- I click 'New Game' (at any time) and all cells become blank
Now build a state object that could "describe" each scenario. Scenario #1 is easy. We'll use an array to represent the nine cells in our grid, array index 0 through 8, starting them with a default null
value.
// Scenario #1
{ board: Array(9).fill(null) }
We want scenario #2 to add the string 'X'
to array index 0
. But how would the app know to place an 'X'
and not 'O'
? We could read the board
every time and know from the count of non-null
cells which symbol to place next, but an easier option is to just create a boolean we 'flip' on every move:
// Scenario #2:
{
board: [ 'X', null, null, null, null, null, null, null, null ],
xIsNext: false
}
This makes scenario #3 straightforward:
// Scenario #3:
{
board: [ 'X', null, null, 'O', null, null, null, null, null ],
xIsNext: true
}
For scenario #4, there's nothing specific to denote in the state
object. We just need to ensure our state modification function checks the value is null
before it changes it to a symbol.
For scenario #5, there's two important things to handle: Identify which cells created the winning line and prevent a click in empty cells from adding new symbols. The winning line could be represented as an array of index values of the three winning cells. If that variable is null
, then no winner has yet been found.
// Scenario #5:
{
board: [ 'X', 'O', 'O', 'X', null, null, 'X', null, null ],
xIsNext: false,
winPattern: [0, 3, 6]
}
The final scenario is a reset of all the fields we now have in our state:
// Scenario #6
{
board: [ null, null, null, null, null, null, null, null, null ],
xIsNext: true,
winPattern: null
}
Note, this is just one possible state solution. Arguably, xIsNext
and winPattern
are all extraneous as their values could be deduced from the board
array at any given time. Deciding when to "cache" info in your state or compute it every time you need it is a pros/cons analysis we can cover later. For now, we're going with the most readable approach by using additional state properties.
We know what our state looks like; now we need functions that allow us to change it. The best way to design these is map them to specific actions the user will perform in our app.
The main action is the user clicks on a cell and the correct symbol is placed. So we want a function that will place a symbol into the appropriate index in our board
array. Our function needs to first know what cell to affect. Then based on various state properties, decide whether to place an 'X'
, 'O'
or make no change (usually we do "validation checks" first and if they pass, we finish the action). And finally, it needs to toggle the symbol in anticipation of next click.
function setMove(cellNo) {
// convert cellNo to integer in case it's still a string from DOM
const cell = Math.abs(cellNo);
// if there is a winner, this action must do nothing and return
if (state.winPattern) return;
// if something is already in the cell, do nothing and return
if (state.board[cell] !== null) return;
// if xIsNext, then place 'X'; otherwise, place 'O'
state.board[cell] = state.xIsNext ? 'X' : 'O';
// set xIsNext to the *opposite* boolean value of current xIsNext
state.xIsNext = !state.xIsNext;
}
Eventually, we also need this action to check if the last move was a winning move and make additional state changes. Let's come back to that.
We also need an action for the "New Game" button. When a new game starts, we want to set our state back to its contents on app initialization.
function newGame() {
state.xIsNext = true;
state.board = Array(9).fill(null);
state.winPattern = null;
}
Every time we change our state, we should run a render function, which is responsible for clearing out all or parts of the DOM and then re-rendering them based only on the current state.
The only dynamic part of our app is the board. This function needs to generate a new HTML snippet and fully replace the old content in the DOM with the newly generated HTML. A good approach to this is to use the content built from the static index.html
file and put it into a JavaScript template literal string. Then we can insert dynamic data from the state where appropriate:
function renderBoard() {
let html = '';
// Create the opening div:
html += '<div class="board">';
// Iterate each "row" in our `state.board`
state.board.forEach((cell, ind) => {
// If new row, open row div:
if (ind === 0 || ind === 3 || ind === 6) {
html += '<div class="row">';
}
// Output blank character " " if cell data is null, otherwise output cell data. Blank
// character helps since CSS reacts differently to elements with/without text content
html += `
<div class="cell" id="${ind}">
<p>${cell ? cell : ' '}</p>
</div>
`;
// Close row div:
if (ind === 2 || ind === 5 || ind === 8) {
html += '</div>';
}
});
html += '</div>';
GAME_EL.html(html);
}
Working through the checklist:
-
Fetch data for the state mod function from the DOM (if applicable). This is applicable because we need to know WHICH cell was clicked. We can retrieve this from the
id
attribute on the.cell
element. -
Call a state modification function. We need to call
setMove()
sending in the cell id. -
Call the appropriate render function. There's only one in this case.
Since the board is being rendered by Javascript and not static HTML, we will need event delegation to detect a click on the cell.
We can't control if the user clicks on the <p>
element within the cell, or the outer area where the <div class="cell">
is. This means we need to use traversal with closest()
to lock in on the .cell
element.
function onCellClick(event) {
const cellId = $(event.target).closest('.cell').attr('id');
setMove(cellId);
renderBoard();
}
$('.board').on('click', '.cell', onCellClick);
Same checklist as above. This time, we DON'T need any information from the DOM. Starting a new game has no dynamic input as there isn't some specific type of new game to start.
We also don't need event delegation as the "New Game" button is permanently on the DOM, in our static HTML.
function onNewGameClick() {
newGame();
renderBoard();
}
$('#new-game').click(onNewGameClick);
We need a standalone function that can analyze the board
array and determine if a win state has
occurred. Let's have the function return null
if there's no win state; otherwise, return an array
containing the winning cell ids. This way we'll know if there's a winner and which cells to highlight
if there is.
Then we're going to need to call this function in our setMove()
state modification function. And we're
also going to need to adjust our renderBoard()
function so that it conditionally places the .win
class
into the .cell
element for all the winning cells.
/**
* Analyzes board to determine if win state occurred
* @param {Array} board
* @returns {Array|null} - Array of indexes that denote winning cells; null if no winner
* */
function checkWinner(board){
const winPatterns = [[0,1,2], [3,4,5], [6,7,8], [0,3,6], [1,4,7], [2,5,8], [2,4,6], [0,4,8]];
for (let i = 0; i < winPatterns.length; i++) {
const winPattern = winPatterns[i];
// Prevent win with three nulls by checking first cell isn't null
if ( !board[winPattern[0]] ) continue;
if ( board[winPattern[0]] === board[winPattern[1]] && board[winPattern[1]] === board[winPattern[2]] ) {
return winPattern;
}
}
return null;
}
function setMove(cellNo) {
// [...other functionality...]
const winPattern = checkWinner(state.board);
if (winPattern) {
state.winPattern = winPattern;
}
}
Inside the callback function where we generate each row, we need to check for if the cellId
exists in our
winPattern
array:
// [...]
state.board.forEach((cell, ind) => {
// `winClass` is a string to be placed in the `class` attribute on the HTML element
// it'll either be 'win' to match our css class or empty string if no class should be added
const winClass = state.winPattern.includes(i) ? 'win' : '';
html += `
<div class="cell ${winClass}" id="${i}">
<p>${cell ? cell : ' ' }</p>
</div>
`;
});