Created
February 5, 2025 03:44
-
-
Save zk-1/2143bf05c3eee8c46e402a286e87dc6d to your computer and use it in GitHub Desktop.
An interactive CLI game of Tic-Tac-Toe written in Ruby
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Name: Tic-Tac-Toe (tic_tac_toe.rb) | |
# Description: An interactive CLI game of Tic-Tac-Toe written in Ruby | |
# Author: Zoë Kelly ([email protected]) | |
# License: MIT | |
# Created: 2025-02-04 | |
# | |
# Usage: 1. Open an interactive Ruby shell (e.g. irb) | |
# 2. require_relative "tic_tac_toe.rb" | |
# 3. game = TicTacToe::Game.new | |
# 4. game.play | |
# | |
# Restart game: game.restart! | |
# Export save: my_save_game = game.save_game | |
# Load save: game = TicTacToe::Game.new(save_game: my_save_game) | |
# frozen_string_literal: true | |
require "forwardable" | |
module TicTacToe | |
X = "X".dup | |
O = "O".dup | |
class Game | |
extend Forwardable | |
attr_accessor :game_state | |
def_delegators :game_state, :display_board, :next_player, :game_over?, :winner, :make_move, :game_in_progress? | |
def initialize(save_game: nil) | |
@game_state = GameState.new(save_game:) | |
end | |
def start_game | |
until game_over? | |
begin | |
display_board | |
player = next_player || first_player | |
print "\n#{player}, it's your turn. Please enter your move: " | |
answer = gets.strip | |
break if answer.upcase == "Q" | |
position = answer.to_i | |
make_move(player:, position:) | |
rescue StandardError => e | |
puts "\n\n😬 #{e.message}\n" | |
end | |
end | |
end | |
def present_intro | |
if !game_in_progress? | |
puts "\n✨ Welcome to Tic-Tac-Toe! ✨" | |
puts "-" * 27 | |
puts "\nPlayer 1 is X, and Player 2 is O" | |
puts "Enter 'Q' at any time to quit" | |
puts "\nThe first player will be: #{first_player}!" | |
else | |
puts "\n✨ Welcome back to Tic-Tac-Toe! ✨" | |
puts "-" * 27 | |
end | |
end | |
def announce_game_outcome | |
display_board | |
if winner | |
puts "\n🥳 #{winner} has won the game!" | |
elsif game_over? | |
puts "\n😅 It's a draw!" | |
else | |
puts "\n😉 Guess we'll continue another time!" | |
end | |
end | |
def first_player | |
@first_player ||= [X, O].sample | |
end | |
def play | |
if game_over? | |
announce_game_outcome | |
return | |
end | |
present_intro | |
start_game | |
announce_game_outcome | |
end | |
def save_game | |
game_state.serialize | |
end | |
def restart! | |
@game_state = GameState.new | |
play | |
end | |
end | |
class GameState | |
class GameOverError < StandardError; end | |
class WrongTurnError < StandardError; end | |
class InvalidInputError < StandardError; end | |
class InvalidMoveError < StandardError; end | |
class InvalidBoardError < StandardError; end | |
BLANK_BOARD = 9.times.map { |_m| nil } # Array of 9 elements, all nil | |
ALLOWED_VALUES = [nil, X, O] | |
attr_reader :board, :last_round, :last_player | |
def initialize(save_game: nil) | |
@board = load_save_game(save_game:) || BLANK_BOARD.dup | |
end | |
def load_save_game(save_game:) | |
save_game.tap do |s| | |
return nil unless | |
s.is_a?(Hash) && | |
s[:board].is_a?(Array) && | |
s[:board].length == 9 && | |
s[:board].all? { |i| ALLOWED_VALUES.include?(i) } && | |
(s[:last_round].nil? || (1..9) === s[:last_round]) && | |
ALLOWED_VALUES.include?(s[:last_player]) | |
end | |
@last_round = save_game[:last_round] | |
@last_player = save_game[:last_player] | |
@board = save_game[:board] | |
end | |
def serialize | |
{ board:, last_round:, last_player: } | |
end | |
def next_round | |
@last_round + 1 | |
end | |
def next_player | |
return nil if !last_player || last_round == 9 | |
last_player == X && O || X | |
end | |
def display_board | |
puts # newline :) | |
puts " #{board[0] || 1} | #{board[1] || 2} | #{board[2] || 3}" | |
puts "-" * 15 | |
puts " #{board[3] || 4} | #{board[4] || 5} | #{board[5] || 6}" | |
puts "-" * 15 | |
puts " #{board[6] || 7} | #{board[7] || 8} | #{board[8] || 9}" | |
end | |
def make_move(player:, position:) | |
validate_move!(player:, position:) | |
player.upcase! | |
@board[position - 1] = player | |
@last_player = player | |
@last_round = @last_round.to_i + 1 | |
end | |
def validate_move!(player:, position:) | |
raise GameOverError, "This game is already over!" if game_over? | |
unless [X, O].include?(player&.upcase) && (1..9).to_a.include?(position) | |
raise InvalidInputError, "That's an invalid move!" | |
end | |
if last_player == player | |
raise WrongTurnError, "It's not #{player}'s turn yet!" | |
end | |
unless @board[position - 1].nil? | |
raise InvalidMoveError, "That position is already taken!" | |
end | |
end | |
def game_over? | |
@last_round == 9 || game_has_been_won? | |
end | |
def game_has_been_won? | |
!!winner | |
end | |
def game_in_progress? | |
!game_over? && last_player && last_round | |
end | |
def winner | |
rows = @board.each_slice(3).to_a | |
columns = rows.transpose | |
diagonals = [ | |
3.times.map { |i| rows[i][i] }, | |
3.times.map { |i| rows.reverse[i][i] }.reverse | |
] | |
winning_tuples = (rows + columns + diagonals).find_all { |t| t.uniq.size == 1 && !t[0].nil? } | |
raise InvalidBoardError, "Invalid game board (more than one winner found)" if winning_tuples.count > 1 | |
winning_tuples.first&.first | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment