@enigma-lake/forge-play-controller-sdk
v2.6.0
Published
The ZOOT play controller: one package, modular parts, per-game adapters.
Keywords
Readme
@enigma-lake/forge-play-controller-sdk
The ZOOT play controller: one package, modular parts, two modules — single-play and multi-play — and a preset per game shape.
It replaces the seven forked @enigma-lake/*-play-controller-sdk packages
(dice, tower, mines, multi, plinko, crash, beerpong-v2). Those were copy-paste
forks of each other — the buttons, inputs, currency selector, coin icons and
free-round modals were identical, and only the state machine and the bar's
composition differed. Here the shared part is core/, the differing part is a
preset of one of two modules, and the bar itself is a list of parts.
See docs/design.md for the design this is built from.
Using it
One button (single-play) or two sides (multi-play)? Pick the module, then a preset in it:
import { PlayController } from "@enigma-lake/forge-play-controller-sdk";
import { tower } from "@enigma-lake/forge-play-controller-sdk/single-play";
// or, two-sided: import { multi } from "@enigma-lake/forge-play-controller-sdk/multi-play";
import "@enigma-lake/forge-play-controller-sdk/style.css";
<PlayController adapter={tower} config={CONTROLLER} model={model} handlers={handlers}>
{scene}
</PlayController>;- The preset is imported statically from ONE module, so the other module never reaches your bundle.
configis plain data (below).modelandhandlersare the game's runtime.- Children are the scene. The bar renders as a sibling layer over it.
Modules and presets
single-play — @enigma-lake/forge-play-controller-sdk/single-play: one stake, one play button.
| id | shape | autoplay | collect | selectors |
| --- | --- | --- | --- | --- |
| single-play/bare | one play button | — | — | — |
| single-play/dice | one button, press and hold | — | — | — |
| single-play/tower | multi-step, bank or continue | selection, 10 × 1500ms | yes | yes |
| single-play/mines | grid reveal, pre-picked tiles | selection, 150 × 1500ms | yes | yes (bomb count) |
| single-play/plinko | rapid fire, overlapping rounds | fire-and-forget, 150 × 500ms | — | yes (risk + rows) |
| single-play/crash | the game draws its own button | — | game's | — |
| single-play/slots | ZOOT's slots: round PLAY, hold for turbo, press to stop | one press, 150 × 500ms | — | the game-speed block (model.speed) |
multi-play — @enigma-lake/forge-play-controller-sdk/multi-play: two stakes and two buttons, LEFT and RIGHT.
| id | shape | autoplay | collect | selectors |
| --- | --- | --- | --- | --- |
| multi-play/multi | two sides | fire-and-forget on the side last played, 100 × 1000ms | — | yes |
| multi-play/beerpong | two sides, singleLocked for one | as multi | — | yes (four tiers) |
| multi-play/crash | two simultaneous bets, the game draws both buttons | — | game's | — |
Import a preset by its name (tower, multi); the module/preset form is its
id. The autoplay count is the first batch under the default template, whose count is a
list (150 · 100 · 50 · 20 · 10): the preset's own default when the list has it,
else 150. On multi-play, model.sideDisabled locks one side at a time, and the
currency locks while either side is.
The ZOOT slots bar
single-play/slots is Coinfall Grab's controller, the bar every ZOOT slot
shares. It carries its own template (single-play/slots, placement dock):
one part, slots, draws a bar along the foot and a pod on the right on a
landscape window, and three stacked plates on a portrait one — the speed, PLAY
and Auto on one outline, the play amount on a pill, and a bar edge to edge of
balance | win | sound · info — and reports its own box — reserve.right is
the pod's plate as it shows, which the scene keeps clear with its own margin. A game
that passes model.sceneWidth (its frame's width, CSS px) gets Coinfall
Grab's narrow-window rule: the pod gives way to the scene, down to its floor
scale, before the scene must.
import { slots } from "@enigma-lake/forge-play-controller-sdk/single-play";
<PlayController adapter={slots} config={{}} model={model} handlers={handlers}>…</PlayController>What it reads beyond the common model:
| Field | What it does |
| --- | --- |
| model.win | { amount, live? } — WIN while a win shows (live), LAST WIN after |
| model.speed | { current, options, onChange, labels? } — a press takes the next option; one arrow lit per step |
| model.stoppable + handlers.onStop | PRESS TO STOP while a manual spin's reels move; without onStop the button is just disabled in flight |
| handlers.onHoldTurbo(on) | a held PLAY NOW began / ended its loop: play those rounds at your fastest speed |
| model.sound, handlers.onInfo | the sound and info icons |
A press on PLAY is one round at the release; held 250 ms it is HOLD FOR TURBO —
a round each time the bar is live again (isPlaying false), until let go.
SPACE does the same, and stops a batch or a manual spin — whatever control
was pressed last and kept the focus; only a text field or a select keeps its space bar.
One press on Auto starts a batch of
behaviour.autoplay.defaultCount (150); a press on Auto or on PLAY stops it.
The play amount opens as a grid over the whole window.
For ZOOT's own slot games. A game reconstructed from a slot package keeps that package's controller.
Templates
A preset is what the bar can do; a template is what it looks like: which
components appear, on which rows, in which variant, and the style. The
default is single — Coinfall Grab's bar — and multi-play's is double,
built on it:
- a group row with the first selector as tabs and Auto with a list-based round count (150 · 100 · 50 · 20 · 10);
- the selectors after the first on the row below;
- the stake, labelled Play amount, with quick-pick floating above the panel on a landscape window;
- the play button (or COLLECT, or the game's own crash buttons);
- double-or-nothing and the free-rounds line where the preset has them;
- a footer with the built-in info, balance and sound widgets
(
handlers.onInfo,model.sound); - a tinted-glass panel, a 374 px pill from 700 px wide (
pillBreakpoint), a full-width sheet below.
single (single-play/single) is every single-play preset's default: one
stake, one PLAY. double (multi-play/double) is every multi-play preset's:
single with a stake and a PLAY per side — extendTemplate(single, { id, type }),
nothing of the look written twice. A game builds its own look the same way:
import { extendTemplate } from "@enigma-lake/forge-play-controller-sdk";
import { single } from "@enigma-lake/forge-play-controller-sdk/single-play";
export const dark = extendTemplate(single, {
id: "single-play/dark",
theme: { panelBg: "#101010" }, // per key over single's
parts: { widgets: { center: "balance" } }, // per part and per key
});and passes it to PlayController (template={…}), or reshapes the default from
its config: parts (variants, false to drop), layout.rows (the rows,
wholesale), theme. See docs/templates.md.
Parts
quickPick · amount · selector · moreSelectors · autoplay · play ·
cashout · doubleOrNothing · freeRounds · widgets.
amount and play belong to the module; the rest are shared. A preset
carries the parts it can show. A game turns one off, lays out rows of its own
(layout.rows), or registers its own:
import { definePart, registerPart } from "@enigma-lake/forge-play-controller-sdk";
registerPart(definePart({ id: "ticket", Component: TicketBar }));
// then: config.parts = { ticket: {} }The built-in ids are reserved: registerPart refuses them, because the
preset's own part always wins.
Config
const CONTROLLER: ControllerConfig = {
layout: { placement: "auto", panelBottom: "0px" },
parts: { doubleOrNothing: false, autoplay: { maxCount: 20 } },
behaviour: { simplified: false, minPlayIntervalMs: 500 },
theme: { panelBg: "#000637", dropdownBg: "#150C47", selectorColors: RISK_COLORS },
i18n: { language: "en", overrides: { cashout: "CASH OUT" } },
onLayout: (box) => reserve(box),
};placement is bottom (centred pill), bottom-full (mobile sheet),
left-rail (374px, full height), centered, or auto — the classic layout: a
rail on a wide landscape window, a sheet otherwise.
Three rules a consumer must know
Everything the bar renders or edits is whole currency units. $1.50 is
1.5, never150. The only cents-shaped field isfreeRounds.stakeCents, which is hundredths of the displayed unit and which the bar divides by 100. The wire conversion belongs to the game's platform layer, not here — and for a fun currency (usd_exchange_rate = 0) the factor is 1, not 100.data-roleanddata-partare the targeting surface. Every interactive element carries adata-role; every part's root carriesdata-part. Class names are hashed and will change. Games used to match on[class*="UI-module__betForm"], and broke on every release.The bar reports its own box.
config.onLayoutanduseControllerBox()give the placement and what to reserve, so nothing has to measure the DOM or hard-code the 1074px breakpoint.
Development
npm install
npm test # vitest
npm run typecheck
npm run lint
npm run build # dist/: ESM + CJS + .d.ts + style.css
npm run check:consumer # a consumer build per module on platform SDK 1.1.8 (network)
npm run playground # every preset of both modules against every lock scenarioThe playground is the review surface: pick a module and a preset, pick a scenario (guest, in flight, invalid amount, round live, collect in flight, free rounds offered and engaged, external operator…), and watch the reported box and the host events.
