Making Four-In-A-Row Using JavaScript - Part 7: Drawing The Board

colinkiama

Colin Kiama

Posted on June 29, 2023

Making Four-In-A-Row Using JavaScript - Part 7: Drawing The Board

Intro

Welcome back! In the previous blog post, you set up the canvas and created drawings on it. Now in this post, you'll draw the game board.

Game Objects

What Is A Game Object?

The game features multiple drawings on the canvas. These distinct drawings take up space both horizontally and vertically. They may be made up of other smaller related drawings. These distinct drawings will be referred to as "game objects".

Why Do We Need Game Objects?

Looking back at the breakdown of the game's UI. Three components can be identified:

  1. Status Area
  2. Game Board
  3. Play Again Button

All of these components share the following things in common:

  • They have position
  • They have dimensions
  • All drawn on the canvas

When referring to these components in a generalised way, we'll be considering these common traits and properties only. In the context of this game, the general name for these components will be "Game Object". The components listed above are all game objects.

To represent this relationship in code, you'll create a GameObject class. The component classes will inherit from the GameObject class. This will avoid you from rewriting the common logic and properties shared across all the components.

Creating The GameObject Class

Unlike with the HTML elements, you will have to implement the size and positioning of game objects yourself. You'll also have to draw the game objects yourself too.

Create a directory under the src directory called components.

In the src/components directory, create a new file called GameObject.js.

In src/components/GameObject.js, add the following to the file:

export default class GameObject {
    x;
    y;
    width;
    height;
    context; // CanvasRenderingContext2D

    constructor(context, x, y, width, height) {
        this.context = context;
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    clear() {
        this.context.clearRect(this.x, this.y, this.width, this.height);
    }
}
Enter fullscreen mode Exit fullscreen mode

Inheriting The GameObject Class

Now that you've created the GameObject class, in the src/components directory, create a new file named Board.js. After, in that file, create a Board class that inherits from the GameObject class:

import GameObject from "./GameObject.js";

export default class Board extends GameObject {

}
Enter fullscreen mode Exit fullscreen mode

To test out whether the GameObject is being inherited by Board correctly, you'll recreate the last post's white rectangle drawing using the Board class.

Add a method called render() to the Board class that will render a white rectangle and then restore the state of the context object:

import GameObject from "./GameObject.js";

export default class Board extends GameObject {
    render() {
        this.context.save();
        this.clear();
        this.context.fillStyle = "white";
        this.context.fillRect(this.x, this.y, this.width, this.height);
        this.context.restore();
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice how the x, y, width, and height fields were not defined in Board. This is because they were inherited from GameObject.

Create a new file in src/components called index.js and fill it with the following contents:

import Board from "./Board.js";

export { Board };
Enter fullscreen mode Exit fullscreen mode

This code block above exports the Board class as a module, making it available to import from src/components/index.js. There will be more components in the future. This change will simplify the code required to import multiple components over time.

Return to the FrontEnd class. Import the Board class from src/components/index.js:

import { FrontEndConfig } from "./constants/index.js";
import { Board } from "./components/index.js";
Enter fullscreen mode Exit fullscreen mode

Add a field called board:

export default class FrontEnd {
    game;
    canvas;
    width;
    height;
    context;
    board;

    // ...
}
Enter fullscreen mode Exit fullscreen mode

Rewrite the start() method in the Board class:

  • Set the board field to a new instance of the Board class
  • Call the render() method on board
export default class FrontEnd {
    // ..

    start() {
        this.board = new Board(this.context, 20, 20, 50, 100);
        this.board.render();
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that the Board uses the same constructor defined in GameObject.

If you check the game with your web server, you'll see the same white rectangle drawn on the canvas as the one you drew in the previous blog post.

Image of canvas drawing with a white rectangle over a blue background

Drawing The Game Board

Awesome! You've figured out how to create your game object and draw it on the canvas. Now it's time to draw the actual game board.

Board Background

In src/components/Board.js, import BoardConfig from the constants file:

import GameObject from './GameObject.js';
import { BoardConfig } from '../constants/index.js';
Enter fullscreen mode Exit fullscreen mode

Then create a new method in the Board class called renderBoardBackground().

After, perform the following steps:

  • Move the code that draws the white rectangle into the renderBoardBackground() method
  • Replace the fill style with the BACKGROUND_COLOR field defined in the imported BoardConfig object.
import GameObject from "./GameObject.js";
import { BoardConfig } from "../constants/index.js";

export default class Board extends GameObject {
    render() {
        this.context.save();
        this.clear();
        this.renderBoardBackground();
        this.context.restore();
    }

    renderBoardBackground() {
        this.context.fillStyle = BoardConfig.BACKGROUND_COLOR;
        this.context.fillRect(this.x, this.y, this.width, this.height); 
    }
}
Enter fullscreen mode Exit fullscreen mode

In the FrontEnd class, import BoardConfig from the constants file:

import { FrontEndConfig, BoardConfig } from "./constants/index.js";
Enter fullscreen mode Exit fullscreen mode

Add a method called createBoard() then do the following:

  • In createBoard(), create a local variable that stores a new instance of the Board class, calls the render() method on it then returns it.
  • Rewrite the start() method so that the board field is set to the result returned from calling the createBoard() method
export default class FrontEnd {
    // ..

    start() {
        this.board = this.createBoard();
    }

    createBoard() {
        let board = new Board(this.context, BoardConfig.MARGIN_LEFT, BoardConfig.MARGIN_TOP, BoardConfig.WIDTH, BoardConfig.HEIGHT);
        board.render();
        return board;
    }
}
Enter fullscreen mode Exit fullscreen mode

The board is now created with the positioning and dimensions from mockups of the game.

Now, with a web server running, if you check your game in your browser, you'll see that a blue rectangle has been rendered on the canvas.

Image of game board background rendered on the canvas

Board Slots

Now you're ready to draw the slots on the board.

Add a parameter called nextBoard to the Board class' render() method:

export default class Board extends GameObject {
    render(nextBoard) {
        this.context.save();
        this.clear();
        this.renderBoardBackground();
        this.context.restore();
    }

    // ..
Enter fullscreen mode Exit fullscreen mode

nextBoard will contain the state of the board. It's an array of arrays of numbers representing the tokens in each board position. These will be used to render the board tokens in the slots.

Next, add a method called renderSlots() to the Board class. To keep the upcoming drawing commands simple, you'll modify the point of origin where you start drawing the slots. This way, you won't have to consider padding in later drawing commands in renderSlots(). To do this, you'll use the CanvasRenderingContext2D.translate() method:

export default class Board extends GameObject {
    // ..

    renderSlots(nextBoard) {
        this.context.translate(this.x + BoardConfig.HORIZONTAL_PADDING, this.y + BoardConfig.VERTICAL_PADDING);
    }
Enter fullscreen mode Exit fullscreen mode

Start drawing the slots.

Set the stroke colour and line width:

this.context.strokeStyle = BoardConfig.SLOT_OUTLINE_COLOR;
this.context.lineWidth = 2;
Enter fullscreen mode Exit fullscreen mode

Store the radius of a slot in a local variable called slotRadius:

const slotRadius = BoardConfig.SLOT_WIDTH / 2;
Enter fullscreen mode Exit fullscreen mode

Update import statements in src/components/Board.js to include TokenColor from the constants directory and Constants from the gameLogic directory:

import { BoardConfig, TokenColor } from "../constants/index.js";
import { Constants } from "../gameLogic/index.js";
import GameObject from "./GameObject.js";
Enter fullscreen mode Exit fullscreen mode

Loop through each slot on the board and:

  • Calculate the positioning:
  • Obtain the token colour to render the slot with
for (let rowIndex = 0; rowIndex < Constants.BoardDimensions.ROWS; rowIndex++) {
    for (let columnIndex = 0; columnIndex < Constants.BoardDimensions.COLUMNS; columnIndex++) {
        // Note slot is a circle. (x, y) coordinates are the circle's centre.
        const totalSlotMarginsX = BoardConfig.SLOT_MARGIN * columnIndex;
        const totalPreviousSlotWidthsX = BoardConfig.SLOT_WIDTH * columnIndex;
        const slotX = totalSlotMarginsX + totalPreviousSlotWidthsX + slotRadius;

        const totalSlotMarginsY = BoardConfig.SLOT_MARGIN * rowIndex;
        const totalPreviousSlotWidthsY = BoardConfig.SLOT_WIDTH * rowIndex;
        const slotY = totalSlotMarginsY + totalPreviousSlotWidthsY + slotRadius;

        const tokenColorValue = nextBoard[rowIndex][columnIndex];

        let tokenColor;

        switch (tokenColorValue) {
            case Constants.BoardToken.YELLOW:
                tokenColor = TokenColor.YELLOW;
                break;
            case Constants.BoardToken.RED:
                tokenColor = TokenColor.RED;
                break;
            default:
                tokenColor = TokenColor.NONE
                break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

You now have the values you need to render each slot. To do so, add a method called renderSlot() to the Board class:

export default class Board extends GameObject {
    // ..

    renderSlot(x, y, radius, color) {
        this.context.beginPath();
        this.context.arc(x, y, radius, 0, Math.PI * 2);
        this.context.closePath();
        this.context.stroke();

        this.context.fillStyle = color;
        this.context.beginPath();
        this.context.arc(x, y, radius - 1, 0, Math.PI * 2);
        this.context.closePath();
        this.context.fill();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, call renderSlot() right after the switch block in renderSlots():

renderSlots(nextBoard) {
    this.context.translate(this.x + BoardConfig.HORIZONTAL_PADDING, this.y + BoardConfig.VERTICAL_PADDING);
    this.context.strokeStyle = BoardConfig.SLOT_OUTLINE_COLOR;
    this.context.lineWidth = 2;

    const slotRadius = BoardConfig.SLOT_WIDTH / 2;

    for (let rowIndex = 0; rowIndex < Constants.BoardDimensions.ROWS; rowIndex++) {
        for (let columnIndex = 0; columnIndex < Constants.BoardDimensions.COLUMNS; columnIndex++) {
            // Note slot is a circle. (x, y) coordinates are the circle's centre.
            const totalSlotMarginsX = BoardConfig.SLOT_MARGIN * columnIndex;
            const totalPreviousSlotWidthsX = BoardConfig.SLOT_WIDTH * columnIndex;
            const slotX = totalSlotMarginsX + totalPreviousSlotWidthsX + slotRadius;

            const totalSlotMarginsY = BoardConfig.SLOT_MARGIN * rowIndex;
            const totalPreviousSlotWidthsY = BoardConfig.SLOT_WIDTH * rowIndex;
            const slotY = totalSlotMarginsY + totalPreviousSlotWidthsY + slotRadius;

            const tokenColorValue = nextBoard[rowIndex][columnIndex];

            let tokenColor;

            switch (tokenColorValue) {
                case Constants.BoardToken.YELLOW:
                    tokenColor = TokenColor.YELLOW;
                    break;
                case Constants.BoardToken.RED:
                    tokenColor = TokenColor.RED;
                    break;
                default:
                    tokenColor = TokenColor.NONE
                    break;
            }

            this.renderSlot(slotX, slotY, slotRadius, tokenColor);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Add a call to renderSlots() in the render() method:

render(nextBoard) {
    this.context.save();
    this.clear();
    this.renderBoardBackground();
    this.renderSlots(nextBoard);
    this.context.restore();
}
Enter fullscreen mode Exit fullscreen mode

Lastly, in the FrontEnd class, update the board.render() method call in createBoard() so that you pass in the current board state:

export default class FrontEnd {
    // ..

    createBoard() {
        let board = new Board(this.context, BoardConfig.MARGIN_LEFT, BoardConfig.MARGIN_TOP, BoardConfig.WIDTH, BoardConfig.HEIGHT);
        board.render(this.game.currentBoard);
        return board;
    }
}
Enter fullscreen mode Exit fullscreen mode

If you check your browser now while your server is running, you'll see empty slots drawn on the board:

Canvas in browser featuring the board with slots

Making The Game Playable

You can attempt to hardcode a board state argument when calling the render() method on the board variable. The game will render the board state accordingly.

However, you currently aren't able to update the board state in-game.

One way to update the board state in-game is by responding to clicks on the canvas.

Unlike with HTML elements, you can't just rely on the built-in DOM events system to handle clicks on our game objects. Since you're drawing on the canvas, you'll have to handle the hit detection yourself and come up with your own event-handling interface.

Introducing Your Event Handling API

In this game, you'll detect clicks by listening for the click event on the body of the page and then pass on the event data to relevant game objects to be processed.

To get started with this, add a new method to the GameObject class called handleClick():

export default class GameObject {
    x;
    y;
    width;
    height;
    context;

    constructor(context, x, y, width, height) {
        this.context = context;
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }

    clear() {
        this.context.clearRect(this.x, this.y, this.width, this.height);
    }

    handleClick(clickEvent) { }
}
Enter fullscreen mode Exit fullscreen mode

handleClick() will be overridden by the GameObject class' inheritors. The inheritors will add logic to the handleClick() method for handling event data from clicks.

Implementing Your Event Handling API

Now, in the Board class, add a field called columnSelected. This will be used to store a callback. The callback will contain logic to run that will be defined in the FrontEnd class:

export default class Board extends GameObject {
    columnSelected;

    // ..
}
Enter fullscreen mode Exit fullscreen mode

After that, add a method called setColumnSelectionHandler(), which will be used to set the logic that will run from the callback:

export default class Board extends GameObject {
    // ..

    setColumnSelectionHandler(callback) {
        this.columnSelected = callback;
    }
}
Enter fullscreen mode Exit fullscreen mode

You now have the prerequisites for overriding handleClick() in the Board class.

Add the following methods to the Board class:

  • handleClick()
  • trySelectColumn()
export default class Board extends GameObject {
    // ..

    handleClick(clickEvent) {
        this.trySelectColumn(clickEvent);
    }

    trySelectColumn(clickEvent) {
        for (let columnIndex = 0; columnIndex < Constants.BoardDimensions.COLUMNS; columnIndex++) {
            const totalSlotMargins = BoardConfig.SLOT_MARGIN * columnIndex;
            const totalPreviousSlotWidths = BoardConfig.SLOT_WIDTH * columnIndex;
            const columnX = this.x + BoardConfig.HORIZONTAL_PADDING + totalSlotMargins + totalPreviousSlotWidths;

            const wasColumnClicked = clickEvent.offsetX >= columnX
                && clickEvent.offsetX <= (columnX + BoardConfig.SLOT_WIDTH)
                && clickEvent.offsetY >= this.y + BoardConfig.VERTICAL_PADDING
                && clickEvent.offsetY <= this.y + BoardConfig.HEIGHT - BoardConfig.VERTICAL_PADDING;

            if (wasColumnClicked) {
                this.columnSelected(columnIndex);
                break;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The trySelectColumn() method figures out if the player clicked a column. If the player has clicked on a column, it will run the columnSelected callback with the selected column's position index. For example, the leftmost column would have an index of 0, and the third column would have an index of 2.

Adding the callback logic

Finally, you'll set up the click events in the FrontEnd class and handle them.

In the start method, add an event listener for clicks on the document's body. This will call the handleClick() method on the board with event data:

export default class FrontEnd {
    // ..

    start() {
        this.board = this.createBoard();

        document.body.addEventListener('click', (clickEvent) => {
            this.board.handleClick(clickEvent);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, you'll add the logic that will run in the Board class' selectedColumn callback.

Add a field called gameOver in the FrontEnd class. Set its value to false in the FrontEnd class' constructor:

export default class FrontEnd {
    game;
    canvas;
    width;
    height;
    context;
    board;
    gameOver;


    constructor(game) {
        this.game = game;
        this.canvas = document.getElementById("canvas");
        this.canvas.style.background = FrontEndConfig.GAME_BACKGROUND_COLOR;
        this.width = canvas.width;
        this.height = canvas.height;
        this.context = this.canvas.getContext("2d");
        this.gameOver = false;

        this.enableHiDPISupport();
    }

    // ..
}
Enter fullscreen mode Exit fullscreen mode

Update the import statements in src/FrontEnd.js to include Constants from the gameLogic directory:

import { FrontEndConfig, BoardConfig } from "./constants/index.js";
import { Board } from "./components/index.js";
import { Constants } from "./gameLogic/index.js";
Enter fullscreen mode Exit fullscreen mode

Create the following methods: playMove() and processMoveResult():

export default class FrontEnd {
    // ..

    playMove(columnIndex) {
        let moveResult = this.game.playMove(columnIndex);
        this.processMoveResult(moveResult);
    }

    processMoveResult(moveResult) {
        if (this.gameOver || moveResult.status.value === Constants.MoveStatus.INVALID) {
            return;
        }

        this.board.render(this.game.currentBoard);

        if (moveResult.status.value === Constants.MoveStatus.WIN || moveResult.status.value === Constants.MoveStatus.DRAW) {
            this.gameOver = true;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In createBoard(), add a line before the call to render() on the board. In that line, call the setColumnSelectionHandler() method:

export default class FrontEnd {
    createBoard() {
        let board = new Board(this.context, BoardConfig.MARGIN_LEFT, BoardConfig.MARGIN_TOP, BoardConfig.WIDTH, BoardConfig.HEIGHT);
        board.setColumnSelectionHandler((columnIndex) => this.playMove(columnIndex));
        board.render(this.game.currentBoard);
        return board;
    }
}
Enter fullscreen mode Exit fullscreen mode

If you check your browser now while your server is running, you'll now be able to place tokens on the board, based on which column you clicked on.

Canvas in a browser featuring a board with slots with a few slots around the bottom left filled with yellow and red tokens

Conclusion

Congratulations! This was a long post (maybe the longest in this blog series so far!). Hopefully, it was worthwhile to you now that you've made a playable four-in-a-row game.

However, it's not quite clear what's going on in the game. The board stops updating when a player wins or the game ends in a draw. There's no indication of the current status of the game.

In the next post, you'll fix that problem. You'll add the status area component to the game. This will enable players and spectators to be aware of the current status of the game at any time.

Thanks for reading!

💖 💪 🙅 🚩
colinkiama
Colin Kiama

Posted on June 29, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related