Full lobby-based multiplayer. You control the game — we handle the money.
Create lobbies, manage rounds, submit actions, and resolve payouts.
How It Works
You define:
Game rules, actions, rounds, resolution logic
Platform handles:
Lobby management, escrow, pool, payouts, real-time sync
Game Flow
Lobby Status Lifecycle
Understanding the lobby status transitions is critical for building a working game.
1open → filling → locked → playing → resolved2 ↘ cancelled3
4open - Created, waiting for players5filling - Has some players, below minPlayers6locked - startCondition met (e.g. lobby full). MUST call pvpStartGame.7playing - Game is active, actions can be submitted8resolved - Game ended, payouts distributed9cancelled - Lobby expired or all players leftstartCondition: 'full', the lobby automatically transitions to locked (not playing) when it fills up. You must call pvpStartGame to start the game. Actions submitted before this will fail with "Lobby is not in playing state".Game Config
1window.GAME_CONFIG = {2 primitive: 'pvp',3 rakePercent: 0.01, // 1% rake (max 0.10 / 10%)4 minPlayers: 2,5 maxPlayers: 4, // max 1006 buyInType: 'fixed', // 'fixed' | 'range' | 'free'7 buyInAmount: 10, // for fixed (in stake units)8 startCondition: 'full', // 'full' | 'timer' | 'manual'9 visibility: 'public', // 'public' | 'private' | 'matchmade'10 payoutMode: 'winner_takes_all', // 'winner_takes_all' | 'proportional' | 'ranked_splits' | 'custom' | 'parimutuel'11 resolutionType: 'auto', // 'auto' | 'manual'12 allowSpectators: true, // opt-in: let others watch live games13 enableChat: true, // opt-in: lobby chat panel for players & spectators14};Config Parameters
| Parameter | Type | Description |
|---|---|---|
| rakePercent | number | Rake as decimal (0.01 = 1%). Max 0.10. fee is accepted as alias. |
| minPlayers | number | Minimum players required to start (min 2) |
| maxPlayers | number | Maximum players allowed in the lobby (max 100) |
| buyInType | string | 'fixed' | 'range' | 'free' |
| buyInAmount | number | Buy-in amount in stake units (for fixed type). If not set, uses the player's selected bet amount. |
| startCondition | string | 'full' (lobby fills up) | 'timer' (countdown) | 'manual' (host starts) |
| visibility | string | 'public' (shown in lobby list) | 'private' (invite only) | 'matchmade' (auto-match with opponent) |
| payoutMode | string | 'winner_takes_all' | 'proportional' | 'ranked_splits' | 'custom' | 'parimutuel' |
| resolutionType | string | 'auto' (any player can resolve) | 'manual' (only the game developer can resolve — for live events, betting pools, etc.) |
| allowSpectators | boolean | If true, other players can watch active games in real time (opt-in) |
| enableChat | boolean | If true, a lobby chat panel is shown for players and spectators (opt-in) |
Limits
| Constraint | Value |
|---|---|
| Max rake | 10% (0.10) |
| Max players per lobby | 100 |
| Lobby timeout | 10 minutes (auto-cancels and refunds if game hasn't started) |
| Min players | 2 |
PostMessage API
fetch(). All communication must go through parent.postMessage() and the response will arrive via window.addEventListener('message').1. Create a Lobby
1parent.postMessage({2 type: 'pvpCreateLobby',3 id: Date.now(),4}, '*');5
6// Response:7// {8// lobbyId, status, totalPool, players[],9// commitHash, inviteCode?, matched?,10// minPlayers, maxPlayers, startCondition,11// stakeUsd, buyInAmount, currency12// }13//14// If visibility is 'matchmade' and an opponent is found:15// matched: true (game may auto-start)2. List Open Lobbies
The platform automatically pushes pvpLobbyListUpdate to your iframe in real time whenever lobbies are created, joined, or resolved. You can also request the list manually:
1parent.postMessage({2 type: 'pvpListLobbies',3 id: Date.now(),4}, '*');5
6// Response:7// {8// lobbies: [{9// id, gameSlug, status, minPlayers, maxPlayers,10// playerCount, // number (NOT a players array)11// buyInAmount, // in crypto units12// currency, totalPool, payoutMode13// }],14// total15// }playerCount (a number), not a players array. Use lobby.playerCount not lobby.players.length.3. Join a Lobby
1parent.postMessage({2 type: 'pvpJoinLobby',3 id: Date.now(),4 lobbyId: '...', // or use inviteCode5 inviteCode: '...',6}, '*');7
8// Response:9// { lobbyId, status, totalPool, players[], stakeUsd }4. Leave a Lobby
1parent.postMessage({2 type: 'pvpLeaveLobby',3 id: Date.now(),4 lobbyId: '...',5}, '*');6
7// Response:8// { success, refundedUsd }5. Start the Game
Required after the lobby reaches locked status. Only the lobby creator should call this.
1parent.postMessage({2 type: 'pvpStartGame',3 id: Date.now(),4 lobbyId: '...',5}, '*');6
7// Response:8// { lobbyId, status: 'playing', currentRound }6. Submit an Action
1parent.postMessage({2 type: 'pvpAction',3 id: Date.now(),4 lobbyId: '...',5 action: { move: 'rock' }, // any JSON6 hidden: true, // optional, default false7 serverRandom: { // optional: attach server-side randomness8 nonce: 'round-1',9 type: 'int',10 min: 0, max: 1,11 },12}, '*');13
14// Response:15// { success, actionId, randomResult? }pvpLobbyUpdate until the game is resolved. If you need to detect when all players have acted, either use hidden: false, or track action counts via pvpUpdateState.7. Deposit to Pool (mid-game)
1parent.postMessage({2 type: 'pvpDeposit',3 id: Date.now(),4 lobbyId: '...',5 depositType: 'bet', // 'bet' | 'raise'6}, '*');7
8// Response:9// { success, deposit, totalPool }8. Update Game State
1parent.postMessage({2 type: 'pvpUpdateState',3 id: Date.now(),4 lobbyId: '...',5 gameState: { board: [...] }, // public state (visible to all)6 playerStates: { 'userId': {} }, // per-player private state7}, '*');8
9// Response:10// { success }9. Advance Round
1parent.postMessage({2 type: 'pvpAdvanceRound',3 id: Date.now(),4 lobbyId: '...',5 roundAction: 'advance', // 'advance' | 'update-phase'6 phase: 'action',7}, '*');8
9// Response:10// { success, round: { roundNumber, phase } }10. Resolve Game
1parent.postMessage({2 type: 'pvpResolve',3 id: Date.now(),4 lobbyId: '...',5 winnerId: 'userId', // for winner_takes_all6 payouts: { 'u1': 0.6 }, // for proportional/custom7 rankings: ['u1', 'u2'], // for ranked_splits8}, '*');9
10// Response:11// {12// success, serverSeed, rakeCollected,13// payouts: [{14// userId, amount, amountUsd,15// payoutType, // 'winnings' | 'refund'16// status // 'finalized'17// }]18// }pvpLobbyUpdate after resolving. The platform unsubscribes the resolver from lobby events. Handle the result directly from the pvpResolve response. Other players will receive the result via pvpLobbyUpdate with status: 'resolved'.Platform Push Events
pvpLobbyUpdate
Pushed to your iframe whenever the lobby state changes — player joins/leaves, game starts, actions submitted, resolved, or cancelled.
1// Pushed to your iframe:2// {3// type: 'pvpLobbyUpdate',4// lobbyId,5// lobby: { status, totalPool, gameState, currentRound, ... },6// players: [{ userId, seatIndex, status, totalDeposited }],7// actions: [{ playerId, action, hidden, createdAt }],8// payouts: [{ userId, amount, amountUsd, payoutType }], // only when resolved9// currentRound: { roundNumber, phase, deadline }10// }pvpLobbyListUpdate
Pushed automatically when any lobby is created, joined, or resolved. Use this to show a live lobby browser.
1// Pushed to your iframe:2// {3// type: 'pvpLobbyListUpdate',4// lobbies: [{ id, playerCount, maxPlayers, buyInAmount, currency, ... }],5// total6// }Payout Modes
| Mode | Resolve Field | Description |
|---|---|---|
| winner_takes_all | winnerId | Single winner gets entire pool minus rake |
| proportional | payouts: { userId: fraction } | Specify fractions per player (must sum to 1.0) |
| ranked_splits | rankings: [userId, ...] | Provide rankings array; platform uses predefined split percentages |
| custom | payouts: { userId: usdAmount } | Exact USD amounts per player |
| parimutuel | winningSide | Players bet on sides; winning side splits pool proportional to deposits |
Manual Resolution (Live Event Betting)
Set resolutionType: 'manual' to restrict who can resolve a game. With manual resolution, only the game developer (the user who uploaded the game) can call pvpResolve. Regular players cannot resolve the game — they can only place bets and wait for the result.
This is designed for live event betting — crab fights, sports, streamer challenges, coin tosses on camera, or anything where a human operator determines the outcome instead of game code.
1// Example: Live betting game config2window.GAME_CONFIG = {3 primitive: 'pvp',4 rakePercent: 0.05, // 5% rake5 minPlayers: 2,6 maxPlayers: 100,7 buyInType: 'range', // players choose their bet size8 buyInMin: 1,9 buyInMax: 500,10 startCondition: 'manual', // dev starts when ready11 visibility: 'public',12 payoutMode: 'parimutuel', // winners split pool proportional to bets13 resolutionType: 'manual', // only dev can resolve14};15
16// Players pick a side (stored in privateState via pvpUpdateState)17// Dev watches the live event, then resolves:18parent.postMessage({19 type: 'pvpResolve',20 id: Date.now(),21 lobbyId: '...',22 winningSide: 'team_a', // everyone who picked team_a splits the pool23}, '*');resolutionType: 'manual', the game developer must be logged in and viewing the game to resolve it. If a non-developer player tries to call pvpResolve, they will get a 403 error. The developer does not need to be a player in the lobby.Payout Types in Response
| payoutType | Meaning |
|---|---|
| winnings | Player won and received funds from the pool |
| refund | Player was refunded (lobby cancelled, game draw, etc.) |
Detecting Win/Loss
Payouts include all players. To determine if the current player won, you need to find their specific payout entry by matching userId.
1// In your pvpLobbyUpdate handler:2if (lobby.status === 'resolved') {3 const players = event.players || [];4 const payouts = event.payouts || [];5
6 // Find current player by seat index7 const mySeat = iAmCreator ? 0 : 1;8 const me = players.find(p => p.seatIndex === mySeat);9 const myPayout = me ? payouts.find(p => p.userId === me.userId) : null;10
11 const won = myPayout && myPayout.amountUsd > 0;12}13
14// For the resolver (who called pvpResolve):15// Handle the result directly from the pvpResolve response,16// since you won't receive a pvpLobbyUpdate.Message Quick Reference
All outgoing messages you send and the push events you receive, at a glance:
| Message | Direction | Key Response Fields |
|---|---|---|
| pvpCreateLobby | You → Platform | lobbyId, status, totalPool, players[], commitHash, inviteCode, matched |
| pvpListLobbies | You → Platform | lobbies[{ id, playerCount, maxPlayers, buyInAmount }], total |
| pvpJoinLobby | You → Platform | lobbyId, status, totalPool, players[] |
| pvpLeaveLobby | You → Platform | success, refundedUsd |
| pvpStartGame | You → Platform | lobbyId, status, currentRound |
| pvpAction | You → Platform | success, actionId, randomResult? (if serverRandom was sent) |
| pvpDeposit | You → Platform | success, deposit, totalPool |
| pvpUpdateState | You → Platform | success |
| pvpAdvanceRound | You → Platform | success, round |
| pvpResolve | You → Platform | success, payouts[], serverSeed, rakeCollected |
| pvpRandom | You → Platform | result (random outcome based on type) |
| pvpLobbyUpdate | Platform → You | lobbyId, lobby, players[], actions[], payouts[] |
| pvpLobbyListUpdate | Platform → You | lobbies[], total |
Spectator Mode (opt-in)
Set allowSpectators: true in GAME_CONFIG to let other users watch live games. Spectators receive pvpLobbyUpdate events with spectatorMode: true — all action buttons should be hidden when this flag is set.
1// To spectate a lobby:2parent.postMessage({ type: 'pvpSpectate', lobbyId: '...' }, '*');3
4// To stop spectating:5parent.postMessage({ type: 'pvpStopSpectating' }, '*');6
7// pvpLobbyUpdate will include spectatorMode: true8// Your game should hide all action buttons when spectatorMode is true9
10// To list active (in-progress) games for spectating:11parent.postMessage({12 type: 'pvpListLobbies',13 status: 'playing', // only show games in progress14 id: 'req-123'15}, '*');Lobby Chat (opt-in)
Set enableChat: true in GAME_CONFIG to show a lobby chat panel. Chat is handled entirely by the platform — your game does not need to implement anything. Players and spectators in the same lobby can send text messages (max 200 chars, 1 msg/sec rate limit). Messages are ephemeral and not persisted.
Server-Side Randomness (Provably Fair)
Every lobby gets a serverSeed (hidden) and commitHash (SHA-256 of the seed, visible to players). After the game resolves, the serverSeed is revealed so players can verify all random outcomes. Two access patterns are available:
Standalone: pvpRandom
Use when you need a full random result visible to both players (coin flips, deck shuffles, etc.).
1parent.postMessage({2 type: 'pvpRandom',3 id: Date.now(),4 lobbyId: '...',5 nonce: 'coin-flip-1', // unique string per random request6 randomType: 'int', // 'int' | 'float' | 'pick' | 'shuffle' | 'check'7 params: { min: 0, max: 1 },8}, '*');9
10// Response:11// { result: { value: 0 } } ← int12// { result: { value: 0.7321 } } ← float13// { result: { indices: [2, 7] } } ← pick (k items from n)14// { result: { order: [3,1,0,2] } } ← shuffle15// { result: { hit: true } } ← check (is index in picked set?)Random Types
| Type | Params | Result | Use Case |
|---|---|---|---|
| int | min, max | { value: number } | Dice rolls, coin flips |
| float | (none) | { value: 0..1 } | Probability checks |
| pick | n, k | { indices: number[] } | Selecting k items from n (sorted) |
| shuffle | n | { order: number[] } | Shuffling a deck of n cards |
| check | n, k, index | { hit: boolean } | Is a specific index in the picked set? |
Inline: serverRandom on pvpAction
Use when you want to attach a random outcome to a player action — the result is computed server-side, stored on the action record, and broadcast to all players via pvpLobbyUpdate. This is ideal for progressive reveals (e.g., checking if a clicked tile has a mine).
1parent.postMessage({2 type: 'pvpAction',3 id: Date.now(),4 lobbyId: '...',5 action: { index: 5 },6 serverRandom: {7 nonce: 'mines', // fixed nonce = consistent layout8 type: 'check',9 n: 25,10 k: 3,11 index: 5,12 },13}, '*');14
15// Response:16// { success: true, actionId: '...', randomResult: { hit: false } }17
18// pvpLobbyUpdate actions[] will include:19// { playerId, action, randomResult: { hit: false }, ... }"mines") when you need consistent results across multiple checks against the same layout. Use unique nonces (like "round-1-flip") for independent random events.serverSeed is revealed. Players can verify any result by computing HMAC-SHA256(serverSeed, nonce) and running the same algorithm. Visit the Provably Fair page to verify.Common Gotchas
Balance Reveal Timing
Send balanceReveal after your reveal animation finishes. A 5-second fallback fires automatically if you never send it.
1parent.postMessage({ type: 'balanceReveal' }, '*');Complete Template
A working coin flip game demonstrating the full PvP lifecycle: lobby creation, joining, starting, actions, resolution, and win detection.
1<!DOCTYPE html>2<html>3<head><title>PVP Coin Flip</title></head>4<body>5 <button id="playBtn" onclick="play()">PLAY</button>6 <div id="status"></div>7 <div id="choices" style="display:none">8 <button onclick="pick('heads')">Heads</button>9 <button onclick="pick('tails')">Tails</button>10 </div>11 <div id="result"></div>12 <script>13 window.GAME_CONFIG = {14 primitive: 'pvp',15 rakePercent: 0.01,16 minPlayers: 2,17 maxPlayers: 2,18 buyInType: 'fixed',19 startCondition: 'full',20 visibility: 'public',21 payoutMode: 'winner_takes_all',22 };23
24 let lobbyId = null;25 let iAmCreator = false;26 let myChoice = null;27 let latestActions = [];28 const pending = new Map();29 let reqId = 0;30
31 function send(data) {32 return new Promise((resolve, reject) => {33 const id = ++reqId;34 pending.set(id, { resolve, reject });35 const payload = { ...data, id };36 if (window.TEST_MODE) payload.gameConfig = window.GAME_CONFIG;37 parent.postMessage(payload, '*');38 setTimeout(() => {39 if (pending.has(id)) {40 pending.delete(id);41 reject(new Error('Timeout'));42 }43 }, 30000);44 });45 }46
47 // Step 1: Create a lobby48 async function play() {49 document.getElementById('playBtn').style.display = 'none';50 document.getElementById('status').textContent = 'Waiting for opponent...';51 iAmCreator = true;52
53 const d = await send({ type: 'pvpCreateLobby' });54 if (d.error) return alert(d.error);55 lobbyId = d.lobbyId;56
57 // If matchmade and opponent found immediately58 if (d.matched) showChoices();59 }60
61 function showChoices() {62 document.getElementById('status').textContent = 'Pick heads or tails!';63 document.getElementById('choices').style.display = 'block';64 }65
66 // Step 2: Submit action (not hidden, so we can count them)67 async function pick(choice) {68 myChoice = choice;69 document.getElementById('choices').style.display = 'none';70 document.getElementById('status').textContent = 'Waiting for opponent...';71 await send({ type: 'pvpAction', lobbyId, action: { choice } });72 }73
74 // Step 3: Resolve (creator only, when both actions are in)75 async function tryResolve() {76 if (!iAmCreator || latestActions.length < 2) return;77
78 // Use server-side randomness (provably fair)79 const flip = await send({80 type: 'pvpRandom', lobbyId,81 nonce: 'coin-flip', randomType: 'int',82 params: { min: 0, max: 1 },83 });84 const coinResult = flip.result.value === 0 ? 'heads' : 'tails';85 const winner = latestActions.find(a => a.action?.choice === coinResult);86 const d = await send({87 type: 'pvpResolve',88 lobbyId,89 winnerId: winner?.playerId || latestActions[0].playerId,90 });91
92 if (!d.error) {93 // Resolver must handle result here (won't get pvpLobbyUpdate)94 const won = myChoice === coinResult;95 document.getElementById('result').textContent = won ? 'YOU WIN!' : 'YOU LOSE';96 parent.postMessage({ type: 'balanceReveal' }, '*');97 }98 }99
100 // Listen for platform events101 window.addEventListener('message', (e) => {102 // Handle request/response103 if (e.data.id && pending.has(e.data.id)) {104 pending.get(e.data.id).resolve(e.data);105 pending.delete(e.data.id);106 return;107 }108
109 if (e.data.type === 'pvpLobbyUpdate') {110 const lobby = e.data.lobby;111 if (!lobby) return;112
113 // Lobby filled -> creator starts the game114 if (lobby.status === 'locked' && iAmCreator) {115 send({ type: 'pvpStartGame', lobbyId });116 }117
118 // Game started -> show choices119 if (lobby.status === 'playing' && !myChoice) {120 showChoices();121 }122
123 // Track visible actions -> resolve when all in124 const actions = e.data.actions || [];125 if (actions.length > 0) latestActions = actions;126 if (actions.length >= 2) tryResolve();127
128 // Non-resolver sees the result here129 if (lobby.status === 'resolved') {130 const payouts = e.data.payouts || [];131 const players = e.data.players || [];132 const mySeat = iAmCreator ? 0 : 1;133 const me = players.find(p => p.seatIndex === mySeat);134 const myPayout = me ? payouts.find(p => p.userId === me.userId) : null;135 const won = myPayout && myPayout.amountUsd > 0;136 document.getElementById('result').textContent = won ? 'YOU WIN!' : 'YOU LOSE';137 parent.postMessage({ type: 'balanceReveal' }, '*');138 }139 }140 });141
142 parent.postMessage({ type: 'gameReady', primitive: 'pvp' }, '*');143 </script>144</body>145</html>Demo / Test Mode Bot
In demo mode (not logged in) and test mode (preview dialog), the platform auto-creates a bot opponent and starts the game immediately — no second player needed. However, the bot has no game logic by default. You must implement the bot's turn yourself so the game is playable in single-player preview.
Detect demo/test mode via window.TEST_MODE or window.PREVIEW_MODE (both are set automatically). The bot is always seat 1. When it's the bot's turn, pick a random valid action and call your normal action handler.
// Example: bot logic for a turn-based tile game
let isDemoMode = false;
if (window.TEST_MODE || window.PREVIEW_MODE) isDemoMode = true;
// Also set it when receiving a pvpLobbyUpdate with demoMode flag
// e.data.demoMode → isDemoMode = true
function scheduleBotTurn() {
if (!isDemoMode || phase !== 'playing') return;
if (currentTurn !== 1) return; // bot is seat 1
// Pick a random valid action for YOUR game
const validMoves = getAvailableMoves(); // you define this
const pick = validMoves[Math.floor(Math.random() * validMoves.length)];
setTimeout(() => {
if (phase !== 'playing' || currentTurn !== 1) return;
doAction(pick, 1); // pass seat 1 so mySeat stays 0
}, 600 + Math.random() * 800);
}
// Call scheduleBotTurn() at the end of your UI update function
// so it triggers automatically after each player move.1. Never mutate mySeat for the bot — pass the acting seat as a parameter to your action handler instead, so UI labels (YOU / THEM) stay correct. For live play, real opponents join via matchmaking or lobby codes.