Search code examples
javascriptooptic-tac-toe

Converting working functional Javascript Tic Tac Toe game to Class based to practice OOP


I'm making an exercise for myself to better understand OOP design by taking a working Javascript functional Tic Tac Toe game with AI to a Class based one. I'm getting stuck on the usual issues with what to put where in classes, single source of truth, loose coupling, etc. Not looking for complete answers here but perhaps some hints on a better strategy?

Here is the original working functional TTT:

import "./styles.css";
// functional TIC TAC TOE
// Human is 'O'
// Player is 'X'

let ttt = {
  board: [], // array to hold the current game
  reset: function() {
    // reset board array and get HTML container
    ttt.board = [];
    const container = document.getElementById("ttt-game"); // the on div declared in HTML file
    container.innerHTML = "";

    // redraw swuares
    // create a for loop to build board
    for (let i = 0; i < 9; i++) {
      //  push board array with null
      ttt.board.push(null);
      // set square to create DOM element with 'div'
      let square = document.createElement("div");
      // insert "&nbsp;" non-breaking space to square
      square.innnerHTML = "&nbsp;";

      // set square.dataset.idx set to i of for loop
      square.dataset.idx = i;

      // build square id's with i from loop / 'ttt-' + i - concatnate iteration
      square.id = "ttt-" + i;
      // add click eventlistener to square to fire ttt.play();
      square.addEventListener("click", ttt.play);
      // appendChild with square (created element 'div') to container
      container.appendChild(square);
    }
  },

  play: function() {
    // ttt.play() : when the player selects a square
    // play is fired when player selects square
    // (A) Player's move - Mark with "O"
    // set move to this.dataset.idx

    let move = this.dataset.idx;

    // assign ttt.board array with move to 0
    ttt.board[move] = 0;
    // assign "O" to innerHTML for this
    this.innerHTML = "O";
    // add "Player" to a classList for this
    this.classList.add("Player");
    // remove the eventlistener 'click'  and fire ttt.play
    this.removeEventListener("click", ttt.play);

    // (B) No more moves available - draw
    // check to see if board is full
    if (ttt.board.indexOf(null) === -1) {
      // alert "No winner"
      alert("No Winner!");
      // ttt.reset();
      ttt.reset();
    } else {
      // (C) Computer's move - Mark with 'X'
      // capture move made with dumbAI or notBadAI
      move = ttt.dumbAI();
      // assign ttt.board array with move to 1
      ttt.board[move] = 1;
      // assign sqaure to AI move with id "ttt-" + move (concatenate)
      let square = document.getElementById("ttt-" + move);
      // assign "X" to innerHTML for this
      square.innerHTML = "X";

      // add "Computer" to a classList for this
      square.classList.add("Computer");
      // square removeEventListener click  and fire ttt.play
      square.removeEventListener("click", ttt.play);

      // (D) Who won?
      // assign win to null (null, "x", "O")
      let win = null;
      // Horizontal row checks
      for (let i = 0; i < 9; i += 3) {
        if (
          ttt.board[i] != null &&
          ttt.board[i + 1] != null &&
          ttt.board[i + 2] != null
        ) {
          if (
            ttt.board[i] == ttt.board[i + 1] &&
            ttt.board[i + 1] == ttt.board[i + 2]
          ) {
            win = ttt.board[i];
          }
        }
        if (win !== null) {
          break;
        }
      }
      // Vertical row checks
      if (win === null) {
        for (let i = 0; i < 3; i++) {
          if (
            ttt.board[i] !== null &&
            ttt.board[i + 3] !== null &&
            ttt.board[i + 6] !== null
          ) {
            if (
              ttt.board[i] === ttt.board[i + 3] &&
              ttt.board[i + 3] === ttt.board[i + 6]
            ) {
              win = ttt.board[i];
            }
            if (win !== null) {
              break;
            }
          }
        }
      }
      // Diaganal row checks
      if (win === null) {
        if (
          ttt.board[0] != null &&
          ttt.board[4] != null &&
          ttt.board[8] != null
        ) {
          if (ttt.board[0] == ttt.board[4] && ttt.board[4] == ttt.board[8]) {
            win = ttt.board[4];
          }
        }
      }
      if (win === null) {
        if (
          ttt.board[2] != null &&
          ttt.board[4] != null &&
          ttt.board[6] != null
        ) {
          if (ttt.board[2] == ttt.board[4] && ttt.board[4] == ttt.board[6]) {
            win = ttt.board[4];
          }
        }
      }
      // We have a winner
      if (win !== null) {
        alert("WINNER - " + (win === 0 ? "Player" : "Computer"));
        ttt.reset();
      }
    }
  },

  dumbAI: function() {
    // ttt.dumbAI() : dumb computer AI, randomly chooses an empty slot

    // Extract out all open slots
    let open = [];
    for (let i = 0; i < 9; i++) {
      if (ttt.board[i] === null) {
        open.push(i);
      }
    }

    // Randomly choose open slot
    const random = Math.floor(Math.random() * (open.length - 1));
    return open[random];
  },
  notBadAI: function() {
    // ttt.notBadAI() : AI with a little more intelligence

    // (A) Init
    var move = null;
    var check = function(first, direction, pc) {
      // checkH() : helper function, check possible winning row
      // PARAM square : first square number
      //       direction : "R"ow, "C"ol, "D"iagonal
      //       pc : 0 for player, 1 for computer

      var second = 0,
        third = 0;
      if (direction === "R") {
        second = first + 1;
        third = first + 2;
      } else if (direction === "C") {
        second = first + 3;
        third = first + 6;
      } else {
        second = 4;
        third = first === 0 ? 8 : 6;
      }

      if (
        ttt.board[first] === null &&
        ttt.board[second] === pc &&
        ttt.board[third] === pc
      ) {
        return first;
      } else if (
        ttt.board[first] === pc &&
        ttt.board[second] === null &&
        ttt.board[third] === pc
      ) {
        return second;
      } else if (
        ttt.board[first] === pc &&
        ttt.board[second] === pc &&
        ttt.board[third] === null
      ) {
        return third;
      }
      return null;
    };

    // (B) Priority #1 - Go for the win
    // (B1) Check horizontal rows
    for (let i = 0; i < 9; i += 3) {
      move = check(i, "R", 1);
      if (move !== null) {
        break;
      }
    }
    // (B2) Check vertical columns
    if (move === null) {
      for (let i = 0; i < 3; i++) {
        move = check(i, "C", 1);
        if (move !== null) {
          break;
        }
      }
    }
    // (B3) Check diagonal
    if (move === null) {
      move = check(0, "D", 1);
    }
    if (move === null) {
      move = check(2, "D", 1);
    }

    // (C) Priority #2 - Block player from winning
    // (C1) Check horizontal rows
    for (let i = 0; i < 9; i += 3) {
      move = check(i, "R", 0);
      if (move !== null) {
        break;
      }
    }
    // (C2) Check vertical columns
    if (move === null) {
      for (let i = 0; i < 3; i++) {
        move = check(i, "C", 0);
        if (move !== null) {
          break;
        }
      }
    }
    // (C3) Check diagonal
    if (move === null) {
      move = check(0, "D", 0);
    }
    if (move === null) {
      move = check(2, "D", 0);
    }
    // (D) Random move if nothing
    if (move === null) {
      move = ttt.dumbAI();
    }
    return move;
  }
};
document.addEventListener("DOMContentLoaded", ttt.reset());

Here is what I have so far of my class based version:

import "./styles.css";

class Gameboard {
  constructor() {
    this.board = [];
    this.container = document.getElementById("ttt-game");
    this.container.innerHTML = "";
  }

  reset() {
    this.board = [];
  }

  build() {
    for (let i = 0; i < 9; i++) {
      this.board.push(null);
      const square = document.createElement("div");
      square.innerHTML = "&nbsp;";
      square.dataset.idx = i;
      square.id = "ttt-" + i;
      square.addEventListener("click", () => {
        // What method do I envoke here? 
        console.log(square) 
      });
      this.container.appendChild(square);
    }
  }
};

class Game {
  constructor() {
    this.gameBoard = new Gameboard();
    this.player = new Player();
    this.computer = new Computer();
  }

  play() {
    this.gameBoard.build();
  }
};

class Player {

};

class Computer {

};

class DumbAI {

};

const game = new Game();

document.addEventListener("DOMContentLoaded", game.play());

My HTML file is very simple with only a <div id="ttt-game"></div> to get started and CSS file is grid.

The biggest issue I'm having is capturing the squares in Game. And where should I put eventListeners ? (my next project is to do a React version).


Solution

  • Here's what I think, good, maintainable and testable code looks like: a bunch of small, self-contained functions, each with as few side-effects as possible. And rather than have state spread around the application, state should exist in a single, central location.

    So, what I have done is decompose your code into small functions. I have pulled the state into a single store that enforces immutability. No weird half-way houses - the application state changes, or it doesn't. If it changes, the entire game is re-rendered. Responsibility for interacting with the UI exists in a single render function.

    And you asked about classes in your question. createGame becomes:

    class Game { 
      constructor() { ... }, 
      start() { ... }, 
      reset() { ... },
      play() { ... }
    }
    

    createStore becomes:

    class Store { 
      constructor() { ... }
      getState() { ... }, 
      setState() { ... } 
    }
    

    playAI and playHuman become:

    class AIPlayer {
      constructor(store) { ... }
      play() { ... }
    }
    
    class HumanPlayer {
      constructor(store) { ... }
      play() { ... }
    }
    

    checkForWinner becomes:

    class WinChecker {
      check(board) { ... }
    }
    

    ...and so on.

    But I ask the rhetorical question: would adding these classes add anything to the code? In my view there are three fundamental and intrinsic problems with class-oriented object orientation:

    1. It leads you down the path of mixing application state and functionality,
    2. Classes are like snowballs - they accrete functionality and quickly become over-large, and
    3. People are terrible at coming up with meaningful class ontologies

    All of the above mean that classes invariably lead to critically unmaintainable code.

    I think code is usually simpler and more maintainable without new, and without this.

    index.js

    import { createGame } from "./create-game.js";
    
    const game = createGame("#ttt-game");
    game.start();
    

    create-game.js

    import { initialState } from "./initial-state.js";
    import { createStore } from "./create-store.js";
    import { render } from "./render.js";
    
    const $ = document.querySelector.bind(document);
    
    function start({ store, render }) {
      createGameLoop({ store, render })();
    }
    
    function createGameLoop({ store, render }) {
      let previousState = null;
      return function loop() {
        const state = store.getState();
        if (state !== previousState) {
          render(store);
          previousState = store.getState();
        }
        requestAnimationFrame(loop);
      };
    }
    
    export function createGame(selector) {
      const store = createStore({ ...initialState, el: $(selector) });
      return {
        start: () => start({ store, render })
      };
    }
    
    

    initial-state.js

    export const initialState = {
      el: null,
      board: Array(9).fill(null),
      winner: null
    };
    
    

    create-store.js

    export function createStore(initialState) {
      let state = Object.freeze(initialState);
      return {
        getState() {
          return state;
        },
        setState(v) {
          state = Object.freeze(v);
        }
      };
    }
    

    render.js

    import { onSquareClick } from "./on-square-click.js";
    import { winners } from "./winners.js";
    import { resetGame } from "./reset-game.js";
    
    export function render(store) {
      const { el, board, winner } = store.getState();
      el.innerHTML = "";
      for (let i = 0; i < board.length; i++) {
        let square = document.createElement("div");
        square.id = `ttt-${i}`;
        square.innerText = board[i];
        square.classList = "square";
        if (!board[i]) {
          square.addEventListener("click", onSquareClick.bind(null, store));
        }
        el.appendChild(square);
      }
    
      if (winner) {
        const message =
          winner === winners.STALEMATE ? `Stalemate!` : `${winner} wins!`;
        const msgEL = document.createElement("div");
        msgEL.classList = "message";
        msgEL.innerText = message;
        msgEL.addEventListener("click", () => resetGame(store));
        el.appendChild(msgEL);
      }
    }
    

    on-square-click.js

    import { play } from "./play.js";
    
    export function onSquareClick(store, { target }) {
      const {
        groups: { move }
      } = /^ttt-(?<move>.*)/gi.exec(target.id);
      play({ move, store });
    }
    

    winners.js

    export const winners = {
      HUMAN: "Human",
      AI: "AI",
      STALEMATE: "Stalemate"
    };
    

    reset-game.js

    import { initialState } from "./initial-state.js";
    
    export function resetGame(store) {
      const { el } = store.getState();
      store.setState({ ...initialState, el });
    }
    

    play.js

    import { randomMove } from "./random-move.js";
    import { checkForWinner } from "./check-for-winner.js";
    import { checkForStalemate } from "./check-for-stalemate.js";
    import { winners } from "./winners.js";
    
    function playHuman({ move, store }) {
      const state = store.getState();
      const updatedBoard = [...state.board];
      updatedBoard[move] = "O";
      store.setState({ ...state, board: updatedBoard });
    }
    
    function playAI(store) {
      const state = store.getState();
      const move = randomMove(state.board);
      const updatedBoard = [...state.board];
      updatedBoard[move] = "X";
      store.setState({ ...state, board: updatedBoard });
    }
    
    export function play({ move, store }) {
      playHuman({ move, store });
    
      if (checkForWinner(store)) {
        const state = store.getState();
        store.setState({ ...state, winner: winners.HUMAN });
        return;
      }
    
      if (checkForStalemate(store)) {
        const state = store.getState();
        store.setState({ ...state, winner: winners.STALEMATE });
        return;
      }
    
      playAI(store);
    
      if (checkForWinner(store)) {
        const state = store.getState();
        store.setState({ ...state, winner: winners.AI });
        return;
      }
    }
    

    Running version:

    const $ = document.querySelector.bind(document);
    
    const winners = {
      HUMAN: "Human",
      AI: "AI",
      STALEMATE: "Stalemate"
    };
    
    function randomMove(board) {
      let open = [];
      for (let i = 0; i < board.length; i++) {
        if (board[i] === null) {
          open.push(i);
        }
      }
      const random = Math.floor(Math.random() * (open.length - 1));
      return open[random];
    }
    
    function onSquareClick(store, target) {
      const {
        groups: { move }
      } = /^ttt-(?<move>.*)/gi.exec(target.id);
      play({ move, store });
    }
    
    function render(store) {
      const { el, board, winner } = store.getState();
      el.innerHTML = "";
      for (let i = 0; i < board.length; i++) {
        let square = document.createElement("div");
        square.id = `ttt-${i}`;
        square.innerText = board[i];
        square.classList = "square";
        if (!board[i]) {
          square.addEventListener("click", ({ target }) =>
            onSquareClick(store, target)
          );
        }
        el.appendChild(square);
      }
    
      if (winner) {
        const message =
          winner === winners.STALEMATE ? `Stalemate!` : `${winner} wins!`;
        const msgEL = document.createElement("div");
        msgEL.classList = "message";
        msgEL.innerText = message;
        msgEL.addEventListener("click", () => resetGame(store));
        el.appendChild(msgEL);
      }
    }
    
    function resetGame(store) {
      const { el } = store.getState();
      store.setState({ ...initialState, el });
    }
    
    function playHuman({ move, store }) {
      const state = store.getState();
      const updatedBoard = [...state.board];
      updatedBoard[move] = "O";
      store.setState({ ...state, board: updatedBoard });
    }
    
    function playAI(store) {
      const state = store.getState();
      const move = randomMove(state.board);
      const updatedBoard = [...state.board];
      updatedBoard[move] = "X";
      store.setState({ ...state, board: updatedBoard });
    }
    
    const patterns = [
      [0,1,2], [3,4,5], [6,7,8],
      [0,4,8], [2,4,6],
      [0,3,6], [1,4,7], [2,5,8]
    ];
    
    function checkForWinner(store) {
      const { board } = store.getState();
      return patterns.find(([a,b,c]) => 
        board[a] === board[b] && 
        board[a] === board[c] && 
        board[a]);
    }
    
    function checkForStalemate(store) {
      const { board } = store.getState();
      return board.indexOf(null) === -1;
    }
    
    function play({ move, store }) {
      playHuman({ move, store });
    
      if (checkForWinner(store)) {
        const state = store.getState();
        store.setState({ ...state, winner: winners.HUMAN });
        return;
      }
    
      if (checkForStalemate(store)) {
        const state = store.getState();
        store.setState({ ...state, winner: winners.STALEMATE });
        return;
      }
    
      playAI(store);
    
      if (checkForWinner(store)) {
        const state = store.getState();
        store.setState({ ...state, winner: winners.AI });
        return;
      }
    }
    
    function createStore(initialState) {
      let state = Object.freeze(initialState);
      return {
        getState() {
          return state;
        },
        setState(v) {
          state = Object.freeze(v);
        }
      };
    }
    
    function start({ store, render }) {
      createGameLoop({ store, render })();
    }
    
    function createGameLoop({ store, render }) {
      let previousState = null;
      return function loop() {
        const state = store.getState();
        if (state !== previousState) {
          render(store);
          previousState = store.getState();
        }
        requestAnimationFrame(loop);
      };
    }
    
    const initialState = {
      el: null,
      board: Array(9).fill(null),
      winner: null
    };
    
    function createGame(selector) {
      const store = createStore({ ...initialState, el: $(selector) });
      return {
        start: () => start({ store, render })
      };
    }
    
    const game = createGame("#ttt-game");
    game.start();
    * {
      box-sizing: border-box;
      padding: 0;
      margin: 0;
      font-size: 0;
    }
    div.container {
      width: 150px;
      height: 150px;
      box-shadow: 0 0 0 5px red inset;
    }
    div.square {
      font-family: sans-serif;
      font-size: 26px;
      color: gray;
      text-align: center;
      line-height: 50px;
      vertical-align: middle;
      cursor: grab;
      display: inline-block;
      width: 50px;
      height: 50px;
      box-shadow: 0 0 0 2px black inset;
    }
    div.message {
      font-family: sans-serif;
      font-size: 26px;
      color: white;
      text-align: center;
      line-height: 100px;
      vertical-align: middle;
      cursor: grab;
      position: fixed;
      top: calc(50% - 50px);
      left: 0;
      height: 100px;
      width: 100%;
      background-color: rgba(100, 100, 100, 0.7);
    }
    <div class="container" id="ttt-game"></div>