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 login https://engine.housebets.com # an API key from the dashboard (Developer)
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.
With an agent: add the MCP server (engine-mcp/server.mjs, with HBENGINE_KEY) 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.
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, ≤ 150 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}], 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`
// 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.
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 fairness dialog, so no fairness button of yours is needed.
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.
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.