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

@duyquangnvx/spindle

v2.0.0-beta.13

Published

Headless TypeScript slot engine

Readme

Spindle

Headless TypeScript slot game engine. Zero runtime dependencies.

Spindle orchestrates the entire spin lifecycle of a slot game — reels, modifiers, wins, cascades, features — without rendering anything. You implement async delegate callbacks; Spindle calls them in the right order and waits for your animations to finish.

Install

npm install @duyquangnvx/spindle

Subpath exports:

import { createSpindle } from "@duyquangnvx/spindle";             // orchestrator
import { SlotPresenter } from "@duyquangnvx/spindle/presenter";   // presenter

Quick Start

import { createSpindle } from "@duyquangnvx/spindle";
import type { SpindleDelegate, SpinResult } from "@duyquangnvx/spindle";

const delegate: SpindleDelegate = {
  // Required — pull spin data from your server
  async requestSpinResult({ context }) {
    const res = await fetch("/api/spin", { method: "POST", body: JSON.stringify(context) });
    return res.json() as Promise<SpinResult>;
  },

  // Required — lifecycle hooks
  async onSpinStart({ mode }) { /* disable buttons, dim UI */ },
  async onSpinEnd({ totalWin, mode }) { /* show total, re-enable UI */ },

  // Optional — implement what your game needs
  async onReelStop({ col, symbols }) { /* animate reel stopping */ },
  async presentWin({ win, winIndex, runningSpinWin }) { /* highlight win, update counter */ },
  async onModifierApply({ modifier }) { /* animate wild expansion, symbol transform, etc. */ },
  async onBigWin({ tier, amount }) { /* big win celebration */ },
};

const spindle = createSpindle(delegate);
const outcome = await spindle.spin();
// → { totalWin, spinWin, featureWin, mechanicWin }

How It Works

Spindle is a presentation orchestrator. Your server computes the result; Spindle drives the visual flow:

spindle.spin()
  │
  ├── onSpinStart()
  ├── requestSpinResult()              ← you call your server here
  ├── Pipeline (per phase):
  │     ├── onReelStop() × N           ← one per column
  │     ├── onModifierApply() × N      ← expanding wilds, symbol transforms, etc.
  │     ├── presentWin() × N          ← one per win, with running totals
  │     └── Cascade loop:
  │           ├── onCascadeDestroy()
  │           └── onCascadeDrop()
  ├── onBigWin()                       ← if bigWinTier present
  ├── onJackpotTrigger()               ← if jackpot present
  ├── Feature transition:              ← if transition present
  │     ├── onFeatureStart()
  │     ├── mode.run() (free spins / hold & spin loop)
  │     └── onFeatureEnd()
  ├── Side mechanic:                   ← if sideMechanic present
  │     └── gamble / pick bonus / wheel bonus
  └── onSpinEnd({ totalWin, spinWin, featureWin, mechanicWin })

Every delegate method returns Promise<void>. Spindle awaits each one — you control timing completely.

Architecture

Data-Driven Design

SpinResult is the single runtime input. Delegate method presence enables features:

| Data in SpinResult | Delegate method | What happens | |---|---|---| | phases[].wins | presentWin | Per-win highlight + running total | | phases[].modifiers | onModifierApply | Modifier animation | | phases[].cascade | onCascadeDestroy + onCascadeDrop | Cascade sequence | | bigWinTier | onBigWin | Big win celebration | | jackpot | onJackpotTrigger | Jackpot celebration | | transition (to: "free") | onFreeGameEnter + FreeGameDelegate | Free spins loop | | transition (to: "holdAndSpin") | onHoldAndSpinEnter + HoldAndSpinDelegate | Hold & Spin loop | | sideMechanic (type: "gamble") | GambleDelegate | Gamble loop | | sideMechanic (type: "pick") | PickBonusDelegate | Pick bonus | | sideMechanic (type: "wheel") | WheelBonusDelegate | Wheel bonus |

If data is present but the delegate method is missing, the step is silently skipped.

Delegate Conventions

| Prefix | Direction | Purpose | Returns | |---|---|---|---| | request* | Engine ← Consumer | Pull data (server call, player choice) | Promise<T> | | on* | Engine → Consumer | Lifecycle / event notification | Promise<void> | | present* | Engine → Consumer | Present a single visual element | Promise<void> |

All callbacks take 1 data object argument — extensible without breaking consumers.

SpinResult

The server contract — everything Spindle needs to drive presentation:

interface SpinResult {
  phases: Phase[];                // grid states (cascade = multiple phases)
  transition?: ModeTransition;    // feature trigger (free spins, hold & spin)
  sideMechanic?: SideMechanicTrigger; // gamble, pick bonus, wheel bonus
  jackpot?: JackpotResult;
  bigWinTier?: string;
  retrigger?: { addedSpins: number };
  gambleAvailable?: boolean;
  totalWin: number;
}

Each Phase contains:

interface Phase {
  grid: SymbolGrid;            // reel × row symbol array
  modifiers?: GridModifier[];  // randomWild, expandingWild, symbolTransform, stickyWild, walkingWild
  wins: WinResult[];           // payline, ways, cluster, scatter
  phaseWin: number;
  multiplier?: number;
  cascade?: CascadeInfo;       // destroy positions + drops
}

SpinOutcome

Returned by spin(), buyFeature(), and resumeFeature():

interface SpinOutcome {
  totalWin: number;     // spinWin + featureWin + mechanicWin
  spinWin: number;      // from pipeline phases
  featureWin: number;   // from feature mode (free spins / hold & spin)
  mechanicWin: number;  // delta from side mechanic (gamble/pick/wheel)
}

API

createSpindle(delegate): Spindle

Factory function. Validates delegate has required methods. No config needed — behavior is driven entirely by SpinResult data + delegate method presence.

spindle.spin(): Promise<SpinOutcome>

Run a full base spin cycle.

spindle.buyFeature(featureType): Promise<SpinOutcome>

Buy direct entry into a feature mode. The returned SpinResult must contain a transition.

spindle.resumeFeature(transition): Promise<SpinOutcome>

Resume a previously interrupted feature (e.g., after app reload).

spindle.isSpinning: boolean

Concurrent spin protection. spin(), buyFeature(), and resumeFeature() share the same lock.

spindle.mode: ModeManager

interface ModeManager {
  current: ModeType;          // "base" | "free" | "holdAndSpin"
  activeMode: GameMode | null;
}

Features

Game Modes

| Mode | Description | |---|---| | Free Spins | Bonus spin loop with retrigger and nested transition support | | Hold & Spin | Lock symbols, respin remaining positions, counter reset on new locks |

Side Mechanics

| Mechanic | Description | |---|---| | Gamble | Post-spin double-or-nothing with configurable rounds | | Pick Bonus | Interactive pick-and-reveal (prize, multiplier, freeSpins, collect) | | Wheel Bonus | Spinning wheel with multi-tier nested wheels |

Grid Modifiers

randomWild | expandingWild | symbolTransform | stickyWild | walkingWild

Win Types

payline | ways | cluster | scatter

Other

| Capability | Description | |---|---| | Cascade | Tumbling reels — destroy wins, drop new symbols, re-evaluate | | Big Win | Tiered celebration (server-defined tier string) | | Jackpot | Progressive jackpot trigger and award | | Anticipation | Suspense animation before specific reel stops |

Presenter

The @duyquangnvx/spindle/presenter subpath provides SlotPresenter — a reference implementation for reel spin animation timing and symbol view management. It's optional; you can drive all presentation purely through delegate callbacks.

import { SlotPresenter } from "@duyquangnvx/spindle/presenter";
import type { SlotPresenterConfig, PresenterDelegate } from "@duyquangnvx/spindle/presenter";

Development

npm test            # vitest — run all tests
npm run lint        # biome check
npm run typecheck   # tsc --noEmit
npm run build       # ESM + CJS dual output via tsdown

License

UNLICENSED