# @energy8platform/game-engine

A casino game engine built on [PixiJS v8](https://pixijs.com/) and [@energy8platform/game-sdk](https://github.com/energy8platform/game-sdk). Provides scene management, responsive scaling, audio, state machines, tweens, UI components, and React integration for developing slot machines, card games, and other iGaming titles.

> **Building on a different renderer?** The platform-specific bits (SpinML math runtime, RTP simulation, DevBridge, branded loading screen, SDK session orchestration) live in the renderer-agnostic [`@energy8platform/platform-core`](../platform-core/README.md). `game-engine` is the Pixi front-end on top of it; Phaser, Three.js, and custom-engine consumers depend only on `platform-core`.

---

## Table of Contents

- [Quick Start](#quick-start)
- [Installation](#installation)
- [Architecture](#architecture)
- [Configuration](#configuration)
- [Scenes](#scenes)
- [Lifecycle](#lifecycle)
- [Assets](#assets)
- [Audio](#audio)
- [Viewport & Scaling](#viewport--scaling)
- [State Machine](#state-machine)
- [Animation](#animation)
- [Slot Reels](#slot-reels)
- [UI Components](#ui-components)
- [Input](#input)
- [Vite Configuration](#vite-configuration)
- [Game Math (SpinML)](#game-math-spinml)
- [DevBridge](#devbridge)
- [React Integration](#react-integration)
- [Debug](#debug)
- [License](#license)

---

## Quick Start

```bash
# Install dependencies. platform-core comes in transitively as a
# regular dependency of game-engine — you don't list it explicitly.
npm install pixi.js @energy8platform/game-sdk @energy8platform/game-engine

# Optional peer dependencies
npm install @pixi/sound                           # Audio
npm install @esotericsoftware/spine-pixi-v8       # Spine animations
npm install @energy8platform/golem                # golem rigs (skeletal symbols / characters + the editor)
npm install react react-dom react-reconciler      # React integration
```

The SpinML engine binaries (`e8`, `e8-server`) come with `platform-core`'s
postinstall — no extra install step for math/dev/simulation.

```typescript
// src/main.ts
import { GameApplication, ScaleMode } from '@energy8platform/game-engine';
import { GameScene } from './scenes/GameScene';

async function bootstrap() {
  const game = new GameApplication({
    container: '#game',
    designWidth: 1920,
    designHeight: 1080,
    scaleMode: ScaleMode.FIT,
    loading: {
      backgroundColor: 0x0a0a1a,
      tapToStart: true,
      minDisplayTime: 2000,
    },
    manifest: {
      bundles: [
        { name: 'preload', assets: [] },
        { name: 'game', assets: [
          { alias: 'background', src: 'background.png' },
          { alias: 'symbols', src: 'symbols.json' },
        ]},
      ],
    },
    audio: { music: 0.5, sfx: 1.0, persist: true },
    debug: true,
  });

  game.scenes.register('game', GameScene);
  await game.start('game');
}

bootstrap();
```

---

## Installation

### Peer Dependencies

| Package | Version | Required |
| --- | --- | --- |
| `pixi.js` | `^8.16.0` | Yes |
| `@energy8platform/game-sdk` | `^2.7.0` | Yes |
| `@pixi/sound` | `^6.0.0` | Optional — audio |
| `@esotericsoftware/spine-pixi-v8` | `~4.2.0` | Optional — Spine animations |
| `@energy8platform/golem` | `>=0.1.0` | Optional — golem rigs (`/golem` entry) |
| `react`, `react-dom` | `>=18.0.0` | Optional — ReactScene |
| `react-reconciler` | `>=0.29.0` | Optional — ReactScene (custom PixiJS reconciler) |

`@energy8platform/platform-core` is a regular dependency (not a peer) — npm pulls it in automatically when you install `game-engine`.

### Sub-path Exports

```typescript
import { GameApplication } from '@energy8platform/game-engine';             // full bundle
import { Scene, SceneManager } from '@energy8platform/game-engine/core';
import { AssetManager } from '@energy8platform/game-engine/assets';
import { AudioManager } from '@energy8platform/game-engine/audio';
import { FlexContainer, Button, Label, Panel, Modal, Layout, ScrollContainer, Toast, ProgressBar, BalanceDisplay, WinDisplay, Slider, Toggle, resolveView } from '@energy8platform/game-engine/ui';
import { Tween, Timeline, Easing, SpriteAnimation } from '@energy8platform/game-engine/animation';
import { createReelSystem, resolveReelConfig, PRESETS } from '@energy8platform/game-engine/slot';
import { DevBridge, FPSOverlay } from '@energy8platform/game-engine/debug';
import { ReactScene, extendPixiElements, extendUIElements, useSDK, useViewport } from '@energy8platform/game-engine/react';
import { defineGameConfig, spinPlugin } from '@energy8platform/game-engine/vite';
import type { GameDefinition, ActionDefinition } from '@energy8platform/game-engine/lua'; // types only
```

---

## Architecture

```
┌──────────────────────────────────────────────────────┐
│                   GameApplication                    │
│  (orchestrates lifecycle, holds all sub-systems)     │
├──────────┬───────────┬───────────┬───────────────────┤
│ Viewport │  Scenes   │  Assets   │  Audio  │  Input  │
│ Manager  │  Manager  │  Manager  │ Manager │ Manager │
├──────────┴───────────┴───────────┴─────────┴─────────┤
│                    PixiJS v8 Application              │
├──────────────────────────────────────────────────────┤
│              @energy8platform/game-sdk                │
└──────────────────────────────────────────────────────┘
```

### Boot Sequence

1. **CSS Preloader** — HTML/CSS overlay while PixiJS initializes
2. **PixiJS initialization** — creates `Application`, initializes `ResizeObserver`
3. **SDK handshake** — connects to casino host (or DevBridge in dev mode)
4. **Canvas Loading Screen** — progress bar, `preload` bundle loaded first
5. **Asset loading** — remaining bundles loaded with combined progress
6. **Tap-to-start** — optional (required on mobile for audio unlock)
7. **First scene** — transitions to the registered first scene

---

## Configuration

### `GameApplicationConfig`

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `container` | `HTMLElement \| string` | `document.body` | Container element or CSS selector |
| `designWidth` | `number` | `1920` | Reference design width |
| `designHeight` | `number` | `1080` | Reference design height |
| `scaleMode` | `ScaleMode` | `FIT` | `FIT` (letterbox), `FILL` (crop), `STRETCH` |
| `orientation` | `Orientation` | `ANY` | `LANDSCAPE`, `PORTRAIT`, `ANY` |
| `loading` | `LoadingScreenConfig` | — | Loading screen options (see types) |
| `manifest` | `AssetManifest` | — | Asset manifest |
| `audio` | `AudioConfig` | — | `{ music, sfx, ui, ambient }` volumes (0-1), `persist` flag |
| `sdk` | `object \| false` | — | SDK options; `false` to disable |
| `pixi` | `Partial<ApplicationOptions>` | — | PixiJS pass-through options |
| `debug` | `boolean` | `false` | Enable FPS overlay |

> Full config types including `LoadingScreenConfig`, `AudioConfig`, `TransitionType` are documented in `src/types.ts`.

---

## Scenes

All game screens are scenes. Extend the base `Scene` class:

```typescript
import { Scene } from '@energy8platform/game-engine';
import { Sprite, Assets } from 'pixi.js';

export class GameScene extends Scene {
  async onEnter(data?: unknown) {
    this.container.addChild(new Sprite(Assets.get('background')));
  }

  onUpdate(dt: number) { /* called every frame */ }
  onResize(width: number, height: number) { /* responsive layout */ }
  async onExit() { /* cleanup before leaving */ }
  onDestroy() { /* final cleanup */ }
}
```

### Scene Navigation

```typescript
const scenes = game.scenes;

scenes.register('menu', MenuScene);
scenes.register('game', GameScene);
scenes.register('bonus', BonusScene);

await scenes.goto('game');                    // replaces entire stack
await scenes.push('bonus', { multiplier: 3 }); // overlay (previous stays)
await scenes.pop();                           // pop back
await scenes.replace('game');                 // replace top scene

// With transitions
await scenes.goto('game', undefined, {
  type: TransitionType.FADE,
  duration: 500,
});
```

---

## Lifecycle

`GameApplication` events:

| Event | Payload | When |
| --- | --- | --- |
| `initialized` | `void` | Engine initialized, PixiJS and SDK ready |
| `loaded` | `void` | All asset bundles loaded |
| `started` | `void` | First scene entered, game loop running |
| `resize` | `{ width, height }` | Viewport resized |
| `orientationChange` | `Orientation` | Device orientation changed |
| `sceneChange` | `{ from, to }` | Scene transition completed |
| `balanceUpdate` | `{ balance }` | Player balance changed (from SDK) |
| `error` | `Error` | An error occurred |
| `destroyed` | `void` | Engine destroyed |

---

## Assets

Assets are organized in named bundles. The `preload` bundle loads first, the rest load together with a combined progress bar.

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

// Runtime API
const assets = game.assets;
await assets.loadBundle('bonus', (progress) => console.log(`${Math.round(progress * 100)}%`));
const texture = assets.get<Texture>('background');
await assets.backgroundLoad('bonus');       // low-priority preload
await assets.unloadBundle('bonus');          // free memory
```

---

## Audio

`AudioManager` wraps `@pixi/sound` with category-based volume. All methods are no-ops if `@pixi/sound` is not installed.

```typescript
const audio = game.audio;

audio.play('click', 'ui');
audio.play('coin-drop', 'sfx', { volume: 0.8 });
audio.playMusic('main-theme', 1000);  // 1s crossfade
audio.stopMusic();

audio.setVolume('music', 0.3);
audio.muteCategory('sfx');
audio.muteAll();
audio.toggleMute();

// Ducking during big win animations
audio.duckMusic(0.2);
audio.unduckMusic();
```

**Categories:** `music`, `sfx`, `ui`, `ambient` — each with independent volume and mute.

> Mobile audio unlock is handled automatically when `tapToStart: true`.

---

## Viewport & Scaling

`ViewportManager` handles responsive scaling using `ResizeObserver` with debouncing.

| Mode | Behavior |
| --- | --- |
| `FIT` | Letterbox/pillarbox — preserves aspect ratio. **Industry standard for iGaming.** |
| `FILL` | Fills container, crops edges |
| `STRETCH` | Stretches to fill (distorts). Not recommended. |

```typescript
const vp = game.viewport;
console.log(vp.width, vp.height, vp.scale, vp.orientation);
vp.refresh(); // force re-calculation
```

---

## State Machine

Generic typed FSM with transition guards, async hooks, and per-frame updates.

```typescript
import { StateMachine } from '@energy8platform/game-engine';

const fsm = new StateMachine<{ balance: number; bet: number }>({
  balance: 10000, bet: 100,
});

fsm.addState('idle', {
  enter: (ctx) => console.log('Waiting...'),
});

fsm.addState('spinning', {
  enter: async (ctx) => {
    await spinReels();
    await fsm.transition('idle');
  },
});

fsm.addGuard('idle', 'spinning', (ctx) => ctx.balance >= ctx.bet);

await fsm.start('idle');
const success = await fsm.transition('spinning'); // false if guard blocks
fsm.update(dt); // call from Scene.onUpdate
```

---

## Animation

### Tween

Promise-based animation system on the PixiJS Ticker. No external libraries.

```typescript
import { Tween, Easing } from '@energy8platform/game-engine';

await Tween.to(sprite, { alpha: 0, y: 100 }, 500, Easing.easeOutCubic);
await Tween.from(sprite, { scale: 0 }, 300, Easing.easeOutBack);
await Tween.fromTo(sprite, { x: -100 }, { x: 500 }, 1000, Easing.easeInOutQuad);
await Tween.delay(1000);

Tween.killTweensOf(sprite);
Tween.killAll();
Tween.reset(); // kill all + remove ticker listener
```

All standard easings available: `linear`, `easeIn/Out/InOut` for `Quad`, `Cubic`, `Quart`, `Sine`, `Expo`, `Back`, `Bounce`, `Elastic`.

### Timeline

Chains sequential and parallel animation steps:

```typescript
const tl = new Timeline();
tl.to(title, { alpha: 1, y: 0 }, 500, Easing.easeOutCubic)
  .delay(200)
  .parallel(
    () => Tween.to(btn1, { alpha: 1 }, 300),
    () => Tween.to(btn2, { alpha: 1 }, 300),
  )
  .call(() => console.log('Done!'));
await tl.play();
```

### SpriteAnimation

Frame-based animation wrapping PixiJS `AnimatedSprite`. Config: `{ fps, loop, autoPlay, onComplete, anchor }`.

```typescript
import { SpriteAnimation } from '@energy8platform/game-engine';

const coin = SpriteAnimation.create(coinTextures, { fps: 30, loop: true });
const sparkle = SpriteAnimation.fromSpritesheet(sheet, 'sparkle_', { fps: 24 });

// Fire-and-forget
const { sprite, finished } = SpriteAnimation.playOnce(textures, { fps: 30 });
container.addChild(sprite);
await finished;
```

### Spine Animations

Requires `@esotericsoftware/spine-pixi-v8`:

```typescript
import { SpineHelper } from '@energy8platform/game-engine';

const spine = await SpineHelper.create('character-skel', 'character-atlas', { scale: 0.5 });
await SpineHelper.playAnimation(spine, 'idle', true);
SpineHelper.setSkin(spine, 'warrior');
```

### Golem Rigs

Requires `@energy8platform/golem`. A rig is a `rig.json` plus its PNG layers under `public/`
(`public/assets/rigs/<id>/`); `npm run rigs` (`golem editor --root public/assets/rigs`) edits them
in the browser and the dev game reloads on every save. `defineGameConfig` strips the editor's
`.rig-history/` from `vite build`.

```typescript
import { loadRigs, RigSymbol, RigPlayer } from '@energy8platform/game-engine/golem';

const rigs = await loadRigs({ h1: 'assets/rigs/h1/rig.json', hero: 'assets/rigs/hero/rig.json' });
// in a reel cell — RigSymbol is a SymbolView (playIdle / playWin / showStatic / resize)
const resolve = (id: string) => rigs[id] ? new RigSymbol(rigs[id].doc, rigs[id].textures, { size: 110 }) : null;
// anywhere on stage — hero.tick(seconds) from the scene's onUpdate
const hero = new RigPlayer(rigs.hero.doc, rigs.hero.textures);
scene.container.addChild(hero.view);
hero.state.setBase('idle');
```

---

## Slot Reels

A fully-configurable reel system driven by a single typed `ReelSystemConfig`. Import from `@energy8platform/game-engine/slot`. **Presentation only** — the engine draws the boards you feed it; the outcome (math) always comes from your `.spin` script / server.

> Try every knob interactively in the **[reel-lab playground](../../examples/reel-lab/)** — it tweaks the whole config live, triggers every feature, and copies the resulting config (TS/JSON) into your game.

### Quick Start

```typescript
import { createReelSystem, resolveReelConfig } from '@energy8platform/game-engine/slot';
import type { SymbolView } from '@energy8platform/game-engine/slot';

// 1. Tell the system how to build each symbol's visual (any PixiJS Container).
const resolve = (id: string): SymbolView | null => new MySymbolSprite(id);

// 2. Create the system — override only what you need; the rest is sensible defaults.
const reels = createReelSystem({
  resolve,
  config: {
    grid: { cols: 5, rows: 3, cellSize: 96, evaluation: 'lines' },
    motion: { style: 'strip', spinUp: 600, blur: { enabled: true, alpha: 0.85, strength: 6, streaks: false } },
  },
});
scene.container.addChild(reels.view);

// 3. Drive it with results from your play loop (board[col][row]).
await reels.spin(targetBoard, { turbo });
await reels.cascade(cascadeSteps, { turbo });   // tumble/avalanche steps
```

### Driving the spin from the game

`spin()` is not a black box — the schedule it runs is public data, it signals every landing, and it
will hand a reel back to you unlanded if you mean to bring it in yourself.

```typescript
// The schedule spin() WOULD run — same anticipation decision, same numbers. Schedule landing
// sounds / camera moves against this instead of re-deriving the engine's formula.
const plan = reels.planSpin(targetBoard, { turbo });
plan.forEach((p) => console.log(p.reel, p.stopTime, p.slowdown, p.anticipated, p.deferred));

await reels.spin(targetBoard, {
  turbo,
  onPlan: (plan) => scheduleAudio(plan),          // the plan actually being executed
  onReelStop: (reel, p) => sfx.play('reel-stop'), // the frame a reel lands
  onCellSeated: (reel, row) => sfx.play('thud'),  // per-cell arrival (the `cascade-drop` impact)

  // Drive anticipation yourself — an explicit list WINS over the configured decision.
  anticipateReels: [3, 4],
  anticipateSlowdown: [, , , 0.5, 0.25],          // scalar, or indexed BY REEL
  anticipateHoldMs: 300,

  // Reels that spin for real, then go DARK instead of showing their result: the engine disposes
  // of the tape and leaves those cells hidden and unseated. You own their data and visibility
  // from that point (a slam stop will not reveal them either).
  deferReveal: [3, 4],
});
```

#### Pacing a `cascade-drop` board

A drop reel is a sequence of per-cell arrivals, so `planSpin` gives it a per-cell schedule:
`plan[reel].cellStopTimes[row]` is the ms at which that cell seats, and `stopTime` is the moment
the whole reel has landed. Three knobs shape it:

| Knob | Effect |
| --- | --- |
| `cellStagger` | ms between consecutive cells of one reel |
| `anticipation.progressiveCellStagger` | how that gap GROWS from cell to cell inside an anticipated reel — the gap before cell #i is `cellStagger * <reel slowdown> * ramp ** i`. 1 (default) keeps the reel evenly spaced |
| `dropOrder` | `'top-down'` (default) deals the reel like cards; `'bottom-up'` fills it the way gravity would — the lowest cell lands first and the rest stack on it |
| `dropSequence` | `'parallel'` (default) starts each reel at `reel * stopStagger * reelStaggerFactor`, so reels can overlap; `'chained'` queues every reel behind the previous one; `'chained-when-anticipated'` keeps the un-armed reels parallel and chains only from the first anticipated reel |

`'chained-when-anticipated'` is the one a scatter hunt wants: the base spin keeps its stop window,
and once anticipation arms, the board goes strictly reel by reel, each slower than the last.

```typescript
motion: {
  style: 'cascade-drop',
  spinUp: 300, stopStagger: 200, reelStaggerFactor: 1,
  cellStagger: 120,
  dropOrder: 'bottom-up',
  dropSequence: 'chained-when-anticipated',
  squash: { enabled: true },
},
anticipation: {
  enabled: true, triggerSymbols: ['SCATTER'], threshold: 2, reels: 'trailing',
  slowdownFactor: 0.75,          // the first armed reel, already slower
  progressiveSlowdown: 0.8,      // …and each one after it slower again
  progressiveHoldMs: 120,        // plus a growing beat before it starts
  progressiveCellStagger: 1.5,   // …and its symbols drip ever wider apart
},
```

The three ramps stack on different axes. On a 5×4 board at the numbers above, the un-armed reels
keep an even 120ms between symbols, and the hunt reels drip:

| reel | gaps between its four symbols |
| --- | --- |
| 2 (first armed) | 160 → 240 → 360ms |
| 3 | 200 → 300 → 450ms |
| 4 | 250 → 375 → 563ms |

`progressiveSlowdown` widens the gap reel by reel, `progressiveCellStagger` widens it cell by cell,
and `progressiveHoldMs` adds a growing beat before each reel starts at all.

> `slowdownFactor` is the lever that actually slows a reel; leaving it at `1` means no slow-down
> however the rest is set. `progressiveHoldMs` is **milliseconds** added per reel, not a ratio —
> the ratio knob is `progressiveSlowdown`.

For a trigger that isn't "N of symbol X landed", give the config its own predicate — it replaces
the built-in counting entirely:

```typescript
reels.update({
  anticipation: {
    enabled: true,
    // e.g. "the round is still alive on every reel so far"; return null / [] for no anticipation
    decide: (target) => (stillAlive(target) ? { reels: [2, 3, 4], holdMs: 400 } : null),
    progressiveSlowdown: 0.7,  // each successive anticipated reel 30% slower than the last
    progressiveHoldMs: 150,    // …and held 150ms longer
  },
});
```

### Config Shape

`resolveReelConfig(partial)` deep-merges your overrides onto `DEFAULT_REEL_CONFIG`:

| Section | Key knobs |
| --- | --- |
| `grid` | `cols`, `rows`, `rowsPerReel[]` (Megaways / variable heights), `cellSize`, `gap`, `evaluation` (`lines`/`ways`/`anywhere`/`cluster`/`megaways`/`infinity`), `mask` |
| `motion` | `style` (`swap`/`strip`/`cascade-drop`), `spinUp`, `hold`, `stopStagger`, `stopMode` (`sequential`/`sync`/`random`), `stopOrder`, `settle`, `squash`, `blur`, `turboFactor`, `intensity`, `slamStop`, `cellStagger` + `reelStaggerFactor` + `dropFallFactor` + `dropOrder` + `dropSequence` (`cascade-drop` pacing) |
| `anticipation` | `enabled`, `triggerSymbols`, `threshold` (N−1), `reels` (`trailing`/indices), `slowdownFactor`, `holdMs`, `decide` (game-supplied predicate), `progressiveSlowdown` + `progressiveHoldMs` + `progressiveCellStagger` (ramps), `zoom` |
| `cascade` | `enabled`, `gravity`, `timings`, `easings`, `perStepDecel`, `dimNonWinners`, `multiplier` (`mode` add/mul, `cap`, `persistInFreeSpins`) |
| `win` | `highlightScale`, `glow`, `frameShake` |
| `features` | per-mechanic config (see below) |

Helpers: `effectiveRowsPerReel(grid)`, `waysCount(grid)` (product of per-reel heights), `mergeReelConfig(base, partial)`.

### Presets

Ready-made configs distilled from shipped games — use directly or as a starting point:

```typescript
import { createReelSystem, resolveReelConfig, PRESETS } from '@energy8platform/game-engine/slot';

const reels = createReelSystem({ resolve, config: PRESETS['stone-rush'].config });
// or merge a preset with your own overrides:
const cfg = resolveReelConfig({ ...PRESETS.megaways.config, grid: { cellSize: 80 } });
```

Available: `classic`, `kitsune-wrath`, `moon-spice`, `stone-rush`, `hot-ross`, `magnus`, `megaways`.

### Feature Mechanics

13 built-in presentation mechanics, each toggled + parameterized under `config.features`: `expandingWild`, `sticky`, `walkingWild`, `randomWild`, `mystery`, `transform` (+ upgrade), `giant`, `split`, `stacked`, `nudge` (xNudge), `multiplier`, `holdAndSpin`, `reelModifier`.

```typescript
const reels = createReelSystem({
  resolve,
  config: { features: { expandingWild: { enabled: true, symbol: 'wild', reels: [1, 2, 3] } } },
});

reels.enabledFeatures();                 // active features in canonical order
await reels.runFeature('expandingWild'); // play a feature's presentation
```

### Custom Features

Not every mechanic is built in — register your own. A `ReelFeature` uses only the public `FeatureContext` (grid geometry, the `fx` overlay layer, `resolve`, `Tween`):

```typescript
import type { ReelFeature } from '@energy8platform/game-engine/slot';
import { Tween, Easing } from '@energy8platform/game-engine/animation';

const FlyingWild: ReelFeature = {
  key: 'custom:flyingWild',
  label: 'Flying wild',
  enabled: () => true,
  async demo(ctx) {
    const from = ctx.grid.cellPosition(ctx.grid.cols - 1, 0);
    const flyer = ctx.resolve('wild')!;
    flyer.position.set(from.x, from.y);
    ctx.fx.addChild(flyer);                                  // overlay above the grid
    await Tween.to(flyer, { 'position.x': 200, 'position.y': 150 }, 640, Easing.easeInOutQuad);
    flyer.destroy();
  },
};

const reels = createReelSystem({ resolve, config, features: [FlyingWild] });
// or later: reels.registerFeature(FlyingWild);
await reels.runFeature('custom:flyingWild');
```

You can also skip the registry entirely and animate directly over `reels.grid` / `reels.fx` with `Tween` — see the [reel-lab custom features](../../examples/reel-lab/src/customFeatures.ts) for working examples (flying wild, reel highlight).

### `ReelSystem` API

| Member | Description |
| --- | --- |
| `view`, `grid`, `fx` | Root container, the `ReelGrid`, and the overlay layer |
| `config`, `board`, `ways`, `multiplier` | Current config / board / ways-to-win / cascade multiplier |
| `spin(target, opts?)` | Animate a spin to `target` (handles anticipation + zoom) |
| `cascade(steps, opts?)` | Run tumble steps; `{ freeSpins }` honours `persistInFreeSpins` |
| `update(partial)` / `setConfig(cfg)` | Re-merge / replace config (rebuilds geometry when needed) |
| `setBoard(board)` / `resize(cellSize)` | Set the board / re-layout |
| `registerFeature(f)` / `runFeature(key)` / `features()` | Custom-feature registry |
| `skip()` / `destroy()` | Slam-stop / tear down |

> **Low-level primitives** are also exported for bespoke setups: `ReelGrid`, `SpinEngine`, `AnticipationController`, `TumbleController`, `SymbolCell`, `AnimatedSymbol`, plus the legacy `ReelSpinController` / `CascadeController` (kept for back-compat). The overlay helpers `BigWinOverlay`, `CountUpDisplay`, `MultiplierAccumulator`, and `FreeSpinsSession` round out the slot toolkit.

---

## UI Components

> Built-in UI system with zero external dependencies. No `@pixi/ui`, `@pixi/layout`, or `yoga-layout` required. Import from `@energy8platform/game-engine/ui`.

### Asset Skinning (`ViewInput`)

Every visual component supports custom artwork via the `ViewInput` type:

```typescript
type ViewInput = string | Texture | Container;
```

- `string` — texture name, resolved via `Sprite.from()` from the asset manager
- `Texture` — wrapped in a `Sprite`
- `Container` — used as-is (NineSliceSprite, AnimatedSprite, custom artwork, etc.)

If no custom view is provided, components fall back to Graphics-based rendering (colors, rounded rects). This means you can prototype with Graphics and later swap to production art with no API changes.

### FlexContainer

Lightweight flexbox-like layout container. Children added via `addChild()` automatically participate in flex layout. Supports auto-sizing (no explicit `width`/`height` required), percentage dimensions, absolute positioning for excluded children, and multi-line alignment.

```typescript
const toolbar = new FlexContainer({
  direction: 'row',
  justifyContent: 'space-between',
  alignItems: 'center',
  gap: 16,
  padding: 12,
});
toolbar.addChild(button1);                       // auto flex layout
toolbar.addChild(button2);
toolbar.addFlexChild(spacer, { flexGrow: 1 });   // with flex config
toolbar.resize(800, 60);
```

**FlexContainerConfig:**

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `direction` | `'row' \| 'column'` | `'row'` | Layout direction |
| `justifyContent` | `'start' \| 'center' \| 'end' \| 'space-between' \| 'space-around'` | `'start'` | Main-axis distribution |
| `alignItems` | `'start' \| 'center' \| 'end' \| 'stretch'` | `'start'` | Cross-axis alignment |
| `alignContent` | `'start' \| 'center' \| 'end' \| 'space-between' \| 'stretch'` | `'start'` | Multi-line cross-axis distribution (with `flexWrap`) |
| `gap` | `number` | `0` | Gap between children |
| `padding` | `number \| [top, right, bottom, left]` | `0` | Padding (shorthand) |
| `paddingTop/Right/Bottom/Left` | `number` | — | Individual padding overrides (take priority over `padding`) |
| `flexWrap` | `boolean` | `false` | Enable wrapping to next line |
| `width` | `number \| string` | — | Container width — pixels or `"50%"` relative to parent |
| `height` | `number \| string` | — | Container height — pixels or `"50%"` relative to parent |

**Auto-sizing:** When no explicit `width`/`height` is set, the container computes its size from content (`_computedWidth`/`_computedHeight`). Cross-axis `alignItems` works automatically (centers relative to tallest/widest child). Parent FlexContainers correctly measure auto-sized children.

**Percentage dimensions:** String values like `"100%"` or `"50%"` resolve against the parent FlexContainer's content area. Works for both container `width`/`height` and child `layoutWidth`/`layoutHeight`.

```typescript
// Parent with explicit size, child fills 100% width and 50% height
const parent = new FlexContainer({ direction: 'column', width: 800, height: 600 });
const child = new FlexContainer({ width: '100%', height: '50%', direction: 'row' });
parent.addFlexChild(child); // child resolves to 800×300
```

**FlexItemConfig** — per-child options passed via `addFlexChild(child, config)` or JSX props:

| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `flexGrow` | `number` | `0` | Flex grow factor |
| `flexShrink` | `number` | `1` | Flex shrink factor (`0` = don't shrink when content overflows) |
| `alignSelf` | `'auto' \| 'start' \| 'center' \| 'end' \| 'stretch'` | `'auto'` | Override parent's `alignItems` for this child |
| `flexExclude` | `boolean` | `false` | Exclude from flex layout (like `position: absolute`) |
| `layoutWidth` | `number \| string` | — | Width override — pixels or `"50%"` of parent content area |
| `layoutHeight` | `number \| string` | — | Height override — pixels or `"50%"` of parent content area |
| `top` | `number` | — | Absolute positioning (only with `flexExclude`) — distance from top edge |
| `right` | `number` | — | Absolute positioning — distance from right edge |
| `bottom` | `number` | — | Absolute positioning — distance from bottom edge |
| `left` | `number` | — | Absolute positioning — distance from left edge |

```typescript
// Fixed item that won't shrink + centered override
toolbar.addFlexChild(logo, { flexShrink: 0 });
toolbar.addFlexChild(badge, { alignSelf: 'center' });

// Absolute positioning (like CSS position: absolute)
toolbar.addFlexChild(closeBtn, { flexExclude: true, top: 8, right: 8 });
toolbar.addFlexChild(background, { flexExclude: true, top: 0, left: 0 });
```

### Layout

Higher-level layout with direction presets (`horizontal`/`vertical`/`grid`/`wrap`), viewport anchoring, and responsive breakpoints. Wraps FlexContainer.

```typescript
const toolbar = new Layout({
  direction: 'horizontal',
  gap: 20,
  alignment: 'center',
  anchor: 'bottom-center',
  padding: 16,
  breakpoints: { 768: { direction: 'vertical', gap: 10 } },
});
toolbar.addItem(spinButton);
toolbar.addItem(betLabel);
toolbar.updateViewport(width, height);
```

**Anchors:** `top-left`, `top-center`, `top-right`, `center-left`, `center`, `center-right`, `bottom-left`, `bottom-center`, `bottom-right`

### Button

Per-state views with Tween animations. Each state accepts a `ViewInput` for full asset control:

```typescript
// Graphics-based (prototyping)
const btn = new Button({
  width: 200, height: 60, borderRadius: 12,
  colors: { default: 0xffd700, hover: 0xffe44d, pressed: 0xccac00, disabled: 0x666666 },
  text: 'SPIN',
  onPress: () => spin(),
});

// Asset-based (production)
const btn = new Button({
  defaultView: 'btn-idle',       // texture name
  hoverView: 'btn-hover',
  pressedView: 'btn-pressed',
  disabledView: 'btn-disabled',
  text: 'SPIN',
  onPress: () => spin(),
});

// NineSlice button
const btn = new Button({
  defaultView: new NineSliceSprite({ texture: Texture.from('btn-9s'), leftWidth: 20, topHeight: 20, rightWidth: 20, bottomHeight: 20 }),
  text: 'BET MAX',
});
```

### ProgressBar

Animated fill bar with optional custom track/fill views:

```typescript
// Graphics-based
const bar = new ProgressBar({ width: 400, height: 20, fillColor: 0x00ff00, animated: true });
bar.progress = 0.75;
bar.update(dt);

// Asset-based
const bar = new ProgressBar({
  width: 400, height: 20,
  trackView: 'bar-track',                       // texture name
  fillView: new NineSliceSprite({ ... }),        // or any Container
});
```

### Slider

Draggable slider with customizable track, fill, and handle views:

```typescript
// Graphics-based
const volume = new Slider({
  min: 0, max: 1, value: 0.5, step: 0.1,
  width: 200, height: 8,
  fillColor: 0xffd700,
  onUpdate: (v) => audio.setVolume('music', v),
  onChange: (v) => console.log('Final:', v),
});

// Asset-based
const volume = new Slider({
  min: 0, max: 1, value: 0.5,
  width: 200, height: 8,
  trackView: 'slider-track',
  fillView: 'slider-fill',
  handleView: 'slider-handle',
  onUpdate: (v) => audio.setVolume('music', v),
});
```

`onUpdate` fires continuously during drag, `onChange` fires once when drag ends.

### Toggle

Two-state toggle switch with animation:

```typescript
// Graphics-based
const mute = new Toggle({
  value: false,
  width: 52, height: 28,
  onColor: 0x22cc22, offColor: 0x666666,
  onChange: (on) => audio.muteAll(!on),
});

// Asset-based (custom ON/OFF views with crossfade)
const ante = new Toggle({
  value: false,
  onView: 'toggle-on',
  offView: 'toggle-off',
  onChange: (on) => setAnteBet(on),
});

// Programmatic control
mute.forceSwitch(true);
```

### ScrollContainer

Touch/drag scrolling with mouse wheel, inertia, and optional visual scrollbar:

```typescript
const scroll = new ScrollContainer({
  width: 600, height: 400,
  direction: 'vertical',
  elementsMargin: 8,
  backgroundColor: 0x1a1a2e,
  scrollbar: true,                    // show scrollbar indicator
  scrollbarColor: 0xaaaaaa,           // or provide custom view:
  // thumbView: 'scrollbar-thumb',    // texture name, Texture, or Container
});
for (let i = 0; i < 50; i++) scroll.addItem(createRow(i));
```

### Panel

Background panel with FlexContainer content layout. Supports Graphics or 9-slice backgrounds:

```typescript
// Graphics background
const panel = new Panel({ width: 600, height: 400, backgroundColor: 0x1a1a2e, borderRadius: 16, padding: 16 });
panel.addContent(myLabel);

// 9-slice texture background
const panel = new Panel({
  nineSliceTexture: 'panel-bg',
  nineSliceBorders: [20, 20, 20, 20],
  width: 600, height: 400, padding: 16,
});
```

### Other UI Components

**Label** — styled text with auto-fit and currency formatting:
```typescript
const label = new Label({ text: 'TOTAL WIN', style: { fontSize: 36, fill: 0xffffff }, maxWidth: 300, autoFit: true });
label.setCurrency(1500, 'USD'); // → "$1,500.00"
```

**BalanceDisplay** — animated currency countup/countdown:
```typescript
const balance = new BalanceDisplay({ prefix: 'BALANCE', currency: 'USD', animated: true });
balance.setValue(9500); // smooth countup
```

**WinDisplay** — dramatic countup with scale pop:
```typescript
await winDisplay.showWin(5000);
winDisplay.hide();
```

**Modal** — full-screen overlay with enter/exit animations:
```typescript
const modal = new Modal({ overlayAlpha: 0.7, closeOnOverlay: true, animationDuration: 300 });
modal.content.addChild(settingsPanel);
await modal.show(viewWidth, viewHeight);
```

**Toast** — transient notifications with optional custom background:
```typescript
const toast = new Toast({ duration: 3000, backgroundView: 'toast-bg' });
await toast.show('Free spins activated!', 'success', viewWidth, viewHeight);
```

---

## Input

`InputManager` provides unified touch/mouse/keyboard handling with gesture detection.

```typescript
const input = game.input;

input.on('tap', ({ x, y }) => console.log(`Tap at ${x}, ${y}`));
input.on('swipe', ({ direction, velocity }) => console.log(`Swipe ${direction}`));
input.on('keydown', ({ key, code }) => { if (code === 'Space') startSpin(); });
if (input.isKeyDown('ArrowLeft')) { /* move left */ }

input.lock();   // lock during animations
input.unlock();

const worldPos = input.getWorldPosition(canvasX, canvasY); // DOM → game-world coords
```

**Events:** `tap`, `press`, `release`, `move`, `swipe`, `keydown`, `keyup`

---

## Vite Configuration

```typescript
// vite.config.ts
import { defineGameConfig } from '@energy8platform/game-engine/vite';

export default defineGameConfig({
  base: '/games/my-slot/',
  devBridge: true,
  devBridgeConfig: './dev.config', // optional custom config path
  vite: { /* additional Vite config */ },
});
```

**What `defineGameConfig` provides:** ESNext build target, asset inlining (<8KB), PixiJS chunk splitting, DevBridge auto-injection in dev mode, dependency deduplication (`pixi.js`, `react`, etc.), and pre-bundling optimization.

---

## Game Math (SpinML)

Game logic is a `.spin` script — a statically-typed, Lua-flavored DSL
JIT-compiled to native code by the `e8` engine. In dev the Vite `spinPlugin`
(injected by `defineGameConfig({ devBridge: true })`) spawns `e8-server`
with hot reload; the same script runs in RTP simulation and in production.

> The runtime, the Vite plugins, and the game-definition types ship from
> [`@energy8platform/platform-core`](../platform-core/README.md) (a
> transitive dep). `game-engine` re-exports `/vite` and the legacy `/lua`
> **types** so existing import paths keep working. The fengari Lua engine
> was removed in 0.28 — legacy Lua games stay on ≤ 0.27.x; see the
> [migration guide](../../docs/lua-to-spin-migration.md).
>
> Language reference: [docs/spinml.md](../../docs/spinml.md).

### DevBridge Integration (recommended)

```typescript
// dev.config.ts — the field is still named luaScript: for the client
// DevBridge it is only the "play via POST /__lua-play" marker (the route
// is served by e8-server through spinPlugin).
import mathScript from './src/game/script.spin?raw';
import gameDefinition from './src/game/config.json';

export default {
  balance: 5000,
  currency: 'USD',
  luaScript: mathScript,
  gameDefinition,
};
```

Actions, costs, and session transitions are declared inside the `.spin`
(`action spin { … opens = free_spin count free_spins_awarded }`), not in the
dev config — the scaffolded `config.json` is derived from `game.spec.ts`.

### RTP Simulation (CLI)

Run the same `.spin` from `dev.config.ts` through millions of iterations to verify math (wraps `e8 simulate`; for the publishable book pipeline use `e8-math` from `stake-math-tools`):

```bash
# Regular spins (1M iterations, default)
npx platform-core-simulate

# Buy bonus simulation
npx platform-core-simulate --action buy_bonus

# Ante bet (a regular action, not a params flag)
npx platform-core-simulate --action ante_spin

# Custom parameters
npx platform-core-simulate --iterations 5000000 --bet 1 --seed <hex> --config ./dev.config.ts
```

Output matches the old server-side simulation format, plus volatility and win-distribution sections:

```
--- Simulation Results ---
Game: my-slot
Action: spin
Iterations: 1,000,000
Duration: 0.3s
Total RTP: 96.48%
Base Game RTP: 72.31%
Bonus RTP: 24.17%
Hit Frequency: 28.45%
Max Win: 5234.50x
Max Win Hits: 3 (rounds capped by max_win)
Bonus Triggered: 4,521 (1 in 221 spins)
Bonus Spins Played: 52,847
--- Volatility ---
Volatility: 6/10 (Medium-High)   StdDev: 12.40   CV: 12.85
```

The CLI reads the script and `gameDefinition` from your `dev.config.ts` — the same config used for DevBridge. Programmatic usage:

```typescript
import { NativeSimulationRunner, findE8Binary } from '@energy8platform/platform-core/simulation';

const runner = new NativeSimulationRunner({
  binaryPath: findE8Binary()!,
  argsPrefix: ['simulate'],
  script: spinSource,
  scriptExt: 'spin',
  gameDefinition,
  iterations: 1_000_000,
  bet: 1.0,
  rng: 'provably-fair',
});

const result = await runner.run();
console.log(formatNativeResult(result));
```

---

## DevBridge

Simulates a casino host for local development using SDK's `Bridge` in `devMode` (shared `MemoryChannel`, no iframe needed). DevBridge is implemented in [`@energy8platform/platform-core`](../platform-core/README.md); the imports below resolve through the re-export shim shipped by `game-engine`.

```typescript
import { DevBridge } from '@energy8platform/game-engine/debug';

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] },
  onPlay: ({ action, bet }) => {
    const win = Math.random() < 0.4 ? bet * 5 : 0;
    return { win };
  },
  // OR use Lua: luaScript, gameDefinition, luaSeed
});

bridge.start();
bridge.setBalance(5000);
bridge.destroy();
```

**Handled (in):** `GAME_READY`, `PLAY_REQUEST`, `PLAY_RESULT_ACK`, `GET_BALANCE`, `GET_STATE`, `OPEN_DEPOSIT`.
**Emitted (out):** `INIT`, `PLAY_RESULT`, `PLAY_ERROR`, `BALANCE_UPDATE`, `STATE_RESPONSE`.

> With the Vite plugin (`devBridge: true`), DevBridge is injected automatically before your app entry point.

> **Error codes & session contract.** In server mode, DevBridge emits the same `PLAY_ERROR` codes the platform does (`INVALID_AMOUNT`, `INSUFFICIENT_FUNDS`, `ACTIVE_SESSION_EXISTS`, `NO_ACTIVE_SESSION`, `SESSION_EXPIRED`, `ENGINE_ERROR`, …) — see the [platform-core DevBridge section](../platform-core/README.md#platform-parity-behavior-server-mode) for the full table and the contract details (server-generated round IDs, `session.history`, `STATE_RESPONSE` shape, `creditPending` semantics).

---

## React Integration

Built-in React reconciler for PixiJS. No `@pixi/react` needed — renders React trees directly into PixiJS Containers. All engine UI components work declaratively in JSX with full TypeScript autocompletion.

### Setup

```typescript
import { extendPixiElements, extendUIElements } from '@energy8platform/game-engine/react';

extendPixiElements();  // Container, Sprite, Text, Graphics, etc.
extendUIElements();    // Button, Label, FlexContainer, Panel, etc.
```

### ReactScene

```tsx
import { ReactScene, useEngine, useBalance } from '@energy8platform/game-engine/react';
import { useState } from 'react';

export class SlotScene extends ReactScene {
  render() { return <SlotUI />; }
}

function SlotUI() {
  const { screen } = useEngine();
  const balance = useBalance();
  const [bet, setBet] = useState(1);

  return (
    <flexContainer direction="column" width={screen.width} height={screen.height} padding={20}>
      {/* Top bar */}
      <flexContainer direction="row" justifyContent="space-between" alignItems="center">
        <balanceDisplay currency="USD" animated value={balance} />
        <label text={`BET: $${bet.toFixed(2)}`} style-fontSize={18} style-fill={0xcccccc} />
      </flexContainer>

      {/* Controls */}
      <flexContainer direction="row" justifyContent="center" gap={12} alignItems="center">
        <button width={50} height={50} borderRadius={25} text="-"
                colors-default={0x444444}
                onPress={() => setBet(b => Math.max(0.2, b - 0.5))} />
        <button width={140} height={60} borderRadius={30} text="SPIN"
                colors-default={0x22aa44} colors-hover={0x33cc55}
                onPress={() => { /* spin */ }} />
        <button width={50} height={50} borderRadius={25} text="+"
                colors-default={0x444444}
                onPress={() => setBet(b => b + 0.5)} />
      </flexContainer>
    </flexContainer>
  );
}
```

### Declarative UI Components

All engine UI components are config-based: the reconciler passes JSX props as a config object to the constructor, and calls `updateConfig()` on prop changes. Children of `<flexContainer>`, `<panel>`, and `<scrollContainer>` automatically participate in their layout system.

```tsx
{/* Asset-skinned button */}
<button defaultView="btn-idle" hoverView="btn-hover" text="SPIN" onPress={handler} />

{/* Panel with 9-slice background */}
<panel nineSliceTexture="panel-bg" nineSliceBorders={[20,20,20,20]} width={400} height={300}>
  <label text="Settings" style-fontSize={24} />
</panel>

{/* Progress bar with custom track/fill */}
<progressBar trackView="bar-track" fillView="bar-fill" progress={0.75} width={300} height={20} />

{/* Scrollable list */}
<scrollContainer width={500} height={400} direction="vertical" scrollbar elementsMargin={8}>
  <label text="Item 1" />
  <label text="Item 2" />
</scrollContainer>

{/* Slider */}
<slider min={0} max={1} value={volume} width={200} height={8}
        fillColor={0xffd700} onUpdate={setVolume} />

{/* Toggle */}
<toggle value={isMuted} onColor={0x22cc22} onChange={setIsMuted} />

{/* Flex item props — work on any element inside <flexContainer> */}
<flexContainer direction="row" width={800} height={60}>
  <graphics draw={drawBg} flexExclude top={0} left={0} /> {/* absolute positioning */}
  <label text="Logo" flexShrink={0} />                      {/* won't shrink */}
  <container flexGrow={1} />                                 {/* fills remaining space */}
  <button text="Menu" alignSelf="center" />                  {/* centered on cross-axis */}
  <sprite texture="close" flexExclude top={4} right={4} />  {/* top-right corner */}
</flexContainer>

{/* Individual padding props */}
<flexContainer direction="column" paddingTop={20} paddingLeft={16} paddingRight={16}>
  <label text="Content" />
</flexContainer>

{/* Percentage dimensions */}
<flexContainer direction="column" width={screen.width} height={screen.height}>
  <flexContainer width="100%" height="50%" direction="row" alignItems="center">
    <label text="Top half" />
  </flexContainer>
</flexContainer>

{/* alignContent for wrapped lines */}
<flexContainer direction="row" flexWrap alignContent="center" width={400} height={400} gap={8}>
  <button width={180} height={40} text="A" />
  <button width={180} height={40} text="B" />
  <button width={180} height={40} text="C" />
</flexContainer>
```

**Dash-notation** for nested config: `colors-default={0xff0000}` → `{ colors: { default: 0xff0000 } }`, `style-fontSize={24}` → `{ style: { fontSize: 24 } }`.

### Hooks

| Hook | Returns | Description |
| --- | --- | --- |
| `useEngine()` | `EngineContextValue` | Full engine context (app, sdk, audio, screen, etc.) |
| `useSDK()` | `CasinoGameSDK \| null` | SDK instance |
| `useAudio()` | `AudioManager` | Audio manager |
| `useInput()` | `InputManager` | Input manager |
| `useViewport()` | `{ width, height, scale, isPortrait }` | Reactive viewport |
| `useBalance()` | `number` | Reactive balance |
| `useSession()` | `SessionData \| null` | Current session |
| `useGameConfig<T>()` | `T \| null` | Game config from SDK |

### Element Registration

```typescript
import { extendPixiElements, extendUIElements, extendCustomElements } from '@energy8platform/game-engine/react';

extendPixiElements();                // Container, Sprite, Text, Graphics, AnimatedSprite, etc.
extendUIElements();                  // Button, Label, FlexContainer, Panel, ProgressBar, etc.
extendCustomElements({ MyWidget });  // your own classes
```

After registration, use as lowercase JSX: `<container>`, `<sprite>`, `<text>`, `<graphics>`, `<button>`, `<flexContainer>`, `<panel>`, etc.

**Props:** Regular props set directly (`alpha`, `visible`, `scale`). Nested via dash: `position-x`, `scale-y`, `colors-default`. Events: `onClick`, `onPointerDown`, `onPress` (Button).

> React is entirely optional. Imperative (`Scene`) and React (`ReactScene`) scenes can coexist in the same game.

---

## Debug

When `debug: true` in config, an FPS overlay (avg FPS, min FPS, frame time) is shown automatically.

```typescript
import { FPSOverlay } from '@energy8platform/game-engine/debug';
const fps = new FPSOverlay(game.app);
fps.show();
fps.toggle();
fps.hide();
```

---

## API Types

All API types are fully documented in TypeScript. Explore `src/types.ts` for config interfaces, enums, and re-exported SDK types. Individual class APIs are visible via IDE autocompletion or by reading the source modules directly.

---

## License

MIT
