Build a House Engine game
A House Engine game is two things: a math pack (what can happen and what it pays, as data) and a bundle (static files that draw it). The engine draws every outcome on our server with our provably-fair stream, moves the money on the player's Housebets wallet, and your bundle shows what the server decided. You never compute money, randomness or balances.
my-game/
hbengine.json {slug, portal, rgs}
math/manifest.json modes, bets, payouts (§2)
math/<table>.tsv weighted outcomes, one per line
bundle/index.html your game (§3)
bundle/assets.json what loads first (§3.2)
bundle/hbtest.json how the pipeline plays one bet (§3.3)
npx hbengine templates # coin dice slot lines tumble keno crash mines plinko tower wheel
npx hbengine init my-game --template lines --slug my-game
cd my-game
npx hbengine connect # a key from House Studio, one click (or hbengine login <portal> with a key)
npx hbengine verify # the engine's own verifier: exact RTP per bet, the mathHash
npx hbengine dev # play your bundle inside the real shell, against the sandbox (after a first upload)
npx hbengine upload # verify + lint + upload; the sandbox plays it; the dashboard has the checks
npx hbengine store # store.json (+ tile.png) → the store page
npx hbengine submit # the newest version that passed, to review
npx hbengine stats # rounds, GGR and RTP by coin once it is played
The dashboard (sign up at the same address) does the rest: upload by dragging the folder in, the store page and tile, the sandbox in any coin and language with a forced first round, submit for review, go live, reports, statements and payouts.
hbengine connect opens the approve page in your browser; an owner or developer approves there, and the terminal shows which studio did and asks Connect to <studio> (<slug>)? [y/N] before it keeps the key (a no revokes it; so does closing the terminal at the question). An agent, or a script with no terminal, passes --studio <slug> instead (Organisation → Profile shows the slug): the portal then lets only that studio approve; without it and without a terminal, hbengine connect stops before asking for a code. A gateway hiccup while waiting is retried a few times.
With an agent: npx hbengine connect adds the MCP server (npx hbengine mcp) to Claude Code, Cursor and Codex, and the same steps are tools (engine_guide, engine_templates, engine_init, engine_verify, engine_lint, engine_upload, engine_store, engine_submit, engine_status, engine_stats): an agent takes a game from a template to review.
1. How it runs
housebets.com page ─ frame ─▶ our shell (same origin as the site)
· mints the player's session, talks to the engine
· header balance, round lifecycle, fairness dialog
└─ frame ─▶ your bundle, on its own origin
sandboxed: no network but its own files,
talks only to the shell (postMessage)
Your page imports the SDK from its own origin — the platform serves it there, your bundle must not carry a copy:
<script type="module" src="/engine-sdk/bet-field.js"></script>
<script type="module" src="./game.js"></script>
Two ways to build the frontend; both ship through the same pipeline:
- The engine panel (
/engine-sdk/ui.js): you draw only the game; the Originals' panel (stake in the player's currency, Bet, Auto with the Originals' rules — count, on win / on loss reset or increase by a percent, stop on profit / loss — mode and bet pickers from your math, Next / Cash out on a ladder, refusals in the player's language) is ours.engine-examples/coinis 30 lines. - Your own frontend: any static web tech (plain, Pixi, Phaser, your framework's build output). You use the SDK's
connect()and show the rounds it returns, and the money fields (<hb-bet-amount>) so currencies are right. BAD CAT (four games, a lobby, skins, music) is the reference. Build it as one page with views inside it, not as pages that frame other pages of the bundle (the upload's scan names such pages,nested-pages).
2. The math pack
math/manifest.json:
{ "format": "house-engine-math/1", "game": "coin", "rtpBp": 9900,
"modes": [ { "id": "coin", "label": "Coin", "kind": "table", "table": "coin.tsv",
"bets": [ { "id": "heads", "label": "Heads", "tiers": [ { "outcomes": [[0, 0]], "multBp": 19800 } ] },
{ "id": "tails", "label": "Tails", "tiers": [ { "outcomes": [[1, 1]], "multBp": 19800 } ] } ] } ] }
math/coin.tsv — one outcome per line, weight<TAB>json; the JSON is what your frontend gets back as round.data[i] (the dice faces, the reel stops, the trap tile), the server never reads it:
1 {"side":"heads"}
1 {"side":"tails"}
- table mode: one draw settles the bet. Each bet names payout tiers over outcome index ranges;
multBpis basis points of the stake, stake included (19800 = 1.98×). A dice with 97 targets is 97 bets on one 100-row table; a 3-reel slot is a 4096-row table whose tiers group the paying combinations. - ladder mode (
"kind": "ladder"): every step is drawn at bet from its table;bets[].surviveare the rows that let the player go on;ladderBp[k-1]is the cumulative payout afterksurvived steps;cashout: truelets the player stop after any step;autoAdvance: trueplays all steps at once (all or nothing). Tower, chicken road, mines-like games are ladders. - Slug
^[a-z0-9][a-z0-9-]{1,23}$; ids^[a-z0-9][a-z0-9-]{0,31}$; weights are integers, Σ ≤ 2⁶³, ≤ 1,000,000 rows, row JSON ≤ 4 KB; ≤ 64 steps. Unknown fields are refused. - RTP is computed exactly (integer arithmetic) for every bet:
9000 ≤ RTP ≤ rtpBp ≤ 9900, every bet must be able to pay. A ladder's RTP is its best stop. An outcome no bet pays on (a push, a tie, a zero segment) is fine — the verifier only names it.hbengine verifyprints RTP, hit rate, max multiplier and (ladders) the RTP at every stop; what it accepts the upload accepts, because it is the same code (POST /engine/v1/verify). - A ladder round's outcome is
round.state:open,cashed_outorbusted(settledfor a table).round.bustedandround.surviveddescribe the drawn path and arrive when the round closes — after a cash-out too, when the path would have busted later. Show the player's result fromround.stateandround.step. - The pack's mathHash names the version. A session is pinned to one hash; a new upload is a new version; players on an open ladder finish on the version they started.
2.1 The maths kit — slots, plinko, anything with features
packages/engine-math (copied into a project as math-kit/ by the lines, tumble and plinko templates) writes the pack from rows you describe — slot.mjs for reels and lines, tumble.mjs for scatter pays with tumbles:
import { fit, rng, writePack } from '../math-kit/pack.mjs';
import { books, slot } from '../math-kit/slot.mjs';
const game = slot({ reels, rows: 3, lines, pays, wild: 'W', scatter: { symbol: 'S', pays, spins, retrigger, multiplier: 2, cap: 60 }, maxWinBp });
const rows = fit(books(game, rng(SEED), 400_000), 9600); // every row a whole round, RTP exactly 96.00%
writePack('math', { game: 'my-slot', rtpBp: 9600, modes: [{ id: 'base', rows, bets: ['spin'] }] });
- A row is a whole round (a "book"): the base spin and every free spin it starts, with what it pays in total. Features, cascades, multipliers, pick bonuses — anything you can play in code — become rows the same way; the page plays back what the row holds.
fit(rows, targetBp)makes the RTP exactly the target: the paying rows keep their proportions, the losing rows share the weight left. Integer arithmetic throughout; the engine's verifier then agrees to the fourth decimal.balance(rows, targetBp)does the same for a table where every row pays (a bonus buy, plinko): the rows above and below the target are each scaled by one integer. Thelinestemplate's bonus buy mode is the free spins alone, priced at what they return on average (27 spins), balanced to exactly 96%.writePacksorts rows so each pay tier is one range, checks the limits (rows, Σ weights, row size) and prints RTP, hit rate and max win.- Tune the model until RTP before the fit is near the target (the gen prints it): the fit should nudge, not reshape, the hit rate.
- Exact games (plinko, wheels, dice) need no fit: weights are exact (
binomial(n, k)), and you solve one multiplier for the RTP — round it to what the page shows, so the board shows exactly what pays. - Write the model the page needs (reels, lines, pays) to
bundle/from the same script, so the page and the table can never disagree.
The studio dashboard shows every bet's RTP, hit rate, largest win and volatility (σ of one round's multiplier: low < 2, medium < 5, high < 15, very high above) from the verified pack; players see the same on the game's page.
3. The bundle
bundle/index.html at the root, everything relative. Budgets: boot group ≤ 2 MB gzip (its JavaScript ≤ 250 KB gzip), any other group ≤ 25 MB gzip, ≤ 300 MB and ≤ 2,500 files in all, one file ≤ 8 MB (code ≤ 4 MB), plain files only.
3.1 The SDK
import { connect } from '/engine-sdk/sdk.js';
const engine = await connect(); // waits for the shell's init
engine.config // game, mathHash, env, currency, limits, modes, balance, locale, wallet, state, activeRound?
const { round, balance } = await engine.play({ mode: 'coin', bet: 'heads', amount: field.amount });
round.data[0] // the row JSON you wrote in the table
engine.revealed(round.id); // the outcome is on screen: the header balance moves now
// round: { id, mode, bet, kind, amount (wallet units), state 'open'|'settled'|'cashed_out'|'busted',
// step, payout, multBp, data: [row JSON per draw], draws: [{index, raw, rejected}], nonce, mathHash }
// engine.config.limits: { min, max, maxWin } in the wallet coin (may be empty in the sandbox)
// a ladder draws every step at the bet; while it is open you see only the steps played,
// once it ends (bust, cash-out, autoAdvance) `data` has every step — show up to the first miss
// a push (a tier paying 1.00×) is `payout === amount`; a loss is `payout === 0`
await engine.action(round, 'advance' | 'cashout'); // ladders; pass the round as you last had it
engine.on('balance' | 'control' | 'pause' | 'resume' | 'ended', fn)
// pause/resume: the shell's reality check (engine.config.rg.realityCheckSec); ended: the session is over
// engine.config.rg: { autoplay: { allowed }, realityCheckSec } — the jurisdiction's rules; the panel obeys them
engine.state.get() / engine.state.put(revision, data) // the player's own saved data, ≤ 256 KB, any device
// both resolve { revision, data }; put with the revision you read
// (0 the first time); a stale revision rejects with `conflict`
// save when the player changes something, not per frame: over 10 writes
// at once and then one a second rejects with `pacing` (retryAfterMs)
// keep a `version` field in it: a new release of your game reads old data
engine.money // §4
engine.openFairness(round.id); engine.reportError(msg);
One play/action at a time; a second is refused with busy. Every refusal is an EngineError with a code (invalid busy insufficient-balance limits pacing pending unavailable session-ended round conflict server), a message to show, and for round the round as it now stands (redraw it). pending means the wallet has not answered: the shell and the server settle it; show the message, do not retry on your own.
engine.reportError(msg), and an uncaught error or unhandled promise rejection in your page (the SDK listens for both on window), is logged in the player's console and counted on the engine as one error for the game's day; the shell also counts a launch per open and a round left open when the page closes on one. Your Reports page and hbengine stats show the counts per day — counts only, never the text: nothing a game sends reaches the studio as text.
With the engine panel instead:
import { mount } from '/engine-sdk/ui.js';
const game = await mount(document.querySelector('hb-game'), {
reveal: async (round, zone) => { /* animate round.data[0]; resolve when on screen */ },
step: async (round, zone) => { /* a ladder round moved */ },
});
game.mode = 'hard'; game.bet = 'over-50'; // when your zone picks (a mode with > 12 bets)
el.addEventListener('select', (e) => draw(e.detail.mode)); // the panel's pick reached your zone
// engine.config.modes carries ids, labels, kinds and each bet's maxMultBp — never the
// tables: what your zone draws (a wheel's segments, a paytable) is a constant of yours.
3.2 Loading in stages — assets.json
{ "groups": { "boot": { "files": ["index.html", "game.js", "game.css"] },
"music": { "files": ["music/a.mp3"], "load": "idle" },
"skins": { "files": ["skins/tabby.webp"], "load": "demand" } } }
boot is what the first frame needs; idle groups load after engine.ready(); demand groups when you ask (createAssetLoader in /engine-sdk/assets.js, or your own loader — the manifest is the contract). Every file in exactly one group.
3.3 Fit — the frame, and what the site's bar does
The game is the frame, as a House Original is: the site gives it the column's width × 720 px (phone: ~364 × 720), wider in theatre mode and the whole screen in fullscreen (the site's buttons). Lay the game out for the box it is given — height: 100%, the board taking what is left — and re-fit when it changes. The frame never grows to the page and never scrolls: the pipeline plays your game at 1194×720 and 364×720, then fullscreen at 1920×1080 and 390×844, and every control must be on screen at each — and no text, image or canvas may be cut by the frame's edge (a reel that runs off a phone fails). Decoration meant to bleed off the edge is marked aria-hidden="true"; a clip inside the frame (a reel window, a ticker) is yours and fine.
The site already shows the title, the balance and a bar under the frame — draw none of them. Showing the balance in the game is allowed (slots do), from engine.balance through engine.money. The bar's buttons reach the game as engine.on('control', ({ action }) => …): sound (toggle your sound), settings; Provably Fair opens the shell's own dialog, the House Originals' modal in the game window: a Seeds tab (the player's client seed, the server seed hash before play, the next hash, the nonce, Rotate seed pair, the revealed pairs) and a Verify tab (server seed, client seed, nonce and mode in, the recomputed result out, matched against the round when opened on one). It is the same for every engine game and it is the shell's, so build no fairness UI of your own — no seeds view, no rotate button, no verifier; engine.openFairness(round.id) opens the shell's dialog on a round from inside your game, filled and verified, when you want a button there (call it on the player's click: the shell takes it at most once every two seconds, not while paused, and never over what the player has typed in the dialog). engine.round(id) gives you the settled round without its seeds. Every round is a bet: send engine.play a stake of at least engine.config.limits.min and never offer a free or zero-stake spin — the server refuses a stake under the minimum and a stake of 0 (a round that costs nothing would still consume a nonce of the player's seed pair), demo or otherwise.
3.4 hbtest.json — how the pipeline plays one bet
{ "open": [{ "page": "index.html", "selector": "a[href='./coin.html']" }],
"stake": { "page": "index.html", "selector": "hb-game hb-bet-amount input", "value": "1.00" },
"bet": { "page": "index.html", "selector": "hb-game [data-main]" } }
Selectors reach into shadow roots; page is the page's file name. The run types the stake, presses the bet, and expects engine.play and then revealed. A ladder's bet opens a round: add "then": [{ "page": "index.html", "selector": "hb-game [data-next]" }, { "page": "index.html", "selector": "hb-game [data-cashout]" }] so the run closes it. Each then step is pressed only if its control is visible and enabled — a bust can end the round before Cash out exists. "shows": { "page": "index.html", "selector": "#face", "path": "data.0.side" } makes the run check that the element shows the value the server drew (path walks the round: data.0.side = round.data[0].side). A game with several screens (a lobby of games) lists the others in "visit": clicks made before open, each screen and back, e.g. [{ "page": "index.html", "selector": "[data-card='dice']" }, { "page": "index.html", "selector": "#back" }]. An error or a scroll on any of them fails the run like one on the bet's screen. A custom frontend names its own elements: any selector works (#play, .stake input); give the Bet button and the stake input stable ids and use those. The engine panel's selectors are a contract: hb-game hb-bet-amount input (stake), hb-game [data-main] (Bet), [data-next], [data-cashout], [data-modes] button[data-id="<mode>"], [data-bets] button[data-id="<bet>"]. The typed stake is parsed in every currency of the run (a fiat at 2 decimals, the coin at 8): keep it representable in both, 1.00 is.
3.5 Libraries (Pixi, Howler, GSAP …)
Ship them in the bundle under bundle/vendor/ — nothing loads from a CDN at run time. Libraries often contain code paths the player's browser will refuse here (new Function, default CDN URLs): under vendor/ the scan only names them, but the conformance run fails a game that trips the policy even once — configure the library not to take those paths.
PixiJS v8: load its no-eval module right after it — the policy has no unsafe-eval, and without this Pixi's renderer cannot start:
<script src="./vendor/pixi.min.js"></script> <!-- pixi.js@8 dist/pixi.min.js -->
<script src="./vendor/unsafe-eval.min.js"></script> <!-- pixi.js@8 dist/packages/unsafe-eval.min.js -->
ticker.add(fn) returns the ticker, not fn: keep fn to remove it when a screen closes, or it runs on destroyed sprites (an error the run catches).
Pixi is ~185 KB gzip and fits the boot budget; put your textures in staged groups. Never edit a library file to get past the scan.
3.6 Compressed textures and WebAssembly (KTX2 / Basis)
The policy allows WebAssembly ('wasm-unsafe-eval') and workers started from blob: (worker-src 'self' blob:), so a KTX2/Basis transcoder runs — GPU-compressed textures keep phone memory down. JavaScript eval and new Function stay refused, which decides how you build the transcoder:
- Ship the transcoder's
.jsand.wasminbundle/vendor/(it loads from the game's own origin;.wasmis served asapplication/wasm). - Its JS glue must not call
new Function/eval. Emscripten's embind does by default: use a build made with-sDYNAMIC_EXECUTION=0, or one that does not use embind. The conformance run fails a game whose transcoder trips the policy, sohbengine testtells you at once. - three.js: `new KTX2Loader().setTranscoderPath('./vendor/basis/') .detectSupport(renderer)`. In Pixi or another engine, point its KTX2 transcoder path at the same
./vendor/folder. - Put
.ktx2textures in stagedassets.jsongroups like any other art; keep a PNG/WebP fallback only if a target device lacks every supported GPU format.
3.7 What a bundle must not do
No request outside its origin (any URL to elsewhere is refused at upload and blocked at runtime), no eval / new Function, no WebSocket, no service worker, no import() of a URL, no fetch('/api/…') of a server of its own (saved data goes through engine.state), no own CSP, no <base>, meta refresh, <object>/<embed>, srcdoc, no copy of engine-sdk/. Browser randomness (Math.random) is for visuals only — every outcome is the server's. localStorage is per device; anything a player keeps across devices goes through engine.state.
4. Currencies — never compute money
The player plays in any Housebets coin and may see it as a fiat (EUR, JPY, …) at the platform's rate. engine.money is the Originals' own money code bound to the session:
const m = engine.money;
m.unit, m.sign, m.decimals // 'EUR', '€', 2 — or 'BTC', '', 8
m.format(amount) // a wallet amount, as the header shows it
m.caption(amount) // '0.00019230 BTC' under a fiat field
m.parse(text) // what the player typed → wallet amount, truncated down
m.mark // our picture of the currency (data: URI)
Stakes go to engine.play in wallet units at the wallet's precision; <hb-bet-amount> does all of it (mark, sign, precision, ½ 2× max, limits, remembered stake):
<hb-bet-amount id="stake" max-button></hb-bet-amount>
<script type="module">
const field = document.getElementById('stake').connect(engine);
field.amount // wallet units for engine.play
field.reason // {code, message} when the stake cannot be played, else null
</script>
Balances and payouts come from the server; show them with m.format. A stake is never below engine.config.limits.min and never 0: there is no free spin (§3.3), so a play button stays disabled until the field holds a playable stake (field.reason === null and field.amount > 0; an empty field's reason is min, and engine.play itself refuses a stake of 0 with limits before anything is sent). The engine panel (ui.js) does exactly this.
5. Languages
engine.config.locale is the player's language (the site's). The panel, the field and every refusal are already in it (13 languages). Your own strings: ship them as bundle/i18n.json — { "en": {…}, "ru": {…} } — and pick by engine.config.locale, falling back to en (required). The engine panel names modes and bets from your math's labels; give it names by language with mount(el, { labels: { de: { base: 'Drehen' } } }) (the lines and keno templates do). The upload tells you which of the site's languages (ar da de en es fi fr hi hu id it ja ko pl pt ru th tr vi zh) you lack. Numbers and money: always through engine.money.
6. What the upload checks — the whole list
hbengine upload = verify + lint + the portal's conformance run (about two minutes on real desktop and phone frames; hbengine status shows the state meanwhile). A version is sandbox only when every line is ok:
| check | what passes |
|---|---|
| math | the pack verifies (§2): format, RTP band, every bet pays |
| budgets | §3 sizes; assets.json well formed, boot present, every file in one group |
| forbidden | nothing of §3.6 (libraries under vendor/: named, §3.5) |
| engine use | the code connects, calls play, calls revealed |
| boot (desktop, phone) | hello answered and ready() within 25 s |
| idle | left untouched for 15 s, the game places no bet: a bet is always the player's act |
| bet | one bet through your UI (hbtest.json) reaches engine.play, then revealed |
| shows | with shows in hbtest.json: the element shows the value of the round the server drew |
| draws | after the round, the play area shows something: its visible content covers at least 2% of the frame (a board with no size fails) |
| currency | under a fiat display (desktop) and a bare coin (phone): the stake sent equals what the Originals send for the typed amount; the screen shows that currency |
| outage | the next bet meets an ambiguous wallet answer: the game keeps running, the site is asked to re-read the wallet |
| fit | the game fits the frame it is given — 1194×720 and 364×720, then fullscreen 1920×1080 and 390×844: no page scrolls, every visible control is inside the frame, no text (or more than a tenth of a picture) is cut by its edge |
| errors | no uncaught error in any page |
| network | no request leaves the bundle's origin, and the policy refuses nothing (a blocked worker, script or style fails the run) |
Then a reviewer plays it (sandbox → review → live), with a kill switch at every level. Money, seeds, limits and refunds are the platform's and the same as for every House Original: you never handle them.
7. Examples
engine-examples/: coin (table, engine panel, 30 lines), dice (97 bets, the zone picks), slot (4096-row table, reels from round.data), lines (a video slot from the maths kit: 5×3, ten lines, wild, scatter, free spins ×2 with retriggers, 96.00% exactly), tumble (a 6 × 5 scatter-pays grid with tumbles and free-spin multipliers, 96.00% exactly), keno (40 numbers, 1–10 picks, hypergeometric weights; the picks choose the mode), crash (a 64-level ladder with cash-out that the page climbs itself with game.advance(); 99% at every level), mines (a ladder of reveals, 1–24 mines, exact weights per reveal), plinko (8/12/16 rows × three risks, binomial weights), tower (8-step ladder with cash-out), wheel (three modes). BAD CAT (badcat-engine/) is a full custom frontend ported as a studio would.
8. Review
Every version a player can see is reviewed by a person at Housebets first. Review works like a pull request.
- Run
npx hbengine preflight(orengine_preflight) first: on the newest version that passed every check (the one submit sends), it checks what a machine can of the review checklist — the RTP and max win your rules quote against the verified maths, rules in every language, the store page, phone and desktop fit, errors, load and network — and prints the facts the rules must quote. A pre-flight fail is pre-ticked on the reviewer's checklist; a pass is a note to them, not a tick, since each item asks more than a machine sees. Thennpx hbengine submit(orengine_submit, or the Review tab) sends the newest version that passed every check. - The reviewer plays it on desktop and phone and goes through the review checklist (the game's Review tab, and
checklistinengine_review): the maths against the rules and the screen, the rules in every language, phone and desktop play, sound, recovery from errors, content you own, nothing aimed at children, no outcome logic or outside servers in the bundle, an honest store page. Go through it yourself before you submit. - Anything to change is a thread on the version: a must fix blocks going live, a suggestion does not. Each thread names its area and says what, where and why.
- Fix every must-fix thread in the project, upload the new version and submit it. Submitting marks the open must-fix threads as fixed in that version (on the Review tab you can untick any it does not fix). To ask the reviewer something instead, reply on the thread (
npx hbengine reply --thread 12 --message "…",engine_reply, or the Review tab), in at most 4,000 characters. - The reviewer checks each fix and resolves the thread, or reopens it with a reply.
- With no must-fix left, the version is approved and an owner puts it live from the Versions tab. Players keep the live version until then.
With an agent: "fix the review" is engine_review → change the project → engine_upload → engine_preflight → engine_submit.