PVP Lobby System
Primitive

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

1. Create LobbySet player count, buy-in, payout mode, start condition
2. Players JoinOpponents join by invite code, direct ID, or matchmaking
3. Lobby LocksWhen startCondition is met, lobby status becomes 'locked'
4. Start GameCreator calls pvpStartGame to transition from 'locked' to 'playing'
5. PlayPlayers submit actions, rounds advance, deposits into pool
6. ResolveGame determines the winner and calls resolve with payout distribution. With resolutionType 'manual', only the game developer can resolve.
7. PayoutPlatform distributes funds, takes rake, reveals server seed

Lobby Status Lifecycle

Understanding the lobby status transitions is critical for building a working game.

1open → filling → locked → playing → resolved
2 ↘ cancelled
3
4open - Created, waiting for players
5filling - Has some players, below minPlayers
6locked - startCondition met (e.g. lobby full). MUST call pvpStartGame.
7playing - Game is active, actions can be submitted
8resolved - Game ended, payouts distributed
9cancelled - Lobby expired or all players left
When using startCondition: '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 100
6 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 games
13 enableChat: true, // opt-in: lobby chat panel for players & spectators
14};

Config Parameters

ParameterTypeDescription
rakePercentnumberRake as decimal (0.01 = 1%). Max 0.10. fee is accepted as alias.
minPlayersnumberMinimum players required to start (min 2)
maxPlayersnumberMaximum players allowed in the lobby (max 100)
buyInTypestring'fixed' | 'range' | 'free'
buyInAmountnumberBuy-in amount in stake units (for fixed type). If not set, uses the player's selected bet amount.
startConditionstring'full' (lobby fills up) | 'timer' (countdown) | 'manual' (host starts)
visibilitystring'public' (shown in lobby list) | 'private' (invite only) | 'matchmade' (auto-match with opponent)
payoutModestring'winner_takes_all' | 'proportional' | 'ranked_splits' | 'custom' | 'parimutuel'
resolutionTypestring'auto' (any player can resolve) | 'manual' (only the game developer can resolve — for live events, betting pools, etc.)
allowSpectatorsbooleanIf true, other players can watch active games in real time (opt-in)
enableChatbooleanIf true, a lobby chat panel is shown for players and spectators (opt-in)

Limits

ConstraintValue
Max rake10% (0.10)
Max players per lobby100
Lobby timeout10 minutes (auto-cancels and refunds if game hasn't started)
Min players2

PostMessage API

Your game runs inside a sandboxed iframe. You cannot call the platform API directly via 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, currency
12// }
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 units
12// currency, totalPool, payoutMode
13// }],
14// total
15// }
The lobby list returns 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 inviteCode
5 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 JSON
6 hidden: true, // optional, default false
7 serverRandom: { // optional: attach server-side randomness
8 nonce: 'round-1',
9 type: 'int',
10 min: 0, max: 1,
11 },
12}, '*');
13
14// Response:
15// { success, actionId, randomResult? }
hidden actions are not visible in 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 state
7}, '*');
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_all
6 payouts: { 'u1': 0.6 }, // for proportional/custom
7 rankings: ['u1', 'u2'], // for ranked_splits
8}, '*');
9
10// Response:
11// {
12// success, serverSeed, rakeCollected,
13// payouts: [{
14// userId, amount, amountUsd,
15// payoutType, // 'winnings' | 'refund'
16// status // 'finalized'
17// }]
18// }
The resolving player does NOT receive a 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 resolved
9// 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// total
6// }

Payout Modes

ModeResolve FieldDescription
winner_takes_allwinnerIdSingle winner gets entire pool minus rake
proportionalpayouts: { userId: fraction }Specify fractions per player (must sum to 1.0)
ranked_splitsrankings: [userId, ...]Provide rankings array; platform uses predefined split percentages
custompayouts: { userId: usdAmount }Exact USD amounts per player
parimutuelwinningSidePlayers 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 config
2window.GAME_CONFIG = {
3 primitive: 'pvp',
4 rakePercent: 0.05, // 5% rake
5 minPlayers: 2,
6 maxPlayers: 100,
7 buyInType: 'range', // players choose their bet size
8 buyInMin: 1,
9 buyInMax: 500,
10 startCondition: 'manual', // dev starts when ready
11 visibility: 'public',
12 payoutMode: 'parimutuel', // winners split pool proportional to bets
13 resolutionType: 'manual', // only dev can resolve
14};
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 pool
23}, '*');
With 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

payoutTypeMeaning
winningsPlayer won and received funds from the pool
refundPlayer 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 index
7 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:

MessageDirectionKey Response Fields
pvpCreateLobbyYou → PlatformlobbyId, status, totalPool, players[], commitHash, inviteCode, matched
pvpListLobbiesYou → Platformlobbies[{ id, playerCount, maxPlayers, buyInAmount }], total
pvpJoinLobbyYou → PlatformlobbyId, status, totalPool, players[]
pvpLeaveLobbyYou → Platformsuccess, refundedUsd
pvpStartGameYou → PlatformlobbyId, status, currentRound
pvpActionYou → Platformsuccess, actionId, randomResult? (if serverRandom was sent)
pvpDepositYou → Platformsuccess, deposit, totalPool
pvpUpdateStateYou → Platformsuccess
pvpAdvanceRoundYou → Platformsuccess, round
pvpResolveYou → Platformsuccess, payouts[], serverSeed, rakeCollected
pvpRandomYou → Platformresult (random outcome based on type)
pvpLobbyUpdatePlatform → YoulobbyId, lobby, players[], actions[], payouts[]
pvpLobbyListUpdatePlatform → Youlobbies[], 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: true
8// Your game should hide all action buttons when spectatorMode is true
9
10// To list active (in-progress) games for spectating:
11parent.postMessage({
12 type: 'pvpListLobbies',
13 status: 'playing', // only show games in progress
14 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 request
6 randomType: 'int', // 'int' | 'float' | 'pick' | 'shuffle' | 'check'
7 params: { min: 0, max: 1 },
8}, '*');
9
10// Response:
11// { result: { value: 0 } } ← int
12// { result: { value: 0.7321 } } ← float
13// { result: { indices: [2, 7] } } ← pick (k items from n)
14// { result: { order: [3,1,0,2] } } ← shuffle
15// { result: { hit: true } } ← check (is index in picked set?)

Random Types

TypeParamsResultUse Case
intmin, max{ value: number }Dice rolls, coin flips
float(none){ value: 0..1 }Probability checks
pickn, k{ indices: number[] }Selecting k items from n (sorted)
shufflen{ order: number[] }Shuffling a deck of n cards
checkn, 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 layout
8 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 }, ... }
Idempotent nonces: Using the same nonce + type + params always returns the same result. Use a fixed nonce (like "mines") when you need consistent results across multiple checks against the same layout. Use unique nonces (like "round-1-flip") for independent random events.
Verification: After the game resolves, the 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

No fetch() from iframeYour game runs sandboxed. Use parent.postMessage() for everything. Direct API calls will fail.
locked ≠ playingWith startCondition 'full', the lobby goes to 'locked' when full. You must call pvpStartGame to begin.
Resolver misses final updateAfter calling pvpResolve, you won't get a pvpLobbyUpdate. Handle the result from the resolve response directly.
Hidden actions are invisibleActions with hidden: true don't appear in pvpLobbyUpdate until the game is resolved. Use hidden: false if you need to count actions.
payoutType is 'winnings'Not 'winning'. Check payoutType === 'winnings' for winner detection.
Lobby list uses playerCountThe lobby list returns playerCount (a number), not a players array.
10-minute lobby timeoutLobbies auto-cancel and refund after 10 minutes if the game hasn't started.
One active lobby per user per gameCreating a new lobby cancels any existing open lobby by the same user for the same game.

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 lobby
48 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 immediately
58 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 events
101 window.addEventListener('message', (e) => {
102 // Handle request/response
103 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 game
114 if (lobby.status === 'locked' && iAmCreator) {
115 send({ type: 'pvpStartGame', lobbyId });
116 }
117
118 // Game started -> show choices
119 if (lobby.status === 'playing' && !myChoice) {
120 showChoices();
121 }
122
123 // Track visible actions -> resolve when all in
124 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 here
129 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.
The bot's seat is always 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.