An example tutorial built with git-tutor https://github.com/lesnitsky/git-tutor
Switch branches/tags
Nothing to show
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Failed to load latest commit information.
src and call it on each iteration of a game loop Aug 19, 2018
.editorconfig
LICENSE
README.md
index.html

README.md

Tic Tac Toe

This tutorial will walk you through a process of creation of a tic-tac-toe game

Built with Git Tutor

Project setup

Before we actually start writing code, I recommend to install editorconfig plugin for your ide/text editor. It will keep code consistent in terms of line-endings style, indentation, newlines

πŸ“„ .editorconfig

root = true

[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true

Every web-app needs an html entry-point, this ain't exception, so let's add simple html file

πŸ“„ index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tic Tac Toe</title>
</head>
<body>

</body>
</html>

index.js will be a js main file

πŸ“„ src/index.js

console.log('Hello world');

Now we need to add script to index.html

πŸ“„ index.html

      <title>Tic Tac Toe</title>
  </head>
  <body>
-
+     <script src="./src/index.js"></script>
  </body>
  </html>

Most likely the codebase will grow, so eventually we'll need some module system. This tutorial is not about setting-up a javascript bundler like webpack, so let's just use es6 modules which are already supported by latest Chrome. To make chrome understand import statement, type attribute should be set to module

πŸ“„ index.html

      <title>Tic Tac Toe</title>
  </head>
  <body>
-     <script src="./src/index.js"></script>
+     <script src="./src/index.js" type="module"></script>
  </body>
  </html>

Let's get started

Game state

Let's define a game state variable

πŸ“„ src/index.js

- console.log('Hello world');
+ const GameState = {
+
+ }

We'll need an information about current player to know whether x or o should be placed on a game field.

πŸ“„ src/index.js

  const GameState = {
-
+     currentPlayer: 0,
  }

0 – x should be placed

1 – o

field property will represent a game state. That's an array of 9 elements (3 columns x 3 rows) with initial value -1. Simple if (fieldValue > 0) check will work to distinguish empty fields from filled.

πŸ“„ src/index.js

  const GameState = {
      currentPlayer: 0,
+     field: Array.from({ length: 9 }).fill(-1),
  }

Game state modifications

Now we need to implement a function which will switch a current player. Let's do this with XOR operator. (how xor works).

πŸ“„ src/index.js

      currentPlayer: 0,
      field: Array.from({ length: 9 }).fill(-1),
  }
+
+ function changeCurrentPlayer(gameState) {
+     gameState.currentPlayer = 1 ^ gameState.currentPlayer;
+ }

To modify field values in plain array we'll need a function to convert row and col indices to an array index

πŸ“„ src/index.js

  function changeCurrentPlayer(gameState) {
      gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  }
+
+ function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
+     return rowIndex * 3 + colIndex;
+ }

Game turn logic

Now we'll start handling game turn logic. Create a function placeholder

πŸ“„ src/index.js

  function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
      return rowIndex * 3 + colIndex;
  }
+
+ function turn(gameState, rowIndex, colIndex) {
+
+ }

Convert row and col indices to plain array index

πŸ“„ src/index.js

  }

  function turn(gameState, rowIndex, colIndex) {
-
+     const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
  }

If game field already contains some value, do nothing

πŸ“„ src/index.js

  function turn(gameState, rowIndex, colIndex) {
      const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
+     const fieldValue = GameState.field[index];
+
+     if (fieldValue >= 0) {
+         return;
+     }
  }

Put player identifier to a field

πŸ“„ src/index.js

      if (fieldValue >= 0) {
          return;
      }
+
+     gameState.field[index] = gameState.currentPlayer;
  }

and change current player

πŸ“„ src/index.js

      }

      gameState.field[index] = gameState.currentPlayer;
+     changeCurrentPlayer(gameState);
  }

Win

The next thing we need to handle is a "win" state. Lets add helper variables which will contain array indices by rows:

πŸ“„ src/index.js

      field: Array.from({ length: 9 }).fill(-1),
  }

+ const Rows = [
+     [0, 1, 2],
+     [3, 4, 5],
+     [6, 7, 8],
+ ];
+
  function changeCurrentPlayer(gameState) {
      gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  }

cols:

πŸ“„ src/index.js

      [6, 7, 8],
  ];

+ const Cols = [
+     [0, 3, 6],
+     [1, 4, 7],
+     [6, 7, 8],
+ ];
+
  function changeCurrentPlayer(gameState) {
      gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  }

and diagonals

πŸ“„ src/index.js

      [6, 7, 8],
  ];

+ const Diagonals = [
+     [0, 4, 8],
+     [2, 4, 6],
+ ];
+
  function changeCurrentPlayer(gameState) {
      gameState.currentPlayer = 1 ^ gameState.currentPlayer;
  }

Now let's take a look at some examples of a "win" state

  1 -1  0
  0  1 -1
 -1 -1  1

Winner is 1. Sum of diagonal values equals 3

We can assume that we can detect a winner by getting a sum of each row, col and diagonal values and comparing it to a 0 (0 + 0 + 0) or 3 (1 + 1 + 1)

But here's another example

  0 -1  1
  1  0 -1
 -1 -1  0

A sum of 1st and 2nd row = 0

Sum of both diagonals = 0

Sum of 1st and 3d cols = 0

That's not the right way to go... 😞

πŸ’‘ Easy fix! Change initial value of field to -3 😎

πŸ“„ src/index.js

  const GameState = {
      currentPlayer: 0,
-     field: Array.from({ length: 9 }).fill(-1),
+     field: Array.from({ length: 9 }).fill(-3),
  }

  const Rows = [

Ok, now we are good. So let's create a simple sum function

πŸ“„ src/index.js

      gameState.field[index] = gameState.currentPlayer;
      changeCurrentPlayer(gameState);
  }
+
+ function sum(arr) {
+     return arr.reduce((a, b) => a + b, 0);
+ }

and a helper function which maps field indices to values

πŸ“„ src/index.js

  function sum(arr) {
      return arr.reduce((a, b) => a + b, 0);
  }
+
+ function getValues(gameState, indices) {
+     return indices.map(index => gameState.field[index]);
+ }

function getWinner should find if some row, col or diagonal sum is 0 or 3. Let's get values of all rows

πŸ“„ src/index.js

  function getValues(gameState, indices) {
      return indices.map(index => gameState.field[index]);
  }
+
+ function getWinner(gameState) {
+     const rows = Rows.map((row) => getValues(gameState, row));
+ }

and do the same for cols and diagonals

πŸ“„ src/index.js

  function getWinner(gameState) {
      const rows = Rows.map((row) => getValues(gameState, row));
+     const cols = Cols.map((col) => getValues(gameState, col));
+     const diagonals = Diagonals.map((col) => getValues(gameState, col));
  }

now let's create a single array of all values in field

πŸ“„ src/index.js

      const rows = Rows.map((row) => getValues(gameState, row));
      const cols = Cols.map((col) => getValues(gameState, col));
      const diagonals = Diagonals.map((col) => getValues(gameState, col));
+
+     const values = [...rows, ...cols, ...diagonals];
  }

and find if some chunk sum equals 0 or 3

πŸ“„ src/index.js

      const diagonals = Diagonals.map((col) => getValues(gameState, col));

      const values = [...rows, ...cols, ...diagonals];
+
+     let winner = -1;
+
+     values.forEach((chunk) => {
+         const chunkSum = sum(chunk);
+
+         if (chunkSum === 0) {
+             winner = 0;
+             return;
+         }
+
+         if (chunkSum === 3) {
+             winner = 1;
+             return;
+         }
+     });
+
+     return winner;
  }

Game loop

Now let's describe a game loop. We'll create a generator function to query row and col for each next turn from outside world. If you are not familliar with generator functions – read this medium post

πŸ“„ src/index.js

      return winner;
  }
+
+ function* gameLoop(gameState) {
+
+ }

Generator should execute until getWinner returns anything but -1.

πŸ“„ src/index.js

  }

  function* gameLoop(gameState) {
+     let winner = -1;
+
+     while (winner < 0) {

+         winner = getWinner(gameState);
+     }
  }

it should also make a turn befor each getWinner call

πŸ“„ src/index.js

      let winner = -1;

      while (winner < 0) {
+         const [rowIndex, colIndex] = yield;
+         turn(gameState, rowIndex, colIndex);

          winner = getWinner(gameState);
      }

Now let's test our gameLoop

Create a mock scenario of a game:

πŸ“„ src/index.js

          winner = getWinner(gameState);
      }
  }
+
+ const turns = [
+     [1, 1],
+     [0, 1],
+     [0, 0],
+     [1, 2],
+     [2, 2],
+ ];

Create a game generator object

πŸ“„ src/index.js

      [1, 2],
      [2, 2],
  ];
+
+ const game = gameLoop(GameState);
+ game.next();

Iterate over game turns and pass each turn to generator

πŸ“„ src/index.js

  const game = gameLoop(GameState);
  game.next();
+
+ turns.forEach(turn => game.next(turn));

After execution of this scenario game generator should finish it execution. This means that leading .next() call should return an object { value: undefined, done: true }

πŸ“„ src/index.js

  game.next();

  turns.forEach(turn => game.next(turn));
+
+ console.log(game.next());

Let's check it with node.js

node src/index.js
{ value: undefined, done: true }

Yay, it works!

Refactor time

Now as a core of a game is ready let's start refactor our index.js and split it in several modules

Drop testing code

πŸ“„ src/index.js

      }
  }

- const turns = [
-     [1, 1],
-     [0, 1],
-     [0, 0],
-     [1, 2],
-     [2, 2],
- ];
-
  const game = gameLoop(GameState);
  game.next();
-
- turns.forEach(turn => game.next(turn));
-
- console.log(game.next());

Move everything but gameLoop from index.js to game-state.js.

πŸ“„ src/game-state.js

const GameState = {
    currentPlayer: 0,
    field: Array.from({ length: 9 }).fill(-3),
}

const Rows = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
];

const Cols = [
    [0, 3, 6],
    [1, 4, 7],
    [6, 7, 8],
];

const Diagonals = [
    [0, 4, 8],
    [2, 4, 6],
];

function changeCurrentPlayer(gameState) {
    gameState.currentPlayer = 1 ^ gameState.currentPlayer;
}

function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
    return rowIndex * 3 + colIndex;
}

function turn(gameState, rowIndex, colIndex) {
    const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
    const fieldValue = gameState.field[index];

    if (fieldValue >= 0) {
        return;
    }

    gameState.field[index] = gameState.currentPlayer;
    changeCurrentPlayer(gameState);
}

function sum(arr) {
    return arr.reduce((a, b) => a + b, 0);
}

function getValues(gameState, indices) {
    return indices.map(index => gameState.field[index]);
}

function getWinner(gameState) {
    const rows = Rows.map((row) => getValues(gameState, row));
    const cols = Cols.map((col) => getValues(gameState, col));
    const diagonals = Diagonals.map((col) => getValues(gameState, col));

    const values = [...rows, ...cols, ...diagonals];

    let winner = -1;

    values.forEach((chunk) => {
        const chunkSum = sum(chunk);

        if (chunkSum === 0) {
            winner = 0;
            return;
        }

        if (chunkSum === 3) {
            winner = 1;
            return;
        }
    });

    return winner;
}

πŸ“„ src/index.js

- const GameState = {
-     currentPlayer: 0,
-     field: Array.from({ length: 9 }).fill(-3),
- }
-
- const Rows = [
-     [0, 1, 2],
-     [3, 4, 5],
-     [6, 7, 8],
- ];
-
- const Cols = [
-     [0, 3, 6],
-     [1, 4, 7],
-     [6, 7, 8],
- ];
-
- const Diagonals = [
-     [0, 4, 8],
-     [2, 4, 6],
- ];
-
- function changeCurrentPlayer(gameState) {
-     gameState.currentPlayer = 1 ^ gameState.currentPlayer;
- }
-
- function getArrayIndexFromRowAndCol(rowIndex, colIndex) {
-     return rowIndex * 3 + colIndex;
- }
-
- function turn(gameState, rowIndex, colIndex) {
-     const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
-     const fieldValue = GameState.field[index];
-
-     if (fieldValue >= 0) {
-         return;
-     }
-
-     gameState.field[index] = gameState.currentPlayer;
-     changeCurrentPlayer(gameState);
- }
-
- function sum(arr) {
-     return arr.reduce((a, b) => a + b, 0);
- }
-
- function getValues(gameState, indices) {
-     return indices.map(index => gameState.field[index]);
- }
-
- function getWinner(gameState) {
-     const rows = Rows.map((row) => getValues(gameState, row));
-     const cols = Cols.map((col) => getValues(gameState, col));
-     const diagonals = Diagonals.map((col) => getValues(gameState, col));
-
-     const values = [...rows, ...cols, ...diagonals];
-
-     let winner = -1;
-
-     values.forEach((chunk) => {
-         const chunkSum = sum(chunk);
-
-         if (chunkSum === 0) {
-             winner = 0;
-             return;
-         }
-
-         if (chunkSum === 3) {
-             winner = 1;
-             return;
-         }
-     });
-
-     return winner;
- }
-
  function* gameLoop(gameState) {
      let winner = -1;

Export everything gameLoop depends on

πŸ“„ src/game-state.js

- const GameState = {
+ export const GameState = {
      currentPlayer: 0,
      field: Array.from({ length: 9 }).fill(-3),
  }
      return rowIndex * 3 + colIndex;
  }

- function turn(gameState, rowIndex, colIndex) {
+ export function turn(gameState, rowIndex, colIndex) {
      const index = getArrayIndexFromRowAndCol(rowIndex, colIndex);
      const fieldValue = gameState.field[index];

      return indices.map(index => gameState.field[index]);
  }

- function getWinner(gameState) {
+ export function getWinner(gameState) {
      const rows = Rows.map((row) => getValues(gameState, row));
      const cols = Cols.map((col) => getValues(gameState, col));
      const diagonals = Diagonals.map((col) => getValues(gameState, col));

and import it in index.js

πŸ“„ src/index.js

+ import { GameState, getWinner, turn } from './game-state.js';
+
  function* gameLoop(gameState) {
      let winner = -1;

Rendering game state on canvas

Add canvas to index.html

πŸ“„ index.html

  </head>
  <body>
      <script src="./src/index.js" type="module"></script>
+     <canvas></canvas>
  </body>
  </html>

and get a reference to canvas with querySelector

πŸ“„ src/index.js

  const game = gameLoop(GameState);
  game.next();
+
+ const canvas = document.querySelector('canvas');

Let's make body full-height

πŸ“„ index.html

      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Tic Tac Toe</title>
+     <style>
+     html, body {
+         height: 100%;
+     }
+     </style>
  </head>
  <body>
      <script src="./src/index.js" type="module"></script>

and reset default margins

πŸ“„ index.html

      html, body {
          height: 100%;
      }
+
+     body {
+         margin: 0;
+     }
      </style>
  </head>
  <body>

Setup canvas size

πŸ“„ src/index.js

  game.next();

  const canvas = document.querySelector('canvas');
+
+ const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
+ canvas.width = size;
+ canvas.height = size;

and get a 2d context

πŸ“„ src/index.js

  const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
  canvas.width = size;
  canvas.height = size;
+
+ const ctx = canvas.getContext('2d');

Move canvas setup code to separate file

πŸ“„ src/canvas-setup.js

export function setupCanvas() {
    const canvas = document.querySelector('canvas');

    const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
    canvas.width = size;
    canvas.height = size;

    const ctx = canvas.getContext('2d');

    return { canvas, ctx };
}

πŸ“„ src/index.js

  const game = gameLoop(GameState);
  game.next();
-
- const canvas = document.querySelector('canvas');
-
- const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
- canvas.width = size;
- canvas.height = size;
-
- const ctx = canvas.getContext('2d');

and import it to index.js

πŸ“„ src/index.js

  import { GameState, getWinner, turn } from './game-state.js';
+ import { setupCanvas } from './canvas-setup.js';

  function* gameLoop(gameState) {
      let winner = -1;

  const game = gameLoop(GameState);
  game.next();
+
+ const { canvas, ctx } = setupCanvas();

Now let's create render function which will visualize the game state

πŸ“„ src/renderer.js

/**
 * @typedef GameState
 * @property {Number} currentPlayer
 * @property {Array<number>} field
 *
 * @param {HTMLCanvasElement} canvas
 * @param {CanvasRenderingContext2D} ctx
 * @param {GameState} gameState
 */
export function draw(canvas, ctx, gameState) {

}

We'll need to clear the whole canvas on each render call

πŸ“„ src/renderer.js

   * @param {GameState} gameState
   */
  export function draw(canvas, ctx, gameState) {
-
+     ctx.clearRect(0, 0, canvas.width, canvas.height);
  }

We'll render each cell with strokeRect, so let's setup cellSize (width and height of each game field cell) and lineWidth (border width of each cell)

πŸ“„ src/renderer.js

   */
  export function draw(canvas, ctx, gameState) {
      ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+     ctx.lineWidth = 10;
+     const cellSize = canvas.width / 3;
+
  }

And finally we rendered smth! πŸŽ‰

πŸ“„ src/renderer.js

      ctx.lineWidth = 10;
      const cellSize = canvas.width / 3;

+     gameState.field.forEach((_, index) => {
+         const top = Math.floor(index / 3) * cellSize;
+         const left = index % 3 * cellSize;
+
+         ctx.strokeRect(top, left, cellSize, cellSize);
+     });
  }

To see the result install live-server

npm i -g live-server
live-server .

Wait, what? Nothing rendered 😒 That's because we forgot to import and call draw function

πŸ“„ src/index.js

  import { GameState, getWinner, turn } from './game-state.js';
  import { setupCanvas } from './canvas-setup.js';
+ import { draw } from './renderer.js';

  function* gameLoop(gameState) {
      let winner = -1;
  game.next();

  const { canvas, ctx } = setupCanvas();
+ draw(canvas, ctx, GameState);

Let's make canvas a bit smaller to leave some space for other UI

πŸ“„ src/canvas-setup.js

  export function setupCanvas() {
      const canvas = document.querySelector('canvas');

-     const size = Math.min(document.body.offsetHeight, document.body.offsetWidth);
+     const size = Math.min(document.body.offsetHeight, document.body.offsetWidth) * 0.8;
      canvas.width = size;
      canvas.height = size;

and add a css border to make all cell edges look the same

πŸ“„ index.html

      body {
          margin: 0;
      }
+
+     canvas {
+         border: 5px solid black;
+     }
      </style>
  </head>
  <body>

It also looks weird in top-left corner, so align canvas to center with flex-box

πŸ“„ index.html

      <style>
      html, body {
          height: 100%;
+         display: flex;
+         align-items: center;
+         justify-content: center;
      }

      body {

So, we've rendered game field cells. Now let's render X and O symbols

πŸ“„ src/renderer.js

          ctx.strokeRect(top, left, cellSize, cellSize);
      });
  }
+
+ /**
+  * @param {CanvasRenderingContext2D} ctx
+  */
+ function drawX(ctx, top, left, size) {
+
+ }

We'll use path to render symbol both for X and O

πŸ“„ src/renderer.js

   * @param {CanvasRenderingContext2D} ctx
   */
  function drawX(ctx, top, left, size) {
+     ctx.beginPath();
+
+     ctx.closePath();
+     ctx.stroke();

  }

Draw a line from top-left to bottom-right

πŸ“„ src/renderer.js

  function drawX(ctx, top, left, size) {
      ctx.beginPath();

+     ctx.moveTo(left, top);
+     ctx.lineTo(left + size, top + size);
+
      ctx.closePath();
      ctx.stroke();

Draw a line from top-right to bottom-left

πŸ“„ src/renderer.js

      ctx.moveTo(left, top);
      ctx.lineTo(left + size, top + size);

+     ctx.moveTo(left + size, top);
+     ctx.lineTo(left, top + size);
+
      ctx.closePath();
      ctx.stroke();

Rendering O is even more simple

πŸ“„ src/renderer.js

      ctx.closePath();
      ctx.stroke();
+ }

+ /**
+  * @param {CanvasRenderingContext2D} ctx
+  */
+ function drawO(ctx, centerX, centerY, radius) {
+     ctx.beginPath();
+
+     ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
+     ctx.closePath();
+
+     ctx.stroke();
  }

And let's actually render X or O depending on a field value

πŸ“„ src/renderer.js

      ctx.lineWidth = 10;
      const cellSize = canvas.width / 3;

-     gameState.field.forEach((_, index) => {
+     gameState.field.forEach((value, index) => {
          const top = Math.floor(index / 3) * cellSize;
          const left = index % 3 * cellSize;

          ctx.strokeRect(top, left, cellSize, cellSize);
+
+         if (value < 0) {
+             return;
+         }
+
+         if (value === 0) {
+             drawX(ctx, top, left, cellSize);
+         } else {
+             drawO(ctx, left + cellSize / 2, top + cellSize / 2, cellSize / 2);
+         }
      });
  }

Nothing rendered? That's correct, every field value is -2, so let's make some turns

πŸ“„ src/index.js

  game.next();

  const { canvas, ctx } = setupCanvas();
+
+ turn(GameState, 0, 1);
+ turn(GameState, 1, 1);
+ turn(GameState, 2, 0);
+
  draw(canvas, ctx, GameState);

πŸ“„ src/renderer.js

          }

          if (value === 0) {
-             drawX(ctx, top, left, cellSize);
+             const margin = cellSize * 0.2;
+             const size = cellSize * 0.6;
+
+             drawX(ctx, top + margin, left + margin, size);
          } else {
-             drawO(ctx, left + cellSize / 2, top + cellSize / 2, cellSize / 2);
+             const radius = cellSize * 0.3;
+             drawO(ctx, left + cellSize / 2, top + cellSize / 2, radius);
          }
      });
  }

Interactions

Everything seems to be done, the only thing left – interactions. Let's start with cleanup:

πŸ“„ src/index.js

  const { canvas, ctx } = setupCanvas();

- turn(GameState, 0, 1);
- turn(GameState, 1, 1);
- turn(GameState, 2, 0);
-
  draw(canvas, ctx, GameState);

Add click listener and calculate clicked row and col

πŸ“„ src/index.js

  const { canvas, ctx } = setupCanvas();

  draw(canvas, ctx, GameState);
+
+ canvas.addEventListener('click', ({ layerX, layerY }) => {
+     const row = Math.floor(layerY / canvas.height * 100 / 33);
+     const col = Math.floor(layerX / canvas.width * 100 / 33);
+ });

Pass row and col indices to game loop generator

πŸ“„ src/index.js

  canvas.addEventListener('click', ({ layerX, layerY }) => {
      const row = Math.floor(layerY / canvas.height * 100 / 33);
      const col = Math.floor(layerX / canvas.width * 100 / 33);
+
+     game.next([row, col]);
  });

and reflect game state changes on canvas

πŸ“„ src/index.js

      const col = Math.floor(layerX / canvas.width * 100 / 33);

      game.next([row, col]);
+     draw(canvas, ctx, GameState);
  });

Now let's congratulate a winner

πŸ“„ src/index.js

          winner = getWinner(gameState);
      }
+
+     setTimeout(() => {
+         alert(`Congratulations, ${['X', 'O'][winner]}! You won!`);
+     });
  }

  const game = gameLoop(GameState);

Oh, we forgot to handle a draw! No worries. Let's add isGameFinished helper:

πŸ“„ src/game-state.js

      return winner;
  }
+
+ export function isGameFinished(gameState) {
+     return gameState.field.every(f => f >= 0);
+ }

and call it on each iteration of a game loop

πŸ“„ src/index.js

- import { GameState, getWinner, turn } from './game-state.js';
+ import { GameState, getWinner, turn, isGameFinished } from './game-state.js';
  import { setupCanvas } from './canvas-setup.js';
  import { draw } from './renderer.js';

  function* gameLoop(gameState) {
      let winner = -1;

-     while (winner < 0) {
+     while (winner < 0 && !isGameFinished(gameState)) {
          const [rowIndex, colIndex] = yield;
          turn(gameState, rowIndex, colIndex);

      }

      setTimeout(() => {
-         alert(`Congratulations, ${['X', 'O'][winner]}! You won!`);
+         if (winner < 0) {
+             alert(`It's a draw`);
+         } else {
+             alert(`Congratulations, ${['X', 'O'][winner]}! You won!`);
+         }
      });
  }

LICENSE

WTFPL