One of the simplest games you can play on a grid is tic-tac-toe: mark spaces in a 3x3 grid with X or O; the player who gets three in a row (horizontally, vertically, or diagonally) wins. Let’s program a simple version of the game using Waffle. This will give a good introduction to some recurring concepts that you can use to build other games in the future.
First, download Waffle, which includes an index.html
file that automatically loads the necessary JavaScript and CSS files. Next, open the game.js
file in a text editor; you’ll see that there is some example code there already which initializes a 10x10 grid, and sets up a simple event listener to toggle grid cell background colors. The first thing we can do is change the grid size to a 3x3 board.
- const rows = 10;
- const columns = 10;
+ const rows = 3;
+ const columns = 3;
Waffle.init(rows, columns);
For this version of the game, we’ll keep things simple — on an empty game board, the first click (or tap) on an empty square will place an X, the second will place an O, and so on. The example code already has set up an onPointDown
function, which is run whenever a player clicks or taps on the game board. It provides us with the Cartesian coordinates of the clicked square. We can modify this code slightly to place X and O graphics.
Waffle.init(rows, columns);
+ // start by placing "X"
+ let player = 'x';
Waffle.onPointDown(({ x, y }) => {
- console.debug(`clicked grid cell (${x}, ${y})`);
- /* replace this with your own code! */
const nextState = Waffle.state;
+ // do nothing if a player clicks on a square that's already filled
+ if (!Waffle.isEmpty(nextState[x][y])) {
+ return;
+ }
+
+ nextState[x][y] = player;
- if (Waffle.isEmpty(nextState[x][y])) {
- nextState[x][y] = 'highlight';
- } else {
- nextState[x][y] = '';
- }
- if (Waffle.isEmpty(nextState[x][y])) {
- nextState[x][y] = player;
- }
Waffle.state = nextState;
});
If you save the game.js
file and load index.html
in a browser (File → Open File → index.html), you should see a 3x3 grid in the center of the page. However, clicking squares inside doesn’t seem to do anything. That’s because Waffle uses CSS classes to apply styling to each square. The page looks for a class style of .x
inside of main.css
, but it’s not there. Let’s add it now. Make your own graphic files to represent an X and O, or else download some samples here. Then add the following at the bottom of main.css
:
#grid {
/* ... */
- .highlight {
- background-color: blueviolet;
- }
+ .x { background: url('x.png') center/100%; }
+ .o { background: url('o.png') center/100%; }
}
These background rules specify the image file that should be used for the background, and that the image should be placed in the center of the element, and take up 100% of the available background space. Save and reload, then click the grid — you should start seeing X icons appear.

However, it’s not actually a game of tic-tac-toe yet, since the second player needs to be able to add marks to the board. We can make this happen by toggling the value of the player
variable between x
and o
. After the call to Waffle.state = nextState;
, add the following condition:
Waffle.state = nextState;
+ // switch players
+ if (player === 'x') {
+ player = 'o';
+ } else {
+ player = 'x';
+ }
Now, when you click or tap an empty square, either an X or O will be placed there. We’re getting close!

The last part of the game is to check to see if a player has won, or whether it has ended in a tie. If we think about the grid in terms of Cartesian coordinates, it might look something like this:
(0, 0) | (1, 0) | (2, 0)
------------------------
(0, 1) | (1, 1) | (2, 1)
------------------------
(0, 2) | (1, 2) | (2, 2)
In total, there are eight ways to win: fill in a column (3), a row (3), or one of the diagonals (2). We can make a list of each of these valid win conditions, and then loop through the list, checking the value of each square in the grid.
Waffle.state = nextState;
+ const winPositions = [
+ [{x: 0, y: 0}, {x: 1, y: 0}, {x: 2, y: 0}], // row 1
+ [{x: 0, y: 1}, {x: 1, y: 1}, {x: 2, y: 1}], // row 2
+ [{x: 0, y: 2}, {x: 1, y: 2}, {x: 2, y: 2}], // row 3
+
+ [{x: 0, y: 0}, {x: 0, y: 1}, {x: 0, y: 2}], // column 1
+ [{x: 1, y: 0}, {x: 1, y: 1}, {x: 1, y: 2}], // column 2
+ [{x: 2, y: 0}, {x: 2, y: 1}, {x: 2, y: 2}], // column 3
+
+ [{x: 0, y: 0}, {x: 1, y: 1}, {x: 2, y: 2}], // diagonal 1
+ [{x: 0, y: 2}, {x: 1, y: 1}, {x: 2, y: 0}], // diagonal 2
+ ];
+
+ for (const group of winPositions) {
+ let winner;
+
+ if (group.every(square => nextState[square.x][square.y] === 'x')) {
+ winner = 'X';
+ } else if (group.every(square => nextState[square.x][square.y] === 'o')) {
+ winner = 'O';
+ }
+
+ if (winner) {
+ alert(`Player "${winner}" wins!`);
+ }
+ }
// switch players
if (player === 'x') {
Put this code block right before the condition that swaps player turns. This should correctly identify when one player has won.

There’s another end game state, however, and that’s when all squares in the board have been filled in — a tie game. Checking for this state is a bit easier than determining the “win” condition: we need to check each square in the board, and if each is filled (without a player having already won) then the game is tied.
if (winner) {
alert(`Player "${winner}" wins!`);
}
}
+ // start with the assumption that all squares have been used
+ let tieGame = true;
+
+ for (let x = 0; x < columns; x += 1) {
+ for (let y = 0; y < rows; y += 1) {
+ // if we find any empty square, it means the game isn't over yet
+ if (Waffle.isEmpty(nextState[x][y])) {
+ tieGame = false;
+ }
+ }
+ }
+
+ if (tieGame) {
+ alert('Tie game!');
+ }
// switch players
if (player === 'x') {

One last feature we can implement is a way to programatically reset the game, so that you can keep playing without reloading the page (which re-initializes all the JavaScript code). A way to do this is keep track of whether the game is considered “over” by using a boolean variable. The next time a player clicks on the game board after the game is over will trigger some code to reset the game. Add the boolean right after the local variable used to store the current player.
let player = 'x';
+ let gameOver = false;
Waffle.onPointDown(({ x, y }) => {
We now need to set that variable to true
if one player wins, or if the game is tied.
if (wonGame) {
alert(`Player "${player}" wins!`);
+ gameOver = true;
}
if (tieGame) {
alert('Tie game!');
+ gameOver = true;
}
Finally, add a block in the onPointDown
handler to check if the game is over, and if so, reset.
Waffle.onPointDown(({ x, y }) => {
+ if (gameOver) {
+ // reset the game
+ gameOver = false;
+ player = 'x';
+ Waffle.fill('');
+ return;
+ }
const nextState = Waffle.state;

And that’s it! You’ve created a version of tic-tac-toe that’s playable via a web browser. This sets the stage for making more complicated games in the future.