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
288 changes: 273 additions & 15 deletions contracts/tic-tac-toe.clar

Large diffs are not rendered by default.

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
6 changes: 3 additions & 3 deletions deployments/default.testnet-plan.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ plan:
- id: 0
transactions:
- contract-publish:
contract-name: tic-tac-toe
expected-sender: ST3P49R8XXQWG69S66MZASYPTTGNDKK0WW32RRJDN
cost: 90730
contract-name: tic-tac-toe-v2
expected-sender: STVP0WP3XXW9XZ2QEBBCA3XGC8ZE4XNFQHFEG8P1
cost: 188820
path: contracts/tic-tac-toe.clar
anchor-block-only: true
clarity-version: 3
Expand Down
5 changes: 5 additions & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.



**/settings/Mainnet.toml
**/settings/Testnet.toml

# dependencies
/node_modules
/.pnp
Expand Down
24 changes: 24 additions & 0 deletions frontend/app/leaderboard-test/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { LeaderboardTabs } from "@/components/leaderboard-tabs";
import { mockLeaderboardData } from "@/lib/mock-data";

export default function LeaderboardTestPage() {
return (
<section className="flex flex-col items-center py-20">
<div className="text-center mb-20">
<h1 className="text-4xl font-bold">Leaderboard Test 🏆</h1>
<span className="text-sm text-gray-500">
Testing with mock data - All features should work
</span>
</div>

<LeaderboardTabs
leaderboardByWins={mockLeaderboardData.byWins}
leaderboardByWinRate={mockLeaderboardData.byWinRate}
leaderboardByWinnings={mockLeaderboardData.byWinnings}
leaderboardByStreak={mockLeaderboardData.byStreak}
/>
</section>
);
}
90 changes: 90 additions & 0 deletions frontend/app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";

import { LeaderboardTabs } from "@/components/leaderboard-tabs";
import {
getLeaderboardByWins,
getLeaderboardByWinRate,
getLeaderboardByWinnings,
getLeaderboardByStreak,
type LeaderboardEntry
} from "@/lib/contract";
import { useEffect, useState } from "react";

export default function LeaderboardPage() {
const [leaderboardData, setLeaderboardData] = useState<{
byWins: LeaderboardEntry[];
byWinRate: LeaderboardEntry[];
byWinnings: LeaderboardEntry[];
byStreak: LeaderboardEntry[];
}>({
byWins: [],
byWinRate: [],
byWinnings: [],
byStreak: [],
});
const [loading, setLoading] = useState(true);

useEffect(() => {
async function fetchLeaderboardData() {
try {
// Fetch all leaderboard data in parallel
const [
leaderboardByWins,
leaderboardByWinRate,
leaderboardByWinnings,
leaderboardByStreak
] = await Promise.all([
getLeaderboardByWins(),
getLeaderboardByWinRate(),
getLeaderboardByWinnings(),
getLeaderboardByStreak()
]);

setLeaderboardData({
byWins: leaderboardByWins,
byWinRate: leaderboardByWinRate,
byWinnings: leaderboardByWinnings,
byStreak: leaderboardByStreak,
});
} catch (error) {
console.error("Error fetching leaderboard data:", error);
} finally {
setLoading(false);
}
}

fetchLeaderboardData();
}, []);

if (loading) {
return (
<section className="flex flex-col items-center py-20">
<div className="text-center mb-20">
<h1 className="text-4xl font-bold">Leaderboard 🏆</h1>
<span className="text-sm text-gray-500">
Loading leaderboard data...
</span>
</div>
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
</section>
);
}

return (
<section className="flex flex-col items-center py-20">
<div className="text-center mb-20">
<h1 className="text-4xl font-bold">Leaderboard 🏆</h1>
<span className="text-sm text-gray-500">
Top players in the Tic Tac Toe arena
</span>
</div>

<LeaderboardTabs
leaderboardByWins={leaderboardData.byWins}
leaderboardByWinRate={leaderboardData.byWinRate}
leaderboardByWinnings={leaderboardData.byWinnings}
leaderboardByStreak={leaderboardData.byStreak}
/>
</section>
);
}
170 changes: 170 additions & 0 deletions frontend/components/leaderboard-table.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"use client";

import { LeaderboardEntry } from "@/lib/contract";
import { abbreviateAddress, formatStx } from "@/lib/stx-utils";

type LeaderboardTableProps = {
entries: LeaderboardEntry[];
category: "wins" | "winRate" | "winnings" | "streak";
currentUser?: string;
};

export function LeaderboardTable({ entries, category, currentUser }: LeaderboardTableProps) {
const getDisplayValue = (entry: LeaderboardEntry) => {
switch (category) {
case "wins":
return entry.stats["games-won"];
case "winRate":
return `${entry.winRate.toFixed(1)}%`;
case "winnings":
return `${formatStx(entry.stats["total-winnings"])} STX`;
case "streak":
return entry.stats["current-win-streak"];
default:
return "-";
}
};

const getCategoryLabel = () => {
switch (category) {
case "wins":
return "Wins";
case "winRate":
return "Win Rate";
case "winnings":
return "Total Winnings";
case "streak":
return "Current Streak";
default:
return "Value";
}
};

const getRankIcon = (rank: number) => {
switch (rank) {
case 1:
return "🥇";
case 2:
return "🥈";
case 3:
return "🥉";
default:
return `#${rank}`;
}
};

if (entries.length === 0) {
return (
<div className="text-center py-12 border rounded-lg">
<p className="text-gray-500 mb-4">
No players found for this category yet.
</p>
<p className="text-sm text-gray-600">
{category === "winRate"
? "Players need at least 3 games to appear in win rate rankings."
: "Start playing to see the leaderboard!"
}
</p>
</div>
);
}

return (
<div className="bg-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700">
<tr>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300">Rank</th>
<th className="px-6 py-4 text-left text-sm font-medium text-gray-300">Player</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-300">{getCategoryLabel()}</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-300">Games</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-300">W/L/D</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-300">Win Rate</th>
<th className="px-6 py-4 text-center text-sm font-medium text-gray-300">Best Streak</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{entries.slice(0, 50).map((entry, index) => {
const rank = index + 1;
const isCurrentUser = currentUser === entry.player;

return (
<tr
key={entry.player}
className={`hover:bg-gray-700 transition-colors ${
isCurrentUser ? "bg-blue-900/30 border-l-4 border-blue-500" : ""
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<span className="text-lg font-bold">
{getRankIcon(rank)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div>
<div className={`text-sm font-medium ${
isCurrentUser ? "text-blue-400" : "text-white"
}`}>
{abbreviateAddress(entry.player)}
{isCurrentUser && (
<span className="ml-2 text-xs bg-blue-500 px-2 py-1 rounded">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="text-sm font-bold text-white">
{getDisplayValue(entry)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="text-sm text-gray-300">
{entry.stats["games-played"]}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="text-sm text-gray-300">
<span className="text-green-400">{entry.stats["games-won"]}</span>
/
<span className="text-red-400">{entry.stats["games-lost"]}</span>
/
<span className="text-yellow-400">{entry.stats["games-drawn"]}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="text-sm text-gray-300">
{entry.winRate.toFixed(1)}%
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
<div className="text-sm text-gray-300">
{entry.stats["best-win-streak"]}
{entry.stats["current-win-streak"] > 0 && (
<span className="ml-1 text-orange-400">
🔥{entry.stats["current-win-streak"]}
</span>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>

{entries.length > 50 && (
<div className="px-6 py-4 bg-gray-700 text-center text-sm text-gray-400">
Showing top 50 players. Total: {entries.length} players
</div>
)}
</div>
);
}
Loading