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

@enigma-lake/forge-play-controller-sdk

v2.6.0

Published

The ZOOT play controller: one package, modular parts, per-game adapters.

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.
  • config is plain data (below). model and handlers are 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

  1. Everything the bar renders or edits is whole currency units. $1.50 is 1.5, never 150. The only cents-shaped field is freeRounds.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.

  2. data-role and data-part are the targeting surface. Every interactive element carries a data-role; every part's root carries data-part. Class names are hashed and will change. Games used to match on [class*="UI-module__betForm"], and broke on every release.

  3. The bar reports its own box. config.onLayout and useControllerBox() 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 scenario

The 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.