Skip to content

Graph engine

Turn chance draws, player decisions, and prize tables into a TypeScript game graph. The shared engine walks it on-chain, so multi-step casino games need no custom Solidity.

The graph engine runs multi-step casino games without a contract of your own. You write the game as a TypeScript state machine: chance draws, player decisions, prize tables and fair ladders. The compiler turns it into a game graph, and one shared contract plays every session by walking that graph. The chain derives the RTP, the maximum payout and the risk figures from the graph before any session opens.

Prefer an engine to your own contract. A graph-engine title needs no Solidity and no contract audit. The chain checks its math when it deploys, so the platform review covers your frontend only. A title whose highest RTP is at or below the approval ceiling (98% at genesis) is whitelisted as soon as it deploys and joins the offering right away. Write your own ICasinoGameV2 contract only for a game neither engine can express (see Getting Started).

EngineFits
Slot engineOne draw settles the bet: slots, scratch cards, Limbo, dice over or under a target.
Graph engineSeveral steps or player choices: Hilo, a tower climb, blackjack, double-or-nothing, roulette, Plinko.

The package is @chain-protocol/casino-sdk/graph-engine. It holds the compiler (compileGraphGame), the reference walks your frontend replays sessions with, the quote helpers and the deploy helpers. @chain-protocol/casino-sdk/graph-engine/contracts/* holds the engine contracts as Solidity source. From 0.6.0 the TypeScript API calls a title a Game, so the definition type is GraphGameDefinition. The Solidity contracts and the deployment.ts field title keep their names.

Run every command below in your game’s project. A project created with npx @chain-protocol/cli init casino already has the casino SDK and the CLI (see Getting Started). Otherwise install the same version of both:

Terminal window
npm install @chain-protocol/casino-sdk
npm install --save-dev @chain-protocol/cli

With Bun, run bun add @chain-protocol/casino-sdk and bun add --dev @chain-protocol/cli.

A title is a GraphGameDefinition. It has four fields:

  • prizeDenominator. Prizes are multiples of the wager in units of 1 / prizeDenominator. With 10, a prize of 19 pays 1.9x.
  • roots. One starting state per bet configuration, in the order game data selects them.
  • key. A unique string for each state. The compiler merges states with the same key.
  • expand. The node a state is. The compiler calls it on every state it reaches.

This title is Climb, a three-rung tower. Each climb survives half the time. The player cashes out for 1.9x at rung 1, 3.9x at rung 2 or 7.6x at rung 3. It is the tower of the Climb example, which you can play with a full frontend:

import type { GraphGameDefinition, NodeSpec } from '@chain-protocol/casino-sdk/graph-engine';
export const LABEL = { cashOut: 0, climb: 1, safe: 2, bust: 3 } as const;
const CASH_OUT_PRIZES = [0, 19, 39, 76];
const TOP_RUNG = 3;
type State =
| { at: 'climb'; rung: number }
| { at: 'rung'; rung: number }
| { at: 'cashOut'; rung: number }
| { at: 'bust' };
const climb: GraphGameDefinition<State> = {
prizeDenominator: 10,
roots: [{ at: 'climb', rung: 0 }],
key: state => JSON.stringify(state),
expand: (state): NodeSpec<State> => {
switch (state.at) {
case 'climb':
return {
kind: 'chance',
outcomes: [
{ state: { at: 'rung', rung: state.rung + 1 }, weight: 1, label: LABEL.safe },
{ state: { at: 'bust' }, weight: 1, label: LABEL.bust },
],
};
case 'rung': {
const cashOut = {
state: { at: 'cashOut', rung: state.rung } as const,
label: LABEL.cashOut,
};
if (state.rung === TOP_RUNG) return { kind: 'decision', actions: [cashOut] };
return {
kind: 'decision',
actions: [cashOut, { state: { at: 'climb', rung: state.rung }, label: LABEL.climb }],
};
}
case 'cashOut':
return { kind: 'terminal', prize: CASH_OUT_PRIZES[state.rung] };
case 'bust':
return { kind: 'terminal', prize: 0 };
}
},
};
export default climb;

expand returns one of five node kinds:

KindWhat it is
terminalSettles the session at prize.
chanceDraws one of its outcomes by weight and moves to that outcome’s state.
tableDraws one prize from weighted tiers and settles at once. Use it for a one-draw payout table.
decisionWaits for the player to pick one of its actions. Action 0 is the default when a session forfeits.
ladderA fair ladder: a whole Hilo-style game in one node. See Fair ladders.

Every chance outcome, table tier and decision action carries a 32-bit label. The engine reports labels, not states, so your frontend renders what happened from them. Keep one label per meaning, such as LABEL.bust, and share the constant between the title and the frontend.

A terminal or table may set stake, the number of wagers escrowed when it settles (1 to 256). A player action that reaches a deeper stake, such as a double, escrows the difference. A random draw cannot escrow, so every outcome of a chance must hold the same stake. A decision holds the stake of its action 0.

To offer several bet configurations, list several roots. A dice title, for example, has one root per target. No root may be reachable from an earlier root or share its state. Choices that only change what the frontend shows belong in the game data’s tail, not in a root.

compileGraphGame(title) enumerates every reachable state, derives the figures the chain will derive, and packs the chunks it deploys. A title that breaks a rule throws, naming the node or the bet configuration at fault. Print each configuration’s RTP and maximum payout:

import { compileGraphGame } from '@chain-protocol/casino-sdk/graph-engine';
import climb from './title.ts';
const { graph, derived } = compileGraphGame(climb);
for (const [index, figures] of derived.configurations.entries()) {
const rtp = (Number(figures.rtpWad) / 1e16).toFixed(2);
const maxPayout = Number(figures.maxPayout) / Number(graph.prizeDenominator);
console.log(`configuration ${index}: RTP ${rtp}%, max payout ${maxPayout}x`);
}

For Climb, expect configuration 0: RTP 97.50%, max payout 7.6x. The RTP assumes best play at every decision, so it is the most any player strategy returns. Best play climbs from rung 1 and cashes out at rung 2.

Point deployment.ts in the project’s root at the title module, and name the title:

import type { DeploymentInput } from '@chain-protocol/casino-sdk';
export default {
kind: 'graph',
name: 'Climb',
title: 'title.ts',
} satisfies DeploymentInput;

Serve your frontend, then start the SDK simulator:

Terminal window
npx chain dev casino --frontend-url http://localhost:5173

Expect Deployed Climb at 0x… and then Play your game at …. Open that URL, pick Climb in the game list and play. Every session runs on the real engine bytecode, with randomness from the local VRF node. The simulator watches the title and every module it imports. Save a change and it deploys the title again. The simulator needs Node 22.18 or later, or Bun, to load a TypeScript title. See Local Simulator → Graph titles.

Submit the repository through the developer platform. The build loads deployment.ts, runs your title module through the platform’s graph compiler without network, and records the chunks and the title inputs hash. It compiles no contract, because a graph title has none. The verify step deploys the title on an in-memory chain and derives it to the end. A title whose derive reverts fails the build, naming the revert. The verdict reports each bet configuration’s RTP and maximum payout.

The review then covers your frontend only. Every title runs on the same engine contract, and the chain refuses a title whose math breaks a rule, so nobody reviews your math by hand. When the title deploys, it registers on the diamond. A title whose highest RTP is at or below the approval ceiling (98% at genesis) is whitelisted at once. A title above the ceiling waits as a pending game until the security council approves it.

A title with a Steering contract builds the same way. The platform also compiles the Steering contract from contracts/ and hashes the title with the address where SteeringDeployer will create it. When the title deploys, the Steering contract deploys first.

The frontend is a normal casino SDK guest. Read Building Casino Games for the bridge, the snapshot and the session lifecycle. This section covers what is specific to graph titles.

The frontend needs the compiled graph to replay sessions. Import your title module and call compileGraphGame once, or compile at build time and ship the result. The manifest field of the compiled title maps every node id back to your own state type, which is often the easiest thing to render from. The engine helpers have no Node imports, and a guest that walks, encodes and quotes a title bundles them in about 19 KB minified.

  • Open a session. The game data names the bet configuration. Empty game data opens configuration 0. encodePlainGameData(configuration, tail) opens another. The engine ignores the tail, which can carry display-only choices such as a dice side.
  • Read where the session stands. The session’s gameState starts with the current node id as 4 bytes. Without a Steering contract, nothing follows it, except at a fair ladder.
  • Act at a decision. Call submitAction with actionData = abi.encode(uint256 actionIndex), the index into the decision’s actions. Each action requests a randomness word, and the randomness callback walks the chance nodes that follow.
  • Show what happened. Replay each randomness word with walkRandomness(graph, from, word), where from is the node the session stood at. It follows the engine draw for draw and returns the labels of every edge it took and the node it stopped at. Render each label.
  • Quote a bet without a chain call. graphQuoteTable(compiled) gives the figures the engine quotes from. Build it at build time and ship it. In the browser, quoteConfiguration(table, configuration, wager) returns what the engine’s quoteCaps, quoteRiskParams and opening reserve give, bit for bit. Still ask the host for the maximum wager with hostApi.getMaxWager({ gameData }).

The frontend never decides an outcome or a payout. The engine settles every session from the graph and the randomness.

Climb is a complete graph-engine game with a frontend. It lives in the Chain repository at packages/casino-sdk/graph-engine/example/, not in the npm package. It has two bet configurations: the tower from Step 1, at 97.5% RTP, and a one-draw scratch card, at 76%.

  • ladder.ts is the title. The frontend, the deploy script and the end-to-end test import the same module, so the chain, the replay and the UI share one definition.
  • start.ts deploys Climb on the SDK simulator’s chain and lists it in the game picker.
  • frontend/ is the guest iframe. It compiles the title in the browser with compileGraphGame, opens a tower or scratch-card session with encodePlainGameData, and reads the current node from the first 4 bytes of gameState. At a rung it offers Cash out (action 0) and Climb (action 1) through submitAction. It replays every randomness word with walkRandomness to show each draw, and calls revealOutcome when a session settles. frontend/src/climb.ts holds the replay.

To run it from a checkout of the Chain repository, in packages/casino-sdk:

Terminal window
bun install
bun run start:graph

Expect Climb 0x… RTP tower 97.50%, scratch card 76.00%, then open http://localhost:3300/?game=http://localhost:3600 and pick Climb in the game list. Open that URL, pick Climb in the game list and press Restart harness. Then climb the tower or scratch a card. The frontend shows the rung you stand on, every draw (safe, bust, or the scratch card’s tier) and the payout.

bun run graph:e2e plays 10 sessions against the running stack and expects 10 sessions opened, played and settled as the reference walk predicts.

A fair ladder is one node that plays a whole climb-and-cash-out game, such as Hilo. It holds its own rungs and a banked multiplier. Every win scales the multiplier by a drawn ratio, and a cash-out pays the multiplier times the ladder’s base prize, up to its cap. A ladder keeps the graph small: Hilo is one ladder of 26 rungs instead of thousands of nodes.

  • Write the ladder as a LadderNode that expand returns: basePrize and capPrize over the title’s prize denominator, ratioDenominator, the entry outcomes and the rungs. Each rung lists its options and whether the player may cash out there. Each option lists its outcomes. Each outcome has a weight, a ratio (0 loses), the rung it moves to and a label.
  • Fairness. Derive checks that every option is fair to within one part in 10,000, so best play returns within 2 basis points of the ladder’s value. It also requires 2 × basePrize <= capPrize, and that no entry outcome reaches the cap.
  • Play. At a rung, actionData is abi.encode(uint256 index). An option index draws that option, and the option count cashes out. The engine refuses an option one of whose wins would bring the cash-out to the cap. walkLadder(graph, node, word, actions, wager) replays a session, and decodeLadderState(gameState) reads the rung and the banked multiplier.
  • Auto-bet. encodeCommittedLadderGameData({ configuration, options }, tail) opens a session that plays every listed option in one randomness callback and settles. The session reserves what the whole chain can win when it opens. A configuration takes at most committedRounds options, a figure derive sets: 93 for Hilo. walkCommittedLadder(graph, rounds, word, wager) replays the callback.

A rung holds at most 254 options and 256 outcomes over all of them.

A table bundle is one session over several single-table bet configurations, such as several roulette chips or several Plinko balls. The wager splits across the entries by units, and the session settles once.

  1. Declare which configurations combine, in the GraphGameDefinition. drawGroups lists groups that share one draw: every table of a group has the same tier weights and labels, so only the prizes differ. maxIndependentDraws (1 to 16) and independentlyDrawable declare configurations that draw independently. Every bundled configuration’s root must be a table of one wager.
  2. Open a session with encodeBundleGameData({ draw, entries }, tail), where draw is 'shared' or 'independent' and each entry is { configuration, units }. A shared bundle takes 1 to 64 entries in ascending order from one draw group. An independent bundle takes up to maxIndependentDraws entries, repeats allowed. checkBundle runs the engine’s checks off chain.
  3. Show the result with walkBundle(graph, bundle, word, wager), which rebuilds each entry’s label and prize. A bundle keeps an empty game state, so read the entries back from the session’s game data with decodeBundleGameData.
  4. Quote a bundle with quoteBundle(bundleFigures(graph, derived, bundle), wager).

A title with a Steering contract cannot declare bundles.

A title may name an immutable Steering contract that implements @chain-protocol/casino-sdk/graph-engine/contracts/IGraphSteering.sol. Use one when most of a game’s state never changes the money, such as positions on a 9×9 board. The graph then holds only what changes the prize, and the Steering contract holds everything else. act turns the player’s actionData into an action of the current decision. respond may force an action when a randomness walk stops at a decision. If respond fails, the engine takes action 0 and carries on, so a Steering contract can never block a settlement.

Derive takes the best action at every decision, so no Steering contract can pay more than the derived RTP. That RTP is a ceiling, not what a typical player earns, so measure the gap by simulation before you ship. A steered title reserves its whole cap when a session opens, and it cannot hold a fair ladder or declare bundles.

Name the Steering contract in deployment.ts as steering: { source, name, constructorArgs }. source is relative to contracts/, and constructorArgs is optional and takes the {{diamond}} and {{token}} placeholders. The SDK simulator deploys it directly. The developer platform creates it through SteeringDeployer, which uses CREATE2 with a zero salt, so its address depends only on the init code. Titles with the same Steering init code share one Steering contract. That is safe because IGraphSteering is view and the session’s gameState holds the steering state. A constructor must not read chain state. If it does, the code at that address can differ from the reviewed build’s code, and the deploy then stops and asks for a rebuild. The platform refuses a steered title with a fair ladder or a bundle when it builds the title.

  • RTP. Derive computes every configuration’s value from the graph and rejects one at or above 100% RTP. Best play at every decision counts. The title then registers on the diamond with the lowest and highest RTP of its configurations. The diamond refuses a title outside the council’s RTP band.
  • Approval. A title whose highest RTP is at or below the approval ceiling is whitelisted at once. The ceiling is 98% at genesis, and the council can change it. A title above it is a pending game until the security council approves it.
  • Game developer. The creation call names the game developer, who collects the game dev fee. Anyone may name any developer, since the name only directs the fee. deployGraphGame takes it as developer and names the signing account without one. An authorship is an optional way for a developer’s wallet to authorize a specific creator wallet: that wallet writes an authorship for the title inputs hash and the creator, the creator passes its id to the creation call as authorshipId, and the named developer must be the authorship’s writer.
  • Structure. Every weight is above zero. A run of chance nodes is at most 16 long, so one randomness word covers it. Every outcome of a chance holds the chance’s stake. A decision has at most 256 actions. A title has at most 65,535 bet configurations and 1,224 chunks.
  • Forfeit. A session that forfeits at a decision pays the decision’s default value, less any part of its action 0’s stake the session has not escrowed.
  • Risk. The quotes and the maximum payout come from the derived figures, never from the frontend or the game data. A session reserves what its next step can win, so a long game ties up little liquidity at its start. Your fees and your maximum wager follow from those quotes. See Contract Constraints → What your quotes decide.

A title deploys in one transaction when its derive fits the 2²⁴ per-transaction gas cap, which holds about 4,400 nodes or about 300 single-table configurations. A larger title derives in batches of calls under 14M gas each, and anyone may send them. The figures below are measured on the local casino host.

TitleNodesConfigurationsDerive
Hilo, one fair ladder of 26 rungs112.10M gas of chunks, 0.60M to derive
A graph near the one-transaction limit4,3311inline, 16.0M gas
Dice, one configuration per target9,8009,800batched, 64 calls, 805.8M gas
A large graph42,0831batched, 89 calls, 941.6M gas

A Hilo session costs about the same gas as a hand-written Hilo contract: about 426k to open and 103k to 107k for each randomness callback.

The developer platform deploys titles for you. To deploy one yourself, for example on a test chain, call deployGraphGame({ publicClient, walletClient, deployer, compiled }) with the GraphTitleDeployer address. It uploads the chunks, creates the title naming developer (the wallet’s account without one) as its game developer, derives it inline or in batches, and checks that the chain derived the compiler’s figures. createBatchedGraphGame and deriveGraphGame run the two halves of a batched deploy, and deriveGraphGame resumes from where the last call stopped.