npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@energy8platform/platform-core

v0.24.4

Published

Energy8 platform core: Lua engine, DevBridge, RTP simulation, and SDK session orchestration. Renderer-agnostic — pair with any game framework (Pixi, Phaser, Three.js, custom).

Downloads

1,350

Readme

@energy8platform/platform-core

Renderer-agnostic core for games on the Energy8 casino platform. Pair it with PixiJS, Phaser, Three.js, DOM, or your own engine — platform-core ships everything that is platform-specific (Energy8 SDK lifecycle, Lua game scripts, RTP simulation, mock host bridge for local dev, branded loading frame, Vite plugins) without dragging in a renderer.

If you want the full PixiJS engine on top of this, install @energy8platform/game-engine instead — it depends on platform-core and adds scenes, UI, animation, viewport, and React integration.


Table of Contents


Why this package exists

The Energy8 casino platform has a contract every game must speak: an SDK handshake, a play-action lifecycle, a Lua execution model used both server-side and locally for development and RTP verification, and a host-side branded loading frame.

That contract is identical regardless of how you render. So it lives here, with zero rendering or DOM-coupled code in the bundle (the only DOM API used is window in the dev-mode MemoryChannel and document in the CSS preloader — neither touches a canvas/WebGL).

You bring the renderer; platform-core brings the platform.


Installation

npm install @energy8platform/platform-core @energy8platform/game-sdk fengari

Peer dependencies

| Package | Version | Required | | --- | --- | --- | | @energy8platform/game-sdk | ^2.7.0 | Yes | | fengari | ^0.1.4 | Yes — Lua engine runtime | | vite | ^5.0.0 \|\| ^6.0.0 | Optional — only if you import /vite |

No pixi.js, no react, no phaser, no DOM rendering library is required.


Quick Start

import { createPlatformSession, createCSSPreloader, removeCSSPreloader } from '@energy8platform/platform-core';
import luaScript from './game.lua?raw';
import { gameDefinition } from './gameDefinition';

const container = document.getElementById('app')!;

// 1. Show the Energy8 brand frame immediately.
createCSSPreloader(container);

// 2. Boot the platform session — DevBridge in dev, real SDK in prod.
const session = await createPlatformSession({
  dev: {
    luaScript,
    gameDefinition,
    balance: 10000,
    currency: 'EUR',
    networkDelay: 200,
  },
  sdk: { devMode: true },
});

session.on('balanceUpdate', ({ balance }) => updateHud(balance));

// 3. Initialize *your* renderer (Phaser, Three, custom). When ready,
//    pull session.initData.assetsUrl, load your assets, then…
removeCSSPreloader(container);

// 4. Drive plays through the SDK.
const result = await session.play({ action: 'spin', bet: 1 });
renderResult(result);

Public API

import {
  // Session lifecycle
  createPlatformSession, PlatformSession,
  type PlatformSessionConfig, type PlatformSessionEvents, type SDKOptions,

  // Lua engine + simulation
  LuaEngine, LuaEngineAPI, createSeededRng,
  ActionRouter, evaluateCondition,
  SessionManager, PersistentState,
  SimulationRunner, formatSimulationResult,
  ParallelSimulationRunner,
  NativeSimulationRunner, findNativeBinary, formatNativeResult,

  // DevBridge mock host
  DevBridge, type DevBridgeConfig,
  type ReplayConfig, type ReplayLaunch,

  // Branded loading frame (lifecycle API)
  createCSSPreloader, setCSSPreloaderProgress, waitCSSPreloaderTap, removeCSSPreloader,
  buildLogoSVG, LOADER_BAR_MAX_WIDTH,

  // Internal utility
  EventEmitter,

  // Platform types (re-exported from @energy8platform/game-sdk + Lua module)
  type InitData, type GameConfigData, type SessionData,
  type PlayParams, type PlayResultData, type BalanceData,
  type GameDefinition, type ActionDefinition, type TransitionRule,
  type LuaEngineConfig, type LuaPlayResult, type SessionConfig,
  type BuyBonusConfig, type AnteBetConfig, type MaxWinConfig,
  type AssetManifest, type AssetBundle, type AssetEntry,
  type LoadingScreenConfig,
  // …more — see src/types.ts
} from '@energy8platform/platform-core';

PlatformSession

createPlatformSession(config) is the entry point. It performs the SDK handshake (and optionally starts a local DevBridge mock host) and returns a typed event source.

const session = await createPlatformSession({
  // Optional. When present, an in-process DevBridge is started so the
  // SDK connects to a local mock host without any real backend.
  dev: {
    balance: 10000,
    currency: 'EUR',
    luaScript: '<your lua source>',  // optional, runs locally via fengari
    gameDefinition: { /* … */ },
    networkDelay: 200,
  },

  // Optional. Pass `false` for offline / head-less use (no SDK at all).
  sdk: { devMode: true },
});

session.sdk;          // CasinoGameSDK | null
session.initData;     // InitData | null  — first handshake response
session.devBridge;    // DevBridge | null
session.balance;      // number   — proxied to SDK
session.currency;     // string
session.isReplay;     // boolean  — true on a historical-round replay launch
session.on('balanceUpdate', ({ balance }) => { /* … */ });
session.on('error', (err) => { /* … */ });

const result = await session.play({ action: 'spin', bet: 1 });
session.destroy();

Inside game-engine, GameApplication wraps this. For non-pixi consumers, this is the layer you talk to directly.

Historical-round replay. session.isReplay is true when the host launched the game to re-watch a recorded round (config.replayMode). The same session.play(...) flow then returns the recorded results instead of live ones. Each game decides what replay means for its UI:

if (session.isReplay) {
  hideBalanceUI();
  hideBetSelector();
  showPlayAgainButton();   // the only CTA in replay
}

In dev, set up the recorded rounds via DevBridge replay mode.

Session continuations: pass the triggering bet, not zero. When the previous result returns nextActions: ['free_spin'] (or any other in-session action with debit: 'none'), pass the same bet that triggered the session:

const fs = await session.play({ action: 'free_spin', bet: triggeringBet, roundId: result.roundId });

The platform validates bet against bet_levels and rejects bet: 0. No double debit happens — the action's debit: 'none' keeps the wallet still, and LuaEngine reads the actual session bet from server-side session state regardless of what the client sends. See Game Development Guide §13.16 for the full conventions list.


Writing your game (config + Lua)

Each game on the Energy8 platform consists of two artefacts:

  1. A GameDefinition (JSON-shaped) — platform metadata: id, type, bet levels, max-win cap, action map with stage transitions, optional buy-bonus / ante-bet config. No game math here.
  2. A Lua script — exports a single execute(state) function that owns all game math (reels, paylines, payouts, cascades, free spins, multipliers).

The same pair runs server-side in production and locally in dev / RTP simulations.

Minimal slot — dev.config.ts

import luaScript from './script.lua?raw';
import type { GameDefinition } from '@energy8platform/platform-core';

const gameDefinition: GameDefinition = {
  id: 'my-slot',
  type: 'SLOT',
  script_path: 'games/my-slot/script.lua',          // S3 key in production
  bet_levels: [0.20, 0.50, 1.00, 2.00, 5.00],
  max_win: { multiplier: 10000 },                    // cap = bet × 10000

  actions: {
    spin: {
      stage: 'base_game',
      debit: 'bet',                                  // deducts the bet
      credit: 'win',                                 // credits total_win
      transitions: [
        // Could branch into a free-spins session here. See full guide.
        { condition: 'always', next_actions: ['spin'] },
      ],
    },
  },
};

export default {
  balance: 10_000,
  currency: 'EUR',
  networkDelay: 200,
  luaScript,
  gameDefinition,
};

Minimal slot — script.lua

local SYMBOLS = { 'A', 'K', 'Q', 'J', '10', '9' }
-- Payouts are *bet multipliers*. The platform scales by the player's
-- actual bet on the way out — never multiply by bet inside the script.
local PAYOUT  = { A = 50, K = 30, Q = 20, J = 10, ['10'] = 5, ['9'] = 2 }

function execute(state)
    -- 3 columns × 3 rows of random symbols
    local matrix = {}
    for col = 1, 3 do
        matrix[col] = {}
        for row = 1, 3 do
            matrix[col][row] = SYMBOLS[engine.random(1, #SYMBOLS)]
        end
    end

    -- Pay out if all 3 symbols on the middle row match
    local center = { matrix[1][2], matrix[2][2], matrix[3][2] }
    local total_win = 0
    if center[1] == center[2] and center[2] == center[3] then
        total_win = PAYOUT[center[1]]
    end

    return {
        total_win = total_win,
        data = { matrix = matrix, win_lines = total_win > 0 and { 2 } or {} },
    }
end

That's the entire contract: a stage to dispatch on (here just base_game) plus a total_win (a bet multiplier, not absolute currency) and an arbitrary data payload. The platform handles the rest — debit/credit (real_win = bet × total_win), balance updates, session lifecycle, cap enforcement. See Game Development Guide §13.2 for the full convention.

Full reference

The mini-example above covers a base-game spin only. For everything else — free spins via creates_session + next_actions, retrigger logic, persistent meters across spins (_persist_*), buy-bonus and ante-bet configuration, table-game session models, the full engine.* Lua API, JSON-Schema input/output validation, deployment and S3 layout — see the comprehensive guide:

Key sections to start with: §2 (GameDefinition shape), §7 (Lua script), §8 (engine.* API), §15 (table games), §16 (persistent state).


Lua Engine

Run platform Lua scripts locally in Node or the browser via fengari (Lua 5.3, pure JS). This replicates server-side execution byte-for-byte, so the same script you ship to production also drives local development and RTP simulations.

import { LuaEngine } from '@energy8platform/platform-core';

const engine = new LuaEngine({
  script: '<your lua source>',
  gameDefinition: { /* … */ },
  seed: 42,            // optional — deterministic RNG
});

const result = engine.execute({
  variables: { bet: 1, balance: 5000 },
  stage: 'base_game',
});
// → { total_win, data, next_actions, session, persistent_state }

Companion classes:

  • ActionRouter — dispatch a play request to the matching action and evaluate transition conditions (&&, ||, comparisons, "always").
  • SessionManager — track session lifecycle: creation, spin counting, retrigger, _persist_ data roundtrip, completion. Supports both fixed-spin slot sessions and unlimited table sessions.
  • PersistentState — cross-spin persistent vars (persistent_state.vars and _persist_game_* convention).

DevBridge (mock casino host)

Mock the casino host for offline development. Uses the SDK's Bridge in devMode with an in-memory MemoryChannel, so there is no postMessage or iframe involved.

import { DevBridge } from '@energy8platform/platform-core/dev-bridge';

const bridge = new DevBridge({
  balance: 10000,
  currency: 'USD',
  networkDelay: 200,
  debug: true,
  gameConfig: { id: 'my-slot', type: 'slot', betLevels: [0.1, 0.5, 1, 5, 10] },

  // Either: implement onPlay yourself
  onPlay: ({ action, bet }) => ({
    totalWin: Math.random() < 0.4 ? bet * 5 : 0,
  }),

  // Or: hand it your Lua game logic (preferred — same code as prod)
  // luaScript, gameDefinition, luaSeed,
});

bridge.start();
// later:
bridge.setBalance(5000);
bridge.destroy();

Most of the time you don't construct DevBridge yourself — createPlatformSession({ dev: { … } }) does it for you.

Platform-parity behavior (Lua mode)

In Lua mode (luaScript + gameDefinition), DevBridge mirrors the server's PlayRound contract so error-handling code written against dev runs unchanged in prod. Invalid requests come back as PLAY_ERROR and the SDK's play() rejects with SDKError(code, message):

| code | when | |-------------------------|----------------------------------------------------------| | INVALID_INPUT | unknown action | | INVALID_AMOUNT | bet not in bet_levels (list or {min, max} range) | | INSUFFICIENT_FUNDS | computed debit > balance (no wallet movement, no fetch) | | ACTIVE_SESSION_EXISTS | non-session action while a session is in progress | | NO_ACTIVE_SESSION | session-required action without an active session | | SESSION_EXPIRED | session past gameDefinition.session_ttl (default 24h) | | ENGINE_ERROR | Lua execution failed (debit is rolled back) |

Other contract details DevBridge enforces:

  • Round IDs are server-generated (crypto.randomUUID). The client value in PlayParams.roundId is ignored for non-session actions and replaced with the active session's id for session-based ones — matches the platform's playRound UUID rules.
  • STATE_RESPONSE returns the last PlayResultData (with session.history populated) while a session is active and not yet completed, mirroring GET /api/games/{id}/session.
  • creditPending is false in the normal path. The wire flag means "wallet credit failed, queued for retry" — never "credit deferred until session completes".
  • session.history is appended on every session round ({spinIndex, win, data}), so the client can rebuild the screen after reload.
  • MapState paritymultiplier, global_multiplier, free_spins_total, max_win_reached are auto-injected into result.data from engine variables when the Lua script doesn't set them explicitly.

Replay mode (historical rounds)

A game can be launched to replay a previously-played round move-by-move instead of placing live bets — the SDK 2.7.3 historical-round replay. No new protocol: the same play() / PLAY_RESULT flow is reused, only the data source and one config flag differ.

In production the casino backend is the replay host. In dev, DevBridge is the host, so it gains an opt-in replay config. You supply a resolve(mode, roundId) callback that returns the recorded rounds — DevBridge stays agnostic about where they come from (fetch, static fixtures, localStorage, …):

const bridge = new DevBridge({
  // … balance / gameConfig as usual …
  replay: {
    // Called once on a replay launch. May be async.
    resolve: (mode, roundId) => fetchRecordedRound(mode, roundId),
    // Optional. Defaults to reading ?replay=1&mode=…&event=… from the URL.
    // Return null for a normal (live) launch.
    detect: () => /* … */ null,
  },
});

Open the game with ?replay=1&mode=BONUS&event=<roundId> and DevBridge switches into replay automatically. In replay it:

  • flips config.replayMode = true in INIT (so sdk.isReplay / session.isReplay is true);
  • takes balance / currency from the recorded results — the wallet is never touched;
  • serves results[cursor] on each PLAY_REQUEST, with no bet/session validation;
  • resets the cursor to 0 on the first spin past the end ("Play Again");
  • returns PLAY_ERROR NO_ACTIVE_SESSION when the record list is empty.

The game reacts via a single flag — see session.isReplay. Each game decides what that means (hide balance/bet/autoplay/buy-bonus, show a "Play Again" CTA); the engine never imposes UI.


RTP Simulation CLI

platform-core ships a binary that runs your Lua script through millions of iterations to verify math and stage distributions. It picks up luaScript and gameDefinition from your dev.config.ts automatically.

# 1M spins (default)
npx platform-core-simulate

# Buy-bonus action (v5: just simulate the action by name)
npx platform-core-simulate --action buy_bonus

# Ante bet — also a regular action in v5
npx platform-core-simulate --action ante_spin

# Custom: 5M iterations, custom config path
npx platform-core-simulate --iterations 5000000 --bet 1 --config ./dev.config.ts

# Force the JS runner (skip native binary)
npx platform-core-simulate --js

Reproducibility: seeds, RNG backend, and replay

The native binary supports the same provably-fair seeding contract as the casino platform's cmd/simulation tool. Pass --seed=<hex> to reproduce a previous run bit-for-bit; if you omit it, the binary generates one and reports it in the output (Master seed: …) so you can rerun the exact distribution later.

# Reproducible run — supply the master seed yourself
npx platform-core-simulate \
  --iterations 1000000 \
  --seed 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff

# Fast PCG RNG — ~50× faster but diverges from production. Local iteration only;
# do NOT publish RTP numbers from --rng=fast.
npx platform-core-simulate --rng fast

# Replay a single round captured in `provably_fair_rounds`. Forces single-worker
# deterministic execution. All three flags are required and require provably-fair RNG.
npx platform-core-simulate \
  --iterations 1 \
  --replay-server-seed <hex> \
  --replay-client-seed <client_seed> \
  --replay-nonce-start 42

The result echoes masterSeed, rngKind, workerSeeds[] (per-worker server_seed sequence), and replay when in replay mode — all also surface on NativeSimulationResult for programmatic use. Hex seeds only apply to the native binary; the JS fallback uses an integer RNG seed (decimal --seed=42) and ignores hex strings with a warning.

Output matches the platform's server-side simulation format. A native Go binary is downloaded for your OS via postinstall (packages/platform-core/bin/simulate-*) for high-throughput runs; if it isn't available, the JS / worker-thread runner is used as a fallback.

Programmatic use:

import { ParallelSimulationRunner, NativeSimulationRunner, formatSimulationResult } from '@energy8platform/platform-core';

const runner = new ParallelSimulationRunner({
  script, gameDefinition,
  iterations: 1_000_000,
  workers: 8,
});
const result = await runner.run();
console.log(formatSimulationResult(result));

// Native runner with the full provably-fair contract:
const native = new NativeSimulationRunner({
  binaryPath, script, gameDefinition,
  iterations: 1_000_000, bet: 1,
  rng: 'provably-fair',                // default; use 'fast' for local iteration only
  seed: '00112233...eeff',             // hex master seed; omit to auto-generate
  // replay: { serverSeed, clientSeed, nonceStart },  // single-round reproduction
});
const r = await native.run();
console.log(`Reproduce with seed=${r.masterSeed}, RTP=${r.totalRtp.toFixed(4)}%`);

Branded Loading Screen

Every Energy8 game shows the same brand frame while it boots. The CSS-only preloader lives here so any renderer hosts the same frame without needing to render anything itself.

import {
  createCSSPreloader,
  setCSSPreloaderProgress,
  waitCSSPreloaderTap,
  removeCSSPreloader,
} from '@energy8platform/platform-core/loading';

createCSSPreloader(document.getElementById('app')!, {
  backgroundColor: 0x0a0a1a,
  backgroundGradient: 'linear-gradient(135deg, #0a0a1a 0%, #1a1a3e 100%)',
  showPercentage: true,        // SVG text becomes "42%" once you push progress
  tapToStart: true,             // default — set false to skip the tap gate
  tapToStartText: 'PLAY',       // default 'TAP TO START'
});

// Drive progress while assets load — the bar switches from CSS-shimmer to
// JS-driven on the first call.
for await (const p of myAssetLoader.load()) {
  setCSSPreloaderProgress(p);  // p ∈ [0, 1] — clamped, NaN treated as 0
}

// Optional tap-to-start gate (useful for mobile audio unlock —
// the click satisfies the browser's user-gesture requirement).
await waitCSSPreloaderTap();   // resolves immediately if tapToStart: false

// Fade out and clean up. Returns a Promise that resolves after the
// 0.4s CSS fade completes (or after a 600ms safety timeout if
// `transitionend` doesn't fire — e.g. in jsdom).
await removeCSSPreloader(container);

Lifecycle contract (one preloader per page)

  • createCSSPreloader(container, config?) — mounts the overlay. Idempotent: a second call while a preloader exists is a no-op.
  • setCSSPreloaderProgress(p) — silent no-op if called before create or after remove. Clamps p to [0, 1]; NaN/±Infinity become 0. The first call switches the loader bar from CSS shimmer to JS-driven width. If showPercentage: true, the SVG text updates to ${Math.round(p * 100)}%. Calls during waitCSSPreloaderTap are ignored — the text reads 'TAP TO START' and we don't flash percentages over it.
  • waitCSSPreloaderTap() — returns Promise<void>. Throws if called before createCSSPreloader (programmer error). Resolves immediately if tapToStart: false. Otherwise: swaps the SVG text to tapToStartText, adds a CSS pulse class, sets cursor: pointer, attaches a pointerdown listener, and resolves on first tap. Subsequent calls return the same memoized Promise.
  • removeCSSPreloader(container) — returns Promise<void>. Idempotent. If a waitCSSPreloaderTap Promise is still pending, it resolves first; then the overlay fades out and the Promise resolves. Was void in earlier versions; the wider return type is backwards-compatible (callers who don't await keep working).

The animated shimmer inside the SVG is pure CSS keyframes, so it appears in offline / first-paint conditions before any JS module finishes parsing. Once you start reporting real progress, JS takes over.

Mobile audio unlock: pair waitCSSPreloaderTap() with your audio system's resume/unlock call inside the same await chain. The user's tap is a valid gesture that satisfies iOS Safari and Chrome on Android.


Vite Plugins

// vite.config.ts (Phaser/Three/custom — full control over your config)
import { defineConfig } from 'vite';
import { devBridgePlugin, luaPlugin } from '@energy8platform/platform-core/vite';

export default defineConfig({
  plugins: [
    devBridgePlugin('./dev.config'),
    luaPlugin('./dev.config'),
  ],
});

What they do:

  • devBridgePlugin injects a virtual entry that boots DevBridge from your ./dev.config before your real entry imports. Dev-only.
  • luaPlugin:
    1. Lets you import luaScript from './game.lua?raw' — Vite returns the file contents.
    2. Spins up a server-side LuaEngine and exposes POST /__lua-play. DevBridge calls this endpoint, so fengari only ever runs in Node and never ships to the browser bundle.
    3. HMR-reloads the Lua engine when *.lua or dev.config* changes.

If you're building a Pixi game, prefer defineGameConfig from @energy8platform/game-engine/vite — it wires both plugins for you and adds Pixi-flavored Vite defaults (chunk splitting, dedupe, etc.).


Asset Manifest type

AssetManifest describes "what to load and in which bundles", in a format both Pixi's Assets, Phaser.Loader, and your own loader can consume.

import type { AssetManifest } from '@energy8platform/platform-core';

const manifest: AssetManifest = {
  bundles: [
    { name: 'preload', assets: [{ alias: 'logo', src: 'logo.png' }] },
    { name: 'game', assets: [
      { alias: 'background', src: 'background.png' },
      { alias: 'symbols', src: 'symbols.json' },
    ]},
  ],
};

platform-core does not load the assets itself — actual loading is renderer-specific. Pixi-side, game-engine's AssetManager wraps pixi.Assets and consumes this format directly.


Pairing with another renderer

A typical Phaser / Three / custom-engine bootstrap looks like:

import {
  createPlatformSession,
  createCSSPreloader,
  setCSSPreloaderProgress,
  waitCSSPreloaderTap,
  removeCSSPreloader,
  type AssetManifest,
} from '@energy8platform/platform-core';

const container = document.getElementById('app')!;
createCSSPreloader(container, { showPercentage: true, tapToStart: true });

const session = await createPlatformSession({
  dev: { luaScript, gameDefinition, balance: 10000, currency: 'EUR' },
  sdk: { devMode: true },
});

// 1. Read SDK init data for assetsUrl and config dimensions
const { assetsUrl } = session.initData ?? { assetsUrl: '/assets/' };

// 2. Boot YOUR renderer however it likes:
const game = new Phaser.Game({ /* … */ });

// 3. Load assets through your renderer's loader, treating `manifest`
//    as the source of truth, and pipe progress into the preloader.
await loadBundles(game.loader, manifest, assetsUrl, (p) => {
  setCSSPreloaderProgress(p);
});

// 4. Wait for the user's tap (resolves immediately if tapToStart: false)
await waitCSSPreloaderTap();
await removeCSSPreloader(container);

// 5. Wire SDK events / play requests
session.on('balanceUpdate', ({ balance }) => game.events.emit('balance', balance));
const result = await session.play({ action: 'spin', bet: 1 });

Nothing in this code is Pixi-specific. The same pattern fits Three.js, Babylon, custom WebGL, or even a DOM-only game.


Branded Game Shell

@energy8platform/platform-core/shell is a vanilla-DOM UI overlay you layer over the game canvas — no Pixi, no React, no framework. It owns the control bar (3 modes: base / freeSpins / replay), the menu, settings, the game-info panel, and a buy-bonus selection overlay, plus generic modals and a replay summary. Branded Energy8 chrome, fully renderer-agnostic — pair it with Pixi, Phaser, Three.js, or a custom engine.

Also re-exported from @energy8platform/game-engine/shell — same module, no extra install for Pixi consumers.

Mental model

The shell is fully driven by the game (single source of truth). It does not subscribe to the SDK/session and holds no game logic. You:

  1. Feed state in — once via the config object, then over time via set* methods.
  2. React to player intent out — subscribe to typed events (spin, betChange, …) and run your game logic, then push the resulting state back via setters.

This keeps replay and mid-spin restore deterministic: the shell never decides anything, it only renders what you tell it and reports what the player tapped.

Quick start

import { createGameShell, removeGameShell } from '@energy8platform/platform-core/shell';

const shell = createGameShell({
  mount: document.getElementById('game')!,   // shell appends its DOM here (position it relative)
  language: 'en',
  currency: { symbol: '€', position: 'left' },
  availableBets: [0.2, 0.5, 1, 2, 5],
  defaultBet: 1,
  currentBet: null,        // null → start at defaultBet; or restore a saved bet
  balance: 1000,
  win: 0,
  mode: 'base',
  gameInfo: { sections: [{ type: 'controls' }] },   // see "Game info" below
  features: {
    turbo: 3,              // 0 = no turbo button, 1–3 = number of turbo levels
    spacebar: true,        // default true; set false to disable the Spacebar → spin shortcut
    autoplay: {},          // null / omitted = off; {} = on; { maxCount: 100 } caps the picker
    buyBonus: [
      { id: 'fs', type: 'bonus', title: 'Buy Free Spins', description: '10 free spins',
        priceMultiplier: 100, volatility: 5 },
    ],
  },
});

// ── player intent  (shell → game) ──
shell.on('spin', () => runSpin(shell.state.bet));
shell.on('betChange', (bet) => { myState.bet = bet; });
shell.on('buyBonusSelect', ({ id }) => buyFeature(id));

// ── game state  (game → shell) ──
shell.setBusy(true);                 // disable controls during an active spin
shell.setBalance(980);
shell.setWin(20);                    // both readouts count up automatically
shell.setBusy(false);

// teardown (single shell per page; fades out, resolves when removed)
await removeGameShell();

createGameShell is a singleton — calling it twice returns the existing shell. Use removeGameShell() to dispose before creating another.

Config reference (ShellConfig)

| Field | Type | Notes | | --- | --- | --- | | mount | HTMLElement | Container the shell DOM is appended into. Give it position: relative. | | theme | ThemeConfig? | { scheme?: 'dark' \| 'light', accent? }. Defaults to dark. accent also tints the BUY BONUS button; per-card accents are BonusOption.accentColor. | | language | string | Currently 'en' is the source language. | | isSocial | boolean? | Swap built-in text to social-casino vocabulary (bet → play, win → …). Game-supplied strings are untouched. | | currency | CurrencyConfig | { symbol, position: 'left'\|'right', maxDecimals?, minDecimals?, separator? }. maxDecimals (default 2) / minDecimals (default maxDecimals): win & total-win show up to maxDecimals, trimming trailing zeros down to minDecimals; balance / bet / prices stay fixed at minDecimals. | | availableBets | number[] | Bet ladder shown in the bet picker. | | defaultBet / currentBet | number / number \| null | currentBet restores a saved bet; null falls back to defaultBet. | | balance / win | number | Initial readouts. | | mode | 'base' \| 'freeSpins' \| 'replay' | Drives which bottom-bar variant renders. | | gameInfo | GameInfoContent | Sections for the game-info overlay (see below). | | features | ShellFeatures | { turbo: 0–3, spacebar?, autoplay, buyBonus }. spacebar?: boolean (default true) — false disables the Spacebar → spin shortcut. autoplay: AutoplayConfig \| nullnull/omitted disables it; {} enables it; { maxCount } caps the picker (drops ∞). buyBonus: BonusOption[] \| false. | | onBonusBuy | (() => void)? | Override the BUY BONUS button action — opens your own UI instead of the built-in overlay (also shows the button without a buyBonus array). See Buy bonus. |

Events (shell.on(name, handler))

| Event | Payload | When | | --- | --- | --- | | spin | — | Spin disc tapped (or Spacebar in base mode). | | betChange | number | Player confirmed a new bet. | | autoplayStart / autoplayStop | { active, remaining } / — | Autoplay picker confirmed / stopped. | | turboChange | number | Turbo level cycled. | | buyBonusSelect | { id } | A type: 'bonus' card was bought. | | featureActivate / featureDeactivate | { id } | A type: 'feature' option (e.g. Ante) toggled. | | menuOpen / settingsOpen / infoOpen | — | Overlay opened. | | settingChange | { key, value } | Settings control changed. Keys: sound (bool), master / music / sfx (0–100). |

State setters (game → shell)

Each setter updates shell.state and re-renders. setBalance / setWin animate a count-up from the previous value.

shell.setBalance(n); shell.setWin(n); shell.setBet(n);
shell.setBusy(true);                 // disables controls mid-spin
shell.setMode('freeSpins');
shell.setFreeSpins({ current: 1, total: 10, totalWin: 0 });   // counter shows "1 / 10"
shell.setFreeSpins({ total: 9, totalWin: 0 });                // current omitted/null → single number "9" (decrement it for a countdown)
shell.setAutoplay({ active: true, remaining: 25 });
shell.setTurbo(2);
shell.setBuyBonusEnabled(false);     // grey out BUY BONUS (e.g. insufficient balance)
shell.setTheme({ scheme: 'light' }); // recolour at runtime
shell.setSocial(true);               // swap vocabulary at runtime (reopen overlays to refresh them)

Read current state any time via shell.state (ShellState: mode, balance, win, bet, busy, autoplay, turbo, freeSpins, activeFeature, …).

Buy bonus & features

features.buyBonus is an array of cards. type: 'bonus' buys into a round (emits buyBonusSelect); type: 'feature' toggles a base-game modifier like Ante. For features, drive the bar readout with:

shell.activateFeature(option);    // bar shows the effective bet, BUY BONUS → DISABLE
shell.deactivateFeature();        // revert

Each card price renders as priceMultiplier × current bet in the shell currency.

Customisation. Two override hooks let a game replace the built-in UI while keeping the shell's buy flow:

// 1) Per-card UI — render your own card; the shell keeps the grid wrapper, accent vars and live
//    re-pricing, and runs the normal confirm → buy flow when you call ctx.select().
{ id: 'fs', title: 'Free Spins', description: '…', priceMultiplier: 100,
  custom: ({ priceText, disabled, accent, select }) => {
    const el = document.createElement('button');
    el.textContent = priceText; el.disabled = disabled;
    el.style.background = accent; el.addEventListener('click', select); // select() = internal flow
    return el;                                                          // ctx also has { bonus, bet, price }
  } }

// 2) Bar button action — open your OWN bonus UI instead of the built-in overlay.
createGameShell({ /* … */, onBonusBuy: () => myGame.openBonusScreen() });

onBonusBuy also makes the BUY BONUS button appear without a features.buyBonus array.

Game info (gameInfo.sections)

The game-info overlay is composed from typed sections — declare what your game has and the shell draws the rest:

  • { type: 'modes', modes: GameMode[] } — comparison table (title / price / rtp / maxWin).
  • { type: 'controls' } — auto-generated control legend.
  • { type: 'paytable', rows: PaytableRow[] } — symbol → win tiers ("<count> x<multiplier>").
  • { type: 'wins', kind, grid, … } — auto-drawn win illustration. kind is 'classic' (paylines), 'cluster', 'anywhere', 'ways', or 'shapes'{ kind: 'shapes', shapes: ShapeDef[] } lists named cell patterns ({ cells: CellRef[], name, description? }) as a grid-illustration row each.
  • { type: 'custom', title, html | node } — your own rules markup.
gameInfo: {
  sections: [
    { type: 'modes', modes: [{ title: 'Base game', price: '1× bet', rtp: 96.5, maxWin: '5,000×' }] },
    { type: 'controls' },
    { type: 'paytable', rows: [
      { symbol: { text: 'Wild' }, wins: [{ count: '5', multiplier: 250 }, { count: '3', multiplier: 50 }] },
    ] },
    { type: 'wins', kind: 'classic', grid: { cols: 5, rows: 3 },
      lines: [[1,1,1,1,1], [0,0,0,0,0], [2,2,2,2,2]] },
    { type: 'custom', title: 'Rules', html: '<p>Match left to right on adjacent reels.</p>' },
  ],
}

Opening overlays & modals programmatically

shell.openSettings();  shell.openInfo();  shell.openBuyBonus();
shell.openBetPicker(); shell.openAutoplayPicker();

// generic card modal
shell.openModal({
  availableClose: true,
  title: 'Connection lost',
  body: 'Reconnecting…',
  actions: [{ title: 'Retry', color: '#e11', on: () => reconnect() }],
});

// non-dismissable replay summary (START REPLAY → onReplay → reopen)
shell.openReplay({ bonusId: 'fs', bet: shell.state.bet, payoutMultiplier: 87.5,
  onReplay: () => playRecordedRound() });

⚠️ Keep features.buyBonus populated in replay mode. The replay summary resolves the mode title and cost multiplier by matching bonusId against features.buyBonus. If you set features.buyBonus: false (or drop the matching option) while replaying, the modal can't find the bonus and falls back to a cost and the raw bonusId as the title. The BUY BONUS button only renders in base mode, so leaving the options populated during replay has no UI downside — it just gives the replay window the data it needs.

Layout & visual system

Transparent neutral chrome that doesn't compete with the game — brand colour appears only on the BUY BONUS control and a duotone icon set. The bottom bar adapts by viewport automatically (a ResizeObserver on the mount): landscape → one row scaled to fit, portrait → stacked mobile layout; Settings / Game info / Buy bonus open as full-screen overlays. Motion is minimal (press feedback, money count-up, overlay fades) and respects prefers-reduced-motion. Spacebar triggers a spin in base mode (ignored while busy, in autoplay, when a modal/input is focused, or when features.spacebar is false).

Live demo

examples/shell-demo is a full reference integration: every config section, all three bar modes, theme/social toggles, viewport presets, and event wiring. QA params: ?screen=<id>&kiosk=1&open=settings|info|buybonus.


Sub-path exports

| Path | What's there | | --- | --- | | @energy8platform/platform-core | Everything — re-exports from all sub-paths | | @energy8platform/platform-core/lua | Browser-safe Lua engine surface: LuaEngine, ActionRouter, SessionManager, PersistentState, JS SimulationRunner, types | | @energy8platform/platform-core/simulation | Node-only. NativeSimulationRunner (Go binary) and ParallelSimulationRunner (worker_threads). Don't import from a browser bundle — the main entry and /lua deliberately exclude these so they can't be tree-shake-leaked. | | @energy8platform/platform-core/dev-bridge | DevBridge, DevBridgeConfig, ReplayConfig, ReplayLaunch | | @energy8platform/platform-core/vite | devBridgePlugin, luaPlugin | | @energy8platform/platform-core/loading | createCSSPreloader, setCSSPreloaderProgress, waitCSSPreloaderTap, removeCSSPreloader, buildLogoSVG, LOADER_BAR_MAX_WIDTH | | @energy8platform/platform-core/shell | createGameShell, removeGameShell — branded renderer-agnostic DOM game shell (control bar, menu, settings, game info, buy bonus) |

The sub-paths exist for tree-shaking — pulling only /lua doesn't drag in DevBridge or vite types. The main entry is convenient for app-level code where size hardly matters.


License

MIT