Frontend
Build the game UI with RISE Wallet integration, ticket management, and animated gameplay
Frontend Architecture
The frontend provides:
- Wallet connection with RISE Wallet passkeys
- Ticket balance display and purchase
- Game state machine (start → countdown → choice → result)
- Animated transitions between states
- API calls to backend for VRF requests
- Winner determination and display
Building the UI Components
UI Components
The Button and Dropdown Menu components were already installed via shadcn/ui in the setup step. They're ready to use from @/components/ui.
Create Navbar Component
Create src/components/Navbar.tsx:
"use client"
import { useAccount, useBalance, useDisconnect, useReadContract } from 'wagmi'
import { Button } from './ui/button'
import { RPS_ADDRESS, ABI } from '@/constants'
import { useState } from 'react'
import { BuyTicketsModal } from './BuyTicketsModal'
export function Navbar() {
const { address, isConnected } = useAccount()
const { disconnect } = useDisconnect()
const [showBuyModal, setShowBuyModal] = useState(false)
const { data: balance } = useBalance({
address: address,
})
const { data: tickets, refetch: refetchTickets } = useReadContract({
address: RPS_ADDRESS,
abi: ABI,
functionName: 'ticketBalance',
args: address ? [address] : undefined,
})
if (!isConnected) return null
return (
<>
<nav className="border-b">
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold">VRF Rock Paper Scissors</h1>
<div className="flex items-center gap-4">
<div className="text-sm">
<div>Balance: {balance?.formatted.slice(0, 6)} ETH</div>
<div>Tickets: {tickets?.toString() || '0'}</div>
</div>
<Button onClick={() => setShowBuyModal(true)} size="lg">
Buy Tickets
</Button>
<div className="text-sm">
{address?.slice(0, 6)}...{address?.slice(-4)}
</div>
<Button onClick={() => disconnect()} variant="outline">
Disconnect
</Button>
</div>
</div>
</nav>
<BuyTicketsModal
open={showBuyModal}
onClose={() => setShowBuyModal(false)}
onSuccess={refetchTickets}
/>
</>
)
}Create Ticket Purchase Modal
Create src/components/BuyTicketsModal.tsx:
"use client"
import { useState } from 'react'
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { RPS_ADDRESS, ABI } from '@/constants'
import { Button } from './ui/button'
import { parseEther } from 'viem'
interface BuyTicketsModalProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
export function BuyTicketsModal({ open, onClose, onSuccess }: BuyTicketsModalProps) {
const [numTickets, setNumTickets] = useState(1)
const { writeContract, data: hash } = useWriteContract()
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
hash,
})
const handleBuy = async () => {
try {
writeContract({
address: RPS_ADDRESS,
abi: ABI,
functionName: 'buyTickets',
args: [BigInt(numTickets)],
value: parseEther((0.001 * numTickets).toString()),
})
} catch (error) {
console.error('Error buying tickets:', error)
alert('Failed to buy tickets')
}
}
if (isSuccess) {
onSuccess()
onClose()
}
if (!open) return null
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full">
<h2 className="text-2xl font-bold mb-4">Buy Tickets</h2>
<div className="mb-4">
<label className="block text-sm font-medium mb-2">
Number of Tickets (0.001 ETH each)
</label>
<input
type="number"
min="1"
max="10"
value={numTickets}
onChange={(e) => setNumTickets(parseInt(e.target.value))}
className="w-full border rounded px-3 py-2"
/>
</div>
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Total: {(0.001 * numTickets).toFixed(3)} ETH
</div>
<div className="flex gap-2">
<Button
onClick={handleBuy}
disabled={isConfirming}
className="flex-1"
>
{isConfirming ? 'Buying...' : 'Buy Tickets'}
</Button>
<Button
onClick={onClose}
variant="outline"
disabled={isConfirming}
>
Cancel
</Button>
</div>
</div>
</div>
)
}Create Main Game Page
Create src/app/page.tsx:
"use client"
import { useState, useEffect } from 'react'
import { useAccount, useConnect, useReadContract } from 'wagmi'
import { Button } from '@/components/ui/button'
import { Navbar } from '@/components/Navbar'
import { RPS_ADDRESS, ABI } from '@/constants'
type GameState = "start" | "countdown" | "choice" | "ai-choosing" | "result"
type Choice = "rock" | "paper" | "scissors" | null
export default function Home() {
const { address, isConnected } = useAccount()
const { connect, connectors } = useConnect()
const [gameState, setGameState] = useState<GameState>("start")
const [playerChoice, setPlayerChoice] = useState<Choice>(null)
const [aiChoice, setAiChoice] = useState<Choice>(null)
const [countdown, setCountdown] = useState(0)
const [gameResult, setGameResult] = useState<"win" | "lose" | "tie" | null>(null)
const { data: tickets, refetch: refetchBalance } = useReadContract({
address: RPS_ADDRESS,
abi: ABI,
functionName: 'ticketBalance',
args: address ? [address] : undefined,
})
// Countdown timer
useEffect(() => {
if (gameState === 'countdown' && countdown > 0) {
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
return () => clearTimeout(timer)
} else if (gameState === 'countdown' && countdown === 0) {
setGameState('choice')
}
}, [gameState, countdown])
const startGame = () => {
setGameState('countdown')
setCountdown(3)
setPlayerChoice(null)
setAiChoice(null)
setWinner(null)
}
const makeChoice = async (choice: Choice) => {
setPlayerChoice(choice)
setGameState('ai-choosing')
try {
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, choice }),
})
const data = await response.json()
if (!response.ok) {
alert(data.error || 'Failed to play game')
setGameState('start')
return
}
// Convert result to choice: 0=rock, 1=paper, 2=scissors
const aiChoiceMap: Choice[] = ["rock", "paper", "scissors"]
const aiResult = aiChoiceMap[data.result]
setAiChoice(aiResult)
// Determine win/lose/tie
const result = determineWinner(choice, aiResult)
setGameResult(result)
setGameState("result")
refetchBalance()
} catch (error) {
console.error('Error:', error)
alert('Failed to play game')
setGameState('start')
}
}
const determineWinner = (player: Choice, ai: Choice): "win" | "lose" | "tie" => {
if (player === ai) return "tie"
const winConditions = {
rock: "scissors",
paper: "rock",
scissors: "paper"
}
return winConditions[player as keyof typeof winConditions] === ai ? "win" : "lose"
}
if (!isConnected) {
return (
<main className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-8">VRF Rock Paper Scissors</h1>
<p className="mb-8 text-gray-600">Connect with RISE Wallet to play</p>
<Button
onClick={() => connect({ connector: connectors[0] })}
size="lg"
>
Connect with Passkey
</Button>
</div>
</main>
)
}
return (
<>
<Navbar />
<main className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
{/* Start State */}
{gameState === 'start' && (
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Rock Paper Scissors</h1>
<p className="mb-8 text-gray-600">
Play against AI powered by RISE VRF
</p>
<p className="mb-8">
Tickets: {tickets?.toString() || '0'}
</p>
<Button
onClick={startGame}
size="lg"
disabled={!tickets || tickets === BigInt(0)}
>
{tickets && tickets > BigInt(0) ? 'Start Game' : 'Buy Tickets to Play'}
</Button>
</div>
)}
{/* Countdown State */}
{gameState === 'countdown' && (
<div className="text-center">
<h2 className="text-6xl font-bold mb-8">
{countdown === 3 ? '🪨' : countdown === 2 ? '📄' : '✂️'}
</h2>
<p className="text-2xl">Get ready...</p>
</div>
)}
{/* Choice State */}
{gameState === 'choice' && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-8">Make your choice!</h2>
<div className="flex gap-4 justify-center">
<Button
onClick={() => makeChoice('rock')}
size="lg"
className="text-6xl p-8"
>
🪨
</Button>
<Button
onClick={() => makeChoice('paper')}
size="lg"
className="text-6xl p-8"
>
📄
</Button>
<Button
onClick={() => makeChoice('scissors')}
size="lg"
className="text-6xl p-8"
>
✂️
</Button>
</div>
</div>
)}
{/* AI Choosing State */}
{gameState === 'ai-choosing' && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-8">AI is choosing...</h2>
<div className="animate-pulse text-6xl">🤖</div>
</div>
)}
{/* Result State */}
{gameState === 'result' && (
<div className="text-center">
<h2 className="text-3xl font-bold mb-8">
{winner === 'player' ? '🎉 You Win!' : winner === 'ai' ? '😢 You Lose!' : '🤝 Tie!'}
</h2>
<div className="flex gap-8 justify-center mb-8 text-6xl">
<div>
<p className="text-sm mb-2">You</p>
{playerChoice === 'rock' ? '🪨' : playerChoice === 'paper' ? '📄' : '✂️'}
</div>
<div className="text-4xl self-center">VS</div>
<div>
<p className="text-sm mb-2">AI</p>
{aiChoice === 0 ? '🪨' : aiChoice === 1 ? '📄' : '✂️'}
</div>
</div>
{winner === 'player' && (
<p className="mb-4 text-green-600 font-bold">You won 0.002 ETH!</p>
)}
<Button onClick={() => setGameState('start')} size="lg">
Play Again
</Button>
</div>
)}
</div>
</main>
</>
)
}Understanding the Game Flow
State Machine
The game follows this state progression:
start → countdown → choice → ai-choosing → result → startstart: Initial state, shows "Start Game" button
countdown: Rock-Paper-Scissors animation with emoji transitions (🪨 → 📄 → ✂️)
choice: Player selects rock, paper, or scissors
ai-choosing: Loading state while backend calls VRF API
result: Shows winner, both choices, and reward info
API Integration
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, choice }),
})
const data = await response.json()
setAiChoice(data.result) // 0=rock, 1=paper, 2=scissorsThe frontend calls the backend API we built earlier. The backend handles all VRF logic and returns the result.
Ticket Balance
const { data: tickets, refetch: refetchBalance } = useReadContract({
address: RPS_ADDRESS,
abi: ABI,
functionName: 'ticketBalance',
args: address ? [address] : undefined,
})We use wagmi's useReadContract to read the player's ticket balance from the smart contract. After each game, we refetch to show updated balance.
Winner Logic
const determineWinner = (player: Choice, ai: Choice): "win" | "lose" | "tie" => {
if (player === ai) return "tie"
const winConditions = {
rock: "scissors",
paper: "rock",
scissors: "paper"
}
return winConditions[player as keyof typeof winConditions] === ai ? "win" : "lose"
}Standard RPS logic: rock beats scissors, paper beats rock, scissors beats paper. Implemented on frontend for immediate UI feedback.
Styling
Update src/app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}Testing the Complete App
Test Flow
- Click "Connect with Passkey"
- Authenticate with your passkey (FaceID/TouchID)
- Click "Buy Tickets"
- Purchase 1-10 tickets (0.001 ETH each)
- Click "Start Game"
- Watch the Rock-Paper-Scissors countdown (🪨 → 📄 → ✂️)
- Choose your move
- See VRF result quickly
- If you win, receive 0.002 ETH automatically

Game Result
After making your choice, the VRF result appears within milliseconds showing the AI's choice and whether you won, lost, or tied.

Verify Everything Works
Check that:
- Wallet connects smoothly
- Ticket balance updates after purchase
- Game plays without errors
- VRF result arrives quickly
- Winner is displayed correctly
- Rewards arrive for wins
- Can play multiple games in a row
Next Steps
Congratulations! You've built a complete VRF-powered game. Here are some ideas to extend it:
- Add leaderboard with top players
- Implement streak tracking
- Add multiplayer mode (PvP instead of PvE)
- Create NFT rewards for winning streaks
- Add sound effects and animations
- Implement session keys for popup-free gameplay
Common Issues
"Transaction reverted": Player has no tickets. Buy tickets first.
"VRF request timed out": Network issue or VRF coordinator down. Retry.
Tickets not updating: Call refetchBalance() after purchases/games.
Wallet won't connect: Make sure you're on RISE Testnet and using a compatible browser (Chrome/Brave).
Need help? Check the complete source code or ask in the RISE Discord.
