Low Level Design Tic-Tac-Toe

Photo by Jon Tyson on Unsplash

Low Level Design Tic-Tac-Toe

Problem Statement

Design a command-line-based, 3x3 grid Tic Tac Toe game that can be played by 2-players.

Requirements

Board

3x3 grid board with the following naming convention for squares of the grid

A1    A2    A3
B1    B2    B3
C1    C2    C3

Players

The game has 2 players. Every player has a name and a character. The character is by default, “X” and “O” for players 1 and 2, but players can pick their own characters. Both players must not have the same character.

Game

  • The game starts with Player 1 inserting their character into one of the boxes

  • Players get alternative turns and insert their characters on boxes

  • On every turn, take command line input of which box to place character

  • After every turn show state of game

  • After every turn, one of 4 conditions can happen

    • The player specifies the wrong box (already has been used);
      In this case, the same player will try again

    • Placing the character results in game-winning condition (specified below);
      in this case, end the game and declare a winner

    • The player specifies the correct box, but doesn’t win game;
      Give a turn to another player and continue the game

    • The board is full (this was the last entry), and no winner;
      Declare game draw and end game

  • Winning conditions:
    Same character in all boxes of:

    • any row

    • Any column

    • Any diagonal

Data Format

Initialise Game & Players

Enter Player 1 Name: 
> John 
Enter Player 1 Character (X):
> ❎
Enter Player 2 Name:
> Jane 
Enter Player 2 Character (O):
> 🛑

Note: If a player doesn’t enter character, default to X and O

Gameplay

Board: 
_  _  _
_  _  _
_  _  _

Player 1: Enter box: 
> A1 


Board: 
❎ _  _
_  _  _
_  _  _

Player 2: Enter box: 
> A2 

Board: 
❎ 🛑 _
_  _  _
_  _  _

Player 2: Enter box: 
>

Minimum Requirements

  • Make sure that you have a working and demonstrable code

  • Make sure that the code is functionally correct

  • Code should be modular and readable

  • Separation of concern should be addressed

  • Please do not write everything in a single file (if not coding in C/C++)

  • Code should easily accommodate new requirements and minimal changes

  • There should be a main method from where the code could be easily testable

  • Write unit tests, wherever applicable

  • No need to create a GUI

Optional Requirements

Please do these only if you’ve time left. You can write your code such that these can be accommodated without changing your code much.

  • Keep the code extensible to change the size of the grid.

  • Keep the code extensible to allow different types of pieces.

  • Keep the code extensible to allow more than 2 players/piece types.

Identifying classes, objects and relationships

  • A Board maintains a 2D array of strings, it has getter/setters and other utility functions relating to the board

  • A Player has a name and a character he will play the game with, we have a place for a player builder as the player needs to be built piecewise by sequential user inputs ie pb=new Player.Builder(); player:Player=pb.setName().setCharacter().b..

  • A Game has a Player and a Board, since the board too needs to be initiated piecewise we need the builder design pattern

  • The main.ts file uses the game builder to build both players(builder) and the board(new) and then builds the game in the process

User Flow

  • The Game. Builder initialises both players with their names and characters and the new board

  • Until the game doesn't draw or somebody wins, a prompt is shown informing which player has the active turn along with the state of the board, once the user specifies the position on the board, a move is made using the game.play()

  • play uses markerboard from the board instance to mark character move on the board

  • After each successful move, we call a function checkWinner with that active player as the context

Code

// main.ts
import { Game } from "./entities/Game";
import * as rl from 'readline-sync'

const gameBuilder = new Game.Builder()

const player1Name = rl.question("Enter Player 1 name: ")
const player1Char = rl.question("Enter Player 1 character (X): ", { defaultInput: "X" })

gameBuilder.addPlayer1(player1Name, player1Char)

const player2Name = rl.question("Enter Player 2 name: ")
const player2Char = rl.question("Enter Player 2 character (O): : ", { defaultInput: "O" })

gameBuilder.addPlayer2(player2Name, player2Char)

const game = gameBuilder.build()

while (game.state == "STARTED") {
    console.log(game.nextTurnPrompt())
    const box = rl.question("Enter Box: ")
    game.play(box)
}
// Modes/Game.ts
import { Player } from "./Player";
import { Board } from "./Board";

type GameState = "STARTED" | "END_WINNER" | "END_DRAW"
export class Game {
    p1: Player
    p2: Player
    board: Board
    turn = 0
    state: GameState = "STARTED"

    private constructor(p1: Player, p2: Player, board: Board) {
        this.p1 = p1;
        this.p2 = p2;
        this.board = board;
    }

    private checkWinner(player: Player): boolean {
        const c = player.character
        const winningLine = `${c}${c}${c}`;// assuming a 3x3 board
        for (let row of ["A", "B", "C"]) {
            if (this.board.getRowAsString(row) == winningLine)
                return true
        }
        for (let col of [0,1,2]) {
            if (this.board.getColAsString(col) == winningLine)
                return true
        }
        for (let diag of [0,1]) {
            if (this.board.getDiagAsString(diag) == winningLine)
                return true
        }

        return false

    }

    nextTurnPrompt(): string {
        const player = this.turn % 2 == 0 ? this.p1 : this.p2;

        return '\n' + this.board.getBoardForDisplay()
            + '\n'
            + `Turn: ${this.turn + 1}  |  Player : ${player.name} (${player.character})`
    }

    play(box: string) {
        const player = this.turn % 2 == 0 ? this.p1 : this.p2;

        const success = this.board.markBoard(box, player.character)

        if (success) {
            if (this.checkWinner(player)) {
                this.state = "END_WINNER"
                console.log(`Game Over! ${player.name} has won!`)
                return
            }
            this.turn++;
        }

        if (this.turn == 9) {
            this.state = "END_DRAW"
            console.log("Game ended in DRAW")
        }
    }

    static Builder = class GameBuilder {
        p1!: Player
        p2!: Player

        addPlayer1(name: string, character: string = "X"): GameBuilder {
            this.p1 = new Player.Builder()
                .setName(name)
                .setCharacter(character)
                .build()
            return this;
        }

        addPlayer2(name: string, character: string = "O"): GameBuilder {
            this.p2 = new Player.Builder()
                .setName(name)
                .setCharacter(character)
                .build()
            return this;
        }

        build(): Game {
            if (!this.p1) {
                throw new Error("You need to create player 1 before building game")
            }

            if (!this.p2) {
                throw new Error("You need to create player 2 before building game")
            }

            return new Game(this.p1, this.p2, new Board())
        }
    }
}
// Models/Player.ts
export class Player {
    name: string;
    character: string;
    static Builder = class PlayerBuilder {
        private name!: string;
        private character!: string;

        setName(value: string): PlayerBuilder {
            this.name = value;
            return this;
        }

        setCharacter(value: string): PlayerBuilder {
            if (value == "_") {
                throw new Error("Underscore '_' is not a valid player character")
            }
            this.character = value;
            return this;
        }

        build(): Player {
            return new Player(this.name, this.character)
        }
    }

    private constructor(name: string, character: string) {
        this.name = name;// private constructor to enforce creation by builder
        this.character = character;
    }
}
// Main/Board.ts
class InvalidBoxNameError extends Error {
    message = 'Invalid box identifier';
}

const BOARD_DEFAULT_SIZE = 3
const ROWS = "ABCDEFGHIJ"

export class Board {
    size!: number
    grid!: Array<Array<string>>

    constructor(size: number = BOARD_DEFAULT_SIZE) {
        this.size = size
        this.grid = []
        for (let i = 0; i < size; i++)
            this.grid.push(new Array(size).fill("_"));
    }

    getBoardForDisplay(): string {
        const displayRows = []
        for (let row of this.grid) {
            displayRows.push(row.join('\t'))
        }
        return displayRows.join('\n')
    }

    markBoard(box: string, character: string): boolean {
        // box: A1 or B2 like that
        if (box.length != 2) {
            throw new InvalidBoxNameError()
        }
        const row = ROWS.indexOf(box.charAt(0))
        const col = Number(box.charAt(1)) - 1

        if (row < 0 || row >= this.size || col < 0 || col >= this.size) {
            throw new InvalidBoxNameError()
        }

        if (this.grid[row][col] != "_") {
            return false;//[row][col] is not empty
        }

        this.grid[row][col] = character
        return true
    }

    getRowAsString(rowName: string): string {
        const row = ROWS.indexOf(rowName)
        if (row == -1 || row >= this.size) {
            throw new Error(`row number is invalid`)
        }
        return this.grid[row].join("")
    }

    getColAsString(col: number): string {
        if (col < 0 || col > this.size) {
            throw new Error("col has to be between 0 and 2")
        }
        const colVals = []
        for (let i = 0; i < this.size; i++) {
            colVals.push(this.grid[i][col])
        }

        return colVals.join("")
    }

    getDiagAsString(diagNo: number): string {
        const diagVals = []
        if (diagNo == 0) {
            for (let i = 0; i < this.size; i++) {
                diagVals.push(this.grid[i][i])
            }

            return diagVals.join("")
        } else if (diagNo == 1) {

            for (let i = 0; i < this.size; i++) {
                diagVals.push(this.grid[i][this.size - 1 - i])
            }
            return diagVals.join("")
        } else {
            throw new Error("Invalid diagonal")
        }
    }
}