New project: OOX

- (7 min read)

This post marks the beginning of a new series that is focused on the development of a game that everybody knows: Tic‐tac‐toe. While that sounds pretty underwhelming at first, my goal here is to explore different ideas on how to implement that game, through various programming languages, libraries, frameworks, and engines, as well as network protocols. The simplicity of Tic‐tac‐toe will prevent me from be too distracted by the gameplay itself, and will instead allow me to focus on the technical aspects.

This project is called “OOX”, which fits the theme of the game.* The first version, discussed in this post, is very simple, as it runs inside a Terminal on Linux. No graphics, no menu, nothing fancy. It is run from the command line interface, it displays a minimal grid, and the players input their moves through text. The only thing that is new, at least to me and possibly to some other people, is that I use Rust to program the whole game. Rust has been on my radar for a while, as I have grown more and more frustrated by my experience with Python.

The repository for this project can be found here; the version for today’s post is 0.1.0. Here is a screenshot of the game, before I start discussing things that I have learned and some choices that I have made along the way.

demo

The code consists of one file because the size of the program is small. We run the game in one main loop, as expected. There are a few loop options in Rust, but since a game typically loops indefinitely, I found that using the simple loop was the best option. Checking the conditions to stop the loop is not as simple as reading one element in an array, so that part is better located in a separate block inside the loop. Speaking of blocks, no surprise here, we have what you would expect for a game:

  • First we display the board (the grid). This is done inside the Terminal, as mentioned.
  • Then we wait for user input and process it. Each player, taking turns, has to choose one space for their next mark.
  • Finally we process the logic, based on the current situation and user input.

Unlike in any modern game, we loop only once each turn, the program halting to wait for user input. We also loop if the user input is not correctly parsed, to make sure the board is always displayed at the same place, right above the input prompt.

An enum Player, with variants P1 and P2, is defined to represent each player. Like some other elements, players could be implemented as something more simple, like integers or strings. But the match feature in Rust, with its exhaustive cases, is a good reason to use an enum here. We implement the trait Display for Player so that it then becomes straightforward to print the name of a player throughout the code. We also add the method next to easily switch between players at the end of a turn.

Next we define Mark as an enum with variants X and O, each of these being assigned to one of the players.

We then implement the board as a fixed array of size the number of rows by the number of columns, that holds Options of Marks. I first thought of adding the variant None to the enum Mark, to represent the absence of mark, which would avoid the need for an Option in the board. But the solution that I settled on might be a bit easier to understand. The board is used to display the current situation to the player, which is done in the function draw. The Unicode bullet character “•” is used to represent an empty space, and nothing else apart from the marks and the coordinates of the grid is drawn. I first drew a grid made of underscore and pipe characters, with actual, empty spaces, but that didn’t look great. Simplicity worked best.

Each turn the player is asked to enter the coordinates of one empty space. The chosen solution resembles that of the chess game, with letters for columns and numbers for rows. The function get_input returns a struct Coordinates, that holds the coordinates that were input. If the parsed coordinates are invalid, the player is asked to enter them again (and we loop once more).

Lastly we process the logic in the function process_logic. Here we are making use of another enum, Status, that stores the result of that function. Variants are SpaceAlreadyTaken, InProgress, Won, and Draw. The board is used to check if the space corresponding to the input coordinates is still empty; if not, the function returns the variant SpaceAlreadyTaken, and the player is asked to enter coordinates again (and we loop once more). Otherwise the board is updated with the mark corresponding to the current player. To check whether the latest move has triggered a win, we could simply check all the rows, columns, and diagonals of the board. The solution chosen here is a bit more complex, but is meant as a way to scale up the complexity of the game without too much performance hit, if we ever wanted to. I know this is a long shot to make such a game lag, but it makes for an interesting exercise nonetheless. So, instead of checking the board on every turn, we have another structure, lines, which is a hash map whose keys are the rows, columns, and diagonals of the board (thereafter named lines). For each of the lines we store the number of empty spaces and which Mark variant, if any, can be found on that line. We only check the lines that contain the selected space. Also, if two different marks can be found on one line, that line is dropped from the hash map so that we don’t check it anymore moving forward.

The game is won if one player scores three of their marks on one line. And the game is a draw if the board is entirely filled up and there is no winner.

Some comments about that work:

  • I initially had the board implemented as an array of arrays, but that proved a bit too rigid to use, as opposed to an array where each row is stacked one after the other.
  • A constant BOARD_SIZE, equal to 3, makes things easier, especially for parts that require an expression and not the result of a function call. We could theoretically have a game with a board of an arbitrary size and with the same rules, simply by changing that constant.
  • It takes some time to get used to the strict rules that the Rust compiler enforces regarding variable ownership and lifetime. I am not sure the current state of the code follows all the best practices, but I will continue to learn how to design things in the most intelligible and efficient manner.

That was quite many concepts to digest to get to this result, including things I have ditched along the way! But the result is very minimalist. Next we will explore how to move the game out of the Terminal and create a better UX for the players. Rust will still be at the core of all that is to come, as it looks like a promising language to dive into.

* In typical, old hacker fashion, the name ”OOX” is a mutually recursive acronym: “OOX” stands for “OOX Or XOO”, while “XOO” stands for “XOO Or OOX”.