5
\$\begingroup\$

Recently I have been wanting to get better with Python, in particular with Python 3.x features and Python OOP, so I wrote what I thought at first would be pretty simple, a TicTacToe game, which turned out to become pretty complex after adding all that I wanted.

Some of the features I added, besides the basic game logic:

  • Player names and cumulative scores

  • Ability to play multiple games without restarting the program

  • Informative game messages throughout the application

Brief overview of the different sections:

  • Player Section: Basic player classes

  • Board Section: Game board with multiple methods, including a play method with certain conditions that must be met for a move/play to be valid.

  • Messages Section: I did not want the game logic code to get polluted with a bunch of print statements and such, so I made a GameMessages class to handle all these. I used Template with a good number of the messages. They are mostly very simple, aside from display_scores method which has some comparison logic to build the text.

  • Game Section: The Game class contains the bulk of the game logic. The start method is responsible for setting up the board, the players, and the logic for playing multiple games in one session while keeping cumulative score. The play_game method contains all the logic used in actually playing games, and is called in a loop within start.

The whole program if self-contained in one single file at the moment. Here is a working demo on repl.it

from typing import Dict
from enum import Enum
from string import Template
from random import randrange

# Note: The ttt/TTT acronym is used for TicTacToe throughout this code.

### Player Section

class PlayerNum(Enum):
    one = 1
    two = 2

class Token(Enum):
    X = 'X'
    O = 'O'

class Player:
    def __init__(self, player_num: PlayerNum, token: Token, name: str):
        self.player_num = player_num
        self.token = token
        self.name = name
        self.score = 0
    def add_win(self):
        self.score += 1

### Board Section

class Cell(Enum):
    empty = '-'
    X = Token.X
    O = Token.O

class Board:
    def __init__(self):
        """Empty TTT board."""
        self.cells = {
            1: Cell.empty, 2: Cell.empty, 3: Cell.empty,
            4: Cell.empty, 5: Cell.empty, 6: Cell.empty,
            7: Cell.empty, 8: Cell.empty, 9: Cell.empty
        }

    def play(self, cell: int, token: Token) -> bool:
        """Attempt to place a token on a cell.
        Returns False if the cell is invalid or already occupied.
        """
        if not 1 <= cell <= 9:
            print('Invalid cell "', cell, '"; cell must be a whole number from 1 to 9.', sep='')
            return False
        elif not self.cells[cell] == Cell.empty:
            print('Cell "', cell, '" is already occupied.', sep='')
            return False
        else:
            self.cells[cell] = token
            return True

    def display(self) -> None:
        """Display the board to the user using stdout."""
        for cell in self.cells:
            print(self.cells[cell].value, end=' ')
            # get a new line/row after each 3rd cell:
            if cell % 3 == 0:
                print()    

    def clear(self) -> None:
        """Make the board empty."""
        for cell in self.cells:
            self.cells[cell] = Cell.empty

    def get_token_cells(self, token: Token) -> list:
        """Return a list of all cell numbers/keys occupied by the provided token."""
        return list(key for key, value in self.cells.items() if value == token)

    def get_empty_cells(self) -> int:
        """Return a list of all cell numbers/keys not occupied by any token."""
        return len(list(key for key, value in self.cells.items() if value == Cell.empty))

### Game Section

class Game:
    def __init__(self):
        self.game_number = 1
        self.victory_conditions = [
            # horizontal
            [1,2,3],
            [4,5,6],
            [7,8,9],
            # vertical
            [1,4,7],
            [2,5,8],
            [3,6,9],
            # diagonal
            [1,5,9],
            [3,5,7]
        ]
        self.start()

    def start(self):
        msg = GameMessages(self)
        msg.welcome()
        # Prepare the players
        player1 = self.setup_player(PlayerNum.one)
        player2 = self.setup_player(PlayerNum.two)
        msg.announce_player(player1)
        msg.announce_player(player2)
        msg.display_controls()
        # Start playing
        continue_playing = True
        while continue_playing == True:            
            self.play_game(player1, player2)
            msg.display_scores(player1, player2)
            continue_selection = ''
            while continue_selection.upper() != 'Y' or continue_selection.upper() != 'N':
                msg.ask_to_play_again()
                continue_selection = input()
                if continue_selection.upper() == 'Y':
                    self.game_number += 1
                    continue_playing = True
                    break
                elif continue_selection.upper() == 'N':
                    msg.final_scores(player1, player2)
                    continue_playing = False
                    break
                else:
                    # print invalid selection
                    print('Invalid selection:', continue_selection)

    def setup_player(self, player_num: PlayerNum) -> Player:
        msg = GameMessages(self)
        msg.ask_player_name(player_num.value)
        name = input()
        if player_num == PlayerNum.one:
            token = Token.X
        else:
            token = Token.O
        return Player(player_num, token, name)

    def is_player_victorious(self, board: Board, token: Token) -> bool:
        """Determine if a token/player meets victory conditions."""
        token_cells = board.get_token_cells(token)
        victorious = False
        for condition in self.victory_conditions:
            if not victorious:
                if len(set(token_cells).intersection(condition)) >= 3:
                    victorious = True
        return victorious

    def play_game(self, player1: Player, player2: Player) -> None:
        """Plays a TTT game."""
        msg = GameMessages(self)
        msg.game_start()
        board = Board()
        # coin_toss to decide first player
        if randrange(1,3) == 1:
            first_player = player1
            second_player = player2
        else:
            first_player = player2
            second_player = player1
        msg.announce_first_player(first_player)
        turn_number = 1
        winning_player = None
        game_over = False
        current_player = first_player
        # game loop
        while game_over == False:
            msg.display_current_turn_board(turn_number, board)
            is_valid_move = False
            while is_valid_move == False:
                msg.ask_player_for_move(current_player)
                target_cell = input()
                try:
                    int(target_cell)
                except ValueError:
                    print('Invalid input:', target_cell, 'is not a valid whole number.')
                    is_valid_move = False
                else:
                    is_valid_move = board.play(int(target_cell), current_player.token)
                    # check victory conditions after most recent play
                    if self.is_player_victorious(board, current_player.token):
                        winning_player = current_player
                        msg.announce_player_win(winning_player)
                        board.display()
                        if winning_player == first_player:
                            if first_player == player1:
                                player1.add_win()
                            else:
                                player2.add_win()
                        else:
                            if second_player == player1:
                                player1.add_win()
                            else:
                                player2.add_win()
                        game_over = True
                # game draw conditions:
                if board.get_empty_cells() == 0 and winning_player == None:
                    msg.announce_draw()
                    game_over = True
            turn_number += 1
            if current_player == first_player:
                current_player = second_player
            else:
                current_player = first_player

### Messages Section

class GameMessages:
    """A collection of message templates to be displayed during a TTT game."""
    def __init__(self, game: Game):
        self.game = game
    def display_controls(self):
        print('Controls: When asked to select a cell number (1-9) to play, ',
              'type a number 1-9 to place your token on that cell:\n',
              '1 2 3\n4 5 6\n7 8 9', sep='')
    def welcome(self):
        print('Welcome to TicTacToe.')
    def game_start(self):
        msg = Template('---\nNow playing game $num!')
        print(msg.substitute(num = self.game.game_number))
    def ask_player_name(self, player_num: int):
        print('Player', player_num, 'enter your name: ', end='')
    def announce_player(self, player: Player):
        msg = Template('Player $num is $name and will use token "$token".')
        print(msg.substitute(
            num = player.player_num.value,
            name = player.name,
            token = player.token.value))
    def announce_first_player(self, player: Player):
        msg = Template('The coin toss results in $name playing the first turn.')
        print(msg.substitute(name = player.name))
    def display_current_turn_board(self, turn: int, board: Board):
        print('Turn', turn, 'board:')
        board.display()
    def ask_player_for_move(self, player: Player):
        msg = Template('$name, select a cell to place your "$token" token in: ')
        print(msg.substitute(
            name = player.name,
            token = player.token.value), end='')
    def announce_draw(self):
        print('Game', self.game.game_number, 'is a draw.')
    def announce_player_win(self, player: Player):
        msg = Template('$name wins game $game_num!')
        print(msg.substitute(
            name = player.name,
            game_num = self.game.game_number))
    def display_scores(self, player1: Player, player2: Player):
        if player1.score == player2.score:
            msg = Template('$name1 and $name2 are tied with $score points each after $games game(s).')
            print(msg.substitute(
                name1 = player1.name,
                name2 = player2.name,
                score = player1.score,
                games = self.game.game_number))
        else:
            if player1.score > player2.score:
                leading_player = player1
                trailing_player = player2
            else:
                leading_player = player2
                trailing_player = player1
            msg = Template('$name1 is in the lead with $score1 points! $name2 is trailing with $score2 points after $games game(s).')
            print(msg.substitute(
                name1 = leading_player.name,
                score1 = leading_player.score,
                name2 = trailing_player.name,
                score2 = trailing_player.score,
                games = self.game.game_number))
    def ask_to_play_again(self):
        print('Play again? (Y/N)')
    def final_scores(self, player1: Player, player2: Player):
        print('FINAL SCORE!')
        self.display_scores(player1, player2)



def main():
    game = Game()

main()
\$\endgroup\$
2
\$\begingroup\$

On the whole, I found your code very easy to read and follow. Your division of work is sensible and easy to understand. There's a couple of things I noticed whilst reading the code:

Line length

You have a few lines that go over the common target of 79 characters. Most of these are under 100 though, which seems reasonable to me, but you do have one that is over 130 characters.

GameMessages

In your Game class, you've got several methods that start by instantiating an instance of GameMessages. This makes me wonder if it should really be a member variable of the Game class so that it can be shared between methods.

while continue_selection.upper() != 'Y' or continue_selection.upper() != 'N':

This line is checking a condition that's always going to be true (you break out of the loop before getting there). I'd consider reworking the loop so that it only contains the retry logic. Something like this:

YES_NO = {'Y','N'}  # constant for acceptable selections


while continue_selection not in YES_NO:
    msg.ask_to_play_again()
    continue_selection = input().upper()

    if continue_selection not in YES_NO:
        print('Invalid selection: ', continue_selection)

if continue_selection == 'Y':                
    self.game_number += 1
else:
    msg.final_scores(player1, player2)
    continue_playing = False
\$\endgroup\$

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.