# Bootcamp #2: Building the SketchGuess App from Scratch
Welcome to Bootcamp #2! In this tutorial, we’ll build SketchGuess, a multiplayer drawing and guessing game, from the ground up using Next.js, Tailwind CSS, ShadCN components, and Arweave Wallet Kit for decentralized interactions. The backend game logic will be implemented using Lua scripts running on AO, providing the compute layer.
The tutorial will cover:
- Project Setup
- Basic File Structure & Core Files
- Game Context Setup
- Utilities Setup
- Integrating Arweave Wallet Kit
- Implementing Core Functionalities:
- Join Waiting Room
- Leave Room
- Start Game
- Draw and Submit Drawing
- Guess and Submit Answer
For each feature, we’ll first implement the Lua backend logic and then work on the corresponding frontend functionality.
## 1. Project Setup
To get started, we need to set up a new Next.js project with Tailwind CSS, ShadCN components, and other required dependencies.
### Step 1: Create a New Next.js Project
Run the following command to create a new Next.js project named `SketchGuess`:
```bash=
npx create-next-app@latest sketch-guess --typescript --use-pnpm
```
### Step 2: Install Dependencies
Navigate into the project directory and install the required dependencies using pnpm:
```bash=
cd sketch-game-2
pnpm dlx shadcn@latest init
pnpm add arweave-wallet-kit@1.0.4
```
> If you face errors with NextJS 15, roll back to version `14.2.15` along with `react` and `react-dom` versions `18`.
## 2. Basic File Structure & Core Files
The image here showcases the eventual project structure we will achieve:

### Core Files Setup
Start with setting up the core files that will help us create the functionality for the rest of the app.
#### Global Context
The first thing we need is a global context for various pages to access common information. We'll create a new folder `src/context` and add file `GameContext.tsx`
```tsx=
"use client";
import { createContext, useContext, useState, ReactNode } from "react";
type GameMode = "landing" | "waiting" | "drawing" | "guessing";
interface Player {
id: string;
bazarId?: string;
name: string;
image?: string;
score?: number;
isCreator?: boolean;
}
interface GameState {
gameProcess: string;
activeDrawer: string;
currentRound: number;
maxRounds: number;
currentTimestamp: number;
}
interface GameContextType {
mode: GameMode;
setMode: (newState: GameMode) => void;
currentPlayer: Player | null;
setCurrentPlayer: (player: Player | null) => void;
joinedPlayers: Player[];
setJoinedPlayers: (players: Player[]) => void;
gameState: GameState;
setGamestate: (gamestate: GameState) => void;
chosenWord: string;
setChosenWord: (word: string) => void;
}
const GameContext = createContext<GameContextType | undefined>(undefined);
export const GameProvider = ({ children }: { children: ReactNode }) => {
const [mode, setMode] = useState<GameMode>("landing");
const [currentPlayer, setCurrentPlayer] = useState<Player | null>(null);
const [joinedPlayers, setJoinedPlayers] = useState<Player[]>([]);
const [gameState, setGamestate] = useState<GameState>({
gameProcess: "w1rR0IJ22YPF-yAkguB89jJ9iXSYOjc7oV2wt58CI00",
activeDrawer: "",
currentRound: 0,
maxRounds: 0,
currentTimestamp: 0,
});
const [chosenWord, setChosenWord] = useState<string>("");
return (
<GameContext.Provider
value={{
mode,
setMode,
currentPlayer,
setCurrentPlayer,
joinedPlayers,
setJoinedPlayers,
gameState,
setGamestate,
chosenWord,
setChosenWord,
}}
>
{children}
</GameContext.Provider>
);
};
export const useGameContext = () => {
const context = useContext(GameContext);
if (!context) {
throw new Error("useGameContext must be used within a GameProvider");
}
return context;
};
```
#### Header and Footer Components
These basic components will be standard.
##### Header.tsx
```tsx=
import { ConnectButton } from "arweave-wallet-kit";
export default function Header() {
return (
<header className="w-full p-4 md:px-8">
<nav className="flex justify-between items-center">
<h1 className="text-2xl font-bold">SketchGuess</h1>
<ConnectButton showBalance={false} />
</nav>
</header>
);
}
```
##### Footer.tsx
```tsx=
export default function Footer() {
return (
<footer className="w-full text-center text-sm text-muted-foreground pb-4">
<p>© 2024 SketchGuess. All rights reserved.</p>
</footer>
);
}
```
#### Layout.tsx
```tsx=
import "./globals.css";
import { Inter } from "next/font/google";
import { GameProvider } from "@/context/GameContext";
import { ArweaveWalletKit } from "arweave-wallet-kit";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Toaster } from "@/components/ui/toaster";
const inter = Inter({ subsets: ["latin"] });
export const metadata = {
title: "SketchGuess",
description: "A multiplayer drawing and guessing game.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full">
<body
className={`${inter.className} flex flex-col h-screen bg-background text-foreground`}
>
<ArweaveWalletKit
config={{
permissions: [
"ACCESS_ADDRESS",
"ACCESS_PUBLIC_KEY",
"SIGN_TRANSACTION",
"DISPATCH",
],
ensurePermissions: true,
}}
theme={{
displayTheme: "light",
}}
>
<GameProvider>
<Header />
<main className="flex-1 overflow-hidden">{children}</main>
<Toaster />
<Footer />
</GameProvider>
</ArweaveWalletKit>
</body>
</html>
);
}
```
#### Page.tsx
```tsx=
"use client";
// import LandingPage from "@/components/LandingPage";
// import WaitingRoom from "@/components/WaitingRoom";
// import GameRound from "@/components/GameRound";
import { useGameContext } from "@/context/GameContext";
import { useConnection } from "arweave-wallet-kit";
import dynamic from "next/dynamic";
const LandingPage = dynamic(() => import("@/components/LandingPage"), {
ssr: false,
});
const WaitingRoom = dynamic(() => import("@/components/WaitingRoom"), {
ssr: false,
});
const GameRound = dynamic(() => import("@/components/GameRound"), {
ssr: false,
});
export default function SketchGuessApp() {
const { mode, setMode, setCurrentPlayer } = useGameContext();
const { connected } = useConnection();
if (!connected) {
setMode("landing");
setCurrentPlayer(null);
}
return (
<div className="flex flex-col justify-center h-full my-10">
{mode === "landing" && <LandingPage />}
{mode === "waiting" && <WaitingRoom />}
{(mode === "drawing" || mode === "guessing") && <GameRound />}
</div>
);
}
```
#### Utils
The final core element is the utils that we can use across our app. Create a new sub folder `src/lib` and add `utils.ts`:
```ts=
import { createDataItemSigner, dryrun, message, result } from "@permaweb/aoconnect";
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export async function dryrunResult(gameProcess: string, tags: { name: string; value: string }[]) {
const res = await dryrun({
process: gameProcess,
tags,
}).then((res) => JSON.parse(res.Messages[0].Data))
return res
}
export async function messageResult(gameProcess: string, tags: { name: string; value: string }[], data?: any) {
const res = await message({
process: gameProcess,
signer: createDataItemSigner(window.arweaveWallet),
tags,
data,
})
let { Messages, Spawns, Output, Error } = await result({
message: res,
process: gameProcess,
})
console.dir({ Messages, Spawns, Output, Error }, { depth: Infinity, colors: true })
return { Messages, Spawns, Output, Error }
}
```
#### Basic Backend Setup
Head over to https://ide.betteridea.dev/ and create a new project with the module Id `2qIQBC_mo5ywHZcTbC3Z-OTqyzserEhHAXscCjqOc1k`.
And then initialize the following one at a time:
```lua=
-- .load-blueprint apm
-- apm.update()
-- apm.install("@rakis/DbAdmin")
-- .load-blueprint chatroom
```
Then setup a database and a table:
```lua=
local sqlite3 = require("lsqlite3")
local dbAdmin = require("@rakis/DbAdmin")
-- Open an in-memory database
db = sqlite3.open_memory()
-- Create a DbAdmin instance
admin = dbAdmin.new(db)
admin:exec([[
CREATE TABLE IF NOT EXISTS leaderboard (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER DEFAULT 0,
isCreator BOOLEAN DEFAULT FALSE,
);
]])
```
And finally some handy variables:
```lua=
GameState = {
currentRound = 1,
maxRounds = 8,
activeDrawer = "",
mode = "In-Waiting",
answeredBy = {},
currentTimeStamp = 0
}
ChosenWord = ""
DrawerId = 1
WordList = {
"cat", "dog", "tree", "house", "sun", "moon", "flower", "car", "train",
"phone", "pizza", "balloon", "book", "computer", "mountain", "river",
"apple", "banana", "cupcake", "guitar", "star", "tiger", "beach", "rainbow",
"rocket", "bird", "fish", "laptop", "pencil", "glasses", "umbrella",
"jungle", "bridge", "robot", "cake", "camera", "chair", "ship", "crown",
"horse", "airplane", "castle", "snowman", "spider", "bat", "globe",
"forest", "elephant", "dolphin", "bicycle", "violin", "butterfly"
}
```
*Remember to update your process id in the Game Context*
## 3. Adding Functionalities
From here we will add functionalities one at a time, starting with the Lua code followed by the frontend connection.
### Welcoming Users
#### LandingPage Component
```tsx=
"use client";
import { Pencil, Users, Clock, Trophy } from "lucide-react";
import PlayerProfile from "./PlayerProfile";
import JoinWaiting from "./JoinWaiting";
export default function LandingPage() {
return (
<div className="flex flex-col items-center justify-between bg-background text-foreground px-6 md:px-12">
<main className="flex-grow flex flex-col items-center justify-center text-center max-w-4xl w-full">
<h2 className="text-4xl md:text-6xl font-bold mb-6">
Draw, Guess, Laugh
</h2>
<p className="text-xl md:text-2xl mb-8 text-muted-foreground">
The ultimate online drawing and guessing game for friends and family.
</p>
<div className="flex items-center justify-center gap-4 w-full">
<PlayerProfile />
<JoinWaiting />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mt-16">
{[
{ icon: Pencil, text: "Draw" },
{ icon: Users, text: "Multiplayer" },
{ icon: Clock, text: "Quick Rounds" },
{ icon: Trophy, text: "Leaderboards" },
].map((feature, index) => (
<div key={index} className="flex flex-col items-center">
<feature.icon className="h-8 w-8 mb-2" />
<span className="text-sm">{feature.text}</span>
</div>
))}
</div>
</main>
</div>
);
}
```
#### Handling Player Profiles
```tsx!
"use client";
import { useGameContext } from "@/context/GameContext";
import { dryrun } from "@permaweb/aoconnect";
import { useActiveAddress, useConnection } from "arweave-wallet-kit";
import { useEffect } from "react";
export default function PlayerProfile() {
const { currentPlayer, setCurrentPlayer } = useGameContext();
const activeAddress = useActiveAddress();
const { connected } = useConnection();
const fetchPlayerProfile = async () => {
console.log("Fetching player profile for", activeAddress);
const profileIdRes = await dryrun({
process: "SNy4m-DrqxWl01YqGM4sxI8qCni-58re8uuJLvZPypY",
tags: [
{
name: "Action",
value: "Get-Profiles-By-Delegate",
},
],
// signer: createDataItemSigner(window.arweaveWallet),
data: JSON.stringify({ Address: activeAddress }),
}).then((res) => JSON.parse(res.Messages[0].Data));
const profileRes = await dryrun({
process: profileIdRes && profileIdRes[0].ProfileId,
tags: [
{
name: "Action",
value: "Info",
},
],
data: "",
}).then((res) => try {
return JSON.parse(res.Messages[0].Data);
} catch (error) {
console.error("Failed to parse JSON:", error);
return {};
};
// console.log("Player profile result", profileRes.Profile);
const playerDetails = {
id: activeAddress!,
name:
profileRes.Profile.DisplayName !== ""
? profileRes.Profile.DisplayName
: "ANON",
image:
profileRes.Profile.ProfileImage !== ""
? profileRes.Profile.ProfileImage
: "NONE",
score: 0,
bazarId: profileIdRes[0].ProfileId,
};
console.log("Player profile result", playerDetails);
setCurrentPlayer(playerDetails);
};
useEffect(() => {
if (connected && activeAddress) fetchPlayerProfile();
}, [connected, activeAddress]);
return (
<div className="text-2xl font-bold text-red-400">
{currentPlayer ? <p>{currentPlayer.name}</p> : <p>ANON</p>}
</div>
);
}
```
### Joining a Game Room
#### Handlers
##### For Registering
```lua=
Handlers.add(
"Register-Player",
"Register-Player",
function(msg)
-- Check if the player is already in the leaderboard
local results = admin:select('SELECT id FROM leaderboard WHERE id = ?;', { msg.From })
if #results > 0 then
msg.reply({ Data = "You are already registered." })
return -- Player is already in the leaderboard
end
table.insert(Members, msg.From)
local isCreator = false
local result = admin:exec('SELECT COUNT(*) as count FROM leaderboard;')
if result[1].count == 0 then
isCreator = true
end
admin:apply('INSERT INTO leaderboard (id, name, score, isCreator) VALUES (?, ?, ?, ?);', { msg.From, msg.Tags.DisplayName, 0, isCreator })
msg.reply({ Data = "Successfully registered to game." })
end
)
```
##### For getting other players
```lua
Handlers.add(
"Joined-Players",
"Joined-Players",
function (msg)
local players = admin:exec("SELECT * FROM leaderboard")
msg.reply({ Action = "Joined Player Res", Data = players})
end
)
```
#### JoinWaiting Component
```tsx
import { useGameContext } from "@/context/GameContext";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import { dryrunResult, messageResult } from "@/lib/utils";
export default function JoinWaiting() {
const { setMode, setJoinedPlayers, currentPlayer, gameState } =
useGameContext();
const handlePlayNow = async () => {
console.log("Button clicked");
if (currentPlayer) {
console.log("Current player:", currentPlayer);
// Wait for the player registration message to be sent to the AO process
const { Messages, Spawns, Output, Error } = await messageResult(
gameState.gameProcess,
[
{
name: "Action",
value: "Register-Player",
},
{
name: "DisplayName",
value: currentPlayer.name,
},
]
);
if (Messages[0].Data === "Successfully registered to game.") {
toast({
title: "Successfully registered.",
description: "Waiting for other players to join.",
});
// setJoinedPlayers([...joinedPlayers, currentPlayer]);
setMode("waiting");
} else if (Messages[0].Data === "You are already registered.") {
toast({
title: "Player already registered.",
description: "Please wait for other players to join.",
});
// setJoinedPlayers([...joinedPlayers, currentPlayer]);
} else return;
const userRes = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Joined-Players",
},
]);
console.log("Joined users result", userRes);
if (
userRes.some(
(user: { id: string; isCreator: number }) =>
user.id === currentPlayer.id && user.isCreator === 1
)
) {
currentPlayer.isCreator = true;
}
setJoinedPlayers(userRes);
setTimeout(() => {
setMode("waiting");
}, 1000);
} else {
toast({
title: "Please login to play.",
description: "Click on the connect button at the top.",
});
}
};
return (
<Button size="lg" className="w-full sm:w-auto" onClick={handlePlayNow}>
Play Now
</Button>
);
}
```
### Leaving a Room
#### Handlers
```lua
Handlers.add(
"Unregister-Player",
"Unregister-Player",
function(msg)
-- Check if the player is already in the leaderboard
local results = admin:select('SELECT id FROM leaderboard WHERE id = ?;', { msg.From })
if #results == 0 then
msg.reply({ Data = "You are not registered." })
return -- Player is not in the leaderboard
end
for i, v in ipairs(Members) do
if v == msg.From then
table.remove(Members, i)
break
end
end
admin:apply('DELETE FROM leaderboard WHERE id = ?;', { msg.From })
msg.reply({ Data = "Successfully unregistered from game." })
end
)
```
#### LeaveGame Component
```tsx
import { useGameContext } from "@/context/GameContext";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import {
createDataItemSigner,
dryrun,
message,
result,
} from "@permaweb/aoconnect";
export default function LeaveGame() {
const { setMode, setJoinedPlayers, currentPlayer, gameState } =
useGameContext();
const handleLeaveRoom = async () => {
console.log("Button clicked");
if (currentPlayer) {
console.log("Current player:", currentPlayer);
try {
// Wait for the player registration message to be sent to the AO process
const sendRes = await message({
process: gameState.gameProcess,
signer: createDataItemSigner(window.arweaveWallet),
tags: [
{
name: "Action",
value: "Unregister-Player",
},
],
});
console.log("Register player result", sendRes);
const { Messages, Spawns, Output, Error } = await result({
// the arweave TXID of the message
message: sendRes,
// the arweave TXID of the process
process: gameState.gameProcess,
});
console.dir(
{ Messages, Spawns, Output, Error },
{ depth: Infinity, colors: true }
);
if (Messages[0].Data === "Successfully unregistered from game.") {
toast({
title: "Successfully unregistered.",
description: "You have left the room.",
});
setJoinedPlayers([]);
setMode("landing");
}
} catch (error) {
toast({
title: "An error occurred while unregistering.",
description: "Please try again.",
});
}
}
};
return (
<Button variant="outline" size="lg" onClick={handleLeaveRoom}>
Leave Room
</Button>
);
}
```
### The Waiting Room
#### Handlers
##### Starting a game
```lua=
Handlers.add(
"Start-Game",
"Start-Game",
function(msg)
-- Create game round table
admin:exec([[
CREATE TABLE IF NOT EXISTS rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
active_drawer TEXT NOT NULL,
word TEXT NOT NULL,
drawing TEXT NOT NULL,
correct_answers TEXT NOT NULL
);
]])
-- Select a random player from the leaderboard to be the active drawer
local results = admin:exec('SELECT id, name FROM leaderboard ORDER BY RANDOM() LIMIT 1;')
local activeDrawerId = results[1].id
local activeDrawer = results[1].name
local math = require("math")
local randomIndex = math.random(#WordList)
local chosenWord = WordList[randomIndex]
ChosenWord = chosenWord
-- print(chosenWord)
GameState.mode = "Drawing"
GameState.currentTimeStamp = msg.Timestamp
GameState.activeDrawer = activeDrawerId
admin:apply('INSERT INTO rounds (active_drawer, word, drawing, correct_answers) VALUES (?, ?, ?, ?);', { activeDrawerId, chosenWord, "", "" })
GameState.currentRound = admin:exec("SELECT id FROM rounds ORDER BY id DESC LIMIT 1;")[1].id
-- ao.send({ Target = ao.id, Action = "Broadcast", Data = "Game-Started. "})
ao.send({ Target = activeDrawerId, Action = "Chosen-Word", Data = chosenWord })
ao.send({ Target = ao.id, Action = "Broadcast", Data = "Game-Started. Welcome to round " .. GameState.currentRound})
ao.send({ Target = ao.id, Action = "Broadcast", Data = "The active drawer is " .. activeDrawer .. " : " .. activeDrawerId .. ". Please wait while they finish drawing." })
end
)
```
##### Getting the Game State
```lua=
Handlers.add(
"Game-State",
"Game-State",
function (msg)
msg.reply({ Action = "Current Game State", Data = GameState})
end
)
```
#### WaitingRoom Component
```tsx=
"use client";
import { Button } from "@/components/ui/button";
import { UserCircle2, Copy, Users } from "lucide-react";
import { useGameContext } from "@/context/GameContext";
import LeaveGame from "./LeaveGame";
import { useEffect } from "react";
import { toast } from "@/hooks/use-toast";
import { dryrunResult, messageResult } from "@/lib/utils";
const pastelColors = [
"bg-red-200",
"bg-yellow-200",
"bg-green-200",
"bg-blue-200",
"bg-indigo-200",
"bg-purple-200",
"bg-pink-200",
"bg-orange-200",
];
export default function WaitingRoom() {
const {
currentPlayer,
joinedPlayers,
setJoinedPlayers,
setMode,
gameState,
setGamestate,
} = useGameContext();
const maxPlayers = 8;
if (!currentPlayer) {
setMode("landing");
return null;
}
const handleStartGame = async () => {
if (joinedPlayers.length >= 2) {
const { Messages, Output, Spawns, Error } = await messageResult(
gameState.gameProcess,
[
{
name: "Action",
value: "Start-Game",
},
]
);
// setMode("drawing");
}
};
const userRes = async () => {
const updatedPlayers = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Joined-Players",
},
]);
console.log("Joined users result in waiting room", updatedPlayers);
if (updatedPlayers !== joinedPlayers) {
setJoinedPlayers(updatedPlayers);
} else console.log("No new players joined");
};
const fetchGameState = async () => {
const GameState = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Game-State",
},
]);
console.log("Game state result", GameState.mode);
setGamestate({
...gameState,
activeDrawer: GameState.activeDrawer,
currentRound: GameState.currentRound,
maxRounds: GameState.maxRounds,
currentTimestamp: GameState.currentTimeStamp,
});
if (GameState.mode == "Drawing") {
toast({
title: "Game in progress.",
description: "You are being redirected to the game.",
});
setMode("drawing");
} else if (GameState.mode == "Guessing") {
toast({
title: "Game in progress.",
description: "You are being redirected to the guessing page.",
});
setMode("guessing");
}
};
useEffect(() => {
const interval = setInterval(() => {
userRes();
fetchGameState();
}, 2000);
return () => clearInterval(interval);
}, [joinedPlayers]);
return (
<div className="flex flex-col items-center justify-between bg-background min-h-screen text-foreground p-6 md:p-12">
<header className="w-full max-w-4xl flex justify-between items-center">
<div className="flex items-center space-x-2 mt-4">
<span className="text-sm font-medium">Room Code:</span>
<code className="bg-muted px-2 py-1 rounded">
{gameState.gameProcess}
</code>
<Button
variant="ghost"
size="icon"
title="Copy room code"
onClick={() => navigator.clipboard.writeText(gameState.gameProcess)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</header>
<main className="flex-grow flex flex-col items-center justify-center w-full max-w-4xl">
<h2 className="text-3xl font-bold mb-8">Waiting Room</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-6 mb-8 w-full max-w-2xl">
{Array.from({ length: maxPlayers }, (_, i) => (
<div
key={i}
className={`flex flex-col items-center justify-center p-4 rounded-lg aspect-square ${
i < joinedPlayers.length
? pastelColors[i % pastelColors.length]
: "bg-muted"
}`}
>
{i < joinedPlayers.length ? (
<>
<UserCircle2 className="h-12 w-12 mb-2 text-primary" />
<span className="text-sm font-medium">
{joinedPlayers[i].name}
</span>
</>
) : (
<Users className="h-12 w-12 text-muted-foreground" />
)}
</div>
))}
</div>
<div className="flex flex-col sm:flex-row gap-4">
{currentPlayer.isCreator && (
<Button
size="lg"
className="px-8"
onClick={handleStartGame}
disabled={joinedPlayers.length < 2}
>
Start Game
</Button>
)}
<LeaveGame />
</div>
<div className="w-full mt-10 max-w-4xl text-center text-sm text-muted-foreground">
<p>
{joinedPlayers.length} / {maxPlayers} players joined
</p>
</div>
</main>
</div>
);
}
```
### Kicking off the Games
#### GameRound Component
```tsx
"use client";
import { useEffect, useState } from "react";
import Sidebar from "./Sidebar";
import Drawing from "./Drawing";
import { useGameContext } from "@/context/GameContext";
import { dryrunResult } from "@/lib/utils";
import Guessing from "./Guessing";
export default function GameRound() {
const { mode, gameState, joinedPlayers, setJoinedPlayers } = useGameContext();
// const [chatMessages, setChatMessages] = useState<
// { playerId: number; message: string }[]
// >([]);
const userRes = async () => {
const updatedPlayers = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Joined-Players",
},
]);
console.log("Joined users result in waiting room", updatedPlayers);
if (updatedPlayers !== joinedPlayers) {
setJoinedPlayers(updatedPlayers);
} else console.log("No active player updates");
};
useEffect(() => {
userRes();
}, [mode]);
return (
<main className="flex bg-background min-h-screen text-foreground">
{mode === "drawing" && <Drawing />}
{mode === "guessing" && <Guessing />}
<Sidebar />
</main>
);
}
```
#### The Sidebar Component
```tsx!
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useGameContext } from "@/context/GameContext";
import {
Pencil,
// MessageCircle,
Trophy,
// ChevronLeft,
// ChevronRight,
} from "lucide-react";
export default function Sidebar() {
const { gameState, joinedPlayers } = useGameContext();
return (
<aside
// className={`w-80 z-10 bg-muted p-6 transition-all duration-300 ease-in-out ${showSidebar ? "translate-x-0" : "translate-x-full"}`}
className={`w-80 z-10 bg-muted p-6`}
>
{/* <Button
variant="ghost"
size="icon"
className="absolute -left-10 top-1/2 transform -translate-y-1/2 bg-muted"
onClick={() => setShowSidebar(!showSidebar)}
>
{showSidebar ? <ChevronRight /> : <ChevronLeft />}
</Button> */}
<Tabs defaultValue="leaderboard">
<TabsList className="w-full">
{/* <TabsTrigger value="chat" className="w-1/2">
<MessageCircle className="w-4 h-4 mr-2" />
Chat
</TabsTrigger> */}
<TabsTrigger value="leaderboard" className="w-1/2">
<Trophy className="w-4 h-4 mr-2" />
Leaderboard
</TabsTrigger>
</TabsList>
{/* <TabsContent value="chat" className="mt-4">
<div className="h-[calc(100vh-12rem)] overflow-y-auto mb-4">
{chatMessages.map((msg, index) => (
<div key={index} className="mb-2">
<span className="font-semibold">
{players.find((p) => p.id === msg.playerId)?.name}:{" "}
</span>
<span>{msg.message}</span>
</div>
))}
</div>
<div className="flex">
<Input
placeholder="Type a message"
className="flex-grow"
onKeyPress={(e) => {
if (e.key === "Enter") {
sendChatMessage(players[0].id, e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
<Button onClick={() => sendChatMessage(players[0].id, "")}>
Send
</Button>
</div>
</TabsContent> */}
<TabsContent value="leaderboard" className="mt-4">
<ul>
{joinedPlayers
// .sort((a, b) => b.score! - a.score!)
.map((user) => (
<li
key={user.id}
className="flex justify-between items-center mb-2"
>
<span className="flex items-center">
{user.name}
{user.id === gameState.activeDrawer && (
<Pencil className="w-4 h-4 ml-2 text-primary" />
)}
</span>
<span>{user.score}</span>
</li>
))}
</ul>
</TabsContent>
</Tabs>
</aside>
);
}
```
### The Drawing
#### Handlers
##### Fetching Chosen Word
```lua
Handlers.add(
"Chosen-Word",
"Chosen-Word",
function(msg)
msg.reply({ Action = "Chosen-Word", Data = ChosenWord })
end
)
```
##### Submitting the Drawing
```lua
andlers.add(
"Submit-Drawing",
"Submit-Drawing",
function(msg)
-- Submit drawing
-- ao.send({ Target = ao.id, Data = msg.Data})
admin:apply('UPDATE rounds SET drawing = ? WHERE id = ?;', { msg.Data, GameState.currentRound })
GameState.mode = "Guessing"
msg.reply({ Data = "Drawing submitted successfully." })
end
)
```
#### The Canvas
```tsx
import { useState, FC, useEffect } from "react";
import { Excalidraw, exportToSvg } from "@excalidraw/excalidraw";
import { useGameContext } from "@/context/GameContext";
import { DataItem } from "arbundles";
import { messageResult } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
interface CanvasProps {
timeLeft: number;
}
const Canvas: FC<CanvasProps> = ({ timeLeft }) => {
const { gameState, setMode } = useGameContext();
const [excalidrawAPI, setExcalidrawAPI] = useState<any>(null);
const handleSubmitDrawing = async () => {
if (!excalidrawAPI) {
return;
}
const elements = excalidrawAPI.getSceneElements();
if (!elements || !elements.length) {
return;
}
const canvas = await exportToSvg({
elements,
appState: {
exportWithDarkMode: false,
},
files: excalidrawAPI.getFiles(),
});
console.log("Excalidraw canvas", canvas);
const svgString = new XMLSerializer().serializeToString(canvas);
const signedData = await (window.arweaveWallet as any).signDataItem({
data: svgString,
tags: [
{
name: "Content-Type",
value: "image/svg+xml",
},
],
});
const dataItem = new DataItem(signedData);
const uploadRes = await fetch(`https://upload.ardrive.io/v1/tx`, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Accept: "application/json",
},
body: dataItem.getRaw(),
}).then((res) => res.json());
console.log("Upload response", uploadRes.id);
const URL = `https://arweave.net/${uploadRes.id}`;
console.log("URL", URL);
const { Messages, Spawns, Output, Error } = await messageResult(
gameState.gameProcess,
[
{
name: "Action",
value: "Submit-Drawing",
},
],
URL
);
setMode("guessing");
};
useEffect(() => {
console.log("Time Left from Canvas", timeLeft);
if (timeLeft === 0) {
toast({
title: "Time's up!",
description: "Submitting your drawing.",
});
handleSubmitDrawing();
}
}, [timeLeft]);
return (
<div className="flex flex-col w-full items-center justify-center gap-8">
<div
style={{ height: "500px", width: "500px" }}
className="bg-red-400 p-6"
>
<Excalidraw excalidrawAPI={(api) => setExcalidrawAPI(api)} />
</div>
<Button variant="outline" size="lg" onClick={handleSubmitDrawing}>
Submit Drawing
</Button>
</div>
);
};
export default Canvas;
```
#### The Drawing View
```tsx!
import { useGameContext } from "@/context/GameContext";
import { dryrunResult, messageResult } from "@/lib/utils";
import { useEffect, useState } from "react";
import Canvas from "./Canvas";
import { toast } from "@/hooks/use-toast";
export default function Drawing() {
const { gameState, currentPlayer, setChosenWord, chosenWord, setMode, mode } =
useGameContext();
const [timeLeft, setTimeLeft] = useState(60);
const fetchGameState = async () => {
const GameState = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Game-State",
},
]);
console.log("Game state result", GameState.mode);
if (GameState.mode == "Guessing") {
toast({
title: "Game started.",
description: "You are being redirected to the guessing page.",
});
setMode("guessing");
}
};
const fetchChosenWord = async () => {
console.log("Fetching chosen word");
// Wait for the player registration message to be sent to the AO process
const { Messages, Spawns, Output, Error } = await messageResult(
gameState.gameProcess,
[
{
name: "Action",
value: "Chosen-Word",
},
]
);
setChosenWord(Messages[0].Data);
};
useEffect(() => {
console.log("Time Stamp:", gameState.currentTimestamp);
if (
currentPlayer &&
currentPlayer.id === gameState.activeDrawer &&
chosenWord === ""
) {
fetchChosenWord();
}
const interval = setInterval(() => {
// userRes();
// fetchGameState();
if (timeLeft > 0) {
setTimeLeft(timeLeft - 1);
} else if (timeLeft === 0 && mode === "drawing") {
fetchGameState();
}
}, 1000);
return () => clearInterval(interval);
}, [timeLeft]);
return (
<div className="flex-grow items-start p-6 md:p-12">
<div className="max-w-4xl mx-auto">
<header className="flex justify-between items-center mb-8">
<div className="flex items-center space-x-4">
<span className="font-medium">
Round {gameState.currentRound}/{gameState.maxRounds}
</span>
<span className="font-medium">Time: {timeLeft}s</span>
</div>
</header>
{currentPlayer && currentPlayer.id === gameState.activeDrawer ? (
<div className="h-screen">
<h2 className="text-xl font-semibold mb-4">Draw: {chosenWord}</h2>
<Canvas timeLeft={timeLeft} />
</div>
) : (
<div className="w-full h-screen flex flex-col items-center justify-start">
<span className="text-xl font-bold text-muted-foreground text-center pt-20">
Waiting for{" "}
<span className="text-red-400">{gameState.activeDrawer}</span> to
finish drawing...
</span>
</div>
)}
</div>
</div>
);
}
```
### Guessing
#### Handlers
##### Fetching the drawing
```lua
Handlers.add(
"Get-Drawing",
"Get-Drawing",
function(msg)
local results = admin:select('SELECT drawing FROM rounds WHERE id = ?;', { GameState.currentRound })
msg.reply({ Data = { results[1].drawing } })
end
)
```
##### Submitting your answer
```lua
Handlers.add(
"Submit-Answer",
"Submit-Answer",
function(msg)
-- Submit answer
local results = admin:select('SELECT word FROM rounds WHERE id = ?;', { GameState.currentRound })
local correctAnswer = results[1].word
local submittedAnswer = msg.Data
if submittedAnswer == correctAnswer then
-- Update correct answers
local results = admin:select('SELECT correct_answers FROM rounds WHERE id = ?;', { GameState.currentRound })
local correctAnswers = results[1].correct_answers
correctAnswers = correctAnswers .. msg.From .. ", "
admin:apply('UPDATE rounds SET correct_answers = ? WHERE id = ?;', { correctAnswers, GameState.currentRound })
admin:apply('UPDATE leaderboard SET score = score + 10 WHERE id = ?;', { msg.From })
msg.reply({ Data = "Correct answer!" })
else
msg.reply({ Data = "Incorrect answer." })
end
-- Update leaderboard
end
)
```
##### Moving on to the next round
```lua
Handlers.add(
"Update-Round",
"Update-Round",
function(msg)
if (msg.Timestamp - GameState.currentTimeStamp) < 20000 then
msg.reply({ Action = "Spam", Data = "Round already updated"})
return
end
GameState.currentRound = GameState.currentRound + 1
if GameState.currentRound < GameState.maxRounds then
DrawerId = DrawerId + 1
-- Find the next player in the leaderboard
local results = admin:exec('SELECT id, name FROM leaderboard')
local drawer = results[DrawerId]
if not drawer then
DrawerId = 1
drawer = results[DrawerId]
end
local activeDrawerId = results[1].id
local activeDrawer = results[1].name
local math = require("math")
local randomIndex = math.random(#WordList)
local chosenWord = WordList[randomIndex]
if chosenWord ~= ChosenWord then
ChosenWord = chosenWord
else
chosenWord = WordList[randomIndex + 1]
ChosenWord = chosenWord
end
-- print (activeDrawer)
-- print(ChosenWord)
GameState.mode = "Drawing"
GameState.currentTimeStamp = msg.Timestamp
GameState.activeDrawer = activeDrawerId
admin:apply('INSERT INTO rounds (active_drawer, word, drawing, correct_answers) VALUES (?, ?, ?, ?);', { activeDrawerId, chosenWord, "", "" })
-- ao.send({ Target = ao.id, Action = "Broadcast", Data = "Game-Started. "})
ao.send({ Target = activeDrawerId, Action = "Chosen-Word", Data = chosenWord })
ao.send({ Target = ao.id, Action = "Broadcast", Data = "Round-Started. Welcome to round " .. GameState.currentRound})
ao.send({ Target = ao.id, Action = "Broadcast", Data = "The active drawer is " .. activeDrawer .. " : " .. activeDrawerId .. ". Please wait while they finish drawing." })
else
GameState.mode = "Completed"
ao.send({ Target = ao.id, Action = "Broadcast", Data = "Game Over!" })
end
end
)
```
#### The Guessing Component
```tsx!
import { useGameContext } from "@/context/GameContext";
import { dryrunResult, messageResult } from "@/lib/utils";
import { useEffect, useState } from "react";
import Image from "next/image";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { toast } from "@/hooks/use-toast";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export default function Guessing() {
const { gameState, setGamestate, currentPlayer, setMode, mode } =
useGameContext();
const [drawing, setDrawing] = useState<string | null>(null);
const [timeLeft, setTimeLeft] = useState(60);
const [guess, setGuess] = useState("");
const [correctGuess, setCorrectGuess] = useState(false);
const imageLoader = ({
src,
width,
quality,
}: {
src: string;
width: number;
quality?: number;
}) => {
return `${src}?w=${width}&q=${quality || 75}`;
};
const fetchDrawing = async () => {
const drawingRes = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Get-Drawing",
},
]);
console.log("Drawing result", drawingRes);
setDrawing(drawingRes[0]);
};
const fetchGameState = async () => {
console.log("Fetching game state");
const GameState = await dryrunResult(gameState.gameProcess, [
{
name: "Action",
value: "Game-State",
},
]);
console.log("Game state result", GameState.mode);
if (GameState.mode == "Drawing") {
toast({
title: "Next round started.",
description: "You are being redirected to the drawing page.",
});
setGamestate({
...gameState,
currentTimestamp: GameState.currentTimeStamp,
currentRound: GameState.currentRound,
activeDrawer: GameState.activeDrawer,
});
setMode("drawing");
}
};
const handleSubmit = async () => {
console.log("Submitting guess", guess);
const { Messages, Spawns, Output, Error } = await messageResult(
gameState.gameProcess,
[
{
name: "Action",
value: "Submit-Answer",
},
],
guess
);
if (Messages[0].Data === "Correct answer!") {
console.log("Correct answer!");
toast({
title: "Correct answer!",
description: "You guessed it right!",
});
setCorrectGuess(true);
} else {
toast({
title: "Incorrect answer!",
description: "Please try again.",
});
}
};
const updateRound = async () => {
console.log("Updating round");
const { Messages, Spawns, Output, Error } = await messageResult(
gameState.gameProcess,
[
{
name: "Action",
value: "Update-Round",
},
]
);
console.log("Round updated result", Messages);
if (Messages[0].Data === "Round updated successfully.") {
console.log("Round updated successfully.");
setMode("drawing");
}
};
useEffect(() => {
if (drawing === null) {
fetchDrawing();
}
}, []);
useEffect(() => {
const interval = setInterval(() => {
// userRes();
// fetchGameState();
if (timeLeft > 0) {
setTimeLeft(timeLeft - 1);
} else if (timeLeft === 0 && mode === "guessing") {
fetchGameState();
updateRound();
}
}, 1000);
return () => clearInterval(interval);
}, [timeLeft]);
// add fetch state via polling to get drawing to guess
return (
<div className="flex-grow items-center p-6 md:p-12">
<div className="max-w-4xl mx-auto">
<header className="flex justify-between items-center mb-8">
<div className="flex items-center space-x-4">
<span className="font-medium">
Round {gameState.currentRound}/{gameState.maxRounds}
</span>
<span className="font-medium">Time: {timeLeft}s</span>
</div>
</header>
<div className="flex flex-col gap-8">
{drawing && (
<div className="w-[500px] h-[500px]">
<AspectRatio ratio={1 / 1}>
<Image
src={drawing}
alt="Image"
layout="fill"
className="rounded-md object-contain"
loader={imageLoader}
/>
</AspectRatio>
</div>
)}
{currentPlayer && currentPlayer.id === gameState.activeDrawer ? (
<h2 className="text-xl font-semibold mb-4">
Wait while others guess!
</h2>
) : (
<>
<div className=" flex gap-6">
<Input
placeholder="Type a message"
className="flex-grow"
onChange={(e) => setGuess(e.target.value)}
disabled={correctGuess}
/>
<Button
variant="outline"
size="lg"
onClick={handleSubmit}
disabled={correctGuess}
>
Submit
</Button>
</div>
<h2 className="text-xl font-semibold mb-4">
It is your time to guess!
</h2>
</>
)}
</div>
</div>
</div>
);
}
```