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:
- Receives player moves from the frontend
- Calls the smart contract's
request()function (as the sponsor) - Watches for VRF fulfillment events with 5ms polling
- Determines the winner
- Sends ETH rewards to winners
- 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: 5This 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.