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
78 changes: 74 additions & 4 deletions contracts/tic-tac-toe.clar
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,22 @@
bet-amount: uint,
board: (list 9 uint),

winner: (optional principal)
winner: (optional principal),

;; NEW: Track total number of moves made in this game
move-count: uint
}
)

;; NEW: Map to store individual move history
;; Key: {game-id, move-number}, Value: {player, move-index, move-value, block-height}
(define-map move-history
{ game-id: uint, move-number: uint }
{
player: principal,
move-index: uint,
move-value: uint,
block-height: uint
}
)

Expand All @@ -37,7 +52,8 @@
is-player-one-turn: false,
bet-amount: bet-amount,
board: game-board,
winner: none
winner: none,
move-count: u1 ;; NEW: Initialize with 1 move (the creation move)
})
)

Expand All @@ -55,6 +71,17 @@
;; Increment the Game ID counter
(var-set latest-game-id (+ game-id u1))

;; NEW: Record the first move in history
(map-set move-history
{ game-id: game-id, move-number: u0 }
{
player: contract-caller,
move-index: move-index,
move-value: move,
block-height: stacks-block-height
}
)

;; Log the creation of the new game
(print { action: "create-game", data: game-data})
;; Return the Game ID of the new game
Expand All @@ -67,14 +94,17 @@
(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))
;; Get current move count
(current-move-count (get move-count 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
(game-data (merge original-game-data {
board: game-board,
player-two: (some contract-caller),
is-player-one-turn: true
is-player-one-turn: true,
move-count: (+ current-move-count u1) ;; NEW: Increment move count
}))
)

Expand All @@ -91,6 +121,17 @@
;; Update the games map with the new game data
(map-set games game-id game-data)

;; NEW: Record this move in history
(map-set move-history
{ game-id: game-id, move-number: current-move-count }
{
player: contract-caller,
move-index: move-index,
move-value: move,
block-height: stacks-block-height
}
)

;; Log the joining of the game
(print { action: "join-game", data: game-data})
;; Return the Game ID of the game
Expand All @@ -103,6 +144,8 @@
(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))
;; Get current move count
(current-move-count (get move-count original-game-data))

;; Is it player one's turn?
(is-player-one-turn (get is-player-one-turn original-game-data))
Expand All @@ -120,7 +163,8 @@
(game-data (merge original-game-data {
board: game-board,
is-player-one-turn: (not is-player-one-turn),
winner: (if is-now-winner (some player-turn) none)
winner: (if is-now-winner (some player-turn) none),
move-count: (+ current-move-count u1) ;; NEW: Increment move count
}))
)

Expand All @@ -137,6 +181,17 @@
;; Update the games map with the new game data
(map-set games game-id game-data)

;; NEW: Record this move in history
(map-set move-history
{ game-id: game-id, move-number: current-move-count }
{
player: contract-caller,
move-index: move-index,
move-value: move,
block-height: stacks-block-height
}
)

;; Log the action of a move being made
(print {action: "play", data: game-data})
;; Return the Game ID of the game
Expand All @@ -151,6 +206,21 @@
(var-get latest-game-id)
)

;; NEW: Get a specific move from game history
;; Returns the move details or none if move doesn't exist
(define-read-only (get-move (game-id uint) (move-number uint))
(map-get? move-history { game-id: game-id, move-number: move-number })
)

;; NEW: Get the total number of moves made in a game
;; Returns the move count or 0 if game doesn't exist
(define-read-only (get-move-count (game-id uint))
(match (map-get? games game-id)
game-data (ok (get move-count game-data))
(ok 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
Expand Down
47 changes: 41 additions & 6 deletions frontend/app/game/[gameId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,58 @@
import { PlayGame } from "@/components/play-game";
import { getGame } from "@/lib/contract";
import { GameHistory } from "@/components/game-history";
import { MoveReplay } from "@/components/move-replay";
import { getGame, getGameHistory } from "@/lib/contract";

type Params = Promise<{ gameId: string }>;

export default async function GamePage({ params }: { params: Params }) {
const gameId = (await params).gameId;
const game = await getGame(parseInt(gameId));
if (!game) return <div>Game not found</div>;

if (!game) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h1 className="text-2xl font-bold text-red-500">Game not found</h1>
<p className="text-gray-500">The game ID {gameId} does not exist.</p>
</div>
);
}

// Fetch game history
const history = await getGameHistory(parseInt(gameId));

return (
<section className="flex flex-col items-center py-20">
<div className="text-center mb-20">
<section className="flex flex-col items-center py-20 gap-12">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold">Game #{gameId}</h1>
<span className="text-sm text-gray-500">
Play the game with your opponent
Play the game or review the move history
</span>
</div>

{/* Main game board and controls */}
<PlayGame game={game} />

{/* Move History Section */}
{history.length > 0 && (
<>
<hr className="w-full max-w-4xl border-gray-700" />

{/* Move Replay Animation */}
<div className="w-full flex justify-center">
<MoveReplay history={history} />
</div>

<hr className="w-full max-w-4xl border-gray-700" />

{/* Detailed Move History Table */}
<GameHistory
history={history}
playerOne={game["player-one"]}
playerTwo={game["player-two"]}
/>
</>
)}
</section>
);
}
}
5 changes: 3 additions & 2 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { GamesList } from "@/components/games-list";
import { getAllGames } from "@/lib/contract";

export const dynamic = "force-dynamic";
// Cache the page for 30 seconds to avoid repeated API calls
export const revalidate = 30;

export default async function Home() {
const games = await getAllGames();
Expand All @@ -18,4 +19,4 @@ export default async function Home() {
<GamesList games={games} />
</section>
);
}
}
93 changes: 93 additions & 0 deletions frontend/components/game-history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { MoveHistoryEntry } from "@/lib/contract";
import { abbreviateAddress } from "@/lib/stx-utils";
import Link from "next/link";

interface GameHistoryProps {
history: MoveHistoryEntry[];
playerOne: string;
playerTwo: string | null;
}

export function GameHistory({ history, playerOne, playerTwo }: GameHistoryProps) {
if (history.length === 0) {
return (
<div className="text-gray-500 text-center py-4">
No moves recorded yet
</div>
);
}

return (
<div className="w-full max-w-2xl">
<h2 className="text-2xl font-bold mb-4">Game History</h2>

<div className="bg-gray-800 rounded-lg overflow-hidden">
<div className="grid grid-cols-5 gap-2 p-4 bg-gray-900 font-semibold text-sm">
<div>Move #</div>
<div>Player</div>
<div>Symbol</div>
<div>Position</div>
<div>Block</div>
</div>

{history.map((move) => {
const isPlayerOne = move.player === playerOne;
const playerName = isPlayerOne ? "Player 1" : "Player 2";
const symbol = move.moveValue === 1 ? "X" : "O";
const symbolColor = move.moveValue === 1 ? "text-blue-400" : "text-red-400";

// Convert move index to board position (e.g., 0 = "Top-Left", 4 = "Center")
const positions = [
"Top-Left", "Top-Center", "Top-Right",
"Mid-Left", "Center", "Mid-Right",
"Bottom-Left", "Bottom-Center", "Bottom-Right"
];
const position = positions[move.moveIndex];

return (
<div
key={move.moveNumber}
className="grid grid-cols-5 gap-2 p-4 border-t border-gray-700 hover:bg-gray-750 transition-colors"
>
<div className="font-mono">{move.moveNumber + 1}</div>

<div>
<Link
href={`https://explorer.hiro.so/address/${move.player}?chain=testnet`}
target="_blank"
className="hover:underline text-blue-400"
>
{abbreviateAddress(move.player)}
</Link>
<div className="text-xs text-gray-500">{playerName}</div>
</div>

<div className={`font-bold text-xl ${symbolColor}`}>
{symbol}
</div>

<div className="text-sm">
{position}
<div className="text-xs text-gray-500">Cell {move.moveIndex}</div>
</div>

<Link
href={`https://explorer.hiro.so/block/${move.blockHeight}?chain=testnet`}
target="_blank"
className="text-sm hover:underline text-blue-400"
>
{move.blockHeight}
</Link>
</div>
);
})}
</div>

<div className="mt-4 text-sm text-gray-500">
Total moves: {history.length}
</div>
</div>
);
}
Loading