@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/spindleSubpath exports:
import { createSpindle } from "@duyquangnvx/spindle"; // orchestrator
import { SlotPresenter } from "@duyquangnvx/spindle/presenter"; // presenterQuick 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 tsdownLicense
UNLICENSED
