8
\$\begingroup\$

I created an HTML5/JS tic-tac-toe game as an exercise and wanted some advice on my js code.

A couple of questions:

  • How can I improve the reset method? (currently reloads the page/re-initializes the game)
  • Anything advice on other improvements?

Live demo: http://jsfiddle.net/mbtt53tt/

tictactoe.html

<!DOCTYPE html>
<html>
<head>
    <title>Triple T's</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        * {box-sizing:border-box;}
        body {width:100%;max-width:500px;padding: 0 20px;margin: 0 auto;font: 20px arial;text-align:center;}
        h1 {font-size:32px;width:100%;margin:20px auto;}
        #game,#reset {margin: 20px auto 0;display: block;width:100%;}
        #scoreboard {border:2px solid #ccc;width:100%;margin:10px auto;padding:10px;}
        ul {display:flex;list-style:none;margin:0;padding:0;width:100%;}
        li {margin:0;padding:0;flex-grow:1;}
        button {border:1px solid #ccc;background:#fefefe;padding:7px 14px;margin:10px 20px;}
    </style>
</head>
<body>
    <h1>Triple T's</h1>
    <div id="scoreboard">
        <ul>
            <li>X: <span id="score_x"></span></li>
            <li>Ties: <span id="score_tie"></span></li>
            <li>O: <span id="score_o"></span></li>
        </ul>
    </div>
    <div id="reset">
        <button id="resetScore">Reset score</button>
        <button id="resetGame">New Game</button>
    </div>
    <canvas id="game" width="500" height="500">
        This text is displayed if your browser does not support HTML5 Canvas.
    </canvas>
    <script async src="./triplet.js"></script>
</body>
</html>

triplet.js

(function () {
    function Board(id, c, r) {
        if (this instanceof Board) {
            this.CANVAS = document.getElementById(id);
            this.CTX = this.CANVAS.getContext("2d");
            this.WIDTH = this.CANVAS.width || 0;
            this.HEIGHT = this.CANVAS.height || 0;
            this.COLS = c || 3;
            this.ROWS = r || 3;
            this.TILEWIDTH = (this.WIDTH / this.COLS);
            this.moveCount = 0;
            this.board = this.gameBoard(this.TILEWIDTH, this.COLS, this.ROWS);
            this.CANVAS.addEventListener('selectstart', function (e) {
                e.preventDefault();
                return false;
            }, false);
            this.winner = [false, ""];
            this.boardDisabled = false;
        } else {
            return new Board(id, c, r);
        }
    }
    Board.prototype.draw = function () {
        var ctx = this.CTX;
        ctx.beginPath();
        ctx.lineWidth = 5;
        ctx.strokeStyle = "#168dd9";
        // Draw column dividers
        for (var i = 1; i <= this.COLS - 1; i++) {
            ctx.moveTo(this.TILEWIDTH * i, 0);
            ctx.lineTo(this.TILEWIDTH * i, this.HEIGHT);
        }
        //Draw horizontal dividers
        for (var i = 1; i <= this.ROWS - 1; i++) {
            ctx.moveTo(0, this.TILEWIDTH * i);
            ctx.lineTo(this.WIDTH, this.TILEWIDTH * i);
        }
        ctx.stroke();
    };
    Board.prototype.gameBoard = function (t, c, r) {
        var b = [],
            count = 0;
        // Create gameboard array with the following data:
        // [x pos, y pos, tile count, empty string for move symbol (x or o)]
        for (var y = 0; y < r; y++) {
            for (var x = 0; x < c; x++) {
                b.push([x * t, y * t, count++, ""]);
            }
        }
        return b;
    };
    Board.prototype.updateScore = function () {
        if (supports_html5_storage()) {
            var p = sessionStorage.score || {
                    "score_x": 0,
                    "score_o": 0,
                    "score_tie": 0
                },
                w = "score_" + (this.winner[1][0] || "tie");
            if (sessionStorage.score) {
                p = JSON.parse(p);
            }
            p[w] ++;
            sessionStorage.score = JSON.stringify(p);
            this.updateScoreBoard();
        }
    };
    Board.prototype.updateScoreBoard = function () {
        if (supports_html5_storage()) {
            var p = sessionStorage.score ? JSON.parse(sessionStorage.score) : {
                "score_x": 0,
                "score_o": 0,
                "score_tie": 0
            };
            for (var s in p) {
                if (p.hasOwnProperty(s)) {
                    document.getElementById(s).innerHTML = p[s];
                }
            }
        }
    };
    Board.prototype.reset = function (x) {
        var timer = x || 4000;
        window.setTimeout(function () {
                window.location.reload(false);
            }, timer);
    };
    Board.prototype.resetScore = function () {
        if (supports_html5_storage()) {
            sessionStorage.removeItem("score");
            this.updateScoreBoard();
        }
    };
    Board.prototype.move = function (coor) {
        var width = this.TILEWIDTH,
            ctx = this.CTX,
            board = this.board,
            blen = board.length;
        //Loop through and find tile that click was detected on
        for (var i = 0; i < blen; i++) {
            if (coor.x > board[i][0] && coor.y > board[i][1] && coor.x < board[i][0] + width && coor.y < board[i][1] + width) {
                var x = board[i][0],
                    y = board[i][1],
                    validTile = board[i][3] === "";
                if (validTile) {
                    if (this.moveCount++ % 2 === 1) {
                        moveO(x, y, width, ctx);
                        board[i][3] = "o";
                    } else {
                        moveX(x, y, width, ctx);
                        board[i][3] = "x";
                    }
                }
                //Check board for winner if move count is 5 or more
                if (this.moveCount > 4) {
                    this.winner = checkWinner(board);
                    var w = this.winner,
                        winner = w[0],
                        shape = w[1][0],
                        boardDisabled = this.boardDisabled;
                    //If there is a winner, redraw winning tiles in red
                    if (winner && !boardDisabled) {
                        if (shape === "o") {
                            for (var j = 1; j < 4; j++) {
                                moveO(board[w[j][1]][0], board[w[j][1]]
                                    [1], width, ctx, "red", 5);
                            }
                        } else {
                            for (var j = 1; j < 4; j++) {
                                moveX(board[w[j][1]][0], board[w[j][1]]
                                    [1], width, ctx, "red", 5);
                            }
                        }
                    }
                    if ((winner || this.moveCount === board.length) && !boardDisabled) {
                        if (!winner) {
                            //If tie, redraw all moves in red
                            for (var j = 0; j < board.length; j++) {
                                if (board[j][3] === "o") {
                                    moveO(board[j][0], board[j][1], width, ctx, "red", 5);
                                } else {
                                    moveX(board[j][0], board[j][1], width, ctx, "red", 5);
                                }
                            }
                        }
                        this.boardDisabled = true;
                        this.updateScore();
                        this.reset();
                    }
                }
                break;
            }
        }
    };

    function checkWinner(mArr) {
        var winner = [false, ""];
        for (var i = 0; i < mArr.length; i++) {
            var hor = [],
                ver = [],
                diag = [];
            if (mArr[i][3] !== "") {
                //horizontal
                if (i % 3 === 0) {
                    for (var j = 0; j < 3; j++) {
                        hor.push([mArr[i + j][3], i + j]);
                    }
                    if (hor.length === 3) {
                        winner = isWinner(hor);
                        if (winner[0]) {
                            return winner;
                        }
                    }
                }
                //vertical && diag/anti diag
                if (i < 3) {
                    for (var j = 0; j + i < mArr.length; j += 3) {
                        ver.push([mArr[i + j][3], i + j]);
                    }
                    if (ver.length === 3) {
                        winner = isWinner(ver);
                        if (winner[0]) {
                            return winner;
                        }
                    }
                    if (i !== 1) {
                        for (var z = 0; z + i < mArr.length - i; z += (4 - i)) {
                            diag.push([mArr[i + z][3], i + z]);
                        }
                        if (diag.length === 3) {
                            winner = isWinner(diag);
                            if (winner[0]) {
                                return winner;
                            }
                        }
                    }
                }
            }
        }
        return winner;
    }

    function isWinner(arr) {
        arr.sort();
        var w = arr[0][0] && arr[0][0] === arr[arr.length - 1][0] ? [true].concat(arr) : [false, ""];
        return w;
    }

    function moveO(x, y, r, ctx, fill, lineW) {
        var x = x + r / 2,
            y = y + r / 2,
            r = r / 2 - (r * 0.15);
        ctx.beginPath();
        ctx.lineWidth = lineW || 3;
        ctx.strokeStyle = fill || "#333";
        ctx.arc(x, y, r, 0, 2 * Math.PI);
        ctx.stroke();
    }

    function moveX(x, y, w, ctx, fill, lineW) {
        var pad = w * 0.15,
            lineCoor = [
                [
                    [x + pad, y + pad], //line 1 start
                    [x + w - pad, y + w - pad] //line 1 end
                ],
                [
                    [x + pad, y + w - pad], //line 2 start
                    [x + w - pad, y + pad] //line 2 end
                ]
            ];
        ctx.beginPath();
        ctx.lineWidth = lineW || 3;
        ctx.strokeStyle = fill || "#333";
        for (var i = 0; i < 2; i++) {
            ctx.moveTo(lineCoor[i][0][0], lineCoor[i][0][1]);
            ctx.lineTo(lineCoor[i][1][0], lineCoor[i][1][1]);
        }
        ctx.stroke();
    }

    function clickTouch(e) {
        var coor = b.CANVAS.relMouseCoords(e);
        if (!b.winner[0]) {
            b.move(coor);
        }
    }

    function clickTouchReset(e) {
            var target = e.target.id;
            if (target === "resetScore" && confirm("Are you sure you want to reset the score?")) {
                b.resetScore();
            } else if (target === "resetGame") {
                b.reset(1);
            }
        }
        // Initialize Game
    var b = new Board("game", 3, 3),
        resetcon = document.getElementById("reset");
    b.draw();
    b.updateScoreBoard();
    //Add event listeners for click or touch
    window.addEventListener("click", clickTouch, false);
    window.addEventListener("touchstart", clickTouch, false);
    resetcon.addEventListener("click", clickTouchReset, false);
    resetcon.addEventListener("touchstart", clickTouchReset, false);
})();
/*****
Get Mouse click coordinates within canvas
Modified to include touch events
Source: http://stackoverflow.com/a/9961416
******/
HTMLCanvasElement.prototype.relMouseCoords = function (event) {
    var totalOffsetX = 0,
        totalOffsetY = 0,
        canvasX = 0,
        canvasY = 0,
        touch = event.touches,
        currentElement = this;
    do {
        totalOffsetX += currentElement.offsetLeft;
        totalOffsetY += currentElement.offsetTop;
    }
    while (currentElement = currentElement.offsetParent)
    canvasX = (touch ? touch[0].pageX : event.pageX) - totalOffsetX;
    canvasY = (touch ? touch[0].pageY : event.pageY) - totalOffsetY;
    canvasX = Math.round(canvasX * (this.width / this.offsetWidth));
    canvasY = Math.round(canvasY * (this.height / this.offsetHeight));
    return {
        x: canvasX,
        y: canvasY
    }
}

function supports_html5_storage() {
    try {
        return 'sessionStorage' in window && window.sessionStorage !== null;
    } catch (e) {
        return false;
    }
}
\$\endgroup\$
4
\$\begingroup\$

This is not a complete review, just a couple of points that jumped out at me:

JavaScript

Naming

Short variable names are hard to understand. While ctx, e, etc are somewhat standard, r, lineW, w, coor, b, t, c, b, s, p, mArr, ver, etc are not and it is very hard to understand what they represent.

Will you in six month still understand what gameBoard = function (t, c, r) is without searching in the code? I would guess not. But if you use gameBoard = function (tileWidth, columnCount, rowCount), it is immediately clear what it does.

So my recommendation would be: find good variable names for the one character names, and write out the abbreviated names (ver, hor, etc).

Some function names also do not express well what they do: draw might be better named drawGameField and moveO better drawO (same for moveX).

Bugs

If I set row and column count to a number different from 3, it seems that nobody can win.

Long functions

The move function is definitely too long. I would at least extract the two red redraws to their own function.

Saving fields in local variables

In your move method, you save fields in local variables:

    var width = this.TILEWIDTH,
        ctx = this.CTX,
        board = this.board,
        blen = board.length;

Why are you doing this instead of using the fields directly? This seems unnecessary and confusing.

Functions of Board

Why are some functions not part of board? move is, but moveX and moveO are not, for example. This seems odd.

Comments

Comments on functions are especially important when the variable names are not too good. But even if you change them, I would still like some comments.

For example: What is the acceptable range for tile width? If I use for example 50, the board does not look good anymore.

Another example: What happens if I set row and column to 4 instead of 3?

And one last example: What does draw draw? Everything? Or only the game field, but not the players choices?

Reset

I extracted your init code in a function:

var b;
// Initialize Game
function init() {
    b = new Board("game", 3, 3),
            resetcon = document.getElementById("reset");
    b.draw();
    b.updateScoreBoard();
    //Add event listeners for click or touch
    window.addEventListener("click", clickTouch, false);
    window.addEventListener("touchstart", clickTouch, false);
    resetcon.addEventListener("click", clickTouchReset, false);
    resetcon.addEventListener("touchstart", clickTouchReset, false);
}
init();

And changed your reset function like this:

Board.prototype.reset = function(x) {
    this.CANVAS.width = this.CANVAS.width; // clear canvas
    init();
};

And I removed this.reset(); from move. Now the reset works without reloading each time.

CSS

I would put this in an external css file, and format it properly (each attribute on its own line, etc.). If you care about performance, minify with a tool later instead of writing harder to read code.

\$\endgroup\$
  • \$\begingroup\$ thanks for the advice/suggestion -- it was just what I needed. LOCAL VARIABLES: I thought it would be easier to type (maybe even more efficient?) to declare local variables in the methods. RESET: I was very close to what you had for the reset method, but I couldn't for the life of me pass var b into the event listeners. From looking at yours, I was missing the b declaration outside the init function...rookie mistake. Thanks again for clearing this up, it was all very helpful! \$\endgroup\$ – Kevin Aug 28 '14 at 17:01
  • \$\begingroup\$ @Kevin where can I see the result? \$\endgroup\$ – gkiko Jul 3 '15 at 10:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.