RISE Logo-Light

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 → start

start: 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=scissors

The 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

Start Development Server

npm run dev
yarn dev
pnpm dev
bun dev

Visit http://localhost:3000

VRF Rock-Paper-Scissors Landing Page

Test Flow

  1. Click "Connect with Passkey"
  2. Authenticate with your passkey (FaceID/TouchID)
  3. Click "Buy Tickets"
  4. Purchase 1-10 tickets (0.001 ETH each)
  5. Click "Start Game"
  6. Watch the Rock-Paper-Scissors countdown (🪨 → 📄 → ✂️)
  7. Choose your move
  8. See VRF result quickly
  9. If you win, receive 0.002 ETH automatically

Active Gameplay - Choose Your Move

Game Result

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

Game Result Screen

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.