Source Code

;; StacksTacToe - Decentralized PvP Tic-Tac-Toe Game
;; A winner-takes-all betting game on Stacks blockchain
;; Supports STX only

;; ============================================
;; Constants
;; ============================================

;; Contract owner
(define-constant CONTRACT_OWNER tx-sender)

;; Game parameters
(define-constant DEFAULT_MOVE_TIMEOUT u144) ;; ~24 hours in blocks (assuming 10 min blocks)
(define-constant MAX_TIMEOUT u1008) ;; ~7 days in blocks

;; Game status
(define-constant STATUS_ACTIVE u0)
(define-constant STATUS_ENDED u1)
(define-constant STATUS_FORFEITED u2)

;; Player marks
(define-constant MARK_EMPTY u0)
(define-constant MARK_X u1)
(define-constant MARK_O u2)

;; Basis points for fee calculation (10000 = 100%)
(define-constant BASIS_POINTS u10000)

;; ============================================
;; Error Codes
;; ============================================

(define-constant ERR_INVALID_ID (err u100))
(define-constant ERR_NOT_ACTIVE (err u101))
(define-constant ERR_INVALID_MOVE (err u102))
(define-constant ERR_NOT_TURN (err u103))
(define-constant ERR_INVALID_BET (err u104))
(define-constant ERR_GAME_STARTED (err u106))
(define-constant ERR_CELL_OCCUPIED (err u107))
(define-constant ERR_TIMEOUT (err u108))
(define-constant ERR_UNAUTHORIZED (err u109))
(define-constant ERR_SELF_PLAY (err u110))
(define-constant ERR_NOT_ADMIN (err u116))
(define-constant ERR_INVALID_TIMEOUT (err u117))
(define-constant ERR_INVALID_FEE (err u118))
(define-constant ERR_NO_REWARD (err u123))
(define-constant ERR_REWARD_CLAIMED (err u124))
(define-constant ERR_NOT_WINNER (err u125))
(define-constant ERR_INVALID_BOARD_SIZE (err u126))
(define-constant ERR_GAME_NOT_FINISHED (err u127))
(define-constant ERR_PAUSED (err u129))

;; ============================================
;; Data Variables
;; ============================================

;; Admin and configuration
(define-data-var contract-paused bool false)
(define-data-var move-timeout uint DEFAULT_MOVE_TIMEOUT)
(define-data-var platform-fee-percent uint u0) ;; Default: no fee
(define-data-var platform-fee-recipient principal CONTRACT_OWNER)

;; Counters
(define-data-var game-id-counter uint u0)

;; ============================================
;; Data Maps
;; ============================================

;; Admin management
(define-map admins principal bool)

;; Game data
(define-map games
    uint
    {
        player-one: principal,
        player-two: (optional principal),
        bet-amount: uint,
        board-size: uint,
        is-player-one-turn: bool,
        winner: (optional principal),
        last-move-block: uint,
        status: uint,
        move-count: uint
    }
)

;; Game boards (game-id => cell-index => mark)
(define-map game-boards { game-id: uint, cell-index: uint } uint)

;; Claimable rewards
(define-map claimable-rewards uint uint)
(define-map reward-claimed uint bool)

;; Player statistics for leaderboard
(define-map player-stats
    principal
    {
        wins: uint,
        total-earned: uint
    }
)

;; ============================================
;; Initialization
;; ============================================

;; Set contract owner as admin
(map-set admins CONTRACT_OWNER true)

;; ============================================
;; Private Helper Functions
;; ============================================

(define-private (is-admin (caller principal))
    (or 
        (is-eq caller CONTRACT_OWNER)
        (default-to false (map-get? admins caller))
    )
)

(define-private (update-player-stats (player principal) (earnings uint))
    (let
        (
            (stats (default-to { wins: u0, total-earned: u0 } (map-get? player-stats player)))
        )
        (map-set player-stats player {
            wins: (+ (get wins stats) u1),
            total-earned: (+ (get total-earned stats) earnings)
        })
    )
)

;; ============================================
;; Admin Functions
;; ============================================

(define-public (add-admin (admin principal))
    (begin
        (asserts! (is-eq tx-sender CONTRACT_OWNER) ERR_UNAUTHORIZED)
        (ok (map-set admins admin true))
    )
)

(define-public (remove-admin (admin principal))
    (begin
        (asserts! (is-eq tx-sender CONTRACT_OWNER) ERR_UNAUTHORIZED)
        (ok (map-set admins admin false))
    )
)

(define-public (set-move-timeout (new-timeout uint))
    (begin
        (asserts! (is-admin tx-sender) ERR_NOT_ADMIN)
        (asserts! (and (> new-timeout u0) (<= new-timeout MAX_TIMEOUT)) ERR_INVALID_TIMEOUT)
        (ok (var-set move-timeout new-timeout))
    )
)

(define-public (set-platform-fee (new-fee-percent uint))
    (begin
        (asserts! (is-admin tx-sender) ERR_NOT_ADMIN)
        (asserts! (<= new-fee-percent u1000) ERR_INVALID_FEE) ;; Max 10%
        (ok (var-set platform-fee-percent new-fee-percent))
    )
)

(define-public (set-platform-fee-recipient (recipient principal))
    (begin
        (asserts! (is-admin tx-sender) ERR_NOT_ADMIN)
        (ok (var-set platform-fee-recipient recipient))
    )
)

(define-public (pause-contract)
    (begin
        (asserts! (is-eq tx-sender CONTRACT_OWNER) ERR_UNAUTHORIZED)
        (ok (var-set contract-paused true))
    )
)

(define-public (unpause-contract)
    (begin
        (asserts! (is-eq tx-sender CONTRACT_OWNER) ERR_UNAUTHORIZED)
        (ok (var-set contract-paused false))
    )
)

;; ============================================
;; Game Functions
;; ============================================

(define-public (create-game (bet-amount uint) (move-index uint) (board-size uint))
    (let
        (
            (game-id (var-get game-id-counter))
            (max-cells (* board-size board-size))
        )
        (asserts! (not (var-get contract-paused)) ERR_PAUSED)
        (asserts! (> bet-amount u0) ERR_INVALID_BET)
        (asserts! (or (is-eq board-size u3) (is-eq board-size u5)) ERR_INVALID_BOARD_SIZE)
        (asserts! (< move-index max-cells) ERR_INVALID_MOVE)
        
        ;; Handle STX payment
        (try! (stx-transfer? bet-amount tx-sender (as-contract tx-sender)))
        
        ;; Create game
        (map-set games game-id {
            player-one: tx-sender,
            player-two: none,
            bet-amount: bet-amount,
            board-size: board-size,
            is-player-one-turn: false,
            winner: none,
            last-move-block: stacks-block-height,
            status: STATUS_ACTIVE,
            move-count: u1
        })
        
        ;; Set first move
        (map-set game-boards { game-id: game-id, cell-index: move-index } MARK_X)
        
        ;; Increment counter
        (var-set game-id-counter (+ game-id u1))
        
        (ok game-id)
    )
)

(define-public (join-game (game-id uint) (move-index uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (max-cells (* (get board-size game) (get board-size game)))
            (cell-value (default-to MARK_EMPTY (map-get? game-boards { game-id: game-id, cell-index: move-index })))
        )
        (asserts! (not (var-get contract-paused)) ERR_PAUSED)
        (asserts! (is-eq (get status game) STATUS_ACTIVE) ERR_NOT_ACTIVE)
        (asserts! (is-none (get player-two game)) ERR_GAME_STARTED)
        (asserts! (not (is-eq tx-sender (get player-one game))) ERR_SELF_PLAY)
        (asserts! (< move-index max-cells) ERR_INVALID_MOVE)
        (asserts! (is-eq cell-value MARK_EMPTY) ERR_CELL_OCCUPIED)
        
        ;; Handle STX payment
        (try! (stx-transfer? (get bet-amount game) tx-sender (as-contract tx-sender)))
        
        ;; Update game
        (map-set games game-id (merge game {
            player-two: (some tx-sender),
            is-player-one-turn: true,
            last-move-block: stacks-block-height,
            move-count: (+ (get move-count game) u1)
        }))
        
        ;; Set second move
        (map-set game-boards { game-id: game-id, cell-index: move-index } MARK_O)
        
        (ok true)
    )
)

(define-public (play (game-id uint) (move-index uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (max-cells (* (get board-size game) (get board-size game)))
            (cell-value (default-to MARK_EMPTY (map-get? game-boards { game-id: game-id, cell-index: move-index })))
            (player-two (unwrap! (get player-two game) ERR_NOT_ACTIVE))
        )
        (asserts! (not (var-get contract-paused)) ERR_PAUSED)
        (asserts! (is-eq (get status game) STATUS_ACTIVE) ERR_NOT_ACTIVE)
        (asserts! (< move-index max-cells) ERR_INVALID_MOVE)
        (asserts! (is-eq cell-value MARK_EMPTY) ERR_CELL_OCCUPIED)
        
        ;; Check turn
        (asserts! 
            (if (get is-player-one-turn game)
                (is-eq tx-sender (get player-one game))
                (is-eq tx-sender player-two)
            )
            ERR_NOT_TURN
        )
        
        ;; Set move
        (let
            (
                (mark (if (get is-player-one-turn game) MARK_X MARK_O))
                (current-player (if (get is-player-one-turn game) 
                    (get player-one game) 
                    player-two))
                (board-size (get board-size game))
            )
            (map-set game-boards { game-id: game-id, cell-index: move-index } mark)
            
            ;; Check for winner AFTER the move
            (if (has-winner game-id board-size)
                ;; Winner found - declare winner and end game
                (begin
                    (try! (declare-winner game-id current-player))
                    (ok true)
                )
                ;; No winner - check for draw
                (if (check-board-full game-id board-size)
                    ;; Board is full - it's a draw
                    (begin
                        (try! (handle-draw game-id))
                        (ok true)
                    )
                    ;; No winner, no draw - continue game
                    (begin
                        (map-set games game-id (merge game {
                            is-player-one-turn: (not (get is-player-one-turn game)),
                            last-move-block: stacks-block-height,
                            move-count: (+ (get move-count game) u1)
                        }))
                        (ok true)
                    )
                )
            )
        )
    )
)

;; ============================================
;; Win Detection Helper Functions
;; ============================================

(define-private (get-cell (game-id uint) (cell-index uint))
    (default-to MARK_EMPTY (map-get? game-boards { game-id: game-id, cell-index: cell-index }))
)

(define-private (check-three-in-row (game-id uint) (idx1 uint) (idx2 uint) (idx3 uint))
    (let
        (
            (cell1 (get-cell game-id idx1))
            (cell2 (get-cell game-id idx2))
            (cell3 (get-cell game-id idx3))
        )
        (and 
            (not (is-eq cell1 MARK_EMPTY))
            (is-eq cell1 cell2)
            (is-eq cell2 cell3)
        )
    )
)

(define-private (check-five-in-row (game-id uint) (idx1 uint) (idx2 uint) (idx3 uint) (idx4 uint) (idx5 uint))
    (let
        (
            (cell1 (get-cell game-id idx1))
            (cell2 (get-cell game-id idx2))
            (cell3 (get-cell game-id idx3))
            (cell4 (get-cell game-id idx4))
            (cell5 (get-cell game-id idx5))
        )
        (and 
            (not (is-eq cell1 MARK_EMPTY))
            (is-eq cell1 cell2)
            (is-eq cell1 cell3)
            (is-eq cell1 cell4)
            (is-eq cell1 cell5)
        )
    )
)

(define-private (check-winner-rows (game-id uint) (board-size uint))
    (if (is-eq board-size u3)
        ;; 3x3 board
        (or
            (check-three-in-row game-id u0 u1 u2)
            (or
                (check-three-in-row game-id u3 u4 u5)
                (check-three-in-row game-id u6 u7 u8)
            )
        )
        (if (is-eq board-size u5)
            ;; 5x5 board - check 5-in-a-row rows
            (or
                (check-five-in-row game-id u0 u1 u2 u3 u4)
                (or
                    (check-five-in-row game-id u5 u6 u7 u8 u9)
                    (or
                        (check-five-in-row game-id u10 u11 u12 u13 u14)
                        (or
                            (check-five-in-row game-id u15 u16 u17 u18 u19)
                            (check-five-in-row game-id u20 u21 u22 u23 u24)
                        )
                    )
                )
            )
            false
        )
    )
)

(define-private (check-winner-cols (game-id uint) (board-size uint))
    (if (is-eq board-size u3)
        ;; 3x3 board
        (or
            (check-three-in-row game-id u0 u3 u6)
            (or
                (check-three-in-row game-id u1 u4 u7)
                (check-three-in-row game-id u2 u5 u8)
            )
        )
        (if (is-eq board-size u5)
            ;; 5x5 board - check 5-in-a-row columns
            (or
                (check-five-in-row game-id u0 u5 u10 u15 u20)
                (or
                    (check-five-in-row game-id u1 u6 u11 u16 u21)
                    (or
                        (check-five-in-row game-id u2 u7 u12 u17 u22)
                        (or
                            (check-five-in-row game-id u3 u8 u13 u18 u23)
                            (check-five-in-row game-id u4 u9 u14 u19 u24)
                        )
                    )
                )
            )
            false
        )
    )
)

(define-private (check-winner-diagonals (game-id uint) (board-size uint))
    (if (is-eq board-size u3)
        ;; 3x3 board
        (or
            (check-three-in-row game-id u0 u4 u8)  ;; Top-left to bottom-right
            (check-three-in-row game-id u2 u4 u6)  ;; Top-right to bottom-left
        )
        (if (is-eq board-size u5)
            ;; 5x5 board - check 5-in-a-row diagonals
            (or
                (check-five-in-row game-id u0 u6 u12 u18 u24) ;; Top-left to bottom-right
                (check-five-in-row game-id u4 u8 u12 u16 u20) ;; Top-right to bottom-left
            )
            false
        )
    )
)

(define-private (check-board-full (game-id uint) (board-size uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) false))
            (max-cells (* board-size board-size))
        )
        (>= (get move-count game) max-cells)
    )
)

(define-private (has-winner (game-id uint) (board-size uint))
    (or
        (check-winner-rows game-id board-size)
        (or
            (check-winner-cols game-id board-size)
            (check-winner-diagonals game-id board-size)
        )
    )
)

;; ============================================
;; Reward and Payout Functions
;; ============================================

(define-private (transfer-payout (recipient principal) (amount uint))
    (as-contract (stx-transfer? amount tx-sender recipient))
)

(define-private (declare-winner (game-id uint) (winner principal))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (total-pot (* (get bet-amount game) u2))
            (fee-amount (/ (* total-pot (var-get platform-fee-percent)) BASIS_POINTS))
            (winner-payout (- total-pot fee-amount))
        )
        ;; Update game status
        (map-set games game-id (merge game {
            winner: (some winner),
            status: STATUS_ENDED
        }))
        
        ;; Store claimable reward
        (map-set claimable-rewards game-id winner-payout)
        
        ;; Update player stats
        (update-player-stats winner winner-payout)
        
        ;; Transfer platform fee if any
        (if (> fee-amount u0)
            (try! (transfer-payout (var-get platform-fee-recipient) fee-amount))
            true
        )
        
        (ok true)
    )
)

(define-private (handle-draw (game-id uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (refund-amount (get bet-amount game))
            (player-two (unwrap! (get player-two game) ERR_NOT_ACTIVE))
        )
        ;; Update game status
        (map-set games game-id (merge game {
            status: STATUS_ENDED
        }))
        
        ;; Refund both players
        (try! (transfer-payout (get player-one game) refund-amount))
        (try! (transfer-payout player-two refund-amount))
        
        (ok true)
    )
)

(define-public (claim-reward (game-id uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (winner (unwrap! (get winner game) ERR_NO_REWARD))
            (reward-amount (unwrap! (map-get? claimable-rewards game-id) ERR_NO_REWARD))
            (already-claimed (default-to false (map-get? reward-claimed game-id)))
        )
        (asserts! (or (is-eq (get status game) STATUS_ENDED) (is-eq (get status game) STATUS_FORFEITED)) ERR_GAME_NOT_FINISHED)
        (asserts! (not already-claimed) ERR_REWARD_CLAIMED)
        (asserts! (is-eq tx-sender winner) ERR_NOT_WINNER)
        
        ;; Mark as claimed
        (map-set reward-claimed game-id true)
        (map-delete claimable-rewards game-id)
        
        ;; Transfer reward
        (try! (transfer-payout winner reward-amount))
        
        (ok true)
    )
)

(define-public (forfeit-game (game-id uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (player-two (unwrap! (get player-two game) ERR_NOT_ACTIVE))
            (timeout-block (+ (get last-move-block game) (var-get move-timeout)))
        )
        (asserts! (is-eq (get status game) STATUS_ACTIVE) ERR_NOT_ACTIVE)
        (asserts! (>= stacks-block-height timeout-block) ERR_TIMEOUT)
        
        ;; Winner is the player who was NOT supposed to move (last player to move)
        (let
            (
                (winner (if (get is-player-one-turn game) player-two (get player-one game)))
                (total-pot (* (get bet-amount game) u2))
                (fee-amount (/ (* total-pot (var-get platform-fee-percent)) BASIS_POINTS))
                (winner-payout (- total-pot fee-amount))
            )
            ;; Update game status
            (map-set games game-id (merge game {
                winner: (some winner),
                status: STATUS_FORFEITED
            }))
            
            ;; Store claimable reward
            (map-set claimable-rewards game-id winner-payout)
            
            ;; Update player stats
            (update-player-stats winner winner-payout)
            
            ;; Transfer platform fee if any
            (if (> fee-amount u0)
                (try! (transfer-payout (var-get platform-fee-recipient) fee-amount))
                true
            )
            
            (ok true)
        )
    )
)

;; ============================================
;; Read-Only View Functions
;; ============================================

(define-read-only (get-game (game-id uint))
    (ok (map-get? games game-id))
)

(define-read-only (get-game-board-cell (game-id uint) (cell-index uint))
    (ok (get-cell game-id cell-index))
)

(define-read-only (get-latest-game-id)
    (ok (var-get game-id-counter))
)

(define-read-only (get-claimable-reward (game-id uint))
    (ok (map-get? claimable-rewards game-id))
)

(define-read-only (is-reward-claimed (game-id uint))
    (ok (default-to false (map-get? reward-claimed game-id)))
)

(define-read-only (get-move-timeout)
    (ok (var-get move-timeout))
)

(define-read-only (get-platform-fee)
    (ok (var-get platform-fee-percent))
)

(define-read-only (is-contract-paused)
    (ok (var-get contract-paused))
)

(define-read-only (get-time-remaining (game-id uint))
    (let
        (
            (game (unwrap! (map-get? games game-id) ERR_INVALID_ID))
            (timeout (var-get move-timeout))
            (last-move (get last-move-block game))
            (expiration (+ last-move timeout))
        )
        (if (>= stacks-block-height expiration)
            (ok u0)
            (ok (- expiration stacks-block-height))
        )
    )
)

(define-read-only (get-player-stats (player principal))
    (ok (default-to { wins: u0, total-earned: u0 } (map-get? player-stats player)))
)

Functions (35)

FunctionAccessArgs
is-adminprivatecaller: principal
update-player-statsprivateplayer: principal, earnings: uint
add-adminpublicadmin: principal
remove-adminpublicadmin: principal
set-move-timeoutpublicnew-timeout: uint
set-platform-feepublicnew-fee-percent: uint
set-platform-fee-recipientpublicrecipient: principal
pause-contractpublic
unpause-contractpublic
create-gamepublicbet-amount: uint, move-index: uint, board-size: uint
join-gamepublicgame-id: uint, move-index: uint
playpublicgame-id: uint, move-index: uint
get-cellprivategame-id: uint, cell-index: uint
check-three-in-rowprivategame-id: uint, idx1: uint, idx2: uint, idx3: uint
check-five-in-rowprivategame-id: uint, idx1: uint, idx2: uint, idx3: uint, idx4: uint, idx5: uint
check-winner-rowsprivategame-id: uint, board-size: uint
check-winner-colsprivategame-id: uint, board-size: uint
check-winner-diagonalsprivategame-id: uint, board-size: uint
check-board-fullprivategame-id: uint, board-size: uint
has-winnerprivategame-id: uint, board-size: uint
transfer-payoutprivaterecipient: principal, amount: uint
declare-winnerprivategame-id: uint, winner: principal
handle-drawprivategame-id: uint
claim-rewardpublicgame-id: uint
forfeit-gamepublicgame-id: uint
get-gameread-onlygame-id: uint
get-game-board-cellread-onlygame-id: uint, cell-index: uint
get-latest-game-idread-only
get-claimable-rewardread-onlygame-id: uint
is-reward-claimedread-onlygame-id: uint
get-move-timeoutread-only
get-platform-feeread-only
is-contract-pausedread-only
get-time-remainingread-onlygame-id: uint
get-player-statsread-onlyplayer: principal