Complete technical reference for RPS Arena - a real-time multiplayer blockchain game built on Base network with USDC payments.
RPS Arena is a real-time competitive multiplayer game where three players battle using Rock-Paper-Scissors mechanics in a 2D arena. The platform leverages blockchain technology for trustless payments and transparent prize distribution.
Canvas Renderer
WebSocket Client
EIP-6963 Wallets
Express HTTP
WebSocket Server
Game Loop (30Hz)
Users, Matches
Lobbies, Stats
WAL Mode
USDC Payments
Wallet Verification
Prize Distribution
The server operates on two ports for separation of concerns:
RPS Arena runs as a single instance due to in-memory WebSocket connections and game state. Horizontal scaling would require migration to Redis for session storage and PostgreSQL for distributed state management.
Three players compete in a real-time arena using Rock-Paper-Scissors combat mechanics. Each player is assigned a role (Rock, Paper, or Scissors) and must eliminate opponents by colliding with the role they beat.
| Collision Type | Result | Effect |
|---|---|---|
| Winning collision | Attacker wins | Defender eliminated instantly |
| Neutral collision (showdown) | Bounce | Both players pushed apart 10px |
| Losing collision | Defender wins | Attacker eliminated instantly |
The physics engine uses swept circle collision detection to handle fast-moving players. This technique checks for collisions along the entire path of movement, preventing players from passing through each other.
// Collision radius = PLAYER_RADIUS * 2 = 44px // Bounce distance: 10px standard, 25px large // Max bounce iterations per tick: 2 function checkCollision(p1, p2, deltaTime) { // Calculate relative velocity and trajectory const relVel = subtract(p1.velocity, p2.velocity); // Solve quadratic for intersection time // Returns collision point if within frame }
Players spawn at predetermined positions based on the deterministic RNG seed for the match:
// Spawn position calculation const spawnPoints = [ { x: ARENA_WIDTH / 2, y: ARENA_HEIGHT * 0.2 }, // Top center { x: ARENA_WIDTH * 0.2, y: ARENA_HEIGHT * 0.8 }, // Bottom left { x: ARENA_WIDTH * 0.8, y: ARENA_HEIGHT * 0.8 } // Bottom right ]; // Positions shuffled using seeded RNG for fairness
When only 2 players remain alive, the game transitions to Showdown Mode - a heart collection race that determines the winner.
| Message | Direction | Description |
|---|---|---|
SHOWDOWN_START | Server | Announces showdown mode, includes heart positions |
SHOWDOWN_READY | Server | Freeze period ended, race begins |
HEART_CAPTURED | Server | Heart captured by player (includes heartId, playerId, score) |
{
"id": "heart-1",
"x": 800,
"y": 450,
"captured": false,
"capturedBy": null
}
If both players reach 2 hearts on the same tick, the winner is determined by random selection to ensure fairness.
// Heart spawn algorithm function spawnHearts(players) { const hearts = []; for (let i = 0; i < 3; i++) { let pos = findValidPosition({ minEdgePadding: 100, minPlayerDistance: 150, minHeartDistance: 100 }); hearts.push({ id: `heart-${i}`, x: pos.x, y: pos.y }); } return hearts; }
RPS Arena uses an authoritative server model with client-side interpolation:
Disconnected players have 30 seconds to reconnect before automatic elimination. Features:
RECONNECT_STATE message on reconnect// Client reconnection with jittered exponential backoff const baseDelay = 1000; // 1 second const maxDelay = 30000; // 30 seconds const maxAttempts = 5; // Jitter prevents "thundering herd" on server restart const jitter = Math.random() * 0.5 + 0.5; // 50-100% const delay = Math.min(baseDelay * Math.pow(2, attempt) * jitter, maxDelay);
When a lobby times out (30 min since first join without filling to 3 players):
const config = { pingInterval: 5000, // 5 seconds maxMessageSize: 16384, // 16 KB connectionTimeout: 10000, // 10 seconds };
| Type | Description | Payload |
|---|---|---|
HELLO | Authenticate with session token | { token: string } |
JOIN_LOBBY | Join lobby with payment proof | { lobbyId: number, txHash: string } |
REQUEST_REFUND | Request timeout refund | { lobbyId: number } |
INPUT | Movement input | { dirX: -1|0|1, dirY: -1|0|1, seq: number } |
PING | Latency measurement | { timestamp: number } |
| Type | Description |
|---|---|
WELCOME | Authentication success with userId and serverTime |
LOBBY_LIST | List of all available lobbies with status |
LOBBY_UPDATE | Single lobby state change |
MATCH_STARTING | Match begins, countdown starts |
ROLE_ASSIGNMENT | Your assigned RPS role |
COUNTDOWN | Countdown timer (10...0) |
PREVIEW_START | 3-second preview phase begins (players see arena) |
GAME_START | Preview ended, movement enabled |
SNAPSHOT | Game state update (20 Hz) with all player positions |
ELIMINATION | Player eliminated event |
BOUNCE | Neutral collision bounce (showdown) |
SHOWDOWN_START | 2 players remain, heart positions sent |
SHOWDOWN_READY | Freeze ended, race begins |
HEART_CAPTURED | Player captured a heart |
MATCH_END | Match complete with winner and payouts |
REFUND_PROCESSED | Refund transaction confirmed |
PLAYER_DISCONNECT | Player disconnected (grace period countdown) |
PLAYER_RECONNECT | Player reconnected |
RECONNECT_STATE | Full state resync after reconnect |
TOKEN_UPDATE | New session token (rotation) |
PONG | Ping response with server timestamp |
ERROR | Error with code and message |
| Code | Description |
|---|---|
AUTH_REQUIRED | No valid session token provided |
AUTH_FAILED | Invalid or expired session token |
LOBBY_FULL | Lobby already has 3 players |
LOBBY_IN_PROGRESS | Match already started in this lobby |
PAYMENT_REQUIRED | No valid payment transaction provided |
PAYMENT_INVALID | Transaction verification failed |
ALREADY_IN_LOBBY | Player is already in a lobby |
RATE_LIMITED | Too many messages sent |
INTERNAL_ERROR | Server-side error occurred |
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/health | Health check (DB, game loop, deferred queue) |
| POST | /api/auth | SIWE authentication |
| POST | /api/logout | Invalidate session |
| GET | /api/lobbies | List all lobbies |
| GET | /api/player/:wallet | Get player stats |
| GET | /api/player/:wallet/history | Match history (paginated) |
| GET | /api/leaderboard | Top 100 players (all/monthly/weekly) |
| POST | /api/player/username | Set username (requires 1+ match) |
| POST | /api/player/photo | Upload profile photo (max 500KB) |
| GET | /api/dev-mode | Check if server is in development mode |
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/bot/add | Add bot player to lobby |
| POST | /api/bot/fill | Fill lobby with bots |
| GET | /api/bot/list | List active bots |
| POST | /api/bot/remove | Remove specific bot |
| POST | /api/dev/reset | Reset lobby state |
| POST | /api/admin/backup | Create database backup |
| GET | /api/admin/backups | List existing backups |
| POST | /api/admin/checkpoint | Manual WAL checkpoint |
| POST | /api/admin/backup/cleanup | Remove old backups |
id TEXT PRIMARY KEY, wallet_address TEXT UNIQUE, username TEXT, created_at TEXT, updated_at TEXT
id TEXT PRIMARY KEY, user_id TEXT, token TEXT UNIQUE, created_at TEXT, expires_at TEXT
id INTEGER PRIMARY KEY (1-12), status TEXT, deposit_address TEXT, deposit_private_key_encrypted TEXT, first_join_at TEXT, timeout_at TEXT, swept_at TEXT, current_match_id TEXT
id TEXT PRIMARY KEY, lobby_id INTEGER, user_id TEXT, payment_tx_hash TEXT, payment_confirmed_at TEXT, refund_tx_hash TEXT, refund_reason TEXT, refunded_at TEXT, joined_at TEXT
id TEXT PRIMARY KEY, lobby_id INTEGER, status TEXT (countdown|running|finished|void), winner_id TEXT, rng_seed TEXT, created_at TEXT, countdown_at TEXT, running_at TEXT, ended_at TEXT, payout_amount INTEGER, payout_tx_hash TEXT
id TEXT PRIMARY KEY, match_id TEXT, user_id TEXT, role TEXT (rock|paper|scissors), spawn_x REAL, spawn_y REAL, eliminated_at TEXT, eliminated_by TEXT, final_x REAL, final_y REAL
match_id TEXT PRIMARY KEY, version TEXT, tick INTEGER, status TEXT, state_json TEXT, updated_at TEXT
id TEXT PRIMARY KEY, match_id TEXT, tick INTEGER, event_type TEXT (start|elimination|bounce|disconnect|end), data TEXT (JSON), created_at TEXT
wallet_address TEXT PRIMARY KEY, username TEXT, profile_photo TEXT, total_matches INTEGER, wins INTEGER, losses INTEGER, total_earnings_usdc INTEGER, total_spent_usdc INTEGER, current_win_streak INTEGER, best_win_streak INTEGER, first_match_at TEXT, last_match_at TEXT, updated_at TEXT
id TEXT PRIMARY KEY, match_id TEXT, lobby_id INTEGER, recipient_address TEXT, amount_usdc INTEGER, attempt_number INTEGER, status TEXT (pending|success|failed), tx_hash TEXT, error_message TEXT, error_type TEXT (transient|permanent), source_wallet TEXT, treasury_balance_before INTEGER, created_at TEXT
username TEXT PRIMARY KEY, reserved_by TEXT, reserved_at TEXT
wallet_address TEXT PRIMARY KEY, first_payment_at TEXT, last_payment_at TEXT, total_payments INTEGER
Composite indexes optimized for: user lookups, session validation, lobby status queries, leaderboard (wins DESC, earnings DESC), match history (user + date), time-filtered stats (monthly/weekly).
Critical (fail immediately): User creation, sessions, match creation, payouts
Non-critical (queue on BUSY): Stats updates, event logging, profile changes
// RPC provider priority order: 1. Primary: BASE_RPC_URL (Alchemy/Infura) 2. Fallback: base.publicnode.com 3. Fallback: 1rpc.io/base 4. Fallback: mainnet.base.org // Health checks every 60 seconds // Tests: block number retrieval + latency // Auto-failover on provider failure
| Error Type | Action | Examples |
|---|---|---|
| Transient | Retry (3x, exponential backoff) | ETIMEDOUT, ECONNRESET, 429, 502-504 |
| Permanent | Fail immediately, alert | Insufficient funds, nonce too low, execution reverted |
// BIP-44 derivation path const path = "m/44'/60'/0'/0/{index}"; // Treasury wallet: index 0 (from TREASURY_MNEMONIC) // Lobby wallets: indices 1-12 (from LOBBY_WALLET_SEED) // Private key encryption: AES-256-GCM // Format: iv:authTag:ciphertext (hex-encoded)
After match completion, lobby wallets are swept to treasury:
Discord alerts triggered when wallet ETH balance falls below 0.001 ETH.
Session tokens are rotated on every WebSocket reconnection. Old tokens are immediately invalidated, preventing replay attacks during the reconnection grace period.
Multi-wallet detection supporting: MetaMask, Rabby, Coinbase Wallet, Trust Wallet, and any EIP-6963 compatible wallet. Fallback to window.ethereum for legacy support.
| Filter | Time Range | Sort Order |
|---|---|---|
| All Time | All matches ever | Wins DESC, Earnings DESC |
| Monthly | Current calendar month | Wins DESC, Earnings DESC |
| Weekly | Since last Monday | Wins DESC, Earnings DESC |
All WebSocket messages validated against strict JSON schemas. Whitelist of allowed message types. Coordinate bounds checking. Direction validation (-1, 0, 1 only). Input sequence tracking for anti-cheat.
INPUT: 120/sec. Other: 10/sec. Connections: 3/IP. Message size: 16KB. Per-IP tracking with 1-hour cleanup to prevent memory leaks.
Private keys encrypted at rest (AES-256-GCM). Deterministic HD derivation (BIP-44). No plaintext keys in logs or errors. Signature verification via SIWE.
Cryptographically random 64-char tokens. Expiration enforcement. Per-user isolation. Token rotation on reconnect prevents replay attacks.
3-block confirmations. Exact amount verification. Hardcoded contract addresses. Nonce management via ethers.js. Gas estimation with buffers.
Scrubs sensitive data (mnemonics, keys). 5% sampling in production. Ignores non-critical errors (WebSocket closes, rate limits). Breadcrumb tracking for debugging.
// Transaction hash validation const TX_HASH_REGEX = /^0x[a-fA-F0-9]{64}$/; // Admin/bot tx patterns (port 3001 only) const ADMIN_TX_REGEX = /^0x(dev_|bot_tx_)[a-zA-Z0-9_]+$/; // Username validation const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/; // Wallet address validation (EIP-55 checksummed) ethers.isAddress(address);
Bot features are only available on port 3001. They are intended for testing and development, not production gameplay.
0xbot{counter}0xbot_tx_{timestamp}_{userId}isBot: true in state// Bot targeting logic if (showdownMode) { target = nearestUncapturedHeart; } else { target = enemyThatBotCanBeat; } moveTowardTarget(bot, target);
App initialization, module orchestration, global state.
WebSocket client, message handling, jittered exponential backoff reconnection.
EIP-6963 multi-wallet detection, connection, signing, fallback to window.ethereum.
Screen management (7 screens), modals, DOM updates, tab navigation.
Canvas 2D rendering, player tokens with role colors, hearts, arena grid, role icons.
Keyboard event tracking (WASD + Arrows), sequence numbers for anti-cheat.
Linear position interpolation between server snapshots for smooth 60fps rendering.
Victory celebration particle animation system.
Animated lobby canvas background with floating RPS icons.
npm start # Production server npm run dev # Development with nodemon npm run init-db # Manual database initialization npm run test:security # Security tests (--production flag available) npm run test:load # Load testing npm run test:e2e # End-to-end testing
{
"status": "healthy",
"database": { "connected": true, "journalMode": "wal" },
"deferredQueue": { "size": 0 },
"gameLoop": { "healthy": true, "activeMatches": 2 }
}
On startup, the server initializes components in this order:
On SIGTERM/SIGINT, the server shuts down in this order:
PRAGMA integrity_check./backups/ directory| Variable | Description |
|---|---|
PORT | HTTP server port (Railway sets this) |
ADMIN_PORT | Admin/testing server port (default: 3001) |
NODE_ENV | Environment mode (production/development) |
DATABASE_PATH | SQLite file path |
BASE_RPC_URL | Primary RPC endpoint (Alchemy/Infura) |
CHAIN_ID | Blockchain network ID (8453 for Base) |
USDC_CONTRACT_ADDRESS | USDC token contract address |
TREASURY_MNEMONIC | Treasury wallet 12-word seed phrase |
LOBBY_WALLET_SEED | Lobby wallets 12-word seed phrase |
WALLET_ENCRYPTION_KEY | 32-byte hex key for AES-256 |
| Variable | Default | Description |
|---|---|---|
SESSION_EXPIRY_HOURS | 24 | Session token expiration |
GAME_TICK_RATE | 30 | Physics ticks per second |
GAME_ARENA_WIDTH | 1600 | Arena width in pixels |
GAME_ARENA_HEIGHT | 900 | Arena height in pixels |
GAME_MAX_SPEED | 450 | Max player speed (px/sec) |
GAME_PLAYER_RADIUS | 22 | Player collision radius |
COUNTDOWN_DURATION | 10 | Role reveal countdown (seconds) |
BUY_IN_AMOUNT | 1000000 | Entry fee (micro-USDC) |
WINNER_PAYOUT | 2400000 | Winner payout (micro-USDC) |
TREASURY_CUT | 600000 | Platform fee (micro-USDC) |
RATE_LIMIT_INPUT | 120 | INPUT messages per second |
RATE_LIMIT_OTHER | 10 | Other messages per second |
MAX_CONNECTIONS_PER_IP | 3 | Concurrent connections per IP |
RECONNECT_GRACE_PERIOD | 30 | Disconnect grace period (seconds) |
DISCORD_WEBHOOK_URL | - | Critical alerts webhook |
DISCORD_ACTIVITY_WEBHOOK_URL | - | Activity logs webhook |
SENTRY_DSN | - | Sentry error tracking DSN |
LOG_LEVEL | info | Winston log level |
BACKUP_INTERVAL_HOURS | 1 | Backup frequency |
BACKUP_KEEP_COUNT | 24 | Backups to retain |
DEBUG_PHYSICS | false | Physics debug logging |
DEBUG_MATCH | false | Match debug logging |
LOBBY_TIMEOUT_MINUTES | 30 | Lobby timeout before refund available |
PREVIEW_DURATION | 3 | Preview phase duration (seconds) |
SHOWDOWN_FREEZE_DURATION | 3 | Showdown freeze duration (seconds) |
STATE_PERSIST_INTERVAL | 5 | Ticks between state persistence |
HEART_PICKUP_RADIUS | 30 | Heart collection radius (pixels) |
MIN_SPAWN_DISTANCE | 200 | Minimum distance between spawn points |