Skip to content

Commit 4cdd2e8

Browse files
Merge pull request #6 from chrisreddington/tictactoe_enhancements
Enhance Tic-tac-toe game with computer opponent and game mode selection
2 parents 725af6f + 94016a0 commit 4cdd2e8

3 files changed

Lines changed: 570 additions & 60 deletions

File tree

cmd/tictactoe.go

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,92 @@ import (
44
"fmt"
55
"os"
66

7+
"github.com/charmbracelet/lipgloss"
78
"github.com/chrisreddington/gh-game/internal/tictactoe"
89
userPrompt "github.com/cli/go-gh/v2/pkg/prompter"
910
"github.com/spf13/cobra"
1011
)
1112

13+
var (
14+
xStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("45")) // bright blue
15+
oStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")) // bright yellow
16+
)
17+
1218
var tictactoeCmd = &cobra.Command{
1319
Use: "tictactoe",
1420
Short: "Play Tic-tac-toe",
15-
Long: `Start a game of Tic-tac-toe where X and O take turns to play.`,
21+
Long: `Start a game of Tic-tac-toe where you can play against another player locally or against the computer.
22+
Choose between two game modes:
23+
- Local Multiplayer: Play against another player on the same computer
24+
- Play Against Computer: Play against an AI opponent that uses basic strategy`,
1625
Run: func(cmd *cobra.Command, args []string) {
17-
game := tictactoe.NewGame()
1826
prompter := userPrompt.New(os.Stdin, os.Stdout, os.Stderr)
1927

28+
// Select game mode
29+
modeIndex, err := prompter.Select(
30+
"Select game mode:",
31+
"Local Multiplayer",
32+
[]string{"Local Multiplayer", "Play Against Computer"},
33+
)
34+
if err != nil {
35+
fmt.Printf("Error selecting game mode: %v\n", err)
36+
return
37+
}
38+
39+
// Initialize game with selected mode
40+
mode := tictactoe.LocalGame
41+
if modeIndex == 1 {
42+
mode = tictactoe.ComputerGame
43+
}
44+
game := tictactoe.NewGame(mode)
45+
46+
// Main game loop
2047
for {
2148
fmt.Println(game)
22-
fmt.Printf("Player %s's turn\n", game.CurrentPlayer)
49+
currentMark := game.CurrentPlayer
50+
style := xStyle
51+
if currentMark == "O" {
52+
style = oStyle
53+
}
54+
fmt.Printf("Player %s's turn\n", style.Render(currentMark))
2355

24-
row, col, err := tictactoe.GetPlayerMove(prompter, game)
25-
if err != nil {
26-
fmt.Printf("Error getting move: %v\n", err)
27-
return
56+
// Get move from either computer or human player
57+
var rowIndex, columnIndex int
58+
if game.IsComputerTurn() {
59+
rowIndex, columnIndex = game.GetComputerMove()
60+
position := rowIndex*3 + columnIndex + 1
61+
fmt.Printf("Computer places %s at position %d\n", oStyle.Render("O"), position)
62+
} else {
63+
var err error
64+
rowIndex, columnIndex, err = tictactoe.GetPlayerMove(prompter, game)
65+
if err != nil {
66+
fmt.Printf("Error getting move: %v\n", err)
67+
return
68+
}
2869
}
2970

30-
if err := game.MakeMove(row, col); err != nil {
71+
// Apply the move
72+
if err := game.MakeMove(rowIndex, columnIndex); err != nil {
3173
fmt.Printf("Invalid move: %v\n", err)
3274
continue
3375
}
3476

77+
// Check win condition
3578
if winner := game.GetWinner(); winner != "" {
3679
fmt.Println(game)
37-
fmt.Printf("Player %s wins!\n", winner)
80+
style := xStyle
81+
if winner == "O" {
82+
style = oStyle
83+
}
84+
if game.Mode == tictactoe.ComputerGame && winner == game.ComputerMark {
85+
fmt.Printf("Computer (%s) wins!\n", style.Render(winner))
86+
} else {
87+
fmt.Printf("Player %s wins!\n", style.Render(winner))
88+
}
3889
return
3990
}
4091

92+
// Check draw condition
4193
if game.IsBoardFull() {
4294
fmt.Println(game)
4395
fmt.Println("It's a draw!")

internal/tictactoe/tictactoe.go

Lines changed: 123 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
1-
// Package tictactoe implements a classic Tic-tac-toe game where two players
2-
// take turns marking spaces on a 3x3 grid. Players alternate placing X's and O's
3-
// on the board until either one player wins by placing three marks in a row,
4-
// column, or diagonal, or the game ends in a draw when the board is full.
1+
// Package tictactoe implements a classic Tic-tac-toe game where players can play
2+
// against another player locally or against a computer opponent.
53
package tictactoe
64

75
import (
86
"errors"
97
"fmt"
8+
"math/rand"
9+
"time"
10+
11+
"github.com/charmbracelet/lipgloss"
12+
)
13+
14+
// GameMode represents the type of game being played (local multiplayer or against computer)
15+
type GameMode int
16+
17+
const (
18+
// LocalGame represents a game played between two local players
19+
LocalGame GameMode = iota
20+
// ComputerGame represents a game played against the computer
21+
ComputerGame
22+
)
23+
24+
var (
25+
// Use accessible colors - blue for X and yellow for O
26+
// These colors have good contrast and are distinguishable for most types of color blindness
27+
xStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("45")) // bright blue
28+
oStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")) // bright yellow
1029
)
1130

1231
// Prompter defines an interface for getting user input
@@ -26,17 +45,25 @@ type Board [3][3]string
2645

2746
// Game represents the current state of a Tic-tac-toe game.
2847
type Game struct {
29-
board Board // The game board storing player moves ("X" or "O", or empty for unplayed)
30-
CurrentPlayer string // CurrentPlayer indicates whose turn it is ("X" or "O")
48+
board Board // The game board storing player moves ("X" or "O", or empty for unplayed)
49+
CurrentPlayer string // CurrentPlayer indicates whose turn it is ("X" or "O")
50+
Mode GameMode // Mode indicates if playing against computer or local player
51+
ComputerMark string // ComputerMark stores which mark (X/O) the computer is using
3152
}
3253

3354
// NewGame creates and initializes a new Tic-tac-toe game with an empty board.
3455
// Setting as X always plays first.
35-
func NewGame() *Game {
36-
return &Game{
56+
func NewGame(mode GameMode) *Game {
57+
game := &Game{
3758
board: Board{},
3859
CurrentPlayer: "X",
60+
Mode: mode,
61+
}
62+
if mode == ComputerGame {
63+
// Computer always plays as O
64+
game.ComputerMark = "O"
3965
}
66+
return game
4067
}
4168

4269
// MakeMove attempts to place the current player's mark at the specified position.
@@ -113,8 +140,8 @@ func (g *Game) IsBoardFull() bool {
113140

114141
// String returns a formatted string representation of the current board state.
115142
// Empty squares are shown as numbers 1-9 for position selection, making it
116-
// easier for players to choose their moves. Played squares show the player's mark.
117-
// The board is displayed with column separators (|) and row separators (-).
143+
// easier for players to choose their moves. Played squares show the player's mark
144+
// in their respective colors (blue for X, yellow for O).
118145
func (g *Game) String() string {
119146
result := "\n"
120147
position := 1
@@ -125,11 +152,16 @@ func (g *Game) String() string {
125152
result += " "
126153
}
127154

128-
// Show position number for empty squares, otherwise show the player's mark
155+
// Show position number for empty squares, otherwise show the colored player's mark
129156
if g.board[rowIndex][columnIndex] == "" {
130157
result += fmt.Sprintf("%d", position)
131158
} else {
132-
result += g.board[rowIndex][columnIndex]
159+
mark := g.board[rowIndex][columnIndex]
160+
if mark == "X" {
161+
result += xStyle.Render("X")
162+
} else {
163+
result += oStyle.Render("O")
164+
}
133165
}
134166

135167
// Add column separators except for the last column
@@ -218,3 +250,82 @@ func GetPlayerMove(prompter Prompter, game GameInterface) (rowIndex, columnIndex
218250

219251
return rowIndex, columnIndex, nil
220252
}
253+
254+
// IsComputerTurn returns true if it's the computer's turn in a computer game.
255+
// This will only return true if the game mode is ComputerGame and the current
256+
// player matches the computer's mark.
257+
func (g *Game) IsComputerTurn() bool {
258+
return g.Mode == ComputerGame && g.CurrentPlayer == g.ComputerMark
259+
}
260+
261+
// GetComputerMove determines the computer's next move using a simple strategy:
262+
// 1. Win if possible
263+
// 2. Block opponent's winning move if possible
264+
// 3. Take center if available
265+
// 4. Take a random corner if available
266+
// 5. Take any available space
267+
// Returns row and column indices for the chosen move.
268+
func (g *Game) GetComputerMove() (rowIndex, columnIndex int) {
269+
// Try to win if possible
270+
if move := g.findWinningMove(g.ComputerMark); move != nil {
271+
return move[0], move[1]
272+
}
273+
274+
// Block opponent's winning move if possible
275+
playerMark := "X" // Player is always X
276+
if move := g.findWinningMove(playerMark); move != nil {
277+
return move[0], move[1]
278+
}
279+
280+
// Take center if available
281+
if g.board[1][1] == "" {
282+
return 1, 1
283+
}
284+
285+
// Take a corner if available
286+
corners := [][2]int{{0, 0}, {0, 2}, {2, 0}, {2, 2}}
287+
rand.Seed(time.Now().UnixNano())
288+
rand.Shuffle(len(corners), func(i, j int) {
289+
corners[i], corners[j] = corners[j], corners[i]
290+
})
291+
for _, corner := range corners {
292+
if g.board[corner[0]][corner[1]] == "" {
293+
return corner[0], corner[1]
294+
}
295+
}
296+
297+
// Take any available space
298+
for rowIndex := 0; rowIndex < 3; rowIndex++ {
299+
for columnIndex := 0; columnIndex < 3; columnIndex++ {
300+
if g.board[rowIndex][columnIndex] == "" {
301+
return rowIndex, columnIndex
302+
}
303+
}
304+
}
305+
306+
return -1, -1
307+
}
308+
309+
// findWinningMove checks if there's a winning move available for the given mark.
310+
// It simulates placing the mark in each empty position and checks if it results in a win.
311+
// Returns the [row, column] indices of the winning move, or nil if no winning move exists.
312+
func (g *Game) findWinningMove(mark string) []int {
313+
// Try each empty position
314+
for rowIndex := 0; rowIndex < 3; rowIndex++ {
315+
for columnIndex := 0; columnIndex < 3; columnIndex++ {
316+
if g.board[rowIndex][columnIndex] == "" {
317+
// Try the move
318+
g.board[rowIndex][columnIndex] = mark
319+
// Check if it's a winning move
320+
if g.GetWinner() == mark {
321+
// Undo the move and return the position
322+
g.board[rowIndex][columnIndex] = ""
323+
return []int{rowIndex, columnIndex}
324+
}
325+
// Undo the move
326+
g.board[rowIndex][columnIndex] = ""
327+
}
328+
}
329+
}
330+
return nil
331+
}

0 commit comments

Comments
 (0)