Skip to content

Instantly share code, notes, and snippets.

@zk-1
Created February 5, 2025 03:44
Show Gist options
  • Save zk-1/2143bf05c3eee8c46e402a286e87dc6d to your computer and use it in GitHub Desktop.
Save zk-1/2143bf05c3eee8c46e402a286e87dc6d to your computer and use it in GitHub Desktop.
An interactive CLI game of Tic-Tac-Toe written in Ruby
# 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