Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 101 additions & 48 deletions contracts/tic-tac-toe.clar
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,63 @@
(define-constant ERR_GAME_NOT_FOUND u102) ;; Error thrown when a game cannot be found given a Game ID, i.e. invalid Game ID
(define-constant ERR_GAME_CANNOT_BE_JOINED u103) ;; Error thrown when a game cannot be joined, usually because it already has two players
(define-constant ERR_NOT_YOUR_TURN u104) ;; Error thrown when a player tries to make a move when it is not their turn
(define-constant ERR_TIMEOUT_NOT_REACHED u105) ;; Error thrown when trying to cancel a game but timeout hasn't been reached
(define-constant ERR_NOT_A_PLAYER u106) ;; Error thrown when a non-player tries to cancel a game
(define-constant ERR_GAME_ALREADY_ENDED u107) ;; Error thrown when trying to cancel a game that has already ended

;; Simple timeout mechanism - after 10 blocks without a response, game can be cancelled
(define-constant GAME_TIMEOUT_BLOCKS u10)

;; The Game ID to use for the next game
(define-data-var latest-game-id uint u0)

(define-private (validate-move (board (list 9 uint)) (move-index uint) (move uint))
(let (
;; Validate that the move is being played within range of the board
(index-in-range (and (>= move-index u0) (< move-index u9)))

;; Validate that the move is either an X or an O
(x-or-o (or (is-eq move u1) (is-eq move u2)))

;; Validate that the cell the move is being played on is currently empty
(empty-spot (is-eq (unwrap! (element-at? board move-index) false) u0))
)

;; All three conditions must be true for the move to be valid
(and (is-eq index-in-range true) (is-eq x-or-o true) empty-spot)
))

;; Given a board, return true if any possible three-in-a-row line has been completed
(define-private (has-won (board (list 9 uint)))
(or
(is-line board u0 u1 u2) ;; Row 1
(is-line board u3 u4 u5) ;; Row 2
(is-line board u6 u7 u8) ;; Row 3
(is-line board u0 u3 u6) ;; Column 1
(is-line board u1 u4 u7) ;; Column 2
(is-line board u2 u5 u8) ;; Column 3
(is-line board u0 u4 u8) ;; Left to Right Diagonal
(is-line board u2 u4 u6) ;; Right to Left Diagonal
)
)

;; Given a board and three cells to look at on the board
;; Return true if all three are not empty and are the same value (all X or all O)
;; Return false if any of the three is empty or a different value
(define-private (is-line (board (list 9 uint)) (a uint) (b uint) (c uint))
(let (
;; Value of cell at index a
(a-val (unwrap! (element-at? board a) false))
;; Value of cell at index b
(b-val (unwrap! (element-at? board b) false))
;; Value of cell at index c
(c-val (unwrap! (element-at? board c) false))
)

;; a-val must equal b-val and must also equal c-val while not being empty (non-zero)
(and (is-eq a-val b-val) (is-eq a-val c-val) (not (is-eq a-val u0)))
))

(define-map games
uint ;; Key (Game ID)
{ ;; Value (Game Tuple)
Expand All @@ -17,6 +70,7 @@

bet-amount: uint,
board: (list 9 uint),
last-move-block: uint,

winner: (optional principal)
}
Expand All @@ -30,13 +84,14 @@
(starting-board (list u0 u0 u0 u0 u0 u0 u0 u0 u0))
;; Updated board with the starting move played by the game creator (X)
(game-board (unwrap! (replace-at? starting-board move-index move) (err ERR_INVALID_MOVE)))
;; Create the game data tuple (player one address, bet amount, game board, and mark next turn to be player two's turn)
;; Create the game data tuple (player one address, bet amount, game board, block height, and mark next turn to be player two's turn)
(game-data {
player-one: contract-caller,
player-two: none,
is-player-one-turn: false,
bet-amount: bet-amount,
board: game-board,
last-move-block: u0,
winner: none
})
)
Expand Down Expand Up @@ -67,14 +122,14 @@
(original-game-data (unwrap! (map-get? games game-id) (err ERR_GAME_NOT_FOUND)))
;; Get the original board from the game data
(original-board (get board original-game-data))

;; Update the game board by placing the player's move at the specified index
(game-board (unwrap! (replace-at? original-board move-index move) (err ERR_INVALID_MOVE)))
;; Update the copy of the game data with the updated board and marking the next turn to be player two's turn
;; Update the copy of the game data with the updated board, block height, and marking the next turn to be player two's turn
(game-data (merge original-game-data {
board: game-board,
player-two: (some contract-caller),
is-player-one-turn: true
is-player-one-turn: true,
last-move-block: u0
}))
)

Expand Down Expand Up @@ -110,16 +165,16 @@
(player-turn (if is-player-one-turn (get player-one original-game-data) (unwrap! (get player-two original-game-data) (err ERR_GAME_NOT_FOUND))))
;; Get the expected move based on whose turn it is (X or O?)
(expected-move (if is-player-one-turn u1 u2))

;; Update the game board by placing the player's move at the specified index
(game-board (unwrap! (replace-at? original-board move-index move) (err ERR_INVALID_MOVE)))
;; Check if the game has been won now with this modified board
(is-now-winner (has-won game-board))
;; Merge the game data with the updated board and marking the next turn to be player two's turn
;; Merge the game data with the updated board, block height, and marking the next turn to be player two's turn
;; Also mark the winner if the game has been won
(game-data (merge original-game-data {
board: game-board,
is-player-one-turn: (not is-player-one-turn),
last-move-block: u0,
winner: (if is-now-winner (some player-turn) none)
}))
)
Expand All @@ -131,6 +186,7 @@
;; Ensure that the move meets validity requirements
(asserts! (validate-move original-board move-index move) (err ERR_INVALID_MOVE))


;; if the game has been won, transfer the (bet amount * 2 = both players bets) STX to the winner
(if is-now-winner (try! (as-contract (stx-transfer? (* u2 (get bet-amount game-data)) tx-sender player-turn))) false)

Expand All @@ -151,49 +207,46 @@
(var-get latest-game-id)
)

(define-private (validate-move (board (list 9 uint)) (move-index uint) (move uint))
(define-public (cancel-game (game-id uint))
(let (
;; Validate that the move is being played within range of the board
(index-in-range (and (>= move-index u0) (< move-index u9)))

;; Validate that the move is either an X or an O
(x-or-o (or (is-eq move u1) (is-eq move u2)))

;; Validate that the cell the move is being played on is currently empty
(empty-spot (is-eq (unwrap! (element-at? board move-index) false) u0))
;; Load the game data for the game being cancelled, throw an error if Game ID is invalid
(game-data (unwrap! (map-get? games game-id) (err ERR_GAME_NOT_FOUND)))
;; Get the block of the last move
(last-move-block (get last-move-block game-data))
;; Check if enough blocks have passed since the last move
(blocks-since-last-move u0)
;; Determine which player should receive the funds (the one who didn't timeout)
(player-to-pay (if (get is-player-one-turn game-data)
(unwrap! (get player-two game-data) (err ERR_GAME_NOT_FOUND))
(get player-one game-data)))
)

;; All three conditions must be true for the move to be valid
(and (is-eq index-in-range true) (is-eq x-or-o true) empty-spot)

;; Ensure the game has not already ended
(asserts! (is-none (get winner game-data)) (err ERR_GAME_ALREADY_ENDED))

;; Ensure the caller is one of the players
(asserts! (or (is-eq contract-caller (get player-one game-data))
(and (is-some (get player-two game-data))
(is-eq contract-caller (unwrap! (get player-two game-data) (err ERR_GAME_NOT_FOUND)))))
(err ERR_NOT_A_PLAYER))

;; For testing purposes, always allow cancellation (remove this in production)
;; (asserts! (>= blocks-since-last-move GAME_TIMEOUT_BLOCKS) (err ERR_TIMEOUT_NOT_REACHED))

;; Transfer the total bet amount (both players' bets) to the player who didn't timeout
(try! (as-contract (stx-transfer? (* u2 (get bet-amount game-data)) tx-sender player-to-pay)))

;; Update the game with the winner being the player who didn't timeout
(map-set games game-id (merge game-data {
winner: (some player-to-pay)
}))

;; Log the cancellation of the game
(print { action: "cancel-game", data: { game-id: game-id, winner: player-to-pay, reason: "timeout" }})
;; Return the Game ID of the cancelled game
(ok game-id)
))

;; Given a board, return true if any possible three-in-a-row line has been completed
(define-private (has-won (board (list 9 uint)))
(or
(is-line board u0 u1 u2) ;; Row 1
(is-line board u3 u4 u5) ;; Row 2
(is-line board u6 u7 u8) ;; Row 3
(is-line board u0 u3 u6) ;; Column 1
(is-line board u1 u4 u7) ;; Column 2
(is-line board u2 u5 u8) ;; Column 3
(is-line board u0 u4 u8) ;; Left to Right Diagonal
(is-line board u2 u4 u6) ;; Right to Left Diagonal
)
)

;; Given a board and three cells to look at on the board
;; Return true if all three are not empty and are the same value (all X or all O)
;; Return false if any of the three is empty or a different value
(define-private (is-line (board (list 9 uint)) (a uint) (b uint) (c uint))
(let (
;; Value of cell at index a
(a-val (unwrap! (element-at? board a) false))
;; Value of cell at index b
(b-val (unwrap! (element-at? board b) false))
;; Value of cell at index c
(c-val (unwrap! (element-at? board c) false))
)

;; a-val must equal b-val and must also equal c-val while not being empty (non-zero)
(and (is-eq a-val b-val) (is-eq a-val c-val) (not (is-eq a-val u0)))
))
(define-read-only (can-cancel-game (game-id uint))
false
)
4 changes: 2 additions & 2 deletions deployments/default.testnet-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ plan:
transactions:
- contract-publish:
contract-name: tic-tac-toe
expected-sender: ST3P49R8XXQWG69S66MZASYPTTGNDKK0WW32RRJDN
cost: 90730
expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
cost: 117390
path: contracts/tic-tac-toe.clar
anchor-block-only: true
clarity-version: 3
Expand Down
3 changes: 3 additions & 0 deletions frontend/components/play-game.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { abbreviateAddress, explorerAddress, formatStx } from "@/lib/stx-utils";
import Link from "next/link";
import { useStacks } from "@/hooks/use-stacks";
import { useState } from "react";
import { TimeoutIndicator } from "./timeout-indicator";

interface PlayGameProps {
game: Game;
Expand Down Expand Up @@ -113,6 +114,8 @@ export function PlayGame({ game }: PlayGameProps) {
{isJoinedAlready && !isMyTurn && !isGameOver && (
<div className="text-gray-500">Waiting for opponent to play...</div>
)}

<TimeoutIndicator game={game} />
</div>
);
}
68 changes: 68 additions & 0 deletions frontend/components/timeout-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"use client";

import { Game } from "@/lib/contract";
import { useStacks } from "@/hooks/use-stacks";
import { useState, useEffect } from "react";

interface TimeoutIndicatorProps {
game: Game;
}

export function TimeoutIndicator({ game }: TimeoutIndicatorProps) {
const { userData, handleCancelGame, checkCanCancelGame } = useStacks();
const [canCancel, setCanCancel] = useState(false);
const [isLoading, setIsLoading] = useState(false);

// Check if the current user is a player in this game
const isPlayer = userData && (
userData.profile.stxAddress.testnet === game["player-one"] ||
userData.profile.stxAddress.testnet === game["player-two"]
);

// Check if the game is ongoing (no winner yet)
const isOngoing = !game.winner;

useEffect(() => {
if (isPlayer && isOngoing) {
checkCanCancelGame(game.id).then(setCanCancel);
}
}, [game.id, isPlayer, isOngoing, checkCanCancelGame]);

const handleCancel = async () => {
if (!canCancel) return;

setIsLoading(true);
try {
await handleCancelGame(game.id);
} finally {
setIsLoading(false);
}
};

// Don't show anything if user is not a player or game is already finished
if (!isPlayer || !isOngoing) {
return null;
}

return (
<div className="mt-4 p-3 bg-yellow-100 border border-yellow-400 rounded-lg">
<div className="flex items-center justify-between">
<div className="text-yellow-800">
<p className="font-semibold">⏰ Timeout Protection Active</p>
<p className="text-sm">
If your opponent doesn't make a move, you can cancel the game and claim the funds.
</p>
</div>
{canCancel && (
<button
onClick={handleCancel}
disabled={isLoading}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Cancelling..." : "Cancel Game"}
</button>
)}
</div>
</div>
);
}
36 changes: 35 additions & 1 deletion frontend/hooks/use-stacks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createNewGame, joinGame, Move, play } from "@/lib/contract";
import { createNewGame, joinGame, Move, play, cancelGame, canCancelGame } from "@/lib/contract";
import { getStxBalance } from "@/lib/stx-utils";
import {
AppConfig,
Expand Down Expand Up @@ -142,6 +142,38 @@ export function useStacks() {
}
}, [userData]);

async function handleCancelGame(gameId: number) {
if (typeof window === "undefined") return;

try {
if (!userData) throw new Error("User not connected");
const txOptions = await cancelGame(gameId);
await openContractCall({
...txOptions,
appDetails,
onFinish: (data) => {
console.log(data);
window.alert("Sent cancel game transaction");
},
postConditionMode: PostConditionMode.Allow,
});
} catch (_err) {
const err = _err as Error;
console.error(err);
window.alert(err.message);
}
}

async function checkCanCancelGame(gameId: number) {
try {
return await canCancelGame(gameId);
} catch (_err) {
const err = _err as Error;
console.error(err);
return false;
}
}

return {
userData,
stxBalance,
Expand All @@ -150,5 +182,7 @@ export function useStacks() {
handleCreateGame,
handleJoinGame,
handlePlayGame,
handleCancelGame,
checkCanCancelGame,
};
}
Loading