Skip to main content

Smart Contract Development: Tic Tac Toe Game

Mari build smart contract Tic Tac Toe lengkap di Stacks dengan betting mechanism! Tutorial ini fokus pada development backend logic dan contract testing.

Overview Smart Contract

Game Features yang Akan Dibangun

Smart Contract Features:
✨ Complete Game Logic: Tic-tac-toe rules implementation
💰 Betting System: Players bet STX untuk bermain
👥 Multiplayer Support: Two players per game
✨ Prize Distribution: Winner gets both bets
🔗 Anti-Cheat: Prevents invalid moves dan cheating
💰 Game State Management: Multiple concurrent games
✨ Turn Validation: Enforces correct player turns
🎯 Win Detection: Automatic winner determination

Learning Objectives

Setelah tutorial ini, Anda akan memahami:

  • Complex state management dalam Clarity
  • Game logic implementation yang secure
  • Financial transactions dalam smart contracts
  • Error handling dan input validation
  • Comprehensive testing strategies
  • Contract deployment dan verification

Step 1: Project Setup dan Architecture

Create New Clarinet Project

# Navigate ke directory yang diinginkan
cd ~/stacks-projects

# Create new Clarinet project
clarinet new tic-tac-toe-game
cd tic-tac-toe-game

# Verify project structure
ls -la

Expected project structure:

tic-tac-toe-game/
Clarinet.toml # Project configuration
contracts/ # Smart contracts directory
tests/ # Test files
settings/ # Network settings
deployments/ # Deployment configurations

Configure Project Settings

Update Clarinet.toml:

[project]
name = "tic-tac-toe-game"
description = "A betting-based Tic Tac Toe game on Stacks blockchain"
authors = ["Your Name <your.email@example.com>"]
telemetry = true
cache_dir = "./.clarinet"

[contracts.tic-tac-toe]
path = "contracts/tic-tac-toe.clar"
clarity_version = 2
epoch = 2.4

[repl]
costs_version = 2
parser_version = 2

Step 2: Game Architecture Design

Game State Structure

;; Game state disimpan dalam map dengan struktur:
;; Key: uint (game-id)
;; Value: game-data tuple
{
player-one: principal, ;; Address player 1 (plays X)
player-two: (optional principal), ;; Address player 2 (plays O)
is-player-one-turn: bool, ;; Current turn indicator
bet-amount: uint, ;; Bet amount dalam microSTX
board: (list 9 uint), ;; Board state representation
winner: (optional principal) ;; Game winner (if finished)
}

Board Representation Logic

Visual Board Layout:
0 | 1 | 2
-----------
3 | 4 | 5
-----------
6 | 7 | 8

Cell Values:
- 0 = Empty cell
- 1 = X (Player One)
- 2 = O (Player Two)

Game Flow Design

Complete Game Flow:
1. Player 1 creates game + places bet + makes first move (X)
2. Player 2 joins game + places matching bet + makes move (O)
3. Players alternate turns making moves
4. Game validation on each move (valid position, correct turn)
5. Win condition checked after each move
6. Game ends: winner gets both bets OR draw returns bets

Step 3: Smart Contract Implementation

Create contracts/tic-tac-toe.clar:

;; Tic Tac Toe Game Contract with Betting
;; Secure multiplayer game dengan financial incentives

;; === CONSTANTS ===
(define-constant THIS_CONTRACT (as-contract tx-sender))

;; Error codes
(define-constant ERR_MIN_BET_AMOUNT (err u100))
(define-constant ERR_INVALID_MOVE (err u101))
(define-constant ERR_GAME_NOT_FOUND (err u102))
(define-constant ERR_GAME_CANNOT_BE_JOINED (err u103))
(define-constant ERR_NOT_YOUR_TURN (err u104))
(define-constant ERR_GAME_ALREADY_FINISHED (err u105))
(define-constant ERR_INSUFFICIENT_FUNDS (err u106))
(define-constant ERR_CANNOT_JOIN_OWN_GAME (err u107))

;; === DATA STORAGE ===

;; Game ID counter untuk unique game identification
(define-data-var latest-game-id uint u0)

;; Main games storage map
(define-map games
uint ;; Game ID (key)
{ ;; Game data (value)
player-one: principal,
player-two: (optional principal),
is-player-one-turn: bool,
bet-amount: uint,
board: (list 9 uint),
winner: (optional principal)
}
)

;; === PUBLIC FUNCTIONS ===

;; Create new game dengan initial move
(define-public (create-game (bet-amount uint) (move-index uint) (move uint))
(let (
;; Get next available game ID
(game-id (var-get latest-game-id))
;; Initialize empty board
(starting-board (list u0 u0 u0 u0 u0 u0 u0 u0 u0))
;; Apply creator's first move
(game-board (unwrap! (replace-at? starting-board move-index move) ERR_INVALID_MOVE))
;; Create initial game data
(game-data {
player-one: contract-caller,
player-two: none,
is-player-one-turn: false, ;; Next turn belongs to player two
bet-amount: bet-amount,
board: game-board,
winner: none
})
)
;; Input validation
(asserts! (> bet-amount u0) ERR_MIN_BET_AMOUNT)
(asserts! (is-eq move u1) ERR_INVALID_MOVE) ;; Creator must play X
(asserts! (validate-move starting-board move-index move) ERR_INVALID_MOVE)

;; Check caller has sufficient balance
(asserts! (>= (stx-get-balance contract-caller) bet-amount) ERR_INSUFFICIENT_FUNDS)

;; Transfer bet amount to contract
(try! (stx-transfer? bet-amount contract-caller THIS_CONTRACT))

;; Store game state
(map-set games game-id game-data)

;; Increment game counter untuk next game
(var-set latest-game-id (+ game-id u1))

;; Emit event for tracking
(print {
action: "create-game",
game-id: game-id,
player-one: contract-caller,
bet-amount: bet-amount,
first-move: move-index
})

;; Return new game ID
(ok game-id)
)
)

;; Join existing game as second player
(define-public (join-game (game-id uint) (move-index uint) (move uint))
(let (
;; Load existing game data
(original-game-data (unwrap! (map-get? games game-id) ERR_GAME_NOT_FOUND))
;; Get current board state
(original-board (get board original-game-data))
;; Apply second player's move
(game-board (unwrap! (replace-at? original-board move-index move) ERR_INVALID_MOVE))
;; Update game data dengan second player
(game-data (merge original-game-data {
board: game-board,
player-two: (some contract-caller),
is-player-one-turn: true ;; Next turn back to player one
}))
)
;; Validation checks
(asserts! (is-none (get player-two original-game-data)) ERR_GAME_CANNOT_BE_JOINED)
(asserts! (is-none (get winner original-game-data)) ERR_GAME_ALREADY_FINISHED)
(asserts! (not (is-eq contract-caller (get player-one original-game-data))) ERR_CANNOT_JOIN_OWN_GAME)

;; Move validation
(asserts! (is-eq move u2) ERR_INVALID_MOVE) ;; Second player must play O
(asserts! (validate-move original-board move-index move) ERR_INVALID_MOVE)

;; Check sufficient funds for matching bet
(asserts! (>= (stx-get-balance contract-caller) (get bet-amount original-game-data)) ERR_INSUFFICIENT_FUNDS)

;; Transfer matching bet to contract
(try! (stx-transfer? (get bet-amount original-game-data) contract-caller THIS_CONTRACT))

;; Update game state
(map-set games game-id game-data)

;; Emit join event
(print {
action: "join-game",
game-id: game-id,
player-two: contract-caller,
move: move-index
})

(ok game-id)
)
)

;; Make move dalam ongoing game
(define-public (play (game-id uint) (move-index uint) (move uint))
(let (
;; Load current game state
(original-game-data (unwrap! (map-get? games game-id) ERR_GAME_NOT_FOUND))
;; Get board state
(original-board (get board original-game-data))
;; Determine current player dan move type
(is-player-one-turn (get is-player-one-turn original-game-data))
(current-player (if is-player-one-turn
(get player-one original-game-data)
(unwrap! (get player-two original-game-data) ERR_GAME_NOT_FOUND)))
(expected-move (if is-player-one-turn u1 u2))
;; Apply the move
(game-board (unwrap! (replace-at? original-board move-index move) ERR_INVALID_MOVE))
;; Check for winning condition
(is-winner (has-won game-board))
;; Check for draw condition
(is-draw (and (not is-winner) (is-board-full-list game-board)))
;; Create updated game state
(game-data (merge original-game-data {
board: game-board,
is-player-one-turn: (not is-player-one-turn),
winner: (if is-winner (some current-player) none)
}))
)
;; Turn validation
(asserts! (is-eq contract-caller current-player) ERR_NOT_YOUR_TURN)
(asserts! (is-none (get winner original-game-data)) ERR_GAME_ALREADY_FINISHED)

;; Move validation
(asserts! (is-eq move expected-move) ERR_INVALID_MOVE)
(asserts! (validate-move original-board move-index move) ERR_INVALID_MOVE)

;; Handle game end scenarios
(if is-winner
;; Winner takes all - transfer double bet amount
(try! (as-contract (stx-transfer?
(* u2 (get bet-amount game-data))
tx-sender
current-player)))
;; Check for draw
(if is-draw
;; Draw - return bets to both players
(begin
(try! (as-contract (stx-transfer?
(get bet-amount game-data)
tx-sender
(get player-one game-data))))
(try! (as-contract (stx-transfer?
(get bet-amount game-data)
tx-sender
(unwrap! (get player-two game-data) ERR_GAME_NOT_FOUND))))
)
false
)
)

;; Update game state
(map-set games game-id game-data)

;; Emit move event
(print {
action: "play",
game-id: game-id,
player: current-player,
move: move-index,
winner: (get winner game-data),
is-draw: is-draw
})

(ok game-id)
)
)

;; === READ-ONLY FUNCTIONS ===

;; Get complete game data
(define-read-only (get-game (game-id uint))
(map-get? games game-id)
)

;; Get latest game ID (for finding most recent games)
(define-read-only (get-latest-game-id)
(var-get latest-game-id)
)

;; Get just the board state
(define-read-only (get-game-board (game-id uint))
(match (map-get? games game-id)
game-data (ok (get board game-data))
(err ERR_GAME_NOT_FOUND)
)
)

;; Get current turn information
(define-read-only (get-current-turn (game-id uint))
(match (map-get? games game-id)
game-data (ok {
is-player-one-turn: (get is-player-one-turn game-data),
current-player: (if (get is-player-one-turn game-data)
(some (get player-one game-data))
(get player-two game-data))
})
(err ERR_GAME_NOT_FOUND)
)
)

;; Get comprehensive game status
(define-read-only (get-game-status (game-id uint))
(match (map-get? games game-id)
game-data (let (
(has-winner (is-some (get winner game-data)))
(has-player-two (is-some (get player-two game-data)))
(board-full (is-board-full-list (get board game-data)))
)
(ok {
status: (if has-winner
"finished"
(if (not has-player-two)
"waiting-for-player"
(if (and (not has-winner) board-full)
"draw"
"in-progress"))),
winner: (get winner game-data),
total-prize: (* u2 (get bet-amount game-data)),
is-draw: (and (not has-winner) board-full)
}))
(err ERR_GAME_NOT_FOUND)
)
)

;; Check if board is full (for draw detection)
(define-read-only (is-board-full (game-id uint))
(match (map-get? games game-id)
game-data (ok (is-board-full-list (get board game-data)))
(err ERR_GAME_NOT_FOUND)
)
)

;; === PRIVATE HELPER FUNCTIONS ===

;; Validate move adalah legal
(define-private (validate-move (board (list 9 uint)) (move-index uint) (move uint))
(let (
;; Check index dalam range 0-8
(index-in-range (and (>= move-index u0) (< move-index u9)))
;; Check move value valid (1 atau 2)
(valid-move (or (is-eq move u1) (is-eq move u2)))
;; Check target cell adalah empty
(empty-spot (is-eq (unwrap! (element-at? board move-index) false) u0))
)
;; All conditions must be true
(and index-in-range valid-move empty-spot)
)
)

;; Check apakah ada winning combination
(define-private (has-won (board (list 9 uint)))
(or
;; Check all possible winning lines
;; Horizontal rows
(is-line board u0 u1 u2)
(is-line board u3 u4 u5)
(is-line board u6 u7 u8)
;; Vertical columns
(is-line board u0 u3 u6)
(is-line board u1 u4 u7)
(is-line board u2 u5 u8)
;; Diagonal lines
(is-line board u0 u4 u8)
(is-line board u2 u4 u6)
)
)

;; Check apakah three positions membentuk winning line
(define-private (is-line (board (list 9 uint)) (a uint) (b uint) (c uint))
(let (
;; Get values at the three positions
(a-val (unwrap! (element-at? board a) false))
(b-val (unwrap! (element-at? board b) false))
(c-val (unwrap! (element-at? board c) false))
)
;; Check all three sama dan not empty
(and
(is-eq a-val b-val)
(is-eq a-val c-val)
(not (is-eq a-val u0))
)
)
)

;; Helper function untuk check board penuh
(define-private (is-board-full-list (board (list 9 uint)))
;; Board penuh jika tidak ada empty cells (0)
(is-eq (len (filter is-empty-cell board)) u0)
)

;; Helper untuk identify empty cells
(define-private (is-empty-cell (cell uint))
(is-eq cell u0)
)

Step 4: Testing Strategy

Setup Modern Testing Environment

# Install testing dependencies
npm install @hirosystems/clarinet-sdk
npm install -D vitest @stacks/transactions vitest-environment-clarinet

Configure Vitest

Create vitest.config.js:

import { defineConfig } from "vite";
import { vitestSetupFilePath, getClarinetVitestsArgv } from "@hirosystems/clarinet-sdk/vitest";

export default defineConfig({
test: {
environment: "clarinet",
pool: "forks",
poolOptions: {
threads: { singleThread: true },
forks: { singleFork: true },
},
setupFiles: [vitestSetupFilePath],
environmentOptions: {
clarinet: {
...getClarinetVitestsArgv(),
},
},
},
});

Main Test Suite

Create tests/tic-tac-toe.test.ts:

import { describe, it, expect } from "vitest";
import { Cl } from "@stacks/transactions";

describe("Tic Tac Toe Game Tests", () => {

it("Can create new game successfully", () => {
const accounts = simnet.getAccounts();
const player1 = accounts.get('wallet_1')!;
const betAmount = 1000000;

const result = simnet.callPublicFn(
'tic-tac-toe',
'create-game',
[Cl.uint(betAmount), Cl.uint(0), Cl.uint(1)],
player1
);

expect(result.result).toEqual(Cl.ok(Cl.uint(0)));
});

it("Player two can join game successfully", () => {
const accounts = simnet.getAccounts();
const player1 = accounts.get('wallet_1')!;
const player2 = accounts.get('wallet_2')!;
const betAmount = 1000000;

// Create game first
simnet.callPublicFn(
'tic-tac-toe',
'create-game',
[Cl.uint(betAmount), Cl.uint(0), Cl.uint(1)],
player1
);

// Join game
const result = simnet.callPublicFn(
'tic-tac-toe',
'join-game',
[Cl.uint(0), Cl.uint(1), Cl.uint(2)],
player2
);

expect(result.result).toEqual(Cl.ok(Cl.uint(0)));
});

it("Winner receives complete prize pool", () => {
const accounts = simnet.getAccounts();
const player1 = accounts.get('wallet_1')!;
const player2 = accounts.get('wallet_2')!;
const betAmount = 1000000;
const gameId = 0;

// Create and join game
simnet.callPublicFn('tic-tac-toe', 'create-game', [Cl.uint(betAmount), Cl.uint(0), Cl.uint(1)], player1);
simnet.callPublicFn('tic-tac-toe', 'join-game', [Cl.uint(gameId), Cl.uint(1), Cl.uint(2)], player2);

// Setup winning game scenario untuk Player 1 (diagonal win: 0, 4, 8)
simnet.callPublicFn('tic-tac-toe', 'play', [Cl.uint(gameId), Cl.uint(3), Cl.uint(2)], player2);
simnet.callPublicFn('tic-tac-toe', 'play', [Cl.uint(gameId), Cl.uint(4), Cl.uint(1)], player1);
simnet.callPublicFn('tic-tac-toe', 'play', [Cl.uint(gameId), Cl.uint(5), Cl.uint(2)], player2);

// Winning move
const winningMove = simnet.callPublicFn(
'tic-tac-toe',
'play',
[Cl.uint(gameId), Cl.uint(8), Cl.uint(1)],
player1
);
expect(winningMove.result).toEqual(Cl.ok(Cl.uint(0)));

// Verify game status
const statusResult = simnet.callReadOnlyFn(
'tic-tac-toe',
'get-game-status',
[Cl.uint(gameId)],
player1
);

expect(statusResult.result).toBeOk(
Cl.tuple({
status: Cl.stringAscii("finished"),
winner: Cl.some(Cl.standardPrincipal(player1)),
"total-prize": Cl.uint(2000000),
"is-draw": Cl.bool(false)
})
);
});

});

Step 5: Run Tests

# Run all tests
npm run test

# Run dengan watch mode
npm run test:watch

# Run dengan coverage
npm run test:coverage

Step 6: Deployment

Network Configuration

Update settings/Testnet.toml:

[network]
name = "testnet"
node_rpc_address = "https://stacks-node-api.testnet.stacks.co"
deployment_fee_rate = 10

[accounts.deployer]
mnemonic = "your testnet mnemonic phrase here"
balance = 100000000000000

Deploy to Testnet

# Generate deployment plan
clarinet deployment generate --testnet

# Deploy contract
clarinet deployment apply --testnet

Troubleshooting

Common Issues

  1. Syntax Errors
clarinet check --verbose
  1. Test Failures
npm run test:watch
  1. Deployment Problems
  • Check STX balance
  • Verify network connectivity
  • Validate mnemonic/private key

Next Steps

Setelah berhasil mengembangkan smart contract:

  1. Frontend Integration: Build UI untuk berinteraksi dengan contract
  2. Advanced Features: Tournament systems, rankings, multiple game modes
  3. Production: Security audits, gas optimization, monitoring

Congratulations! Anda telah berhasil membangun smart contract Tic Tac Toe yang lengkap dengan betting mechanism dan modern testing approach.


Selanjutnya: Mari kita build frontend interface untuk berinteraksi dengan smart contract ini.

📱 Lanjut ke UI Development 🎮