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
189 changes: 136 additions & 53 deletions contracts/tic-tac-toe.clar
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
(define-constant THIS_CONTRACT (as-contract tx-sender)) ;; The address of this contract itself
(define-constant ERR_MIN_BET_AMOUNT u100) ;; Error thrown when a player tries to create a game with a bet amount less than the minimum (0.0001 STX)
(define-constant ERR_INVALID_MOVE u101) ;; Error thrown when a move is invalid, i.e. not within range of the board or not an X or an O
(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

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

Expand All @@ -22,6 +15,93 @@
}
)


(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)
))


(define-constant THIS_CONTRACT (as-contract tx-sender)) ;; The address of this contract itself
(define-constant ERR_MIN_BET_AMOUNT u100) ;; Error thrown when a player tries to create a game with a bet amount less than the minimum (0.0001 STX)
(define-constant ERR_INVALID_MOVE u101) ;; Error thrown when a move is invalid, i.e. not within range of the board or not an X or an O
(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

;; Elo Rating System Summary:
;; This block keeps track of each player's skill rating. Every new player starts at 1200 points.
;; After each completed game the winner gains rating and the loser loses rating based on the expected outcome of the match.
;; The expected outcome uses bucketed rating differences to approximate the classic Elo probability curve while keeping the math integer-only.
;; Ratings are stored in the `ratings` map and exposed through `get-rating` so the frontend can display them.
(define-constant ELO_DEFAULT u1200)
(define-constant ELO_K u32)
(define-constant ELO_SCALE u1000)
(define-constant ELO_TABLE (list u500 u640 u760 u850 u910 u947 u969 u983 u990))

(define-map ratings principal uint)

(define-private (elo-expected-high (diff uint))
;; Split the rating difference into buckets of 100 points.
(let ((bucket (/ diff u100))
;; Clamp the bucket index to the size of the lookup table (8 is the max index).
(idx (if (> bucket u8) u8 bucket)))
;; Fetch the expected score for the higher rated player, defaulting to 0.5 if the bucket is missing.
(default-to u500 (element-at? ELO_TABLE idx))
)
)

(define-private (update-elo (p1 principal) (p2 principal) (winner principal))
;; Calculate the updated ratings for both players based on the match outcome.
(let (
;; Load current rating for player one (default 1200).
(r1 (default-to ELO_DEFAULT (map-get? ratings p1)))
;; Load current rating for player two (default 1200).
(r2 (default-to ELO_DEFAULT (map-get? ratings p2)))
;; Absolute rating difference to lookup expected score bucket.
(diff (if (>= r1 r2) (- r1 r2) (- r2 r1)))
;; Expected score for higher rated player scaled by 1000.
(ehigh (elo-expected-high diff))
;; Expected score for player one (flip if player two is higher rated).
(e1 (if (>= r1 r2) ehigh (- ELO_SCALE ehigh)))
;; Expected score for player two is the complement of player one.
(e2 (- ELO_SCALE e1))
;; Rating gain for player one if they win: K * (1 - expected).
(inc1 (/ (* ELO_K (- ELO_SCALE e1)) ELO_SCALE))
;; Rating loss for player one if they lose: K * expected.
(dec1 (/ (* ELO_K e1) ELO_SCALE))
;; Rating gain for player two if they win.
(inc2 (/ (* ELO_K (- ELO_SCALE e2)) ELO_SCALE))
;; Rating loss for player two if they lose.
(dec2 (/ (* ELO_K e2) ELO_SCALE))
;; Apply winner/loser adjustments and guard against underflow.
(new1 (if (is-eq p1 winner) (+ r1 inc1) (if (> r1 dec1) (- r1 dec1) u0)))
;; Same adjustment for player two.
(new2 (if (is-eq p2 winner) (+ r2 inc2) (if (> r2 dec2) (- r2 dec2) u0)))
)
(begin
;; Persist updated rating for player one.
(map-set ratings p1 new1)
;; Persist updated rating for player two.
(map-set ratings p2 new2)
;; Return true to signal success.
true
)
)
)


(define-public (create-game (bet-amount uint) (move-index uint) (move uint))
(let (
;; Get the Game ID to use for creation of this new game
Expand Down Expand Up @@ -61,6 +141,7 @@
(ok game-id)
))


(define-public (join-game (game-id uint) (move-index uint) (move uint))
(let (
;; Load the game data for the game being joined, throw an error if Game ID is invalid
Expand Down Expand Up @@ -97,6 +178,40 @@
(ok game-id)
))


;; 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)))
))


;; 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
)
)


(define-public (play (game-id uint) (move-index uint) (move uint))
(let (
;; Load the game data for the game being joined, throw an error if Game ID is invalid
Expand Down Expand Up @@ -133,6 +248,15 @@

;; 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)
;; update Elo ratings on win by passing both players and the winner to `update-elo`
(if is-now-winner (let (
;; Cache player one principal for the rating update.
(p1 (get player-one original-game-data))
;; Player two must exist at this point; unwrap or raise an error.
(p2 (unwrap! (get player-two original-game-data) (err ERR_GAME_NOT_FOUND)))
)
(update-elo p1 p2 player-turn)
) true)

;; Update the games map with the new game data
(map-set games game-id game-data)
Expand All @@ -143,6 +267,7 @@
(ok game-id)
))


(define-read-only (get-game (game-id uint))
(map-get? games game-id)
)
Expand All @@ -151,49 +276,7 @@
(var-get latest-game-id)
)

(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-read-only (get-rating (who principal))
;; Return the stored rating or the default value if the player has not played yet.
(default-to ELO_DEFAULT (map-get? ratings who))
)
10 changes: 10 additions & 0 deletions deployments/default.simnet-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,43 @@ genesis:
- name: deployer
address: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: faucet
address: STNHKEPYEPJ8ET55ZZ0M5A34J0R3N5FM2CMMMAZ6
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_1
address: ST1SJ3DTE5DN7X54YDH5D64R3BCB6A2AG2ZQ8YPD5
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_2
address: ST2CY5V39NHDPWSXMW9QDT3HC3GD6Q6XX4CFRK9AG
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_3
address: ST2JHG361ZXG51QTKY2NQCVBPPRRE2KZB1HR05NNC
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_4
address: ST2NEB84ASENDXKYGJPQW86YXQCEFEX2ZQPG87ND
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_5
address: ST2REHHS5J3CERCRBEPMGH7921Q6PYKAADT7JP2VB
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_6
address: ST3AM1A56AK2C1XAFJ4115ZSV26EB49BVQ10MGCS0
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_7
address: ST3PF13W7Z0RRM42A8VZRVFQ75SV1K26RXEP8YGKJ
balance: "100000000000000"
sbtc-balance: "1000000000"
- name: wallet_8
address: ST3NBRSFKX28FQ2ZJ1MAKX58HKHSDGNV5N7R21XCP
balance: "100000000000000"
sbtc-balance: "1000000000"
contracts:
- costs
- pox
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,4 @@ export default function CreateGame() {
</div>
</section>
);
}
}
2 changes: 1 addition & 1 deletion frontend/app/game/[gameId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export default async function GamePage({ params }: { params: Params }) {
<PlayGame game={game} />
</section>
);
}
}
13 changes: 9 additions & 4 deletions frontend/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";

:root {
--background: #ffffff;
--foreground: #171717;
}

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}

@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
Expand All @@ -15,7 +20,7 @@
}

body {
color: var(--foreground);
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
2 changes: 1 addition & 1 deletion frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ export default function RootLayout({
</body>
</html>
);
}
}
2 changes: 1 addition & 1 deletion frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ export default async function Home() {
<GamesList games={games} />
</section>
);
}
}
2 changes: 1 addition & 1 deletion frontend/components/game-board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,4 @@ export function GameBoard({
</div>
</div>
);
}
}
2 changes: 1 addition & 1 deletion frontend/components/games-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,4 @@ export function GamesList({ games }: { games: Game[] }) {
</div>
</div>
);
}
}
10 changes: 6 additions & 4 deletions frontend/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { abbreviateAddress } from "@/lib/stx-utils";
import Link from "next/link";

export function Navbar() {
const { userData, connectWallet, disconnectWallet } = useStacks();
const { userData, eloRating, connectWallet, disconnectWallet } = useStacks();

return (
<nav className="flex w-full items-center justify-between gap-4 p-4 h-16 border-b border-gray-500">
Expand All @@ -27,9 +27,11 @@ export function Navbar() {
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
className="rounded-lg bg-blue-500 px-3 py-2 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 flex items-center gap-2"
>
{abbreviateAddress(userData.profile.stxAddress.testnet)}
<span>{abbreviateAddress(userData.profile.stxAddress.testnet)}</span>
{/* Badge showing the player's current Elo rating fetched through `useStacks()` */}
<span className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white/90">Rating: {eloRating ?? "-"}</span>
</button>
<button
type="button"
Expand All @@ -51,4 +53,4 @@ export function Navbar() {
</div>
</nav>
);
}
}
Loading