Patric Johansson On Applied .NET

July 17, 2020

Tic-Tac-Toe with JavaScript and MVC

Filed under: JavaScript — Patric Johansson @ 4:46 PM
Tags: ,
Tic-Tac-Toe game

Tic-Tac-Toe is a fairly easy game to implement. This version is played by two users (X and O) and allows boards of various sizes. Also, number of marks in row needed to win can be set. In above screenshot, we have a 6×6 board and four marks are needed to win. X is next and with the right move can end the game.

The HTML and CSS code is simple, a table for the board and two CSS classes to display images for X (markX) and O (markO). The cells (td) are fixed size and added using JavaScript.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <title>Tic-Tac-Toe</title>
    <style>
        table {
            background: url("background.jpg");
            background-size: contain;
            width: auto;
            height: auto;
            border: 5px solid black;
            border-spacing: 0px;
            padding: 0;
            margin: 0;
        }
        td {
            width: 50px;
            height: 50px;
            border: 5px solid black;
            border-spacing: 0px;
            padding: 0;
            margin: 0;
        }
        .markX {
            background: url("x.png");
            background-size: contain;
        }
        .markO {
            background: url("o.png");
            background-size: contain;
        }
    </style>
</head>
<body>
    <select id="lengthComboBox" onchange="onchangeLengthCombobox()">
        <option value="3" selected="selected">3x3</option>
        <option value="4">4x4</option>
        <option value="5">5x5</option>
        <option value="6">6x6</option>
        <option value="7">7x7</option>
        <option value="8">8x8</option>
        <option value="9">9x9</option>
    </select>
    <select id="marksInRowNeededComboBox" onchange="restartGame()">
        <option value="3" selected="selected">3</option>
    </select>
    <input type="button" id="restartButton" value="Start over!" onclick="restartGame()">
    <table id="boardTable" />
    <script src="TicTacToe.js"></script>
</body>
</html>

The behavior is all in JavaScript, including some DOM manipulation. We start with some event handlers, to hook up to our comboboxes and button. The number of marks in row needed is dependent on the size of the board, must be less than or equal. Not possible to get 4 in a row on a 3×3 board for example. We use updateMarksInRowNeededComboBox function of the view object to update the combobox to set number of marks in row to win.

// Event Handlers
window.onload = restartGame;

function restartGame() {
    controller.restartGame();
}

function onchangeLengthCombobox() {
    view.updateMarksInRowNeededComboBox();
    controller.restartGame();
}

The view object is responsible for getting user inputs and updating the UI. Since we follow the MVC patters, the view can access the model object. The generateBoard function clears the table (with id boardTable) and adds new cells. The cells have ids that correspond to their position (x, y) in the grid in the form of x:y, for example 3:5. We use the string split function to get x and y from a cell id.

Various mouse events are hooked up to the cells in order to provide highlighting on mouse over and add a mark on mouse click. Function highlightMarks is used to make some cells appear dimmed so winning marks in row stand out. Functions displayMark and hideMark are used for showing the mark on mouse over.

// View
var view = {
    getBoardLengthInput: function () {
        var lengthComboBox = document.getElementById("lengthComboBox");
        var length = lengthComboBox.options[lengthComboBox.selectedIndex].value;
        return parseInt(length);
    },
    getMarksInRowNeededInput: function () {
        var marksInRowNeededComboBox = document.getElementById("marksInRowNeededComboBox");
        var marksInRowNeeded = marksInRowNeededComboBox.options[marksInRowNeededComboBox.selectedIndex].value;
        return parseInt(marksInRowNeeded);
    },
    generateBoard: function () {
        var table = document.getElementById("boardTable");
        for (var i = table.rows.length - 1; i >= 0; i--) {
            table.deleteRow(i);
        }
        for (var y = 0; y < model.board.length; y++) {
            row = table.insertRow(y);      
            for (x = 0; x < model.board.length; x++) {
                var cell = row.insertCell(x);
                cell.id = x + ":" + y;
                cell.onclick = this.placeMark;
                cell.onmouseenter = this.displayMark;
                cell.onmouseleave = this.hideMark;
            }
        }
    },
    updateMarksInRowNeededComboBox: function () {
        var marksInRowNeededComboBox = document.getElementById("marksInRowNeededComboBox");
        for (var i = marksInRowNeededComboBox.length - 1; i >= 0; i--) {
            marksInRowNeededComboBox.remove(i);
        }
        for (var i = 3; i <= view.getBoardLengthInput(); i++) {
            var option = document.createElement("option");
            option.value = i;
            option.text = i;
            if (i == 3) {
                option.selected = "selected";
            }
            marksInRowNeededComboBox.add(option);
        }
    },
    highlightMarks: function (marks) {
        for (var y = 0; y < model.board.length; y++) {
            for (var x = 0; x < model.board.length; x++) {
                if (!marks.includes(x + ":" + y)) {
                    var cell = document.getElementById(x + ":" + y);
                    cell.style.opacity = "0.25";
                }
            }
        }
    },
    placeMark: function (e) {
        var coords = e.target.id.split(":");
        var x = parseInt(coords[0]);
        var y = parseInt(coords[1]);
        controller.placeMark(x, y);
    },
    displayMark: function (e) {
        var coords = e.target.id.split(":");
        var x = parseInt(coords[0]);
        var y = parseInt(coords[1]);
        if (model.isFree(x, y) && !model.isLocked) {
            var cell = document.getElementById(x + ":" + y);
            if (model.nextPlayer == "X") {
                cell.setAttribute("class", "markX");
            }
            else {
                cell.setAttribute("class", "markO");
            }
        }
    },
    hideMark: function (e) {
        var coords = e.target.id.split(":");
        var x = parseInt(coords[0]);
        var y = parseInt(coords[1]);
        if (model.isFree(x, y)) {
            var cell = document.getElementById(x + ":" + y);
            cell.removeAttribute("class");
        }
    }
}

The model object holds our data, the game board and which user is next. The model is self contained and have no knowledge of the view or controller. The board is an array of arrays, since JavaScript don’t support two dimensional arrays.It has some fairly complicated logic to check if a move resulted in a win, function isWin. Once the board is full or a user won, we set isLocked to true which will stop user interaction with the board until game is restarted.

// Model
var model = {
    board: new Array(),
    nextPlayer: "O",
    isLocked: false,
    isFree: function (x, y) {
        var mark = this.board[x][y];
        return mark == undefined;
    },
    placeMark: function (x, y, mark) {
        this.board[x][y] = mark;
    },
    isWin: function (x, y, mark) {
        if (this.board[x][y] != mark) {
            return [];
        }

        var marksInRowNeeded = view.getMarksInRowNeededInput();   
        var winningMarksArr = [];

        for (var i = 0; i < this.board.length; i++) {
            if (this.board[i][y] == mark) {
                winningMarksArr.push(i + ":" + y);
            }
            else {
                winningMarksArr = [];
            }
            if (winningMarksArr.length == marksInRowNeeded) {
                return winningMarksArr;
            }
        }
        winningMarksArr = [];
        for (var i = 0; i < this.board.length; i++) {
            if (this.board[x][i] == mark) {
                winningMarksArr.push(x + ":" + i);
            }
            else {
                winningMarksArr = [];
            }
            if (winningMarksArr.length == marksInRowNeeded) {
                return winningMarksArr;
            }
        }
        winningMarksArr = [];
        var minX = x - Math.min(x, y);
        var minY = y - Math.min(x, y);
        var maxX = x + this.board.length - Math.max(x, y) - 1;
        for (var i = 0; i <= maxX - minX; i++) {
            if (this.board[minX + i][minY + i] == mark) {
                winningMarksArr.push((minX + i) + ":" + (minY + i));
            }
            else {
                winningMarksArr = [];
            }
            if (winningMarksArr.length == marksInRowNeeded) {
                return winningMarksArr;
            }
        }
        winningMarksArr = [];
        minX = x - Math.min(x, this.board.length - y - 1); 
        minY = y - Math.min(y, this.board.length - x - 1);
        maxX = x + Math.min(y, this.board.length - x - 1);
        maxY = y + Math.min(x, this.board.length - y - 1);
        for (var i = 0; i <= maxX - minX; i++) {
            if (this.board[maxX - i][minY + i] == mark) {
                winningMarksArr.push((maxX - i) + ":" + (minY + i));
            }
            else {
                winningMarksArr = [];
            }
            if (winningMarksArr.length == marksInRowNeeded) {
                return winningMarksArr;
            }
        }
        return [];
    },
    isBoardFull: function () {
        for (var i = 0; i < this.board.length; i++) {
            for (var j = 0; j < this.board.length; j++) {
                if (this.board[i][j] == undefined) {
                    return false;
                }
            }
        }
        return true;
    },
    generateBoard: function(length) {
        var arr = new Array(length);
        for (var i = 0; i < length; i++) {
            arr[i] = new Array(length);
            for (var j = 0; j < length; j++) {
                arr[i][j] = undefined;
            }
        }
        this.board = arr;
    }
};

Finally, our controller, is in charge of game flow and rules. It reacts on user input from the view object and updates the model accordingly. Function placeMark is run whenever user clicks on a free cell. It checks that it is an allowed move, then updates the model. Next it checks with model if we have a win or full board and if so, commands the view to display this using function highlightMarks.

// Controller
var controller = {
    restartGame: function () {
        var boardLength = view.getBoardLengthInput();
        model.generateBoard(boardLength);
        model.nextPlayer = "O"
        model.isLocked = false;
        view.generateBoard();
    },
    placeMark : function (x, y) {
        if (!model.isFree(x, y) ||
            model.isLocked) {
            return;
        }

        model.placeMark(x, y, model.nextPlayer);
        var winningMarksArr = model.isWin(x, y, model.nextPlayer);
        if (winningMarksArr.length > 0) {
            view.highlightMarks(winningMarksArr);
            model.isLocked = true;
        }
        else if (model.isBoardFull()) {
            view.highlightMarks([]);
            model.isLocked = true;
        }
        else {
            model.nextPlayer = model.nextPlayer == "O" ? model.nextPlayer = "X" : model.nextPlayer = "O";
        }
    }
}

Full source code can be found here.

Leave a Comment »

No comments yet.

RSS feed for comments on this post. TrackBack URI

Leave a comment

Blog at WordPress.com.