# 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: ![image](https://hackmd.io/_uploads/SkkMu51-yg.png) ### 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>&copy; 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> ); } ```