Custom state transitions — build any multi-step game with branching logic.
Define states, transitions, and probabilities. The platform handles RNG, payouts, and session persistence. Good for blackjack, poker, adventure/RPG, complex custom games.
How It Works
You define:
States, transitions, multipliers, and terminal conditions
Platform handles:
Transition RNG, session persistence, payout calculation
What is an MDP?
MDP stands for Markov Decision Process — but you don't need a math degree to use it. Think of it as a flowchart for your game:
Each arrow has a probability. The platform rolls the dice at each step and follows the arrow.
You define the boxes (states), the arrows (transitions), and the probabilities. The platform validates that the math produces a fair game (≤ 97% RTP) and handles all the randomness. If you've ever drawn a game flow on paper — "if the player hits, there's a 30% chance they bust" — you already understand the concept.
Core Concepts
| Concept | Description |
|---|---|
| State | A named position in the game (e.g. 'deal', 'player_turn', 'bust') |
| Transition | A possible move from one state to another, with a probability |
| Action | Player input that triggers a transition (e.g. 'hit', 'stand') |
| Terminal State | A state that ends the game and determines the payout multiplier |
| Initial State | Where every new session begins |
Game Config
State machine games need two things: a GAME_CONFIG declaring the primitive, and a GAME_GENERATOR object that defines the MDP (Markov Decision Process). The platform extracts the generator, runs it in a sandbox, and validates RTP via value iteration.
1// 1. Declare the primitive2window.GAME_CONFIG = {3 primitive: 'state-machine'4};5
6// 2. Define the MDP (Markov Decision Process)7window.GAME_GENERATOR = {8 // Return all possible starting states9 // Each entry: [[stateKey, stateData], probability, deltaWager, payout]10 // payout = null → game begins (non-terminal)11 // payout = number → game is immediately over with that payout12 initialStates: function() {13 return [[['start', 0], 1.0, 0, null]];14 },15
16 // Return allowed player actions for a given state17 // Return [] for terminal states18 allowedActions: function(state) {19 if (state[0] === 'win' || state[0] === 'lose') return [];20 return ['flip'];21 },22
23 // Return possible transitions for a (state, action) pair24 // Each entry: [[nextState, nextStateData], probability, deltaWager, payout]25 // payout = null → non-terminal (game continues)26 // payout = number → terminal (game ends, player receives payout × stake)27 transitions: function(state, action) {28 if (action === 'flip') {29 return [30 [['win', 0], 0.485, 0, 2.0], // 48.5% → win 2x31 [['lose', 0], 0.515, 0, 0] // 51.5% → lose32 ];33 }34 return [];35 },36
37 // Return true if this state is terminal (game over)38 // Must be consistent with allowedActions — terminal states must return []39 isTerminal: function(state) {40 return state[0] === 'win' || state[0] === 'lose';41 }42};Generator Functions
| Function | Returns | Description |
|---|---|---|
| initialStates() | Array of [state, prob, deltaWager, payout] | All possible starting configurations. payout=null for non-terminal. |
| allowedActions(state) | string[] | Valid player actions. Return [] for terminal states. |
| transitions(state, action) | Array of [nextState, prob, deltaWager, payout] | Possible outcomes. payout=null if game continues, number if game ends. |
| isTerminal(state) | boolean | Returns true if the state is terminal (game over). Must match allowedActions — if isTerminal returns true, allowedActions must return []. |
Transition Tuple Fields
| Index | Field | Description |
|---|---|---|
| 0 | nextState | [stateKey, stateData] — the state to transition to |
| 1 | probability | Chance of this transition (all probs for an action must sum to 1.0) |
| 2 | deltaWager | Additional wager added during this transition (usually 0). Set to a number > 0 for games where the player must wager more mid-game (e.g. doubling down in blackjack). |
| 3 | payout | null = game continues, number = terminal payout multiplier (0 = loss, 2.0 = 2× win) |
Generator at a Glance
A quick cheat sheet for the four generator functions:
| Function | Input | Output | When Called |
|---|---|---|---|
| initialStates() | (none) | Array of starting tuples | Once, when session begins |
| allowedActions(state) | [key, data] | string[] of action names | Each step, to show player options |
| transitions(state, action) | [key, data], string | Array of outcome tuples | When player takes an action |
| isTerminal(state) | [key, data] | boolean | Each step, to check if game is over |
State Format
Each state is a 2-element array: [stateKey, stateData] wherestateKey is a string identifier and stateData is any serializable value (number, object, etc.) for tracking game-specific data.
null for non-terminal transitions. If a transition leads to a state where allowedActions() returns actions (i.e. the game continues), the payout must be null. Only set a numeric payout when the game is ending (terminal state). The platform enforces this at upload time and at runtime — a numeric payout on a non-terminal transition will be rejected.PostMessage API
State machine games use two messages: start and action. Responses are matched by id.
1. Start a Session
1parent.postMessage({2 type: 'stateMachineStart',3 id: Date.now(), // Unique request ID4 stake: window.GAME_STAKE,5 currency: window.GAME_CURRENCY6}, '*');7
8// Response (matched by id):9// {10// sessionId: "abc123",11// currentState: ["start", 0], — current state [key, data]12// allowedActions: ["flip"],13// totalWager: 1, — total wagered so far14// status: "playing", — "playing" or "finished"15// commitHash: "fa3b...", — provably fair16// stake: 0.01,17// stakeUsd: 1018// }2. Take an Action
1parent.postMessage({2 type: 'stateMachineAction',3 id: Date.now(),4 sessionId: state.sessionId,5 action: 'flip'6}, '*');7
8// Response if NOT terminal (matched by id):9// {10// currentState: ["player_turn", {...}],11// allowedActions: ["hit", "stand"],12// totalWager: 1,13// totalPayout: 0,14// gameOver: false,15// status: "playing"16// }17
18// Response if TERMINAL:19// {20// currentState: ["win", 0],21// allowedActions: [],22// totalWager: 1,23// totalPayout: 2.0,24// payoutMultiplier: 2.0,25// payoutUsd: 20,26// serverSeed: "abc...",27// gameOver: true,28// status: "finished"29// }Active Sessions
State machine games persist across page reloads. The platform restores the session automatically:
1window.addEventListener('message', (e) => {2 if (e.data.type === 'sessionResume' && e.data.primitive === 'state-machine') {3 const s = e.data.session;4 // s.sessionId, s.currentState, s.allowedActions, s.totalWager, s.totalPayout5 // Restore your game UI to match the current state6 }7});Example: Simple Hi-Lo
A 3-round high-low game with cashout after round 1. States encode the round number; terminal states pay out (or not).
1window.GAME_CONFIG = {2 primitive: 'state-machine'3};4
5window.GAME_GENERATOR = {6 // State key = ['ROUND', roundNumber] or ['DONE', 0]7 initialStates: function() {8 return [[['ROUND', 1], 1.0, 0, null]];9 },10
11 allowedActions: function(state) {12 if (state[0] === 'DONE') return [];13 var round = state[1];14 var actions = ['GUESS_HIGH', 'GUESS_LOW'];15 if (round > 1) actions.push('CASHOUT');16 return actions;17 },18
19 transitions: function(state, action) {20 var round = state[1];21 var done = ['DONE', 0];22
23 if (action === 'CASHOUT') {24 // Cash out at current multiplier — terminal (payout = number)25 var cashoutMultiplier = Math.pow(1.5, round - 1);26 return [[done, 1.0, 0, cashoutMultiplier]];27 }28
29 // Guess high or low — 50/5030 var nextRound = round + 1;31 var winMultiplier = Math.pow(1.5, round);32 if (nextRound > 3) {33 // Final round → both outcomes are terminal34 return [35 [done, 0.5, 0, winMultiplier], // correct guess → win36 [done, 0.5, 0, 0] // wrong guess → lose37 ];38 }39 return [40 [['ROUND', nextRound], 0.5, 0, null], // advance (non-terminal → payout MUST be null)41 [done, 0.5, 0, 0] // lose (terminal → payout = 0)42 ];43 },44
45 isTerminal: function(state) {46 return state[0] === 'DONE';47 }48};Balance Reveal Timing
The player's visible balance updates as soon as the terminal stateMachineAction resolves on the server. To show an outcome animation before the balance updates, send balanceReveal when your end-of-game animation finishes.
1// In your terminal state handler, after the outcome animation:2parent.postMessage({ type: 'balanceReveal' }, '*');gameOver is true in the response) queue a deferred update. Non-terminal actions update immediately since no net balance change occurs. A 30-second fallback fires automatically if you never send balanceReveal.Complete Template
The template uses a promise-based sendToParent helper that matches responses by id, consistent with all game primitives.
1<!DOCTYPE html>2<html>3<head><title>My State Game</title></head>4<body>5 <button onclick="playGame()">PLAY</button>6 <div id="state"></div>7 <div id="actions"></div>8 <div id="result"></div>9
10 <script>11 // ── Config: just declare the primitive ──12 window.GAME_CONFIG = {13 primitive: 'state-machine'14 };15
16 // ── Generator: defines the MDP for RTP validation ──17 window.GAME_GENERATOR = {18 initialStates: function() {19 // [[stateKey, stateData], probability, deltaWager, payout]20 return [[['START', 0], 1.0, 0, null]];21 },22 allowedActions: function(state) {23 if (state[0] === 'DONE') return [];24 return ['FLIP'];25 },26 transitions: function(state, action) {27 var done = ['DONE', 0];28 return [29 [done, 0.485, 0, 2.0], // win30 [done, 0.515, 0, 0.0] // lose31 ];32 },33 isTerminal: function(state) {34 return state[0] === 'DONE';35 }36 };37
38 // ── Communication ──39 var pendingRequests = new Map();40 var requestId = 0;41
42 function sendToParent(data) {43 return new Promise(function(resolve, reject) {44 var id = ++requestId;45 pendingRequests.set(id, { resolve: resolve, reject: reject });46
47 var payload = Object.assign({}, data, { id: id });48 // In preview mode, include game config so the platform49 // can validate without an upload50 if (window.TEST_MODE) {51 payload.gameConfig = window.GAME_CONFIG_FOR_API;52 }53 parent.postMessage(payload, '*');54
55 setTimeout(function() {56 if (pendingRequests.has(id)) {57 pendingRequests.delete(id);58 reject(new Error('Request timeout'));59 }60 }, 30000);61 });62 }63
64 window.addEventListener('message', function(e) {65 // Restored session after refresh66 if (e.data && e.data.type === 'sessionResume' && e.data.primitive === 'state-machine') {67 state.sessionId = e.data.session.sessionId;68 document.getElementById('state').textContent =69 'State: ' + JSON.stringify(e.data.session.currentState);70 renderActions(e.data.session.allowedActions || []);71 return;72 }73
74 if (e.data && e.data.type === 'stakeUpdate') {75 state.stake = e.data.stake;76 state.stakeUsd = e.data.stakeUsd;77 state.currency = e.data.currency;78 return;79 }80 // id-based response routing81 if (e.data && e.data.id && pendingRequests.has(e.data.id)) {82 var h = pendingRequests.get(e.data.id);83 pendingRequests.delete(e.data.id);84 if (e.data.error) h.reject(new Error(e.data.error));85 else h.resolve(e.data);86 }87 });88
89 // ── State ──90 var state = {91 sessionId: null,92 stake: 0,93 stakeUsd: 0,94 currency: 'USDT'95 };96
97 // ── UI helpers ──98 function renderActions(actions) {99 var el = document.getElementById('actions');100 el.innerHTML = actions.map(function(a) {101 return '<button onclick="takeAction(\'' + a + '\')">' +102 a + '</button>';103 }).join(' ');104 }105
106 // ── Game flow ──107 async function playGame() {108 try {109 var resp = await sendToParent({110 type: 'stateMachineStart',111 stake: state.stake,112 currency: state.currency113 });114
115 state.sessionId = resp.sessionId;116 document.getElementById('state').textContent =117 'State: ' + JSON.stringify(resp.currentState);118 renderActions(resp.allowedActions);119 } catch (err) {120 alert(err.message);121 }122 }123
124 async function takeAction(action) {125 if (!state.sessionId) return;126 try {127 var resp = await sendToParent({128 type: 'stateMachineAction',129 sessionId: state.sessionId,130 action: action131 });132
133 document.getElementById('state').textContent =134 'State: ' + JSON.stringify(resp.currentState) +135 ' (wager: ' + resp.totalWager + ', payout: ' + resp.totalPayout + ')';136
137 if (resp.gameOver) {138 document.getElementById('actions').innerHTML = '';139 document.getElementById('result').textContent =140 resp.totalPayout > 0141 ? 'Won! Payout: ' + resp.totalPayout + 'x (\$' + (resp.payoutUsd || 0) + ')'142 : 'Lost!';143 state.sessionId = null;144 } else {145 renderActions(resp.allowedActions);146 }147 } catch (err) {148 alert(err.message);149 }150 }151
152 // ── Init ──153 state.stake = window.GAME_STAKE || 1;154 state.stakeUsd = window.GAME_STAKE_USD || 0;155 state.currency = window.GAME_CURRENCY || 'USDT';156 parent.postMessage({ type: 'gameReady' }, '*');157 </script>158</body>159</html>