Skip to content

HOPE 1 (Tic-Tac-Toe Medley)

Overview

For HOPE 1, you are to create an MVC-based implementation of several variants of Tic-Tac-Toe in Python as a terminal game.

This HOPE has Checkpoint Tasks; if your implementation has fully-working features for a given checkpoint, you will get the points for that checkpoint.


Tic-Tac-Toe

Tic-Tac-Toe is a two-player game played on a grid with three rows and three columns. This is classic Tic-Tac-Toe, with two players, Player A and Player B, taking turns placing a token on unoccupied cells of a 3x3 grid. Player A goes first.

The cells of the grid are labeled from 0 to 8, starting from the first row, and first column.


Three Men's Morris

Three Men's Morris is a variant of Tic-Tac-Toe, using a slightly modified 3x3 grid, with the cells now as points (see figure below). Each player only has three (3) tokens. The game begins the same as Tic-Tac-Toe (Player A still goes first). Once all six (6) tokens are placed (assuming there is no winner yet), the game enters the moving phase, with each player moving one of their tokens per turn. A token can be moved to any empty adjacent point on the board. Adjacent points are connected by lines, illustrated below:

Below is an example of moving a token:

If a player is not able to make a move during their turn, the game ends in a draw.


Ruleset

There are two rulesets possible: Tic-Tac-Toe and Tic-Tac-Sorry

Tic-Tac-Toe

For the Tic-Tac-Toe variant, the game ends when one of the players is able to have one row, column, or diagonal fully occupied with only their own tokens. The player able to do this is declared the winner. If the grid is fully occupied and the game has no winner, a draw is declared.

Tic-Tac-Sorry

For the Tic-Tac-Sorry variant, the game ends the moment a row, column, or diagonal is formed, following an AAB, BAA, ABB, or BBA pattern. The winner is the player which owns the one token that is different (e.g. Player B for AAB, Player A for ABB).

It is possible that placing/moving a token in a single turn causes a win condition for both players. When this happens, the game ends in a draw.

For example, we have the following game in progress:

It is Player B's turn, putting a 'B' token at cell 2 (top right cell).

There are two winning patterns here, (1) the AAB in the first row, and (2) the BBA in the third column. This game has ended in a draw.

If the grid is fully occupied and the game has no winner, a draw is declared.

Utilities

You must have a file in utils.py containing exactly the following:

from collections.abc import Sequence
from enum import StrEnum
from typing import Protocol


class Player(StrEnum):
    P1 = 'Player A'
    P2 = 'Player B'


class Ruleset(Protocol):
    def has_won(self, grid: Sequence[Player | None], player: Player) -> bool:
        ...

Note that the grid is represented as a length-9 sequence, where each element is a Player (representing a player's token) or None (if there is no token).

If you need to define more types that are accessible across the different MVC parts, create a new file instead of editing utils.py.

Model

The model component of your program should be in model.py which should contain at most two model classes, TicTacToeModel and MorrisModel.

TicTacToeModel

The TicTacToeModel model should have the following methods:

  • def __init__(self) initializer

    • Your initializer should accept no additional parameters
    • You are free to implement the initializer as you see fit
  • @property
    def current_player(self) -> Player:

    • Must return the player playing the current turn
  • @property
    def winner(self) -> Player | None:

    • Must return winning player, or None if no winner yet or if the game ends in a draw
  • @property
    def is_game_done(self) -> bool:

    • Must return whether the game is done
    • Used in case of draws as winner alone cannot disambiguate ties and still-running games
    • The game is considered done when there is a winner or if the grid is fully occupied
  • @property
    def grid(self) -> list[Player | None]:

    • Must return a list containing the state of the grid
    • Modifying the list returned by this property method should not alter the state of the model
  • def place_token(self, loc: int) -> bool:

    • Attempts to place a token of the current player at location loc.
    • loc must be validated to be a valid cell to place a token
    • Should not affect the state of the game if the game is over
    • Returns True if placing the token is valid, otherwise False (also, return False if the game is over)
    • If this returns True, the current player should also be updated.

MorrisModel

The MorrisModel model has the same methods as TicTacToe model, with one additional method:

  • def move_token(self, loc_from: int, loc_to: int) -> bool:
    • Attempts to move a token of the current player at location loc_from to loc_to.
    • loc_from and loc_to must be validated to be valid cells to move a token from/into
    • Should not affect the state of the game if the game is over
    • Returns True if the move is valid, otherwise False (also, return False if the game is over)
    • If this returns True, the current player should also be updated.

Some details may change for the implementation of each Checkpoint. Please see the Checkpoints section below for modifications to the model classes.

Additional classes

You are free to define additional classes, but only the above classes will be used directly during checking.

Additional methods

You are free to define additional private methods in your model classes, but only the above methods will be called directly during checking.

Ruleset

There are two Rulesets that you can implement, namely the TicTacToeRuleset and TicTacSorryRuleset classes. Your Ruleset classes should be in your model.py.

Ruleset classes should contain the following method/s:

  • has_won(self, grid: Sequence[Player | None], player: Player) -> bool:

    • Returns True if player has won the game, based on the game state
    • Returns False otherwise.
    • Note that if the win condition activates for both players (e.g., in Tic-Tac-Sorry), this should return False.

View

The view should display the game on the terminal. All view-related code must be in view.py.

You must write a View class, containing the following methods:

  • def print_grid(self, grid: Sequence[Player | None]) -> None:

    • Prints the 3 x 3 grid, one line per row, with each cell separated by a space
    • Should display the cell number (0, 1, 2, 3, 4, 5, 6, 7, 8) of empty cells
    • Should display an A for Player A tokens, and a B for Player B tokens

    Sample grid:

    A 1 2
    B 4 5
    6 7 8
    
  • get_input(self, player: Player) -> int:

    • Displays the current player as a prompt (e.g., Player A's turn to move:)
    • Should accept input. Assume that the input/s is/are valid integers (i.e., from 0 to 8). Note that the input does not necessarily represent a valid move.
    • Returns an int equivalent of the input.
  • display_game_over_text(self, winner: Player | None) -> None:

    • Displays the winner of the game if there is a winner.
    • Displays a message indicating that the game is a draw, otherwise.
  • display_error(self, error: str) -> None

    • Displays an error message for invalid moves.

Some details of the View class may change per Checkpoint. Please see the Checkpoints section below for more details.

Controller

You will need to write your own controller to use both your model and view to run and test your game. A sample controller will be provided for some checkpoints. Your Controller code will not be checked.

Submission

All class methods must be type-annotated and should pass strict Pyright testing. You will lose points if your code has Pyright errors.

Checkpoints

Note that the point assignments below are cumulative. That is, the points shown include the points from previous checkpoints.

Checkpoint 0: Classic Tic-Tac-Toe (40 ❤️)

For this checkpoint, you will need to implement:

  • TicTacToeModel (as described in the Model section)
  • View (as described in the View section)

Checkpoint 0.5: Tic-Tac-Toe Configurable (60 ❤️)

For this checkpoint, you will need to implement:

  • TicTacToeModel, with the initializer modified:
    • def __init__(self, ruleset: Ruleset) initializer
      • Your initializer must accept one parameter, the Ruleset to be used for the game.
  • Rulesets TicTacToeRuleset and TicTacSorryRuleset
  • View (as described in the View section)

Changing the Ruleset passed to the Model should change the win condition of the game.

Checkpoint 1: Three-Men-Morris Classic (60 ❤️ + 80 🔴)

You can go straight to Checkpoint 1 without finishing Checkpoints 0 or 0.5.

This implementation follows the Tic-Tac-Toe ruleset.

For this checkpoint, you will need to implement:

  • MorrisModel (as described in the Model section)
  • View, with the get_input method modified:
    • get_input(self, player: Player) -> tuple[int, int | None]:

      • Displays the current player as a prompt (e.g., Player A's turn to move:)
      • Should accept a line of input. Can get up to two integers, separated by spaces, as input. Assume that the string input contains only integer characters and spaces.
      • Returns a tuple[int, None] if only one int was inputted. This is when a player wants to place a token.
      • Returns a tuple[int, int] if two ints were inputted. This is when a player wants to move a token.

Checkpoint 2: Three-Men-Morris Configurable (60 ❤️ + 100 🔴)

For this checkpoint, you will need to implement:

  • MorrisModel, with the initializer modified:
    • def __init__(self, ruleset: Ruleset) initializer
      • Your initializer must accept one parameter, the Ruleset to be used for the game.
  • Rulesets TicTacToeRuleset and TicTacSorryRuleset
  • View, with the get_input method modified:
    • get_input(self, player: Player) -> tuple[int, int | None]:

      • Displays the current player as a prompt (e.g., Player A's turn to move:)
      • Should accept a line of input. Can get up to two integers, separated by spaces, as input. Assume that the string input contains only integer characters and spaces.
      • Returns a tuple[int, None] if only one int was inputted.
      • Returns a tuple[int, int] if two ints were inputted.

Changing the Ruleset passed to the Model should change the win condition of the game.


Unit test bonus (0 - 30 🔴)

You can get up to 30 🔴 additional points by writing unit tests for the methods of TicTacToeModel, MorrisModel, TicTacToeRuleset, TicTacSorryRuleset, and View. The points you get will be scaled with your unit test code coverage. All tests must be written in a separate tests.py. All tests must be runnable using pytest.

Pyxel View bonus for Checkpoint 0.5 and Checkpoint 2 (30 ❤️)

For the Pyxel View bonus, you may modify the Model, View, and Controller code for Checkpoint 0.5 and Checkpoint 2. This bonus will only be credited if you have a fully-working Checkpoint 0.5 or Checkpoint 2.

The Pyxel View for Checkpoint 0.5 must have:

  • 3x3 grid display

  • mouse click functionality to place tokens

  • restart key/button to restart the game

  • text indicators for current turn and game over text

The Pyxel View for Checkpoint 0.5 must have:

  • 3x3 lattice display, with the lines connecting adjacent points

  • mouse click functionality to place new tokens and move existing tokens

  • restart key/button to restart the game

  • text indicators for current turn and game over text

Grading

HOPE 1 will be scored over 100 🔴 points.

Git submission

  • Deadline: end of lab session