RISE Logo-Light

Backend API

Build the orchestration API that handles VRF requests, event watching, and reward distribution

Backend Architecture

The backend serves as the game orchestrator. It:

  1. Receives player moves from the frontend
  2. Calls the smart contract's request() function (as the sponsor)
  3. Watches for VRF fulfillment events with 5ms polling
  4. Determines the winner
  5. Sends ETH rewards to winners
  6. Returns game results to the frontend

Why a backend? This pattern lets us focus on VRF, event watching and automated reward distribution.

API Implementation

Create API Route

Create src/app/api/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { createWalletClient, http, createPublicClient, parseAbiItem, decodeEventLog } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { riseTestnet } from 'viem/chains'
import { RPS_ADDRESS, ABI } from '@/constants'

const sponsor = privateKeyToAccount(process.env.DEV_PRIVATE_KEY! as `0x${string}`)

const walletClient = createWalletClient({
  chain: riseTestnet,
  transport: http(),
  account: sponsor
})

const publicClient = createPublicClient({
  chain: riseTestnet,
  transport: http()
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { address, choice } = body

    // Validate inputs
    if (!address) {
      return NextResponse.json(
        { error: 'Player address is required' },
        { status: 400 }
      )
    }

    if (!choice) {
      return NextResponse.json(
        { error: 'No choice given' },
        { status: 400 }
      )
    }

    console.log('Game started - Player:', address, 'Choice:', choice)

    // Call request function
    console.log('Requesting VRF...')
    const hash = await walletClient.writeContract({
      address: RPS_ADDRESS as `0x${string}`,
      abi: ABI,
      functionName: 'request',
      args: [address as `0x${string}`]
    })

    console.log('Request transaction sent:', hash)

    // Wait for transaction receipt
    const receipt = await publicClient.waitForTransactionReceipt({ hash })

    if (receipt.status === 'reverted') {
      console.error('Request transaction reverted')
      return NextResponse.json(
        { error: 'Transaction reverted - player may not have tickets' },
        { status: 400 }
      )
    }

    console.log('Request transaction confirmed')

    // Parse ChoiceRequested event to get request ID
    const choiceRequestedEvent = receipt.logs.find(log => {
      try {
        const decoded = decodeEventLog({
          abi: ABI,
          data: log.data,
          topics: log.topics
        })
        return decoded.eventName === 'ChoiceRequested'
      } catch {
        return false
      }
    })

    if (!choiceRequestedEvent) {
      console.error('ChoiceRequested event not found in transaction logs')
      throw new Error('ChoiceRequested event not found')
    }

    const decodedChoiceRequested = decodeEventLog({
      abi: ABI,
      data: choiceRequestedEvent.data,
      topics: choiceRequestedEvent.topics
    })

    //@ts-ignore
    const requestId = decodedChoiceRequested!.args!.requestId

    console.log('VRF Request ID:', requestId.toString())
    console.log('Waiting for VRF result...')

    // Watch for ChoiceResult event with matching request ID
    const result = await new Promise<number>((resolve, reject) => {
      const timeout = setTimeout(() => {
        unwatch()
        console.error('Timeout waiting for VRF result')
        reject(new Error('Timeout waiting for VRF result (30s)'))
      }, 30000) // 30 second timeout

      const unwatch = publicClient.watchEvent({
        address: RPS_ADDRESS as `0x${string}`,
        event: parseAbiItem('event ChoiceResult(uint256 indexed requestId, uint256 result)'),
        pollingInterval: 5,
        onLogs: (logs) => {
          logs.forEach(log => {
            if (log.args.requestId === requestId) {
              console.log('VRF Result received:', log.args.result)
              clearTimeout(timeout)
              unwatch()
              resolve(Number(log.args.result))
            }
          })
        }
      })
    })

    const aiChoiceMap = ["rock", "paper", "scissors"]
    console.log('AI chose:', aiChoiceMap[result])

    // Determine if player won and send reward
    const playerWon = determineWin(choice, result)

    if (playerWon) {
      console.log('Player won! Sending reward...')
      try {
        const rewardHash = await walletClient.sendTransaction({
          to: address as `0x${string}`,
          value: BigInt(2000000000000000) // 0.002 ETH
        })

        const rewardReceipt = await publicClient.waitForTransactionReceipt({ hash: rewardHash })

        if (rewardReceipt.status === 'reverted') {
          console.error('Reward transaction reverted')
          return NextResponse.json(
            { error: 'Failed to send reward - transaction reverted' },
            { status: 500 }
          )
        }

        console.log('Reward sent:', rewardHash)
      } catch (rewardError) {
        console.error('Failed to send reward:', rewardError)
        return NextResponse.json(
          { error: 'Game completed but reward failed to send' },
          { status: 500 }
        )
      }
    } else {
      console.log('Player lost or tied')
    }

    return NextResponse.json({
      result,
      requestId: Number(requestId),
      playerChoice: choice,
      success: true
    })

  } catch (error) {
    console.error('API Error:', error)

    // More specific error messages
    if (error instanceof Error) {
      if (error.message.includes('Timeout')) {
        return NextResponse.json(
          { error: 'VRF request timed out - please try again' },
          { status: 408 }
        )
      }
      if (error.message.includes('reverted')) {
        return NextResponse.json(
          { error: 'Transaction failed - check if you have tickets' },
          { status: 400 }
        )
      }
    }

    return NextResponse.json(
      { error: 'Internal server error - check console logs' },
      { status: 500 }
    )
  }
}

function determineWin(playerChoice: string, aiResult: number): boolean {
  const aiChoiceMap = ["rock", "paper", "scissors"]
  const aiChoice = aiChoiceMap[aiResult]

  if (playerChoice === aiChoice) return false // tie

  const winConditions = {
    rock: "scissors",
    paper: "rock",
    scissors: "paper"
  }

  return winConditions[playerChoice as keyof typeof winConditions] === aiChoice
}

Understanding the Flow

1. Request Initiation:

const hash = await walletClient.writeContract({
  address: RPS_ADDRESS,
  abi: ABI,
  functionName: 'request',
  args: [address]
})

The sponsor wallet calls the contract's request() function on behalf of the player.

2. Parse Request ID:

const choiceRequestedEvent = receipt.logs.find(log => {
  const decoded = decodeEventLog({ abi: ABI, data: log.data, topics: log.topics })
  return decoded.eventName === 'ChoiceRequested'
})

Extract the requestId from the ChoiceRequested event emitted by the contract.

3. Watch for VRF Result:

const unwatch = publicClient.watchEvent({
  address: RPS_ADDRESS,
  event: parseAbiItem('event ChoiceResult(uint256 indexed requestId, uint256 result)'),
  pollingInterval: 5,  // Poll every 5ms for fast feedback
  onLogs: (logs) => {
    // Find matching requestId and resolve
  }
})

This uses viem's watchEvent to poll for the ChoiceResult event. The 5ms polling interval ensures we catch the VRF result as soon as it's available.

4. Game Logic:

function determineWin(playerChoice: string, aiResult: number): boolean {
  const aiChoiceMap = ["rock", "paper", "scissors"]
  const aiChoice = aiChoiceMap[aiResult]

  if (playerChoice === aiChoice) return false // tie

  const winConditions = {
    rock: "scissors",
    paper: "rock",
    scissors: "paper"
  }

  return winConditions[playerChoice as keyof typeof winConditions] === aiChoice
}

Standard rock-paper-scissors logic: rock beats scissors, paper beats rock, scissors beats paper.

5. Reward Distribution:

if (playerWon) {
  const rewardHash = await walletClient.sendTransaction({
    to: address,
    value: BigInt(2000000000000000) // 0.002 ETH
  })
}

Winners automatically receive 0.002 ETH (double their 0.001 ETH ticket cost).

Key Concepts

Polling Interval:

pollingInterval: 5

This is in milliseconds. We use 5ms for extremely fast feedback. Standard is 1000ms (1 second). RISE's fast confirmations make aggressive polling viable.

Event Filtering:

if (log.args.requestId === requestId) {
  resolve(Number(log.args.result))
}

Multiple VRF requests may be in flight. We filter by requestId to match this specific game.

Timeout Handling:

const timeout = setTimeout(() => {
  unwatch()
  reject(new Error('Timeout waiting for VRF result (30s)'))
}, 30000)

VRF should respond in 3-5ms, but we allow 30 seconds as a safety net for network issues.

Error Handling

The API handles several error cases:

No Tickets:

{
  "error": "Transaction reverted - player may not have tickets",
  "status": 400
}

VRF Timeout:

{
  "error": "VRF request timed out - please try again",
  "status": 408
}

Reward Failed:

{
  "error": "Game completed but reward failed to send",
  "status": 500
}

Performance Optimization

Why 5ms polling?

RISE's shreds deliver confirmations in 3ms. With 5ms polling, we're guaranteed to catch the event in the first poll cycle after VRF fulfillment. This creates a nearly instant game experience.

Next Steps

The backend is complete! Next, we'll build the frontend UI that connects the wallet, displays the game, and calls this API.

Make sure your sponsor wallet has enough testnet ETH to pay for VRF requests and rewards.