Created
November 17, 2025 15:03
-
-
Save 1998code/90768001939492d84408f7b65708aa3d to your computer and use it in GitHub Desktop.
reTicTacCHA
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
| // | |
| // reTicTacCHA.swift | |
| // Tic-Tac-Toe reCAPTCHA | |
| // | |
| // A fun reCAPTCHA-styled Tic-Tac-Toe game where users must beat an AI | |
| // to prove they're human. Features glass morphism UI inspired by iOS 18+ | |
| // and a minimax AI opponent with configurable difficulty. | |
| // | |
| // Created by Ming on 17/11/2025. | |
| // | |
| import SwiftUI | |
| import Combine | |
| // MARK: - Game Logic | |
| /// Represents the two players in the game | |
| enum Player { | |
| case human // User playing as X | |
| case ai // AI opponent playing as O | |
| /// Returns the symbol (X or O) for each player | |
| var symbol: String { | |
| switch self { | |
| case .human: return "X" | |
| case .ai: return "O" | |
| } | |
| } | |
| } | |
| /// Tracks the current state of the game | |
| enum GameState { | |
| case playing // Game is in progress | |
| case humanWon // Human won - verification successful | |
| case aiWon // AI won - verification failed | |
| case draw // Board full with no winner - verification failed | |
| } | |
| /// Main game controller managing game state and logic | |
| class TicTacToeGame: ObservableObject { | |
| // MARK: - Published Properties | |
| /// 3x3 board represented as array of 9 positions (nil = empty, "X" = human, "O" = AI) | |
| @Published var board: [String?] = Array(repeating: nil, count: 9) | |
| /// Current game state (playing, won, lost, or draw) | |
| @Published var gameState: GameState = .playing | |
| /// Whether the initial checkbox has been clicked | |
| @Published var isChecked: Bool = false | |
| /// Whether to show loading spinner in checkbox | |
| @Published var showProgress: Bool = false | |
| /// Whether to show the game dialog (vs initial CAPTCHA screen) | |
| @Published var showGame: Bool = false | |
| /// Message displayed at top of game dialog | |
| @Published var message: String = "Select all squares to defeat the AI" | |
| // MARK: - Game Logic | |
| /// Handles human player's move at the specified board index | |
| /// - Parameter index: Position on the board (0-8) | |
| func makeMove(at index: Int) { | |
| // Only allow moves on empty squares during active gameplay | |
| guard board[index] == nil && gameState == .playing else { return } | |
| // Place human's X | |
| board[index] = Player.human.symbol | |
| // Check if human won with this move | |
| if checkWinner(for: Player.human) { | |
| gameState = .humanWon | |
| message = "Verification successful! You're human!" | |
| return | |
| } | |
| // Check if board is full (draw) | |
| if isBoardFull() { | |
| gameState = .draw | |
| message = "Verification failed. Please try again." | |
| return | |
| } | |
| // AI responds immediately (synchronous to avoid race conditions) | |
| let moveIndex: Int | |
| // 40% chance to make a random move (gives human a better chance to win) | |
| if Double.random(in: 0...1) < 0.4 { | |
| let availableMoves = board.indices.filter { board[$0] == nil } | |
| moveIndex = availableMoves.randomElement() ?? 0 | |
| } else { | |
| // Use minimax algorithm for optimal play | |
| moveIndex = findBestMove() | |
| } | |
| board[moveIndex] = Player.ai.symbol | |
| // Check game end conditions | |
| // Always prioritize checking if human won (handles edge cases) | |
| if checkWinner(for: Player.human) { | |
| gameState = .humanWon | |
| message = "Verification successful! You're human!" | |
| } else if checkWinner(for: Player.ai) { | |
| gameState = .aiWon | |
| message = "Verification failed. Unusual activity detected." | |
| } else if isBoardFull() { | |
| gameState = .draw | |
| message = "Verification failed. Please try again." | |
| } | |
| } | |
| // MARK: - AI Algorithm | |
| /// Finds the best move using minimax algorithm | |
| /// - Returns: Index of the optimal move (0-8) | |
| func findBestMove() -> Int { | |
| var bestScore = Int.min | |
| var bestMove = 0 | |
| // Try each available position and evaluate using minimax | |
| for i in 0..<9 { | |
| if board[i] == nil { | |
| // Simulate AI move | |
| board[i] = Player.ai.symbol | |
| let score = minimax(depth: 0, isMaximizing: false) | |
| // Undo move | |
| board[i] = nil | |
| // Track the move with the highest score | |
| if score > bestScore { | |
| bestScore = score | |
| bestMove = i | |
| } | |
| } | |
| } | |
| return bestMove | |
| } | |
| /// Minimax algorithm for optimal move selection | |
| /// - Parameters: | |
| /// - depth: Current depth in the game tree | |
| /// - isMaximizing: True if maximizing player (AI), false if minimizing player (human) | |
| /// - Returns: Score for the current board state | |
| func minimax(depth: Int, isMaximizing: Bool) -> Int { | |
| // Base cases: check terminal states | |
| if checkWinner(for: Player.ai) { return 10 - depth } // AI wins (favor faster wins) | |
| if checkWinner(for: Player.human) { return depth - 10 } // Human wins | |
| if isBoardFull() { return 0 } // Draw | |
| if isMaximizing { | |
| // AI's turn: maximize score | |
| var bestScore = Int.min | |
| for i in 0..<9 { | |
| if board[i] == nil { | |
| board[i] = Player.ai.symbol | |
| let score = minimax(depth: depth + 1, isMaximizing: false) | |
| board[i] = nil | |
| bestScore = max(score, bestScore) | |
| } | |
| } | |
| return bestScore | |
| } else { | |
| // Human's turn: minimize score | |
| var bestScore = Int.max | |
| for i in 0..<9 { | |
| if board[i] == nil { | |
| board[i] = Player.human.symbol | |
| let score = minimax(depth: depth + 1, isMaximizing: true) | |
| board[i] = nil | |
| bestScore = min(score, bestScore) | |
| } | |
| } | |
| return bestScore | |
| } | |
| } | |
| // MARK: - Helper Functions | |
| /// Checks if the specified player has won the game | |
| /// - Parameter player: The player to check (human or AI) | |
| /// - Returns: True if the player has three in a row | |
| func checkWinner(for player: Player) -> Bool { | |
| let winPatterns = [ | |
| [0, 1, 2], [3, 4, 5], [6, 7, 8], // Rows | |
| [0, 3, 6], [1, 4, 7], [2, 5, 8], // Columns | |
| [0, 4, 8], [2, 4, 6] // Diagonals | |
| ] | |
| // Check all possible win patterns (rows, columns, diagonals) | |
| return winPatterns.contains { pattern in | |
| pattern.allSatisfy { board[$0] == player.symbol } | |
| } | |
| } | |
| /// Checks if the board is completely filled | |
| /// - Returns: True if no empty squares remain | |
| func isBoardFull() -> Bool { | |
| board.allSatisfy { $0 != nil } | |
| } | |
| /// Resets the entire game to initial state | |
| func reset() { | |
| board = Array(repeating: nil, count: 9) | |
| gameState = .playing | |
| isChecked = false | |
| showProgress = false | |
| showGame = false | |
| message = "Select all squares to defeat the AI" | |
| } | |
| /// Starts a new game (called after clicking checkbox) | |
| func startGame() { | |
| // Clear previous game state | |
| board = Array(repeating: nil, count: 9) | |
| gameState = .playing | |
| message = "Select all squares to defeat the AI" | |
| // Show game dialog and hide progress spinner | |
| withAnimation { | |
| showGame = true | |
| showProgress = false | |
| } | |
| } | |
| } | |
| // MARK: - Views | |
| /// Main content view with gradient background | |
| struct ContentView: View { | |
| @StateObject private var game = TicTacToeGame() | |
| var body: some View { | |
| ZStack { | |
| // Gradient background | |
| LinearGradient( | |
| gradient: Gradient(colors: [ | |
| Color(red: 0.85, green: 0.90, blue: 1.0), | |
| Color(red: 0.95, green: 0.95, blue: 0.98), | |
| Color(red: 0.90, green: 0.85, blue: 1.0) | |
| ]), | |
| startPoint: .topLeading, | |
| endPoint: .bottomTrailing | |
| ) | |
| .ignoresSafeArea() | |
| // Switch between initial CAPTCHA and game views | |
| VStack(spacing: 0) { | |
| if !game.showGame { | |
| InitialCaptchaView(game: game) | |
| .transition(.opacity) | |
| } else { | |
| GameCaptchaView(game: game) | |
| .transition(.opacity) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /// Initial CAPTCHA screen with "I'm not a robot" checkbox | |
| struct InitialCaptchaView: View { | |
| @ObservedObject var game: TicTacToeGame | |
| var body: some View { | |
| VStack(spacing: 20) { | |
| Spacer() | |
| VStack(spacing: 15) { | |
| HStack(spacing: 12) { | |
| // Checkbox showing different states: unchecked, loading, success, or failure | |
| CheckboxView( | |
| isChecked: $game.isChecked, | |
| showX: game.gameState == .aiWon || game.gameState == .draw, | |
| showCheck: game.gameState == .humanWon, | |
| showProgress: game.showProgress | |
| ) | |
| .onTapGesture { | |
| // Only respond to taps when not already in progress | |
| if !game.showProgress && !game.showGame { | |
| // Show loading spinner | |
| withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { | |
| game.showProgress = true | |
| } | |
| // Start game after brief delay | |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { | |
| game.startGame() | |
| } | |
| } | |
| } | |
| // Dynamic text based on game outcome | |
| Text( | |
| game.gameState == .humanWon ? "Confirmed you're not a robot 🎉" : | |
| (game.gameState == .aiWon || game.gameState == .draw) ? "You're a robot, try again!" : | |
| "I'm not a robot" | |
| ) | |
| .font(.system(size: 16)) | |
| .foregroundColor(.primary) | |
| Spacer() | |
| } | |
| .padding() | |
| .glassEffect(.clear.interactive(), in: .rect(cornerRadius: 12)) // iOS 18+ glass morphism effect | |
| .shadow(color: Color.black.opacity(0.15), radius: 10, x: 0, y: 5) | |
| // Footer with branding and reset button | |
| HStack { | |
| Image(systemName: "shield.checkered") | |
| .font(.system(size: 10)) | |
| .foregroundColor(.gray) | |
| Text("reTicTacCHA") | |
| .font(.system(size: 10)) | |
| .foregroundColor(.gray) | |
| Spacer() | |
| Text("Privacy - Terms") | |
| .font(.system(size: 9)) | |
| .foregroundColor(.blue) | |
| Button(action: { | |
| withAnimation { | |
| game.reset() | |
| } | |
| }) { | |
| Image(systemName: "arrow.clockwise") | |
| .font(.system(size: 9)) | |
| .foregroundColor(.gray) | |
| } | |
| } | |
| .padding(.horizontal, 5) | |
| } | |
| .frame(width: 280) | |
| Spacer() | |
| } | |
| } | |
| } | |
| /// Main game dialog showing the Tic-Tac-Toe board | |
| struct GameCaptchaView: View { | |
| @ObservedObject var game: TicTacToeGame | |
| var body: some View { | |
| VStack(spacing: 0) { | |
| Spacer() | |
| VStack(spacing: 0) { | |
| // Header | |
| HStack { | |
| Image(systemName: "shield.checkered") | |
| .foregroundColor(.white) | |
| Text(game.message) | |
| .font(.system(size: 14, weight: .medium)) | |
| .foregroundColor(.white) | |
| Spacer() | |
| Button(action: { | |
| withAnimation { | |
| game.showGame = false | |
| } | |
| }) { | |
| Image(systemName: "xmark") | |
| .foregroundColor(.white.opacity(0.8)) | |
| } | |
| } | |
| .padding() | |
| .background( | |
| ZStack { | |
| Color(red: 0.24, green: 0.52, blue: 0.98) | |
| Color.white.opacity(0.1) | |
| } | |
| ) | |
| .clipShape(UnevenRoundedRectangle(topLeadingRadius: 16, topTrailingRadius: 16)) | |
| .foregroundColor(.white) | |
| // Game Board - 3x3 grid with dynamic sizing | |
| GeometryReader { geometry in | |
| // Calculate square size to fit perfectly in available width | |
| let squareSize = (geometry.size.width - 2) / 3 // Account for 2 dividers | |
| VStack(spacing: 0) { | |
| ForEach(0..<3) { row in | |
| HStack(spacing: 0) { | |
| ForEach(0..<3) { col in | |
| let index = row * 3 + col | |
| GameSquare( | |
| symbol: game.board[index], | |
| isWinning: false | |
| ) | |
| .frame(width: squareSize, height: squareSize) | |
| .onTapGesture { | |
| game.makeMove(at: index) | |
| } | |
| if col < 2 { | |
| Divider() | |
| .frame(width: 1) | |
| .background(Color.gray.opacity(0.3)) | |
| } | |
| } | |
| } | |
| .frame(height: squareSize) | |
| if row < 2 { | |
| Divider() | |
| .frame(height: 1) | |
| .background(Color.gray.opacity(0.3)) | |
| } | |
| } | |
| } | |
| } | |
| .aspectRatio(1, contentMode: .fit) | |
| .background(.thinMaterial) | |
| // Footer | |
| HStack { | |
| Button(action: { | |
| withAnimation { | |
| game.reset() | |
| } | |
| }) { | |
| HStack { | |
| Image(systemName: "arrow.clockwise") | |
| Text("New Challenge") | |
| } | |
| .font(.system(size: 12)) | |
| .foregroundColor(.blue) | |
| } | |
| Spacer() | |
| if game.gameState != .playing { | |
| Button(action: { | |
| withAnimation { | |
| game.showGame = false | |
| } | |
| }) { | |
| Text("VERIFY") | |
| .font(.system(size: 12, weight: .semibold)) | |
| .foregroundColor(.white) | |
| .padding(.horizontal, 20) | |
| .padding(.vertical, 8) | |
| .background( | |
| game.gameState == .humanWon ? | |
| Color.green : Color(red: 0.24, green: 0.52, blue: 0.98) | |
| ) | |
| .cornerRadius(4) | |
| } | |
| } | |
| } | |
| .padding() | |
| .glassEffect(.clear.interactive(), in: .rect(cornerRadius: 0)) | |
| HStack { | |
| Image(systemName: "shield.checkered") | |
| .font(.system(size: 10)) | |
| .foregroundColor(.gray) | |
| Text("reTicTacCHA") | |
| .font(.system(size: 10)) | |
| .foregroundColor(.gray) | |
| Spacer() | |
| Text("Privacy - Terms") | |
| .font(.system(size: 9)) | |
| .foregroundColor(.blue) | |
| Button(action: { | |
| withAnimation { | |
| game.reset() | |
| } | |
| }) { | |
| Image(systemName: "arrow.clockwise") | |
| .font(.system(size: 9)) | |
| .foregroundColor(.gray) | |
| } | |
| } | |
| .padding(.horizontal) | |
| .padding(.vertical, 8) | |
| .glassEffect(.clear.interactive(), in: .rect(cornerRadius: 0)) | |
| } | |
| .frame(width: 360) | |
| .clipShape(RoundedRectangle(cornerRadius: 16)) | |
| .glassEffect(.clear.interactive(), in: .rect(cornerRadius: 16)) | |
| .shadow(color: Color.black.opacity(0.25), radius: 20, x: 0, y: 10) | |
| Spacer() | |
| } | |
| } | |
| } | |
| /// Checkbox component with multiple states: empty, loading, success (checkmark), failure (X) | |
| struct CheckboxView: View { | |
| @Binding var isChecked: Bool | |
| var showX: Bool = false // Show red X for failure | |
| var showCheck: Bool = false // Show green checkmark for success | |
| var showProgress: Bool = false // Show loading spinner | |
| var body: some View { | |
| ZStack { | |
| RoundedRectangle(cornerRadius: 3) | |
| .stroke(Color.gray.opacity(0.5), lineWidth: 2) | |
| .frame(width: 28, height: 28) | |
| .background( | |
| RoundedRectangle(cornerRadius: 3) | |
| .fill(Color.white) | |
| ) | |
| if showProgress { | |
| ProgressView() | |
| .progressViewStyle(CircularProgressViewStyle(tint: .blue)) | |
| .scaleEffect(0.7) | |
| } else if showX { | |
| Image(systemName: "xmark") | |
| .foregroundColor(.red) | |
| .font(.system(size: 18, weight: .bold)) | |
| } else if showCheck { | |
| Image(systemName: "checkmark") | |
| .foregroundColor(.green) | |
| .font(.system(size: 18, weight: .bold)) | |
| } else if isChecked { | |
| Image(systemName: "checkmark") | |
| .foregroundColor(.green) | |
| .font(.system(size: 18, weight: .bold)) | |
| } | |
| } | |
| } | |
| } | |
| /// Individual square in the 3x3 Tic-Tac-Toe grid | |
| struct GameSquare: View { | |
| let symbol: String? // "X", "O", or nil for empty | |
| let isWinning: Bool // Currently unused - could highlight winning squares | |
| var body: some View { | |
| ZStack { | |
| Color.clear | |
| if let symbol = symbol { | |
| if symbol == "X" { | |
| Image(systemName: "xmark") | |
| .font(.system(size: 50, weight: .bold)) | |
| .foregroundColor(.blue) | |
| } else { | |
| Image(systemName: "circle") | |
| .font(.system(size: 50, weight: .medium)) | |
| .foregroundColor(.red) | |
| } | |
| } else { | |
| Rectangle() | |
| .stroke(Color.white.opacity(0.3), lineWidth: 1) | |
| } | |
| } | |
| .background(Color.white.opacity(0.01)) | |
| .contentShape(Rectangle()) | |
| } | |
| } | |
| #Preview { | |
| ContentView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment