Skip to main content

gamie/
tictactoe.rs

1//! Tic-Tac-Toe game implementation
2//!
3//! A classic 3x3 Tic-Tac-Toe game where two players take turns placing pieces
4//! on the board, attempting to get three in a row horizontally, vertically, or diagonally.
5//!
6//! See [`Game`] for the main game interface.
7
8pub use impls::{Error, Game, Player, Status};
9
10mod impls {
11    use core::convert::Infallible;
12    use thiserror::Error;
13
14    const BOARD_WIDTH: usize = 3;
15    const BOARD_HEIGHT: usize = 3;
16
17    /// A Tic-Tac-Toe game instance
18    ///
19    /// The game is played on a 3x3 board where two players alternate placing their pieces.
20    /// Player0 always goes first. The game ends when a player gets three pieces in a row
21    /// (horizontally, vertically, or diagonally) or when the board is full (resulting in a draw).
22    ///
23    /// # Examples
24    ///
25    /// ```rust
26    /// # use gamie::tictactoe::Game;
27    /// let mut game = Game::new().unwrap();
28    ///
29    /// // Player0's turn
30    /// game.put(1, 1).unwrap(); // Center position
31    ///
32    /// // Player1's turn
33    /// game.put(0, 0).unwrap(); // Top-left corner
34    ///
35    /// // Continue playing...
36    /// ```
37    #[derive(Debug, Clone)]
38    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
39    #[cfg_attr(
40        feature = "rkyv",
41        derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive),
42        rkyv(derive(Debug))
43    )]
44    pub struct Game {
45        board: [[Option<Player>; BOARD_HEIGHT]; BOARD_WIDTH],
46        move_count: usize,
47        next_player: Player,
48        status: Status,
49    }
50
51    /// Represents a player in the game
52    ///
53    /// There are two players in Tic-Tac-Toe. Player0 always makes the first move,
54    /// followed by Player1, and they continue alternating turns throughout the game.
55    #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, Ord, PartialOrd)]
56    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57    #[cfg_attr(
58        feature = "rkyv",
59        derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive),
60        rkyv(
61            derive(Debug, Eq, Hash, PartialEq, Ord, PartialOrd),
62            compare(PartialEq, PartialOrd)
63        )
64    )]
65    pub enum Player {
66        Player0,
67        Player1,
68    }
69
70    /// The current status of the game
71    ///
72    /// The game can be in one of three states:
73    /// - [`Ongoing`](Status::Ongoing): The game is still in progress
74    /// - [`Win`](Status::Win): A player has won by getting three in a row
75    /// - [`Draw`](Status::Draw): All positions are filled with no winner
76    #[derive(Debug, Clone, Eq, PartialEq)]
77    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
78    #[cfg_attr(
79        feature = "rkyv",
80        derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive),
81        rkyv(derive(Debug, Eq, PartialEq), compare(PartialEq))
82    )]
83    pub enum Status {
84        Ongoing,
85        Draw,
86        Win(Player),
87    }
88
89    /// Errors that can occur when placing a piece on the board
90    ///
91    /// These errors prevent invalid moves from being made during the game.
92    #[derive(Debug, Clone, Error)]
93    #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
94    #[cfg_attr(
95        feature = "rkyv",
96        derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive),
97        rkyv(derive(Debug))
98    )]
99    pub enum Error {
100        /// The position is already occupied by a piece
101        #[error("position occupied")]
102        PositionOccupied,
103        /// The game has already ended (either in a win or draw)
104        #[error("game ended")]
105        GameEnded,
106    }
107
108    struct LastMove {
109        player: Player,
110        row: usize,
111        col: usize,
112    }
113
114    impl Game {
115        /// Creates a new Tic-Tac-Toe game
116        pub const fn new() -> Result<Self, Infallible> {
117            Ok(Self {
118                board: [[None; BOARD_HEIGHT]; BOARD_WIDTH],
119                move_count: 0,
120                next_player: Player::Player0,
121                status: Status::Ongoing,
122            })
123        }
124
125        /// Gets the piece at the specified position
126        ///
127        /// Returns `Some(Player)` if a piece is present at the given position,
128        /// or `None` if the position is empty.
129        ///
130        /// # Parameters
131        ///
132        /// - `row`: The row index (0-2)
133        /// - `col`: The column index (0-2)
134        ///
135        /// # Panics
136        ///
137        /// Panics if `row` or `col` is out of bounds (greater than 2)
138        pub const fn get(&self, row: usize, col: usize) -> Option<Player> {
139            self.board[row][col]
140        }
141
142        /// Places a piece for the current player at the specified position
143        ///
144        /// The current player's piece (returned by [`next_player()`](Self::next_player))
145        /// is placed at the given position. After a successful placement, the turn
146        /// automatically switches to the other player, and the game status is updated
147        /// to check for a win or draw.
148        ///
149        /// # Parameters
150        ///
151        /// - `row`: The row index (0-2)
152        /// - `col`: The column index (0-2)
153        ///
154        /// # Errors
155        ///
156        /// Returns an error if:
157        /// - The position is already occupied ([`Error::PositionOccupied`])
158        /// - The game has already ended ([`Error::GameEnded`])
159        ///
160        /// # Panics
161        ///
162        /// Panics if `row` or `col` is out of bounds (greater than 2)
163        pub fn put(&mut self, row: usize, col: usize) -> Result<(), Error> {
164            if matches!(self.status, Status::Win(_) | Status::Draw) {
165                return Err(Error::GameEnded);
166            }
167
168            if self.board[row][col].is_some() {
169                return Err(Error::PositionOccupied);
170            }
171
172            self.board[row][col] = Some(self.next_player);
173
174            let last_move = LastMove {
175                player: self.next_player,
176                row,
177                col,
178            };
179
180            self.move_count += 1;
181            self.next_player = self.next_player.other();
182
183            self.update_status(last_move);
184
185            Ok(())
186        }
187
188        /// Gets the next player whose turn it is to move
189        ///
190        /// Returns the player who should make the next move. This changes
191        /// after each successful call to [`put()`](Self::put).
192        pub const fn next_player(&self) -> Player {
193            self.next_player
194        }
195
196        /// Gets the current game status
197        ///
198        /// Returns whether the game is ongoing, ended in a draw, or won by a player.
199        /// The status is automatically updated after each move.
200        pub const fn status(&self) -> &Status {
201            &self.status
202        }
203
204        fn update_status(&mut self, last_move: LastMove) {
205            // To determine if the game has ended with the last move, we check
206            // all 3 positions in each direction (horizontal, vertical, and diagonal)
207
208            // Check horizontal
209            if self.get(last_move.row, 0) == Some(last_move.player)
210                && self.get(last_move.row, 1) == Some(last_move.player)
211                && self.get(last_move.row, 2) == Some(last_move.player)
212            {
213                self.status = Status::Win(last_move.player);
214                return;
215            }
216
217            // Check vertical
218            if self.get(0, last_move.col) == Some(last_move.player)
219                && self.get(1, last_move.col) == Some(last_move.player)
220                && self.get(2, last_move.col) == Some(last_move.player)
221            {
222                self.status = Status::Win(last_move.player);
223                return;
224            }
225
226            // Check top-left to bottom-right diagonal
227            if last_move.row == last_move.col
228                && self.get(0, 0) == Some(last_move.player)
229                && self.get(1, 1) == Some(last_move.player)
230                && self.get(2, 2) == Some(last_move.player)
231            {
232                self.status = Status::Win(last_move.player);
233                return;
234            }
235
236            // Check top-right to bottom-left diagonal
237            if last_move.row + last_move.col == BOARD_WIDTH - 1
238                && self.get(0, 2) == Some(last_move.player)
239                && self.get(1, 1) == Some(last_move.player)
240                && self.get(2, 0) == Some(last_move.player)
241            {
242                self.status = Status::Win(last_move.player);
243                return;
244            }
245
246            // Check for draw
247            if self.move_count == BOARD_HEIGHT * BOARD_WIDTH {
248                self.status = Status::Draw;
249            }
250        }
251    }
252
253    impl Player {
254        /// Returns the opposite player
255        ///
256        /// If this player is Player0, returns Player1, and vice versa.
257        pub const fn other(self) -> Self {
258            match self {
259                Player::Player0 => Player::Player1,
260                Player::Player1 => Player::Player0,
261            }
262        }
263    }
264
265    #[cfg(test)]
266    mod tests {
267        use crate::tictactoe::*;
268
269        #[test]
270        fn test() {
271            let mut game = Game::new().unwrap();
272
273            game.put(1, 1).unwrap();
274
275            assert_eq!(game.next_player(), Player::Player1);
276
277            game.put(1, 0).unwrap();
278
279            assert_eq!(game.next_player(), Player::Player0);
280            assert!(matches!(game.put(1, 1), Err(Error::PositionOccupied)));
281
282            game.put(2, 2).unwrap();
283            game.put(2, 0).unwrap();
284            game.put(0, 0).unwrap();
285
286            assert_eq!(game.status(), &Status::Win(Player::Player0));
287            assert!(matches!(game.put(0, 2), Err(Error::GameEnded)));
288        }
289
290        #[test]
291        fn corner_opening_moves_do_not_win() {
292            for (row, col) in [(0, 0), (0, 2), (2, 0), (2, 2)] {
293                let mut game = Game::new().unwrap();
294
295                game.put(row, col).unwrap();
296
297                assert_eq!(game.status(), &Status::Ongoing);
298                assert_eq!(game.next_player(), Player::Player1);
299            }
300        }
301    }
302}
303
304#[cfg(feature = "rkyv")]
305pub mod rkyv {
306    pub use super::impls::{
307        ArchivedError, ArchivedGame, ArchivedPlayer, ArchivedStatus, ErrorResolver, GameResolver,
308        PlayerResolver, StatusResolver,
309    };
310}