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}