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

flowsgames

v1.0.0

Published

Flows Games Widget Library — game-agnostic widget for jackpots, FlashWins, and more

Readme

flowsgames — Flows Games Widget Library

Embeddable FlashWins game widget for the FlowsPlay ecosystem. Real-time prize updates via Server-Sent Events, configurable Lottie win animations, and player opt-in management — delivered as a standard Web Component.

Package: flowsgames · Registry: npm public · Version: 1.0.0


Table of Contents


Overview

flowsgames provides an embeddable game widget that integrates FlashWins jackpot games into any web page. It connects to the FlowsPlay API to fetch game data and subscribes to a Server-Sent Events stream for real-time prize value updates and win notifications.

Key capabilities:

  • Sticky toolbar bar + expandable game modal
  • Real-time prize value updates via SSE
  • Configurable Lottie (.lottie) win celebration animation via winAnimationUrl in the appearance DTO, with optional winAnimationLoop to control whether it loops
  • Player opt-in / opt-out management — when the game's requiresOptIn is false the bar's CTA toggle (knob + label) is hidden and the entire bar becomes the click target to open the modal; the modal footer likewise omits opt-in / opt-out buttons
  • Desktop and mobile layout variants (auto-switched by breakpoint)
  • Multi-language support (auto-detects browser language)
  • Shadow DOM style isolation — zero CSS leakage in either direction
  • Framework-agnostic Web Component API (<flows-games>)
  • Backward-compatible imperative shim (flowsGames.startWidget(...))

Architecture

The widget is built around a Lit custom element (<flows-games>) backed by a service layer. A backward-compatibility shim in flowsgames.ts ensures older integrations using the imperative flowsGames.* API continue to work without any changes.

Back-compat: the element was previously named <flows-flash-wins> (before it was generalised to also serve jackpots). That tag is still registered as an alias of <flows-games>, so existing raw-HTML/framework integrations keep rendering — but new integrations should use <flows-games>.

flowsgames.ts (entry point + compat shim)
│
├── src/FlowsGamesElement.ts   ← Lit custom element <flows-games> (modern API)
│   ├── GameAPIService        ← XMLHttpRequest API client
│   ├── EventSourceManager         ← SSE subscription manager
│   ├── WidgetStateManager         ← local state
│   └── creates → <flows-games-portal> (body portal in live mode,
│                                             shadow root child in preview mode)
│
├── src/FlowsGamesPortal.ts    ← Lit custom element <flows-games-portal>
│   ├── Renders game modal + winner overlay
│   ├── <dotlottie-wc>             ← Lottie win animation (winAnimationUrl/winAnimationLoop)
│   └── All state driven by property assignment from FlowsGamesElement
│
└── FlowsGames.class.ts            ← Legacy class controller (kept for reference)
    └── delegates → FlowsGamesElement public API

Shadow DOM portals: The sticky toolbar lives inside FlowsGamesElement's shadow root. The game modal and winner overlay are rendered by <flows-games-portal> appended to document.body, escaping overflow/z-index constraints. The portal is cleaned up on disconnectedCallback(). In preview mode, the portal renders inside the shadow root instead (no fixed overlays, no backdrop).

Floating drag-handle: When layout === 'Floating', the closed bar renders a six-dot drag handle (.fw-bar__drag-handle) as its left-most child on both desktop and mobile, and the 32×32 game icon next to it is also draggable (it carries the fw-bar__drag-handle class in Floating). So the draggable region is the grip plus the game icon — pressing anywhere else on the bar never repositions the widget. Clicking anywhere on the bar body (including the opt-in toggle area) opens the game modal, regardless of whether opt-in is required — this whole-bar click-to-open applies to both the Floating and ToolBar layouts. The grip/icon show cursor: grab (grabbing while active); the rest of the bar body shows cursor: pointer. Dragging is gated on the Floating layout — in ToolBar neither the grip nor the icon is draggable. When _isMobile is true the root receives a flows-widget--mobile modifier class that CSS uses to switch the floating bar to the mobile FAB sizing.

The six-dot icon stays compact (16×16), but the handle's hit area is a full-height grip column (≥28px wide on desktop, ~36px on the mobile FAB) so it clears the 44px minimum touch target — important on the 44px-tall mobile FAB where the grip is the only way to move the widget. The column has no background; only the dots brighten on hover/press (collapsing under reduced-motion). Dot colour follows a theme fallback chain (--dragHandleDotColour--titleBaseCloseTextColour → translucent white) so the grip stays legible on both light and dark themes.

Keyboard accessibility: The clickable bar body is a role="button" with tabindex="0" and an aria-label, so it is reachable by Tab and opens the game modal on Enter/Space (keys originating in the drag handle are ignored), with a visible focus ring. Both the game modal and the winner modal close on Escape (the winner modal still plays its leave animation). In the non-interactive 'game-modal' preview state, Escape is suppressed along with the other close affordances.


Installation

NPM

npm install flowsgames
# or
bun add flowsgames

CDN (Browser IIFE)

<!-- Staging -->
<script src="https://unpkg.com/flowsgames@latest/js/browser/flowsgames.js"></script>

<!-- Production -->
<script src="https://unpkg.com/flowsgames@latest/js/browser/flowsgames.js"></script>

Note: CSS is adopted directly into Shadow DOM via adoptedStyleSheets at runtime — no external <link> tags are needed.

CDN with Subresource Integrity (SRI)

For maximum security, pin a specific version and verify the bundle with Subresource Integrity. SRI requires an immutable URL, so use the versioned path, not the floating flowsgames.js:

| Channel | Versioned URL pattern | | ---------- | ------------------------------------- | | production | flowsgames-v<version>.js | | beta-stage | flowsgames-beta-stage-v<version>.js | | beta-prod | flowsgames-beta-prod-v<version>.js |

Each versioned bundle ships with a matching .integrity sidecar at the same path. The beta prerelease channel embeds the backend slug in the filename so the stage-backed and prod-backed bundles published from the same git tag don't collide.

<script
  src="https://cdn.flows.world/js/flowsgames-v1.0.0.js"
  integrity="sha384-2iJXPCblvxnk0E6YxdTSiN5isP+3YHq6uZiwsq6Bbk4ZA9Mamg9jDPeDISGgS0vb"
  crossorigin="anonymous"
></script>

<flows-games
  org-id="YOUR_ORG_ID"
  game-id="YOUR_GAME_ID"
  player-id="YOUR_PLAYER_ID"
></flows-games>

Important: integrity is tied to one exact bundle. To upgrade, swap both the version in the URL and the integrity hash to the new release's values.

The canonical hash for each release is published next to the bundle as a .integrity sidecar file:

# production
curl -s https://cdn.flows.world/js/flowsgames-v1.0.0.integrity
# beta-stage prerelease
curl -s https://cdn.flows.world/js/flowsgames-beta-stage-v1.0.0-beta.28.integrity
# → sha384-…

To verify the hash matches the bundle yourself:

curl -s https://cdn.flows.world/js/flowsgames-v1.0.0.js \
  | openssl dgst -sha384 -binary \
  | openssl base64 -A

Prerequisite: crossorigin="anonymous" requires cdn.flows.world to serve Access-Control-Allow-Origin: * (or the integrator's origin). If your page fails to load the script with a CORS error, the CDN response is missing this header — contact Flows support.

Once the script is loaded, initialize the widget with the imperative API:

// Minimal — required parameters only
flowsGames.startWidget("org-123", "game-456", "player-789");

// Full signature
flowsGames.startWidget(
  "org-123", // orgId      (required) — organization identifier
  "game-456", // gameId     (required) — FlashWins game identifier
  "player-789", // playerId   (optional) — player session identifier
  "en", // language   (optional) — BCP 47 tag; defaults to browser language
  "brand-abc", // brandId    (optional) — sub-brand appearance override
  480, // breakpoint (optional) — mobile layout threshold in px; default 480
  null, // rootElement (optional) — explicit mount target; falls back to
  //   #fw_widget_placeholder, else an auto-created fixed wrapper on document.body
  {
    desktopPositionVariant: "BottomRight", // 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight' | 'Top' | 'Bottom'
    mobilePositionVariant: "Bottom", // same variants
    appendToRoot: false, // scope portal to root element instead of document.body
  },
);

Quick Start

Option 1 — Web Component (recommended)

<!-- Load the bundle — registers <flows-games> automatically -->
<script src="https://cdn.flows.world/js/browser/flowsgames.js"></script>

<!-- Declare the widget as an HTML tag -->
<flows-games
  org-id="org-123"
  game-id="game-456"
  player-id="player-789"
  language="en"
></flows-games>

Option 2 — Imperative API (backward-compatible shim)

<script src="https://cdn.flows.world/js/browser/flowsgames.js"></script>
<script>
  flowsGames.startWidget(
    "org-123", // orgId    (required)
    "game-456", // gameId   (required)
    "player-789", // playerId (optional)
  );
</script>

Option 2b — Placeholder div (declarative placement)

Place a div with id="fw_widget_placeholder" anywhere in your HTML. When startWidget is called without an explicit rootElement, the widget mounts inside this element instead of being appended to <body>. If no placeholder exists either, startWidget auto-creates one (a #fw_widget_placeholder div tagged data-fw-auto-created, appended to <body> and removed on closeWidget()) so the bar always has a wrapper that can be pinned to the viewport — without it a ToolBar Bottom bar would land mid-page in normal flow and the modal would dock off the wrong rect.

When a placeholder is used (supplied or auto-created), startWidget applies position: fixed and viewport-edge styles directly to it based on the active position variant (skipped only when an explicit rootElement is supplied — the integrator owns its layout):

| Variant | Placeholder style applied | | ------------- | --------------------------------------------------------------- | | Top | top: 0; left: 0; right: 0; width: 100% — sticky top bar | | Bottom | bottom: 0; left: 0; right: 0; width: 100% — sticky bottom bar | | TopLeft | top: 20px; left: 20px | | TopRight | top: 20px; right: 20px | | BottomLeft | bottom: 20px; left: 20px | | BottomRight | bottom: 20px; right: 20px (default) |

Position variants are declared as data- attributes on the placeholder div. startWidget reads them, styles the placeholder accordingly, and mirrors them onto the widget element.

<!-- Sticky bottom toolbar on mobile, bottom-right float on desktop -->
<div
  id="fw_widget_placeholder"
  data-desktop-position-variant="BottomRight"
  data-mobile-position-variant="Bottom"
></div>

<script src="https://cdn.flows.world/js/browser/flowsgames.js"></script>
<script>
  flowsGames.startWidget("org-123", "game-456", "player-789");
</script>

If #fw_widget_placeholder is not present, the widget falls back to document.body (no sticky styles applied).

Option 3 — ESM Import

import flowsGames from "flowsgames";

flowsGames.startWidget("org-123", "game-456", "player-789", "en");

API Reference

<flows-games> Attributes

| Attribute | Type | Required | Default | Description | | -------------------------- | ------------------------------------------------------------------------------- | -------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | org-id | string | Yes | — | Organization identifier | | game-id | string | Yes | — | FlashWins game identifier | | player-id | string | No | — | Player session identifier. When set: enables the player-session jackpot, personal user_* SSE, and opt-in. When omitted: anonymous (combined-only, opt-in NotRequired, broadcast SSE only) | | language | string | No | Browser language | BCP 47 language tag (e.g. "en", "fr") | | brand-id | string | No | — | Sub-brand override for game appearance | | device-breakpoint | number | No | 480 | Pixel width below which mobile layout activates | | desktop-position-variant | 'TopLeft' \| 'TopRight' \| 'BottomLeft' \| 'BottomRight' \| 'Top' \| 'Bottom' | No | 'BottomRight' | Widget position on desktop. Floating variants: TopLeft/TopRight/BottomLeft/BottomRight (300×60 pill, 20px from corner). Toolbar variants: Top/Bottom. | | mobile-position-variant | 'TopLeft' \| 'TopRight' \| 'BottomLeft' \| 'BottomRight' \| 'Top' \| 'Bottom' | No | 'Bottom' | Widget position on mobile. Same variants as above. Floating mobile renders as a fluid FAB (width: calc(100vw - 32px); max-width: 269px; height: 44px; border-radius: 12px; 16px from corner). | | append-to-root | boolean | No | false | When true, the portal is appended to the element's parent instead of document.body. The parent must have position: relative (or similar). Useful in toolbar mode to scope the overlay to a container. |

Element Methods

Called on the DOM element reference (document.querySelector('flows-games')):

Integrator-facing methods:

| Method | Returns | Description | | -------------------------------------- | --------------- | --------------------------------------------------------------------------------------------- | | gameRoundStart() | boolean | Lock widget during a game spin. Queues any win events received while locked. | | gameRoundEnd() | boolean | Unlock widget after spin completes. Processes the latest queued win event (if any). | | lock() | boolean | Manually lock interaction (prevents modal open, opt-in changes). | | unlock() | boolean | Manually unlock interaction. | | swapAppearance(appearance, variant?) | Promise<void> | Swap the game appearance stylesheet without resetting widget state. No-op if not yet mounted. |

Internal lifecycle methods (used by FlowsGamesClass and the preview API):

| Method | Returns | Description | | ---------------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | mount(baseData, config, properties) | Promise<void> | Adopt CSS, set data, trigger first render. | | unmount() | void | Full teardown: remove stylesheets, stop intervals, reset all state. | | refreshBar(baseData, config, properties) | void | Config-only refresh after an opt-in/opt-out toggle. Preserves the live, SSE-decremented prize count (FW-1051); the passed baseData is adopted only when none is set yet. | | showWinner(isOther, event, data, variant, layout, cb?) | void | Show win announcement. Merges the event's prize snapshot (prizePool for self-wins, prizes for other-wins) into _baseData.prizes by unqid, and the event's remainingPrizes (per-day total) into _baseData.remainingPrizes, so the modal's headline + per-row remaining reflect post-event state. | | setLocked(locked) | void | Programmatically set locked state. | | enablePreviewMode(interactive?) | void | Must be called before appending to DOM; routes portal into shadow root. | | applyPreviewState(state, winEvent?, winLevelId?) | Promise<void> | Set the visible state after showPreview() resolves. Jackpot win-modal picks the won level via winLevelId (headline fallback); FlashWins via winEvent. | | showPreview(game, appearance, layout, variant, language, optedIn?, interactive?) | Promise<void> | Render a preview with mock data — no API calls. Used by FlowsPlay editor. Game-agnostic & overloaded: pass FlashWinsGame + FlashWinsGameAppearance for a FlashWins preview, or JackpotGame + JackpotGameAppearance for a jackpot preview (discriminated via 'levels' in game). Jackpot-only (no combined preview). | | swapAppearance(appearance, variant?) (jackpot) | Promise<void> | The existing swapAppearance also handles a mounted jackpot (JackpotGameAppearance) — no-blink theme swap. |

Return value behavior: Integrator methods return true on success or if already in target state. Return false if the widget is not initialized.

Events Dispatched

Public events dispatched on the <flows-games> element. All are composed: true so they cross the shadow root boundary and reach listeners on the host's ancestors (including document).

| Event | Detail | Description | | ------------------------------------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | flashwins-loaded | void | Widget is initialized and visible. | | flashwins-won | FlashWinsWonEventDTO | The current player won a prize. Detail carries prizePool, prizeAwarded, gameId, brandId, hotseatId, and optional remainingPrizes (per-day total). | | flashwins-won-other | FlashWinsWonOtherEventDTO | Another player won. Detail carries the post-event prizes snapshot and optional remainingPrizes (per-day total). Silent for the bar UI; emitted for integrators. | | flashwins-closed | FlashWinsCloseEventDTO | Game closed via any of flashwin_finished, flashwin_closed, or flashwin_stopped. Reason is preserved on detail. flashwin_finished hides the bar but keeps listening; closed/stopped fully tear down. In a combined FlashWins+jackpot widget, a FlashWins close only removes the FlashWins half — the jackpot keeps running (single-mode) — and vice-versa. | | flashwins-open | FlashWinsOpenEventDTO | Game opened (flashwin_open SSE). Always emitted; the widget also shows itself if it was hidden (not-yet-started game, or hidden by a prior flashwin_finished). | | flashwins-position-variant-changed | { positionVariant } | Fired after mount() and on resize-driven layout switches; used to reapply sticky styles on #fw_widget_placeholder. |

Generalized + jackpot families (game-type aware). Alongside the flashwins-* events above (kept, unchanged), the widget also dispatches a jackpot-* family for the jackpot game and a generalized flowsgames-* family for both games. The flowsgames-* detail carries a flat game: 'flashwin' | 'jackpot' field so a single listener can handle both games. All three families are fully backwards compatible, are composed: true/bubbles: true, and are delivered through both addEventListener and the shim on()/off(). (jackpot_value is a silent in-place value refresh — no public event.)

| Event | Detail | Description | | ------------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | | jackpot-loaded | void | Jackpot half initialized. | | jackpot-won | JackpotWonEventDTO | Jackpot self-win. | | jackpot-won-other | JackpotWonOtherEventDTO | Another player won a jackpot. | | jackpot-open | JackpotOpenEventDTO | Jackpot opened. | | jackpot-closed | JackpotClosedEventDTO | Jackpot level closed (full reseed/final-close fidelity). | | flowsgames-loaded | { games: ('flashwin' \| 'jackpot')[] } | Widget loaded; games lists which halves mounted. | | flowsgames-won | (FlashWinsWonEventDTO \| JackpotWonEventDTO) & { game } | Either game self-win. | | flowsgames-won-other | (FlashWinsWonOtherEventDTO \| JackpotWonOtherEventDTO) & { game }| Either game other-win. | | flowsgames-open | { game } | Either game opened. | | flowsgames-closed | { game, reason } | Either game closed (FlashWins keeps its SSE reason; jackpot uses 'jackpot_closed'). | | flowsgames-position-variant-changed | { positionVariant } | Mirrors flashwins-position-variant-changed. |

// One listener handles both games via detail.game:
widget.addEventListener("flowsgames-won", (e) => {
  if (e.detail.game === "jackpot") console.log("jackpot win:", e.detail);
  else console.log("flashwins win:", e.detail);
});
// or via the shim:
flowsGames.on("flowsgames-won", (detail) => console.log(detail?.game, detail));

Internal events (consumed inside the shadow root by FlowsGamesClass; bubbles: false, not part of the public API):

| Event | Detail | Description | | ------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------- | | fw-opt-in | — | User clicked opt-in button. | | fw-opt-out | — | User clicked opt-out button. | | fw-winner-closed | — | Winner modal close requested. Fired in phase 1 of the two-phase close (animation start) — not when the leave animation completes. |

const widget = document.querySelector("flows-games");

widget.addEventListener("flashwins-loaded", () => {
  console.log("Widget ready");
});

widget.addEventListener("flashwins-won", (e) => {
  console.log("Player won:", e.detail);
});

Listening via the flowsGames shim (on / off)

If you mount the widget through the imperative shim, you can subscribe to the same public events with flowsGames.on(eventName, handler) without reaching into the DOM. Handlers are typed per event (the detail argument is narrowed from FlowsGamesEventMap), are buffered by the shim, and are automatically (re-)attached on every startWidget() — so you can register before the widget mounts and your handler survives closeWidget()startWidget() re-mounts.

on() returns an unsubscribe function; off(eventName, handler) removes a specific handler.

// Register before or after startWidget — both work.
const off = flowsGames.on("flashwins-loaded", () => {
  console.log("Widget ready");
});

flowsGames.on("flashwins-won", (detail) => {
  // `detail` is typed as FlashWinsWonEventDTO | undefined
  console.log("Player won:", detail?.prizeAwarded);
});

flowsGames.startWidget(orgId, gameId, playerId);

off(); // stop listening to flashwins-loaded
// or: flowsGames.off("flashwins-loaded", handler)

Imperative Shim API (flowsGames.*)

The flowsGames global object (attached to window in browser IIFE builds) provides full backward compatibility:

| Method | Signature | Description | | -------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | startWidget | (orgId, gameId, playerId, language?, brandId?, breakpoint?, rootElement?, options?) | Mount the widget. Appends <flows-games> to rootElement if provided, then to #fw_widget_placeholder if found in the DOM, otherwise to document.body. Position variants are read from placeholder data attributes; options.desktopPositionVariant / options.mobilePositionVariant override them. Pass options.appendToRoot = true to scope the portal to the root element. Returns the mounted element. | | startPreviewWidget | (containerEl, state, game, appearance, options?) → Promise<FlowsGamesElement> | Mount a preview widget into containerEl showing the given PreviewState. No API calls, no SSE. Game-agnostic & overloaded: pass FlashWinsGame + FlashWinsGameAppearance + PreviewWidgetOptions (win via winEvent) for a FlashWins preview, or JackpotGame + JackpotGameAppearance + JackpotPreviewWidgetOptions (win via winLevelId) for a jackpot preview. The game/appearance/options triple is kept paired by the overloads. Jackpot-only (no combined preview); startJackpotPreviewWidget was merged into this method. | | closeWidget | () | Unmount and destroy the widget. Detaches event bridges but keeps on() handlers buffered for the next startWidget(). | | on | <K extends FlowsGamesEventName>(event: K, handler: (detail: FlowsGamesEventMap[K]) => void) → () => void | Subscribe to a public widget event (typed per event). Buffered by the shim and auto-(re)attached on every startWidget() / startPreviewWidget(), so it works before mount and survives re-mounts. Returns an unsubscribe function. | | off | <K extends FlowsGamesEventName>(event: K, handler: (detail: FlowsGamesEventMap[K]) => void) → void | Remove a handler previously registered with on() (pass the same reference). | | lock | () → boolean | Delegate to element lock(). | | unlock | () → boolean | Delegate to element unlock(). | | gameRoundStart | () → boolean | Delegate to element gameRoundStart(). | | gameRoundEnd | () → boolean | Delegate to element gameRoundEnd(). | | environment | string (read-only getter) | The effective environment (e.g. 'local', 'staging', 'production', 'betaStage'). Returns the runtime override set via setEnvironment() when present, otherwise the build-time __BUILD_ENV__ define. Falls back to 'staging' when not defined. | | setEnvironment | (env: string \| null) → void | Override the backend the widget targets at runtime — point a build's API/SSE calls at a different env ('staging' / 'production' / …), or pass null to revert to the build env. Unknown values are ignored. Must be called before startWidget(). On a local build, CSS stays local even under override (you test the local bundle's styles). Intended for the test harness, not production integrations. |

startWidget Usage

// Minimal — required parameters only
flowsGames.startWidget("org-123", "game-456", "player-789");

// Full signature
flowsGames.startWidget(
  "org-123", // orgId      (required) — organization identifier
  "game-456", // gameId     (required) — FlashWins game identifier
  "player-789", // playerId   (optional) — player session identifier
  "en", // language   (optional) — BCP 47 tag; defaults to browser language
  "brand-abc", // brandId    (optional) — sub-brand appearance override
  480, // breakpoint (optional) — mobile layout threshold in px; default 480
  null, // rootElement (optional) — explicit mount target; falls back to
  //   #fw_widget_placeholder, else an auto-created fixed wrapper on document.body
  {
    desktopPositionVariant: "BottomRight", // 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight' | 'Top' | 'Bottom'
    mobilePositionVariant: "Bottom", // same variants
    appendToRoot: false, // scope portal to root element instead of document.body
  },
);

// With game lifecycle hooks
const widget = flowsGames.startWidget("org-123", "game-456", "player-789");

widget.addEventListener("flashwins-loaded", () => {
  console.log("Widget ready");
});

widget.addEventListener("flashwins-won", (e) => {
  console.log("Player won:", e.detail); // { prize, currency, value }
});

// Lock during spin, unlock when result is known
function onSpinStart() {
  flowsGames.gameRoundStart();
}
function onSpinEnd() {
  flowsGames.gameRoundEnd();
}

// Tear down
flowsGames.closeWidget();

StartWidgetOptions

| Option | Type | Description | | ------------------------ | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | desktopPositionVariant | PositionVariant | Override the desktop position variant. Takes priority over placeholder data attributes. | | mobilePositionVariant | PositionVariant | Override the mobile position variant. Takes priority over placeholder data attributes. | | appendToRoot | boolean | When true, the widget portal is appended to the root element instead of document.body. The root element must have position: relative (or similar). |

PreviewWidgetOptions (for startPreviewWidget)

type PreviewState = "closed" | "game-modal" | "win-modal";

// FlashWins preview — pass this when game is a FlashWinsGame.
interface PreviewWidgetOptions {
  layout?: WidgetLayoutVariant; // Default: 'ToolBar'
  variant?: BrowserVariant; // Default: 'Desktop'
  language?: string; // Default: 'en'
  winEvent?: FlashWinEvent; // Required when state === 'win-modal'
  optedIn?: boolean; // Default: true
  interactive?: boolean; // Default: false — buttons rendered but disabled
}

// Jackpot preview — pass this when game is a JackpotGame. Same shape minus
// winEvent, plus winLevelId to pick the won level for the 'win-modal' state.
interface JackpotPreviewWidgetOptions {
  layout?: WidgetLayoutVariant; // Default: 'ToolBar'
  variant?: BrowserVariant; // Default: 'Desktop'
  language?: string; // Default: 'en'
  optedIn?: boolean; // Default: true
  interactive?: boolean; // Default: false — buttons rendered but disabled
  winLevelId?: string; // Won level for 'win-modal'; headline (level 1) fallback
}

Preview State Behaviour (interactive=false)

| State | Desktop | Mobile | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | 'closed' | Bar visible; modal opens on bar click with dimmed overlay + backdrop; close enabled (X / Close / backdrop dismiss; dimmer is visual only) | Same | | 'game-modal' | Bar hidden; modal shown position:relative, no backdrop, no dimmer; close blocked | Bar visible but dimmed; modal shown; close blocked | | 'win-modal' | Bar hidden; winner overlay shown inline | Bar hidden; winner overlay shown inline |

In 'game-modal' state the header X button, footer Close button, and backdrop click are all suppressed. Opt-in / opt-out buttons still work but toggle the status locally without closing the modal. In 'closed' state the modal is fully closeable; only the opt-in / opt-out buttons are disabled while the non-interactive dimmer is showing.

Real-Time SSE Events

The widget internally subscribes to two SSE channels upon initialization:

  • User channel (user_${playerId}): receives flashwin_won events for the current player
  • Broadcast channel (broadcast_${workspaceId}): receives flashwin_won_other, flashwin_open, flashwin_finished, flashwin_closed, and flashwin_stopped events

The widget subscribes even when its UI is not shown — a game scheduled for a future start time stays hidden but keeps a live listener so it can appear when the round opens. See Startup visibility below.

All reasons are lowercase. Prize entries on the wire use the shape FlashWinsSSEPrize = { amount, quantity, remaining, type, unqid } where unqid is the canonical prize identifier and remaining is the prize's campaign-wide remaining count. Both win events also carry an optional remainingPrizes?: number — the post-event per-day total (today's allotment), distinct from per-prize campaign remaining. It is optional so partial payloads don't break; when absent the "Total prizes left" headline falls back to the sum of per-prize remaining. The full DTO union is exported as FlashWinsSSEEventDTO from src/models/types/api/SSEEventDTO.ts.

Startup visibility

When the widget starts it fetches the game and only shows the bar when any positive signal holds:

showWidget = game.open === true || game.status === 'open' || game.startTime <= now

status is one of 'open' / 'closed' / 'finished'. If none of the signals hold — e.g. a game scheduled for a future startTime whose status is 'closed' or 'finished' (or missing) — the widget stays hidden but remains SSE-subscribed. It then follows the round lifecycle: it appears on flashwin_open, hides (and keeps listening) on flashwin_finished, and tears down on flashwin_stopped / flashwin_closed. This supports daily-schedule campaigns where rounds open and finish each day before the campaign is finally stopped or closed.

Session-mapping retry

When a player-id is set, the backend maps the client to a player account asynchronously, so a call made immediately after a session starts can briefly return 400 while that mapping settles. The widget retries these calls silently: on a 400 whose body is "detail": "Client not associated with a player account", it waits 5 s, retries, then waits 20 s and retries once more (3 attempts total). During the wait the widget stays hidden — no error is surfaced. If all three attempts still fail, it stops and stays hidden. Any other 400 (or non-400) is handled normally. Anonymous embeds (no player-id) never trigger this path.

| SSE reason | Payload | Behavior | | -------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | flashwin_won | gameId, brandId, hotseatId, prizePool: FlashWinsSSEPrize[], prizeAwarded, remainingPrizes? | Self-win. Show Lottie (if winAnimationUrl set) + win modal. Awarded prize is prizePool.find(p => p.unqid === prizeAwarded). If locked, queued and shown after gameRoundEnd(). Merges prizePool into _baseData.prizes and the optional remainingPrizes (per-day total) into _baseData.remainingPrizes so the headline + per-row remaining reflect post-event state. | | flashwin_won_other | prizes: FlashWinsSSEPrize[], remainingPrizes? | Other player won. Show brief notification highlighting the highest-amount prize. Queued when locked (only if no flashwin_won queued). Merges prizes into _baseData.prizes and the optional remainingPrizes (per-day total) into _baseData.remainingPrizes. | | flashwin_open | — | Round opened. Always re-dispatched as the public flashwins-open composed event. If the widget is currently hidden (not-yet-started game, or hidden by a prior flashwin_finished), it refetches the game + appearance and shows itself. Idempotent if already shown. | | flashwin_finished | — | A round ended (e.g. a daily round). Hides the bar but keeps the SSE subscription alive so the next flashwin_open re-shows the widget. Deferred until any open win screens are dismissed. Dispatches the public flashwins-closed event. | | flashwin_closed | — | Campaign ended — scheduled end time reached / all prizes gone. Full teardown (unmounts and stops SSE). Deferred until any open win screens are dismissed. Dispatches the public flashwins-closed event. | | flashwin_stopped | — | Campaign stopped manually. Full teardown (unmounts and stops SSE). Deferred until any open win screens are dismissed. Dispatches the public flashwins-closed event. |

Currency: not carried on the wire. The win modal resolves currency from the game-level value on _baseData.currency (falls back to 'EUR').

Prize exhaustion — prize rows where campaign remaining <= 0 are hidden from the modal automatically (the underlying _baseData.prizes is left untouched so the headline-prize lookup and the awarded-prize lookup keep working). The widget does not self-close when every prize reaches zero — widget hide/teardown is driven exclusively by the server-side close SSE events: flashwin_finished hides the bar but keeps listening, while flashwin_closed / flashwin_stopped fully tear down.

"Total prizes left" count — the count shown on both the closed bar (Floating + ToolBar) and the game modal (desktop + mobile) uses one shared computation (totalPrizesLeft in src/utils/prizeUtils.ts). It shows the game's per-day total remainingPrizes (today's allotment) when present, falling back to the sum of per-prize campaign remaining when it's absent. Per-prize remaining is campaign-wide and only drives per-row visibility in the modal, so for an advanced game (where the per-day allotment differs from the campaign sum) the count reflects today's figure everywhere it's shown while the modal rows still reflect campaign state. SSE win events carry an optional remainingPrizes so the count updates live after a win on both the bar and the modal.


Integration Guides

Vanilla JavaScript

<script src="https://cdn.flows.world/js/browser/flowsgames.js"></script>

<flows-games
  id="my-widget"
  org-id="org-123"
  game-id="game-456"
  player-id="player-789"
></flows-games>

<script>
  const widget = document.getElementById("my-widget");

  widget.addEventListener("flashwins-loaded", () => {
    console.log("Widget ready");
  });

  widget.addEventListener("flashwins-won", (e) => {
    console.log("Won prize:", e.detail.prize);
  });

  // During game spin:
  function onSpinStart() {
    widget.gameRoundStart();
  }
  function onSpinEnd() {
    widget.gameRoundEnd();
  }
</script>

React

import { useEffect, useRef } from "react";
import "flowsgames"; // registers <flows-games>

// Tell TypeScript about the custom element
declare global {
  namespace JSX {
    interface IntrinsicElements {
      "flows-games": {
        "org-id": string;
        "game-id": string;
        "player-id": string;
        language?: string;
        "brand-id"?: string;
        "device-breakpoint"?: number;
        ref?: React.Ref<HTMLElement>;
      };
    }
  }
}

function CasinoPage() {
  const widgetRef = useRef<HTMLElement>(null);

  useEffect(() => {
    const widget = widgetRef.current;
    if (!widget) return;

    const handleWin = (e: Event) => {
      const detail = (e as CustomEvent).detail;
      console.log("Player won:", detail);
    };

    widget.addEventListener("flashwins-won", handleWin);
    return () => widget.removeEventListener("flashwins-won", handleWin);
  }, []);

  return (
    <flows-games
      ref={widgetRef}
      org-id="org-123"
      game-id="game-456"
      player-id="player-789"
      language="en"
    />
  );
}

Vue 3

<template>
  <flows-games
    ref="widgetRef"
    org-id="org-123"
    game-id="game-456"
    player-id="player-789"
    language="en"
    @flashwins-won="onWin"
  />
</template>

<script setup lang="ts">
import { ref } from "vue";
import "flowsgames";

const widgetRef = ref<HTMLElement | null>(null);

function onWin(e: CustomEvent) {
  console.log("Won:", e.detail);
}
</script>

In vite.config.ts, configure compilerOptions.isCustomElement:

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.startsWith("flows-"),
        },
      },
    }),
  ],
};

Build System

The build is orchestrated by build.mjs using Bun.

Build Steps

  1. CSS processing — PostCSS processes all CSS source files → single js/baseStyle.css (bar + modal combined)
  2. Browser IIFE bundle — Bun bundles to js/browser/flowsgames.js (global window.flowsGames)
  3. Node ESM bundle — Bun bundles to js/flowsgames.js (ESM main entry)
  4. TypeScript declarationstsc emits .d.ts files to js/

Environment Injection

The __BUILD_ENV__ constant is replaced at build time via Bun's --define flag:

ENVIRONMENT=staging  →  __BUILD_ENV__ = "staging"  →  staging API URLs selected
CSS_VERSION=1.4.0    →  __BUILD_CSS_VERSION__ = "1.4.0"  →  cssUrl points at the versioned CSS file

CSS_VERSION is optional and empty by default (local/dev builds keep the floating cssUrl). The CDN release flow sets it on a second build pass once semantic-release has computed the version — see Publishing.

Build Commands

# Install dependencies
bun install --frozen-lockfile --ignore-scripts

# Build for a specific environment
bun run build:local        # Local dev (localhost:3001 API)
bun run build:beta-stage   # Beta prerelease channel, staging backend
bun run build:beta-prod    # Beta prerelease channel, production backend
bun run build:staging      # Staging (stage-api.flowsplay.world)
bun run build:production   # Production (api.flowsplay.world)

# Generic build (reads ENVIRONMENT env var, defaults to staging)
bun run build

# Build + serve
bun run serve:local             # Hot-reload dev server on port 3001
bun run serve:beta-stage        # Dev server with the beta-stage bundle
bun run serve:beta-stage:fast   # Same, with a faster mock SSE tick (only matters while opt-in is mocked)
bun run serve:beta-prod         # Dev server with the beta-prod bundle (hits prod APIs)
bun run serve:staging           # Static test server (staging build)
bun run serve:production        # Static test server (production build)

Build Output

js/
├── flowsgames.js          # ESM bundle — npm main entry
├── flowsgames.d.ts        # TypeScript declarations
├── FlowsGames.class.d.ts
├── baseStyle.css          # PostCSS-processed CSS
├── flowsgames-base.css
└── browser/
    └── flowsgames.js      # Browser IIFE bundle (window.flowsGames)

Environment Configuration

Defined in src/config/environments.ts:

interface MockEndpoints {
  game?: boolean;
  appearance?: boolean;
  optInStatus?: boolean;
  sse?: boolean;
}

interface EnvironmentConfig {
  apiBaseUrl: string; // v2 — combined game (/games/{id}) + opt-in routes
  appearanceBaseUrl: string; // v2  — FlashWins appearance route
  eventUrl: string;
  cssUrl: string; // adopted into both shadow roots (bar + modal). On a CDN release build it resolves to the versioned file (see below)
  mock?: MockEndpoints; // per-endpoint mock toggles — see "Bundled mocks" below
}

The game endpoint follows the v2 swagger: GET {apiBaseUrl}{orgId}/games/{gameId}[/brand/{brandId}][/players/sessions/{sessionId}] (GameAPIService.GetGame). This single combined endpoint returns the GameDtoV2 envelope { jackpot, flashwin } (both nullable, ≥1 set) and is the source for the FlashWins half and run-mode. The flashwin half matches FlashWinsGameResponseDto 1:1 (with nullable string/number fields, a nullable prizes array, and the v2-only prizeDistributionType ('Total' | 'Daily')/instanceId/status/remainingPrizes); the widget normalizes it onto the internal FlashWinsGame model via src/services/gameAdapter.ts (coerces nullable wire fields incl. a null prizes to [], passes prizeDistributionType through, injects gameId from the caller, injects recentWinners: [] as a placeholder until the backend ships it). If a combined game carries a jackpot half but jackpot appearance fails to load, the widget gracefully renders FlashWins-only.

Player-session jackpot — one fetch. When a playerId (session id) is provided, GetGame hits the session-scoped path …/games/{gameId}[/brand/{brandId}]/players/sessions/{sessionId} (the /brand/ segment sits before /players/sessions/). The same GameDtoV2 envelope comes back, but its jackpot half is already player-session-evaluated — no separate jackpot fetch. When the player isn't eligible (or there's no jackpot) the envelope simply carries jackpot: null and the widget renders FlashWins-only. player-id is optional: with no session the widget hits the plain …/games/{gameId}[/brand/{brandId}] path — a generic (non-player-evaluated) jackpot half — and runs anonymously (opt-in resolved to NotRequired without a GET, only broadcast SSE channels subscribed, no personal user_* channel).

The appearance endpoint also follows the v2 swagger: GET {appearanceBaseUrl}{orgId}/flashwins/{flashWinId}/appearance?locale={locale}. The path parameter is flashWinId — the widget reads it from the flashWinId field on the game GET response (not from the gameId URL config param it was started with). The wire DTO matches FlashWinGameAppearanceDto 1:1; the service normalizes it onto the internal FlashWinsGameAppearance model via src/services/appearanceAdapter.ts (renaming *VariantPosition*PositionVariant, mapping 'Toolbar''ToolBar', falling back to 'ToolBar' for unsupported variant values with a console.warn).

The opt-in routes follow the v2 swagger: GET/PATCH {apiBaseUrl}{orgId}/flashwins/{flashWinId}/players/sessions/{playerId}/optinstatus, plus the optional brand-scoped variant .../players/sessions/{playerId}/brand/{brandId}/optinstatus when a brand-id is set. Like appearance, the path is keyed by flashWinId (read from the game GET response, not the gameId URL config param). The request/response bodies are { isOptedIn: boolean }. The session routes are unauthenticated per swagger (no x-api-key).

Jackpot opt-in mirrors the same shape but keys the path by jackpotGroupId under the /jackpots/ segment: GET/PATCH {apiBaseUrl}{orgId}/jackpots/{jackpotGroupId}/players/sessions/{playerId}[/brand/{brandId}]/optinstatus (via GetJackpotPlayerOptInStatus / UpdateJackpotOptInStatus). It is wired to the same inline bar opt-in/opt-out toggle — there is no dedicated jackpot opt-in modal. Same { isOptedIn } bodies and mock.optInStatus gating; the backend route is live on v2 (mock.optInStatus: false in every env), so it hits the real endpoint — the mock path is dev-only.

requiresOptIn is per game — in a combined game the jackpot and FlashWins each carry their own opt-in requirement and status. The widget tracks opt-in state per game (on each game's data), so in combined-both mode the bar CTA and modal footer reflect the active game's own state, and toggling opt-in calls that game's endpoint (/flashwins/{id}/... vs /jackpots/{groupId}/...).

| Environment | Legacy v1.1 Base URL (unused by FlashWins) | Game + Opt-in Base URL (v2) | Appearance Base URL (v2) | Event URL | CSS URL | Mocked endpoints | | ------------ | ---------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------- | ---------------- | | local | http://localhost:3001/api/game/v1.1/sites/ | http://localhost:3001/api/game/v2/sites/ | http://localhost:3001/api/game/v2/sites/ | http://localhost:3001/subscribe | http://localhost:3001/js/baseStyle.css | — | | unitTest | https://mock.api.example.flows.123.gov.mt | https://mock.api.example.flows.123.gov.mt/v2/sites/ | https://mock.api.example.flows.123.gov.mt/v2/sites/ | https://mock.event.example.123.gov.mt/subscribe | https://cdn.flows.world/css/flowsgames-staging.baseStyle.css | — | | betaStage | https://stage-api.flowsplay.world/game/v1.1/sites/ | https://stage-api.flowsplay.world/game/v2/sites/ | https://stage-api.flowsplay.world/game/v2/sites/ | https://push-events.flows.world/subscribe | https://cdn.flows.world/css/flowsgames-beta.baseStyle.css | — | | betaProd | https://api.flowsplay.world/game/v1.1/sites/ | https://api.flowsplay.world/game/v2/sites/ | https://api.flowsplay.world/game/v2/sites/ | https://push-events.flows.world/subscribe | https://cdn.flows.world/css/flowsgames-beta.baseStyle.css | — | | staging | https://stage-api.flowsplay.world/game/v1.1/sites/ | https://stage-api.flowsplay.world/game/v2/sites/ | https://stage-api.flowsplay.world/game/v2/sites/ | https://push-events.flows.world/subscribe | https://cdn.flows.world/css/flowsgames-staging.baseStyle.css | — | | production | https://api.flowsplay.world/game/v1.1/sites/ | https://api.flowsplay.world/game/v2/sites/ | https://api.flowsplay.world/game/v2/sites/ | https://push-events.flows.world/subscribe | https://cdn.flows.world/css/flowsgames.baseStyle.css | — |

The v2 swagger requires an x-api-key header for the appearance endpoint. The widget intentionally does not send one — this matches the sibling jackpot widget's pattern. If staging starts enforcing it, the call returns 401 and the widget degrades gracefully (logs an error, renders nothing).

The CSS URL column above is the floating cssUrl baked in by __BUILD_ENV__. On a CDN release build the second build pass injects the released version (__BUILD_CSS_VERSION__), and getEnvironmentConfig() rewrites cssUrl to the immutable versioned file: flowsgames-v<version>.baseStyle.css for staging/production, or flowsgames-<backend-slug>-v<version>.baseStyle.css for the prerelease envs (see the table in the next section). Local/dev builds leave __BUILD_CSS_VERSION__ empty and keep the floating cssUrl.

Prerelease channel (beta) — backend split

beta is a prerelease channel (semantic-release tag). It is built twice — once against staging APIs (betaStage) and once against production APIs (betaProd) — so integrators can validate the same prerelease against either backend without rebuilding. The npm publish is still one per branch (one channel tag); the backend split lives only on the CDN:

| Channel | Backend | env key | CDN script (channel-tagged) | CDN script (SRI-pinnable) | CDN CSS (floating) | CDN CSS (versioned) | | ------- | --------------------- | ----------- | --------------------------- | ------------------------------------- | ------------------------------- | ------------------------------------------------ | | beta | stage-api.flowsplay | betaStage | flowsgames-beta-stage.js | flowsgames-beta-stage-v<version>.js | flowsgames-beta.baseStyle.css | flowsgames-beta-stage-v<version>.baseStyle.css | | beta | api.flowsplay | betaProd | flowsgames-beta-prod.js | flowsgames-beta-prod-v<version>.js | flowsgames-beta.baseStyle.css | flowsgames-beta-prod-v<version>.baseStyle.css |

Each SRI-pinnable script ships with a matching .integrity sidecar at the same path.

The floating CSS is shared per channel — the CSS bundle is theme-equivalent across the two backends, so the floating file only varies by channel. The versioned CSS mirrors the JS slug pattern (per-backend slug) and is the file the published bundle's runtime cssUrl points at, so a given released bundle always loads the exact CSS it shipped with (no drift across releases). Unlike the JS, versioned CSS has no .integrity sidecar. For staging/production the versioned CSS is unsuffixed: flowsgames-v<version>.baseStyle.css.

Bundled mocks (prerelease channel)

Both prerelease envs can mock endpoints per-endpoint while a backend is staged in. Today every flag is false — every prerelease env hits the real backend for all endpoints:

| Endpoint | Prerelease behavior | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | GET /games/{gameId} (combined game DTO) | Real — hits the env's backend (staging or prod). | | `GET /flashwins/{flashWinId}/appea