State Machine
Primitive

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:

START
→ player action →
State A
→ player action →
State B
END (payout)

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

ConceptDescription
StateA named position in the game (e.g. 'deal', 'player_turn', 'bust')
TransitionA possible move from one state to another, with a probability
ActionPlayer input that triggers a transition (e.g. 'hit', 'stand')
Terminal StateA state that ends the game and determines the payout multiplier
Initial StateWhere 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 primitive
2window.GAME_CONFIG = {
3 primitive: 'state-machine'
4};
5
6// 2. Define the MDP (Markov Decision Process)
7window.GAME_GENERATOR = {
8 // Return all possible starting states
9 // Each entry: [[stateKey, stateData], probability, deltaWager, payout]
10 // payout = null → game begins (non-terminal)
11 // payout = number → game is immediately over with that payout
12 initialStates: function() {
13 return [[['start', 0], 1.0, 0, null]];
14 },
15
16 // Return allowed player actions for a given state
17 // Return [] for terminal states
18 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) pair
24 // 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 2x
31 [['lose', 0], 0.515, 0, 0] // 51.5% → lose
32 ];
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

FunctionReturnsDescription
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)booleanReturns true if the state is terminal (game over). Must match allowedActions — if isTerminal returns true, allowedActions must return [].

Transition Tuple Fields

IndexFieldDescription
0nextState[stateKey, stateData] — the state to transition to
1probabilityChance of this transition (all probs for an action must sum to 1.0)
2deltaWagerAdditional 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).
3payoutnull = 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:

FunctionInputOutputWhen Called
initialStates()(none)Array of starting tuplesOnce, when session begins
allowedActions(state)[key, data]string[] of action namesEach step, to show player options
transitions(state, action)[key, data], stringArray of outcome tuplesWhen player takes an action
isTerminal(state)[key, data]booleanEach 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.

Probabilities for each action's outcomes must sum to 1.0. The platform validates RTP at upload time by running value iteration over your generator.
Payout must be 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 ID
4 stake: window.GAME_STAKE,
5 currency: window.GAME_CURRENCY
6}, '*');
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 far
14// status: "playing", — "playing" or "finished"
15// commitHash: "fa3b...", — provably fair
16// stake: 0.01,
17// stakeUsd: 10
18// }

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.totalPayout
5 // Restore your game UI to match the current state
6 }
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/50
30 var nextRound = round + 1;
31 var winMultiplier = Math.pow(1.5, round);
32 if (nextRound > 3) {
33 // Final round → both outcomes are terminal
34 return [
35 [done, 0.5, 0, winMultiplier], // correct guess → win
36 [done, 0.5, 0, 0] // wrong guess → lose
37 ];
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' }, '*');
Only terminal actions (where 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], // win
30 [done, 0.515, 0, 0.0] // lose
31 ];
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 platform
49 // can validate without an upload
50 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 refresh
66 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 routing
81 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.currency
113 });
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: action
131 });
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 > 0
141 ? '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>