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
Noneif no winner yet or if the game ends in a draw
- Must return winning player, or
-
@property
def is_game_done(self) -> bool:- Must return whether the game is done
- Used in case of draws as
winneralone 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
listcontaining the state of the grid - Modifying the list returned by this property method should not alter the state of the model
- Must return a
-
def place_token(self, loc: int) -> bool:- Attempts to place a token of the current player at location
loc. locmust 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
Trueif placing the token is valid, otherwiseFalse(also, returnFalseif the game is over) - If this returns
True, the current player should also be updated.
- Attempts to place a token of the current player at location
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_fromtoloc_to. loc_fromandloc_tomust 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
Trueif the move is valid, otherwiseFalse(also, returnFalseif the game is over) - If this returns
True, the current player should also be updated.
- Attempts to move a token of the current player at location
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
Trueifplayerhas won the game, based on the game state - Returns
Falseotherwise. - Note that if the win condition activates for both players (e.g., in Tic-Tac-Sorry), this should return
False.
- Returns
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
Afor Player A tokens, and aBfor 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
0to8). Note that the input does not necessarily represent a valid move. - Returns an
intequivalent of the input.
- Displays the current player as a prompt (e.g.,
-
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
Rulesetto be used for the game.
- Your initializer must accept one parameter, the
RulesetsTicTacToeRulesetandTicTacSorryRulesetView(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 theget_inputmethod 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 oneintwas inputted. This is when a player wants to place a token. - Returns a
tuple[int, int]if twoints were inputted. This is when a player wants to move a token.
- Displays the current player as a prompt (e.g.,
-
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
Rulesetto be used for the game.
- Your initializer must accept one parameter, the
RulesetsTicTacToeRulesetandTicTacSorryRulesetView, with theget_inputmethod 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 oneintwas inputted. - Returns a
tuple[int, int]if twoints were inputted.
- Displays the current player as a prompt (e.g.,
-
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