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 " " non-breaking space to square
square.innnerHTML = " ";
// 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 = " ";
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).
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:
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>