Skip to main content

Command Palette

Search for a command to run...

Low Level Design Tic-Tac-Toe

Published
โ€ข7 min readโ€ขView as Markdown
Low Level Design Tic-Tac-Toe
P

Hello, I'm Paras Kaushik! ๐Ÿ‘‹ I'm a dedicated software engineer based in India, specializing in C++ and proficient in the MERN stack.

๐Ÿค Interested in collaborating on innovative projects that require my technical expertise.

๐Ÿ’ฌ Passionate about participating in discussions related to software architecture and best practices.

๐Ÿ“ง Feel free to reach out to me via email: [paraskaushik12@gmail.com]

๐Ÿ”— Connect with me on LinkedIn: [https://www.linkedin.com/in/the-paras-kaushik/]

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().build()

  • 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")
        }
    }
}

More from this blog

Blogs by Paras

52 posts

Hello, I'm Paras Kaushik! ๐Ÿ‘‹ I'm a dedicated software engineer based in India, specializing in C++ and proficient in the MERN stack. ๐Ÿ’ผ Open to Collaboration