Changelog
Latest published version: v1.0.1. Full per-release notes are generated from packages/pixi-reels/CHANGELOG.md.
pixi-reels
1.0.1
Patch Changes
- #150
6a96d60Thanks @igaming-bulochka! - Fix: buffer-anchored big symbols no longer render empty, and big-symbol blocks no longer jitter, when falling through a tumble cascade.CascadePlacePhasenow preservesbufferAbovetarget cells, so a “tail-visible” block (anchor above the viewport) keeps its anchor through the animated place path instead of being overwritten with a random symbol and leaving its visible cell empty. The place and drop-in phases now animate each block anchor exactly once instead of once per occupied visible row — previously the duplicate drop tweens fought over the anchor’s position (the jitter) and could land it a row off target.
1.0.0
Major Changes
-
#140
d7dfc9dThanks @igaming-bulochka! - Hide internal exports from the package entry:OCCUPIED_SENTINEL,ReelSetInternalConfig,ResolvedReelGridConfig,OffsetCalculator,RandomSymbolProvider,SymbolFactory,StopSequencer, andReelMotion. -
#140
d7dfc9dThanks @igaming-bulochka! - HideSpinController,SpinControllerHooks, and the built-in phase classes (StartPhase,SpinPhase,StopPhase,AnticipationPhase,AdjustPhase,CascadeFallPhase,CascadePlacePhase,CascadeDropInPhase) from the package entry — they are internal wiring. Register custom phases by extendingReelPhaseand callingbuilder.phases(f => f.register(...)). Phase config TYPES (StartPhaseConfig, etc.) remain exported. -
#140
d7dfc9dThanks @igaming-bulochka! - Remove thedirectionoption fromDestroySymbolsOptionsandReelSymbol.playDestroy(). The default destroy is now a pure “poof” — a brief anticipation pop then a fast scale-to-0 + alpha-to-0 implode (~200 ms total, no rotation). Subclasses overridingplayDestroyshould drop thedirectionparameter from their signature. -
#140
d7dfc9dThanks @igaming-bulochka! - Remove the legacystring[][]form fromsetResultandinitialFrame. Use theColumnTarget[]shape, which survivesstructuredClone/ JSON /postMessage. -
#140
d7dfc9dThanks @igaming-bulochka! - Remove negative-index slot mutation on result grids. UseColumnTarget.bufferAboveandColumnTarget.bufferBelowto target buffer cells. -
#140
d7dfc9dThanks @igaming-bulochka! - Remove the unusedsymbol:recycledevent fromReelEvents. -
#140
d7dfc9dThanks @igaming-bulochka! - RemoveReelSetBuilder.visibleSymbols(). Use.visibleRows()instead. -
#140
d7dfc9dThanks @igaming-bulochka! - Rename internal-leaking methods onReel/ReelSetto drop their leading underscore:getAnchorRow,peekTargetShape,clearTargetShape. -
#140
d7dfc9dThanks @igaming-bulochka! - RenameReelSet.skip()toReelSet.skipSpin()for symmetry withskipNudge(). -
#140
d7dfc9dThanks @igaming-bulochka! - EnablestripInternalin tsconfig: methods marked@internalare removed from the published.d.ts(Reel.reshape,Reel.setStopFrame,Reel.setCrossReelResolver,Reel.getAnchorRow,Reel.notifySpinStart,Reel.notifySpinEnd,Reel.notifyLanded,Reel.snapToGrid). The runtime methods still exist; only the type declarations are removed. -
#140
d7dfc9dThanks @igaming-bulochka! - Move the headless testing harness to a dedicated subpath:import { createTestReelSet, FakeTicker, HeadlessSymbol, spinAndLand, captureEvents, expectGrid, countSymbol } from 'pixi-reels/testing'. It is no longer re-exported frompixi-reels, so production bundles never pull it in. -
#140
d7dfc9dThanks @igaming-bulochka! - Replace the inline-options-object signature ofReelSet.refill()with a typedRefillOptionsinterface and aRefillResultreturn type that mirrorsRunCascadeResult. Addssignal: AbortSignalfor mid-refill cancellation. The result now exposeswinnersRefilled,finalGrid,wasSkipped, andduration(previously the misnamedSpinResultshape).
Minor Changes
-
#140
d7dfc9dThanks @igaming-bulochka! - Add:driveGsapWithTicker(ticker)helper that pins GSAP to the PixiJS ticker (and returns a disposer that restores GSAP’s own ticker). Encapsulates the one-line incantation every integration had to remember, so engine animations don’t freeze in hidden tabs / iframes. -
#140
d7dfc9dThanks @igaming-bulochka! - Add: injectablerngonReelSetBuilder(andRandomSymbolProvider), defaulting toMath.random. Regulated / provably-fair deployments can now inject a seeded, audited PRNG so the on-screen scrolling strip is reproducible from a seed for dispute resolution and frame-level regression. -
#140
d7dfc9dThanks @igaming-bulochka! - Add: the symbol recycle pool now auto-sizes its per-id capacity to the whole strip (every visible + buffer cell, floored at 20), eliminating destroy/recreate churn on large and MultiWays grids. A newReelSetBuilder.poolCapacity(n)override is available for memory-constrained or unusually swap-heavy deployments. -
#140
d7dfc9dThanks @igaming-bulochka! - Add:SpinOptions.signal(AbortSignal) andSpinOptions.timeoutMs(watchdog). A spin whose result never arrives can no longer hang forever — aborting the signal or exceeding the timeout rejects thespin()promise and force-stops the reels to a clean grid.signalrejects withsignal.reasonwhen it is anError, so a failed/cancelled fetch propagates directly. -
#140
d7dfc9dThanks @igaming-bulochka! - Add:whenSpineReady()resolves once the optional Spine import settles, so constructingSpineSymbols on a cold start no longer throws a misleading “not installed” error before the dynamic import resolves (the constructor message now names that cause too). Adds an opt-inSpineSymbolOptions.strictthat throws on an unmapped idle/win animation instead of silently showing nothing.
Patch Changes
-
#140
d7dfc9dThanks @igaming-bulochka! - Fix:enableDebug(reelSet, key?)now registers each reel set under a per-instance key onwindow.__PIXI_REELS_DEBUG_INSTANCESinstead of letting multiple reel sets clobber the singlewindow.__PIXI_REELS_DEBUGglobal (which still points at the most recently enabled instance for convenience). -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:EventEmitterno longer drops a persistenton()listener when the same handler reference is also registered viaonce().emitnow removes the firedonceentry by identity instead of by(fn, context), which previously deleted every listener sharing that function reference. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:StandardMode.computeDeltaYnow clamps displacement symmetrically (±half a symbol). The upward step-back inStartPhase(and large frame deltas) previously moved more than one slot per tick, skippingReelMotion’s single-wrap-per-call invariant and desyncing the symbol array from the view.Reel.updatealso clamps pathologicaldeltaMsspikes (backgrounded-tab refocus, non-Pixi tickers). -
#140
d7dfc9dThanks @igaming-bulochka! - Fix: the “nudge in flight” guard that blocksspin()/setResult()/pin()is now reference-counted. With parallel nudges across reels, the first to settle no longer clears the guard early and lets a later call race a still-live nudge (which could tear a frame or desync a pin). -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:ObjectPoolnow guards against double-release (the same instance was pooled twice and then handed to two cells, silently aliasing one symbol) and against use afterdestroy()(acquirethrows,releaseno-ops) so a late ticker/promise callback can’t resurrect or leak the pool. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix: pin migration on a MultiWays reshape now resolves cell collisions deterministically. When two pins clamp onto the same row, the topmost keeps the cell and the other is expired (withpin:expiredreason'collision') and its overlay released — previously the second silently overwrote the first in the pin map and orphaned an overlay. Pin-overlay Y is also computed through a single helper so placement agrees across reshape. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:Reel.destroy()now emits'destroyed'beforeremoveAllListeners()(so listeners actually receive it) and destroys each symbol’s view instead of releasing live symbols back into the shared pool and then destroying their views out from under it (which handed a destroyed view to the nextacquire()). -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:setResult/initialFramebuffer-count validation now measures the highest defined index, not raw array length. A sparsebufferAbove: ['X', undefined, undefined](common from serializers that pre-size arrays) no longer throws a spuriousRangeError, while a defined entry beyond the consumable range still throws. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:SymbolSpotlight.cycle()now actually cycles. It previously aborted its own signal on the first line (becauseshow()calledhide()), flashing only the first win line for zero time and ignoringdisplayDuration/gapDuration/cycles. Teardown between lines is now separated from the cycle-abort, andhide()still interrupts a running cycle promptly. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:StopPhase.onSkip()now places the full target frame (buffers included) instead of slicing to the visible window. A directskip()previously droppedbufferAbove/bufferBelowtargets — e.g. a big symbol’s tail parked above the visible area — and landed the wrong frame. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:ReelViewportdim overlay is now reference-counted. The spotlight and cascadedestroySymbols({ dim })share one overlay; an overlapping pair no longer hides the dim out from under the other (flicker / lost dim in cascade+win sequences). The overlay hides only when the last consumer releases it. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix:RandomSymbolProvidernow fails loud instead of degrading silently — it throws on an empty symbol set or an all-zero total weight (which previously returnedundefinedor ignored weights), andupdateWeights()drops exclusions referencing symbols no longer present so stale game-mode exclusions don’t linger. -
#140
d7dfc9dThanks @igaming-bulochka! - Fix: throw on a concurrentspin(),setResult(),pin(), orsetShape()call whilenudge()is in flight, instead of leaving the behavior undefined. -
#140
d7dfc9dThanks @igaming-bulochka! - Perf: the main entry is now under 5 KB gzipped (down from ~20.8 KB) after hidingSpinController+ the built-in phase classes and moving the testing harness to thepixi-reels/testingsubpath.
0.9.0
Minor Changes
-
#138
2728db7Thanks @igaming-bulochka! - Add: big-symbol anchors can now sit in bufferAbove or bufferBelow. The classic UK fruit-machine landing. a 1xH wild lands with most of it hidden above the visible window, only the bottom cell (“the tail”) shows at row 0. works end-to-end throughsetResult,refill, andnudge._coordinateBigSymbolsnow iterates the full strip range (-bufferAbovetovisibleRows + bufferBelow) and validates against strip capacity instead of just visible. Anchors at any strip slot are accepted as long as the block fits end-to-end. Pass an anchor atbufferAbove[i]via the explicitColumnTargetform ({ visible: [...], bufferAbove: [...] }) or via the legacyframe[col][-1]negative-index form; the coordinator paints OCCUPIED stubs at the rest of the block’s cells (in buffer, visible, or buffer-below as needed).The validation error message changed:
exceeds reel heightwas visible-only; now readsextends past the bottom of the stripwith the exact computed values. The new check is more permissive. a 1x4 block on a 3-visible-row reel with 1 bufferBelow is now LEGAL where it previously threw.getSymbolFootprintmay return a negativeanchor.rowfor blocks anchored in bufferAbove.getBlockBoundshandles this by computing pixel coordinates from the row offset directly rather than delegating togetCellBounds(which still rejects negative rows). Consumers readinganchor.rowshould accept negative values.Fix:
ReelMotion._maxYwas hard-coded to(visibleRows + 1) * slotH, which collapsed tostrip[last].yexactly whenbufferBelow >= 2and fired a phantom wrap on the first nudge displacement. the anchor landed one strip slot too far. The threshold now scales withbufferBelow(maxY = (visibleRows + bufferBelow) * slotH), symmetric with the existingminY = -(bufferAbove + 1) * slotH. Nudges withbufferBelow >= 2now match the documented survival math.Live recipes:
/recipes/big-symbol-partial-land/,/recipes/big-symbol-held-respin/.
Patch Changes
-
#138
2728db7Thanks @igaming-bulochka! - Internal: sharpen comments around the big-symbol coordinator’s uniform-buffer assumption and_finalizeFrame’s scan asymmetry. both were silently load-bearing on contracts that weren’t spelled out. Also extendsColumnTarget.bufferAbove/bufferBelowJSDoc to explicitly document the big-symbol anchor capability. discoverable in IDE tooltips. No runtime change. -
#138
2728db7Thanks @igaming-bulochka! - Fix:ReelSet.setResultandReelSetBuilder.initialFramenow throw aRangeErrorwhen aColumnTarget.bufferAbove/bufferBelowcarries more entries than the engine’s configuredbufferSymbols(...), instead of silently dropping the extras.Previously, calling
.bufferSymbols(1)and passingbufferAbove: ['X', 'Y']would materialize botharr[at -1] set to 'X'andarr[at -2] set to 'Y', but the next clone (cloneColumn) only iterates-1..-bufferAbove.Ywas written to the array, dropped on the next pass, and never reached the reel. No error, no warning; the only symptom was “my targeted symbol never lands.” Same problem on thebufferBelowside via indices pastvisible + bufferBelow.The check now fails fast at the API entry point with a column-pointing message:
setResult column 2: bufferAbove has 2 entries but engine bufferSymbols=1. extra entries would be silently dropped. Increase bufferSymbols(...) on the builder or remove the extra entries.The legacyframe[col][-k]form is also validated for negative-index keys beyond-bufferAbove. The legacy form’s arraylengthis intentionally not checked. in MultiWays the per-reelvisibleRowschanges betweensetShape()andsetResult(), and any length-based check would false-positive on legitimate post-reshape calls.This is user-visible error behavior: input that previously silently failed now throws. Callers passing more entries than the configured buffer size should either increase
bufferSymbols(...)or trim the extra entries.
0.8.0
Minor Changes
-
#136
743e73dThanks @igaming-bulochka! - Add:ReelSet.nudge(col, options). shift a single reel by N positions after it has landed, revealing caller-suppliedincomingsymbols. The classic UK fruit-machine nudge.API surface includes:
NudgeOptions.distance/.direction/.incoming. required;incomingis top-down by FINAL on-strip position (overflow lands in the matching off-screen buffer).NudgeOptions.duration/.ease. default'power2.out'; overshooting eases are clamped so wraps never fire past the landing position.NudgeOptions.startDelay. defer the tween for staggeredPromise.allwaves.NudgeOptions.signal: AbortSignal. cancel mid-tween; strip still snaps to landed; promise rejects withAbortErrorandnudge:cancelledfires.ReelSet.skipNudge(col?)/Reel.skipNudge(). fast-forward an in-flight tween;nudge()resolves normally.- Events:
nudge:start(after pre-placement),nudge:complete,nudge:cancelledon the reel-set bus;phase:enter('nudge')/phase:exit('nudge')per-reel.
Big-symbol blocks on the target reel are nudged through as a unit when the rotation preserves the block:
- down:
anchor + h - 1 + distance < total(block may extend into bufferBelow) - up:
anchor - distance >= bufferAbove(anchor must land in visible. engine doesn’t render bufferAbove anchors today)
Cross-reel blocks (
w > 1) throw. splitting an anchor from its other-reel cells isn’t safe under a single-reel nudge.Also fixes
ReelMotion._wrapTopToBottomto use a symmetric<= minYboundary check (previously strict< minY, so an upward shift that landed exactly on the threshold no-op’d silently. exposed bynudgesince standard spinning only moves downward).
0.7.0
Minor Changes
-
#133
fbe6ac0Thanks @igaming-bulochka! - Add: speed-scoped tumble overrides + AbortSignal on cascade symbol events.SpeedProfilenow accepts an optionaltumble?: TumbleConfigfield. When the active speed profile defines one, the cascade fall + drop-in phases merge its fields over the base config registered via.tumble(...). sosetSpeed('turbo')can shortenfall.duration,dropIn.duration, and per-row staggers, not just the per-reelstopDelay. Profiles without atumblefield behave identically to before..tumble({ fall: { duration: 300 }, dropIn: { duration: 600, rowStagger: 60 } }) .speed('default', SPEED_DEFAULT) .speed('turbo', { ...SPEED_TURBO, tumble: { fall: { duration: 120 }, dropIn: { duration: 220, rowStagger: 20 }, }, }) .speed('snap', { ...SPEED_TURBO, tumble: { fall: { duration: 0 }, dropIn: { duration: 0 } } })cascade:fall:symbol,cascade:dropIn:symbol, andcascade:gravity:symbolnow carry asignal: AbortSignalfield. The signal aborts when the phase is skipped / slammed; listeners that schedule parallel tweens (squish, bounce, badge animations) can register a one-shot cleanup so a slam-stop kills their work alongside the library’s own timeline. The signal stays un-aborted on natural completion. only explicit skips trigger it.events.on("cascade:dropIn:symbol", ({ view, duration, signal }) => { const t = gsap.to(view.scale, { x: 1.15, y: 0.78, duration: duration / 1000, }); signal.addEventListener( "abort", () => { t.kill(); view.scale.set(1, 1); }, { once: true } ); });
0.6.0
Minor Changes
-
#120
579ed0cThanks @igaming-bulochka! - Add: two-stage cascade refill (gravity → hold → drop-in) for tumble slots that want an anticipation beat between survivors landing and new symbols entering.The default refill animates survivors and new symbols together in one beat (the Sweet Bonanza / Sugar Rush feel). A handful of slots split it in two: survivors slide first, a global beat for anticipation visuals (multiplier roll, mascot react, SFX peak), then new symbols enter. often staggered per column. That flavor is now first-class.
Opt in via
mode: 'gravity-then-drop'onrefill()(orrefillMode: 'gravity-then-drop'onrunCascade()):await reelSet.destroySymbols(winners); reelSet.setDropOrder("ltr", 110); // per-column wave for stage B await reelSet.refill({ winners, grid: nextGrid, mode: "gravity-then-drop", gravityHoldMs: 350, // anticipation window });New options:
refill({ mode }).'combined'(default, unchanged) or'gravity-then-drop'.refill({ gravityHoldMs }). global pause between gravity end and drop-in start. Default250.refill({ onGravityComplete }). awaitable hook between stages; extends the hold for async work (multiplier count-ups, etc.).runCascade({ refillMode, gravityHoldMs, onGravityComplete }). same options forwarded into every refill in the chain. The hook receives{ chain, winners }.
New events:
cascade:gravity:start.{ reelIndex }. A reel’s gravity stage begins.cascade:gravity:symbol. same shape ascascade:dropIn:symbol, scoped to survivors.cascade:gravity:end.{ reelIndex }. A reel’s gravity stage settled.
These fire only in two-stage mode; combined mode is unchanged. Per-column stagger inside the drop-in stage uses the existing
setDropOrder('ltr', stepMs).step < dropIn.durationgives an overlapping wave,step >= dropIn.durationgives strictly sequential columns. The gravity stage always runs all reels in parallel.See the Cascade anticipation refill recipe for a live example.
-
#120
579ed0cThanks @igaming-bulochka! - Cascade DX pass: collapse ~30 lines of slot orchestration to ~3 with a canonical detect → destroy → refill chain, retire the legacyexamples/shared/cascadeLoop.tshelper, and align every recipe / example / doc onto the new API.reelSet.destroySymbols(cells, opts?). the canonical “fade out winners” step. Defers to each symbol’splayDestroy()so subclasses (Spine, particles) get art-appropriate disintegration without the spin handler caring. Bumps each view’s zIndex so destroys aren’t clipped, alternates rotation by column for cohesive cluster pops, optional viewport dim. Replaces ~10 lines of duplicateddestroyWinnershelpers in every cascade recipe.reelSet.runCascade({ detectWinners, nextGrid, onCascade?, pauseAfterDestroyMs?, maxChain?, destroyOptions?, signal? }). the canonical cascade chain orchestration. Loops detect → destroy → pause → refill untildetectWinnersreturns[]. Caller supplies the game-rules callbacks; the library owns the timing. Both callbacks may beasync. Passsignal: AbortSignalfor caller-driven cancellation (the right shape for “player tapped slam between refills,” wherereelSet.skip()is a no-op because the engine is idle). The awaitedRunCascadeResult({ chainLength, totalWinners, finalGrid, wasSkipped }) is the canonical “the chain is over” signal. no separate event for that, since “round” is a slot-UX term (bet→payout) rather than a reel-engine one and the engine-level “press-spin → all-stopped” is already covered byspin:start/spin:allLanded.cascade:place:endpayload now includesisInitial: booleanandwinnerRows: readonly number[]so decoration listeners can tell new arrivals from survivors sliding into a hole.Also exports the named option / result types.
DestroySymbolsOptions,RunCascadeOptions,RunCascadeResult. so apps can pass typed config objects around or extend them in adapter layers.Non-breaking for the library API. Removed the legacy
examples/shared/cascadeLoop.tshelper (runCascade(reelSet, stages, opts),tumbleToGrid,diffCells) since every recipe + example + integration test has been migrated to the newreelSet.runCascade/reelSet.destroySymbols/reelSet.refillsurface. Site recipes (cascade-6x5,spin-then-cascade,multiways-cascade,cascade-winpresenter,remove-symbol) and React recipe components (RemoveSymbolRecipe,CascadeStarterRecipe) all use the new API; thecascade-tumbleandpyramid-cascadeexamples were rewritten the same way.New guide
your-first-cascade.mdxwalks a tutorial through the canonical API end-to-end.cascades.mdxdocuments the two-moments mental model, thepauseAfterDestroyMs/destroyOptions/signalknobs onrunCascade, and the choice betweenrefill()andrunCascade(). -
#120
579ed0cThanks @igaming-bulochka! - Add: chain- and destroy-scoped cascade lifecycle events so HUDs and audio buses can hook a cascade chain without pollingisSpinning(which oscillates between refills).New events on
reelSet.events:cascade:chain:start.{ chain, winners, currentGrid }. Fired insiderunCascade(...)afterdetectWinnersreturns winners, beforedestroySymbolsruns.chainis 1-indexed.cascade:chain:end.{ chain, winners, nextGrid }. Mirror ofchain:start. fired after the refill drop-in settles, before the loop iterates to the nextdetectWinners.cascade:destroy:start/cascade:destroy:end.{ cells }. Fired around everydestroySymbols(...)call (both direct and insiderunCascade). Empty-batch calls do not emit. Use these to cue a shatter SFX, dim a HUD, or capture pre-destroy grids for replay logging. without overriding the cascade loop.
Event ordering per
runCascade()call (per stage with winners):cascade:chain:start→cascade:destroy:start→ (destroy tweens) →cascade:destroy:end→onCascadecallback → pause → refill (cascade:place:end+cascade:dropIn:*per reel) →cascade:chain:endThe runCascade chain itself is delimited by the returned
Promise.awaitthe call to know when it’s done and read theRunCascadeResultsummary. There is intentionally nocascade:round:*event pair: “round” in slot UX is a bet→payout transaction (your concern, not the engine’s), and the engine-level “press-spin → all-stopped” is already covered byspin:start/spin:allLanded.Every cascade event uses a consistent three-part
cascade:<scope>:<step>taxonomy. -
#120
579ed0cThanks @igaming-bulochka! - AddgravityHold: Promise<void>torefill()andrunCascade()so callers can gate the drop-in stage on an already-in-flight animation / SFX / network call without wrapping it in a callback.// Single refill. pass the promise directly. await reelSet.refill({ winners, grid: next, mode: "gravity-then-drop", gravityHoldMs: 150, // minimum wall-clock floor gravityHold: multiplierRoll.done, // wait for the in-flight roll });gravityHoldMsandgravityHoldrace in parallel viaPromise.all. whichever finishes LAST gates the drop-in. Pass both when you want a wall-clock floor under an animation that might finish quickly.onGravityComplete(the existing callback hook) still runs AFTER both resolve, so it can read post-hold state.// Per-cascade. runCascade calls the builder once per stage. await reelSet.runCascade({ detectWinners, nextGrid, refillMode: "gravity-then-drop", gravityHoldMs: 150, gravityHold: ({ chain, winners }) => { multiplier.bumpTo(chain + 1); return multiplier.done; // each cascade waits for its own roll }, });Site recipes: SPIN/SKIP button is now bigger (56x56 vs 40x40), vertically centered on the right edge of the canvas, and uses the
SkipForwardicon (lucide-react) instead ofSquarewhen active. Larger touch target, more obvious as the primary action. -
#120
579ed0cThanks @igaming-bulochka! - Round-aware slam-stop: single-pressskip()with side effects, newslamStop(), newskipStage.ReelSet.skip()is now round-aware. A “round” is onespin()plus all itsrefill()s, until the nextspin(). The first press ofskip()in a round slams the current drop AND applies a round-scoped side effect:- Standard mode: boosts the active speed profile to the fastest registered one (emits
skip:boosted). The speed takes effect on the NEXT spin (mid-spin speed switching is not supported by phases). Boost persists acrossrefill()calls and is restored on the nextspin(). unless the app changed speed manually between rounds, in which case the manual choice is preserved. - Cascade/tumble mode: flags the round so every subsequent
refill()auto-slams with no animation. One press ends a multi-drop cascade.
Subsequent
skip()presses in the same round each slam the current drop. The universalif (isSpinning) reelSet.skip()button pattern across recipes now always lands the spin on a single press, while still benefiting from the boost / auto-slam side effect.Breaking:
skip()no longer needs two presses to slam. single press lands the drop. Callers that already relied onskip()slamming work as before. Callers expecting a non-slamming “boost only” press should usereelSet.setSpeed('superTurbo')directly.skip()THROWS if called beforesetResult()arrives (no result to land on. pre-result slam would land on random spin-buffer state). UserequestSkip()for the deferred-slam pattern, or wrapskip()intry { ... } catch {}and route torequestSkip()in the catch. Refill paths take a result at entry, so this guard only fires in the initial-spin pre-setResultwindow.requestSkip()bypasses staging entirely and slams whensetResult()arrives.- The test harness
spinAndLand()was migrated toslamStop()to keep its semantics explicit.
Added:
ReelSet.slamStop(). always slams, no side effects.ReelSet.skipStage.0 | 1 | 2getter;0until the first press,2after. (1is reserved for forward compat.)skip:boostedevent.{ previous, current }: SpeedProfile. Fires only on standard-mode boost; cascade auto-slam doesn’t emit it.ReelSymbol.playDestroy(opts?).opts.direction: 1 | -1for coherent rotation (e.g.w.reel % 2 === 0 ? 1 : -1),opts.delay: number(seconds) for per-winner stagger, andopts.signal: AbortSignalso a mid-destroy abort can snap to the destroyed pose without waiting for the full ~300 ms tween. Default direction stays random for back-compat.
- Standard mode: boosts the active speed profile to the fastest registered one (emits
-
#120
579ed0cThanks @igaming-bulochka! - Replace.cascade()with.tumble()and split cascade-drop into three independently overridable phases.Breaking changes:
.cascade(DropRecipes...)is removed.DropRecipes,DropStartPhase,DropStopPhase,CascadeAnticipationPhase, and their*Configtypes no longer export frompixi-reels. Use.tumble({ fall, dropIn })on the builder and override individual phases via.phases(f => f.register('cascade:fall'|'cascade:place'|'cascade:dropIn', MyPhase)).New:
reelSet.refill({ winners, grid })for Moment B cascade refills. Gravity-correct geometry. untouched survivors stay, survivors above a hole slide down, new symbols enter from above into the topwinners.lengthrows. Per-symbolcascade:fall:symbol/cascade:dropIn:symbolevents fire right before each tween so listeners can run parallel tweens on any view property in sync with the library’s motion. Per-reel boundary events:cascade:fall:start/cascade:fall:end/cascade:place:end/cascade:dropIn:start/cascade:dropIn:end.See
docs/recipes/tumble-cascade.mdfor the full recipe (drop-on-click, server wait with spinner, cascading multiplier).
Patch Changes
-
#120
579ed0cThanks @igaming-bulochka! - Fix five audit-discovered defects in the tumble-cascade pipeline:-
CascadeFallPhase/CascadeDropInPhasenow emit their:endevents on skip. Previously a slam mid-fall (or mid-drop, mid-gravity) killed the timeline without firing the pairedcascade:fall:end/cascade:dropIn:end/cascade:gravity:end, so any HUD or audio bus pairing:start/:endto track in-flight cascade work drifted out of balance on every slam. The pre-fall delay window (where:starthas not yet fired) still skips silently, so no unpaired:endis emitted. -
runCascade({ gravityHold })now invokes the per-cascade builder at the gravity-end boundary as documented, not at refill-start. Side effects in the builder (e.g.multiplier.bumpTo(chain + 1); return multiplier.done) now line up with the gravity-end beat the player sees. To support this,refill({ gravityHold })accepts a factory() => Promise<void>in addition to a barePromise<void>. pass a factory when the side effect of starting the promise should fire at gravity-end; pass a bare promise when you already hold an in-flight handle. -
runCascade({ pauseAfterDestroyMs })wait is now cancellable viasignal. Previously an abort during the pause ran the setTimeout to completion before the loop exited. up topauseAfterDestroyMsof dead air between slam intent and exit. Now the wait races againstsignal.abortedand unblocks within a microtask. -
A new
cascade:gravity:errorevent surfaces user-suppliedgravityHold/onGravityCompleterejections (or throws). The engine still slams to recover so the refill promise settles, but the original rejection reason is no longer silently swallowed. listen on the event to forward the error to your own logger / alarm. The console.error log was also tightened to identify the likely culprit. -
movePinonFlightCreated/onFlightCompletedhook throws now log viaconsole.errorinstead of being silently swallowed. The animation still continues (a throwing hook MUST NOT leak a flight symbol or leave the pin map out of sync) but the bug is no longer invisible.
Also clarifies the
skip()documentation:skip()THROWS beforesetResult()arrives. The docstring onrequestSkip()andskipStagenow notes that queued-pre-setResultrequests do not advanceskipStageuntil the slam fires. -
0.5.0
Minor Changes
-
#111
dc2a526Thanks @igaming-bulochka! - Add: cascade + multiways combination.ReelSetBuilder.multiways(...)can now be paired with.cascade(...)orspinningMode(new CascadeMode()). the build-time throw added in ADR 012 is lifted.AdjustPhaseruns betweenSpinPhaseandDropStopPhaseso the new shape commits before the drop-in fills it. Shape changes apply per-spin only; mid-cascade-chain reshape is unsupported (see ADR 015). Closes #74. -
#116
7afe3a9Thanks @igaming-bulochka! - Add:ColumnTarget. explicit{ visible, bufferAbove?, bufferBelow? }input shape. Accepted by bothReelSet.setResultandReelSetBuilder.initialFramealongside the legacystring[][]form. SurvivesstructuredClone, JSON, andpostMessage(the legacy negative-index form does not).Fix:
setResult(legacystring[][]form) now honoursframe[col][-1]…[-bufferAbove]end-to-end. Previously the negative-index slots were dropped inside_applyPinsToGrid(when pins were active) and_coordinateBigSymbols(always) by plain spread clones, so the convention only worked throughinitialFrame. The clones now use a property-preserving helper.Fix:
Reel.placeSymbols(skip / turbo land path) now reads the negative-index slot for the buffer-above cell instead of always random-filling it. Buffer-below targeting viasymbolIds[visibleRows]is unchanged.
Patch Changes
- #115
1f30d8eThanks @MaksimKiselev! - Fix: negative indices ininitialFramenow correctly populate buffer-above slots. Settingframe[col][-1](or[-2]for deeper buffers) places the symbol in the corresponding buffer-above cell instead of being silently ignored.
0.4.0
Minor Changes
-
#98
b4baccaThanks @igaming-bulochka! - Auto-pickSharedRectMaskStrategywhen any registered symbol hasunmask: trueandsymbolGap.x > 0.The default
RectMaskStrategydraws one mask rect per reel, with the gaps between reels NOT clipped. fine in the common case. But when anunmask: truesymbol renders above the reel mask, neighboring (still-masked) symbols on adjacent reels visibly clip at the column gap, and players see a half-cropped neighbor next to the unmasked overlay.The auto-pick now triggers in either case:
- big symbols registered (
SymbolData.sizewithw > 1orh > 1), or - unmasked symbols registered (
SymbolData.unmask: true),
provided the layout has a horizontal gap (
symbolGap.x > 0). Explicit.maskStrategy(...)calls always win.Console emits a one-line
console.infohint identifying which condition triggered the auto-pick. Pairs with the existing big-symbol auto-pick. the same mechanism, broader trigger set. - big symbols registered (
-
#91
d211ca4Thanks @igaming-bulochka! - AddReelSetBuilder.gsap(instance)for explicit GSAP dependency injection.The engine internally drives every tween, timeline, and
delayedCallthrough a single boundgsapinstance. By default that is thegsapresolved at the engine’s own module path. fine for the common case where bundlerdedupecollapses both the engine’s and the consumer’s'gsap'to one module instance.In setups where two
gsapinstances exist at runtime (symlinked workspaces, npm-link, misconfigureddedupe), tweens started by the engine live on a different root timeline than the one the consumer drives. animations stall, double-fire, or freeze on hidden tabs. Calling.gsap(myGsap)in the builder rebinds the engine to the consumer’s instance:import { gsap } from 'gsap'; const reelSet = new ReelSetBuilder() .reels(5).visibleRows(3).symbolSize(200, 200) .symbols(...) .ticker(app.ticker) .gsap(gsap) // ensure engine and app share one instance .build();Internally this is implemented via a tiny
getGsap()/setGsap()shim inutils/gsapRef.ts. Every internal animation site now reads throughgetGsap()instead of importing'gsap'directly. A regression-guard test asserts no runtimegsap.timeline(/gsap.to(/gsap.delayedCall(calls outside the shim itself.No behavioural change for consumers who don’t call
.gsap(). -
#99
544607dThanks @igaming-bulochka! - Add a frame-state recorder to the debug module:startRecording(reelSet, tag),stopRecording(reelSet),getFrames(tag?),clearFrames().Each lifecycle event (
spin:start,spin:reelLanded,spin:allLanded,spin:complete) captures oneDebugSnapshotwhile a recording session is active. Frames are tagged with the string passed tostartRecording, so multiple sessions can share one global log and be filtered out viagetFrames(tag). Per-process buffer is capped at 1000 frames by default (rolling window); override viastartRecording(reelSet, tag, { maxFrames }). Recording auto-detaches when the reel set emits'destroyed'.Designed for AI agents and debug harnesses that need a frame-by-frame trace of a spin sequence. particularly useful for diagnosing flicker, double-fires, or off-by-one frame issues that aren’t visible from a single point-in-time
debugSnapshot.Also exposed on
__PIXI_REELS_DEBUGafterenableDebug(reelSet):__PIXI_REELS_DEBUG.startRecording("my-tag"); await reelSet.spin(); __PIXI_REELS_DEBUG.stopRecording(); __PIXI_REELS_DEBUG.getFrames("my-tag");startRecordingis idempotent per reel set. calling it twice on the same set replaces the prior session. -
#95
1abfc45Thanks @igaming-bulochka! - AddReel.setSymbolAt(visibleRow, symbolId)andReelSet.setSymbolAt(col, row, symbolId). public API for swapping a single visible cell’s symbol identity in place at rest.Useful for live presentation effects that don’t fit the
setResult/placeSymbolsflow:- converting a symbol to a wild after a cascade pop,
- swapping to a sticky variant after a win is paid out.
The method funnels into the same internal activate path as the rest of the engine, so the swapped-in symbol gets its proper parent (masked vs unmasked container),
zIndex, and visual reset for free. no follow-uprefreshZIndexrequired.Validation (all guards fail loud):
- throws if the reel is in motion (
speed !== 0orisStopping). a mid-spin swap would be overwritten by the next wrap/stop frame anyway. - throws if
visibleRowis not an integer in[0, visibleRows). - throws if
symbolIdis not registered. - throws if the target row is a non-anchor cell of a big-symbol block.
- throws if the target row currently holds the anchor of a big-symbol block. big blocks span multiple cells (and possibly reels) and require
placeSymbolsplus the cross-reel OCCUPIED coordinator. - throws if
symbolIditself is a big symbol. same reason. ReelSet.setSymbolAtadditionally throws if the cell currently has an active pin; callunpin(col, row)first to overwrite.
Emits
symbol:createdon the per-reel event bus, matching motion-driven swaps. -
#78
9f6f0daThanks @igaming-bulochka! - Add:reelSet.spin({ holdReels: [...] })for subset spinning.Held reels skip START / SPIN / STOP entirely and stay on whatever symbols they’re currently showing. no more “fragment the board into one ReelSet per column” workaround for Hold & Win, sticky / expanding wilds, or trigger-column bonus respins. Held reels count as already-landed for the
spin:allLandedresolver, so only the non-held reels actually animate.// Hold reels 0 and 4; only reels 1, 2, 3 reroll. const spin = reelSet.spin({ holdReels: [0, 4] }); reelSet.setResult(serverGrid); // entries at 0/4 are ignored await spin;Behaviour:
setResult(grid)still expects a fullreelCount-length grid; held entries are ignored.setAnticipation([...])silently filters held indices.setStopDelays([...])entries at held indices are ignored.- No
spin:reelLanded/spin:stoppingevent fires for held reels;spin:allLandedfires once every non-held reel lands. - Out-of-range / duplicate / non-integer entries in
holdReelsare silently filtered. - Big-symbol blocks crossing the held / non-held boundary are not supported. author results so big symbols stay inside a contiguous run of non-held reels.
Exports
SpinOptionsfrom the package root. -
#92
aa8be14Thanks @igaming-bulochka! - MakeSymbolData.unmask: trueactually re-parent the symbol view toviewport.unmaskedContainer.Until now the
unmaskflag onSymbolDatawas accepted by the builder but never read by the engine. symbols always landed inside the reel’s masked container regardless of the flag. With this change, every code path that places a symbol into the reel._setupSymbolPositions,_replaceSymbol(both stub-install and stub-replace branches and the regular swap), andreshape. consults_symbolsData[id].unmaskand parents the view toviewport.unmaskedContainerwhen set.When unmasked, the engine sets the view’s X to
reel.container.xand addsreel.container.yto the view’s Y so the at-rest cell position aligns with the reel column (sinceunmaskedContainersits at viewport-local 0,0).Documented limitation in
SymbolData.unmaskJSDoc:ReelMotionwritesview.yin reel-local coords every frame, so an unmasked symbol on the strip will appear shifted vertically byreel.container.ywhile the reel is spinning. Treatunmask: trueas a landed-state flag. it is correct at rest and during static frames, but not designed to stay visually accurate while the reel is spinning. For mid-spin “stays visible above mask” overlays, use a cell pin instead.Pyramid layouts: registering any unmasked symbol on a slot where any reel has a non-zero
offsetY(pyramid / trapezoid) now throws atbuild(). Reason: the same motion-layer issue persists at landing.snapToGridwrites reel-local Y, mispositioning the unmasked view byreel.container.yeven at rest. Use cell pins for above-mask overlays on pyramid slots, or remove the per-reel offset. -
#104
1dc8d08Thanks @feddorovich! -reelSet.spin()accepts an optional{ mode: 'standard' | 'cascade' }argument that picks the phase chain for a single spin. Tumble-cascade slots can now do classic strip-spin + bounce on the first round and drop-in tumble on subsequent waves..cascade(...)on the builder still wires the drop-in phases. but they are now registered underdropStart/dropStopkeys instead of overwritingstart/stop. The default mode flips to'cascade'when.cascade(...)was called, so existing callers that just callspin()without args see no change.Calling
spin({ mode: 'cascade' })on a builder that didn’t configure.cascade(...)throws a clear error. The newSpinOptionstype is exported from the package barrel. -
#103
18474eeThanks @feddorovich! - AddedReelSet.requestSkip()(andSpinController.requestSkip()). a slam-stop entry point that’s safe to call beforesetResult()arrives. If the result is already pending, it behaves exactly likeskip(). Otherwise the skip is queued and fires automatically as soon assetResult()lands.Use this from UI handlers in server-driven slots: a player tapping the spin button to slam-stop before the WebSocket response reaches the client no longer snaps every reel onto whatever buffer state happened to be mid-scroll. Existing
skip()is unchanged.
Patch Changes
-
#93
f111da8Thanks @igaming-bulochka! - Fix:Reel._replaceSymbolnow sets the canonical zIndex inline on every symbol activation.Previously the activate path set
view.zIndex = 0and relied on a follow-uprefreshZIndex()call to apply the real formula(symbolData.zIndex ?? 0) * 100 + arrayIndex. All current callers happen to callrefreshZIndexafter, but the contract was fragile: any future caller that swapped a single symbol via the activate path would see the wrong layering until the next motion-wrap.A new private helper
_computeSymbolZIndex(symbolId, index)centralizes the formula and is used by bothrefreshZIndex(full rescan) and_replaceSymbol(single-symbol activate). OCCUPIED stubs receivearrayIndexdirectly, matching whatrefreshZIndexwould assign.No public API change. The fix unblocks future single-symbol swap APIs (e.g. a public
setSymbolAt) without forcing every caller to remember torefreshZIndexafterwards. -
#97
db32899Thanks @igaming-bulochka! - Fix:ReelSetBuilder.bufferSymbols(count)now clamps0, negative numbers,NaN, and non-finite values to the minimum of 1, with a single console warning per process.Buffer rows are off-screen cells the reel keeps around the visible window so symbols can fade/slide in cleanly. The motion layer’s wrap detection assumes at least one buffer row above and one below. passing
0would produce an inconsistent state that surfaced later as visible flicker on motion-wrap, not as a clear configuration error at build time.The clamp is preferred over a thrown error so existing user code that accidentally passed
0keeps running. The warning fires once per process (regardless of how many builders hit the bad value) so logs stay readable when a faulty default is wired into a loop. -
#94
6a5c8d1Thanks @igaming-bulochka! - Fix:SpineReelSymbolone-shot animation promises (playWin/playLanding/playOut) no longer dangle when the track is hijacked.Three previously-leaking scenarios now settle the returned promise instead of hanging forever:
- Concurrent one-shots. calling
playOut()whileplayWin()is in flight resolves the priorplayWinpromise (its track was overwritten) before starting the new one. playBlurmid-animation. entering a SPIN that triggers blur while a win is still animating settles the win promise.- Listener leak. back-to-back one-shots no longer accumulate stale listeners on the Spine state. Each new one-shot detaches the prior listener.
Refactored to a single internal
_resolveOneShot()helper called fromonActivate,onDeactivate,stopAnimation,playBlur, and the start of every new_playOneShot. The track-entry guard (done !== entry) is preserved so unrelated entries firing complete on the same track are correctly ignored.This unblocks reliable
await symbol.playWin()patterns in win presenters and cascade orchestration. - Concurrent one-shots. calling
-
#77
265136aThanks @igaming-bulochka! - Fix: stop reparenting recycled symbols on spotlight hide and always anchorReel._replaceSymbolto its own container.Two related bugs caused symbols to render in the wrong reel after rapid spin/skip cycles, particularly when the win spotlight runs alongside an expanding-wild mechanic that triggers many
placeSymbolscalls in quick succession:SymbolSpotlight.hide()reparented every symbol it had ever tracked back to itsoriginalParent, even whenpromoteAboveMask: false(no reparenting onshow()) or after the shared symbol pool had recycled the instance into a different reel. The recycled symbol got yanked from its new owner, leaving a hole there and a stranger in the original reel.Reel._replaceSymbolused the capturedoldSymbol.view.parentas the destination for the replacement view. If the old symbol had been moved (by the spotlight or by pool recycling), the new symbol landed in a foreign container. symbols accumulated in the wrong reel across spins.
Both paths now anchor to the reel’s own container; the spotlight only reparents symbols whose view is still in
spotlightContainer(i.e., never recycled away). -
#101
7a7670cThanks @feddorovich! -ReelSymbol.activate()andReelSymbol.deactivate()now both reset the container’salpha,scale,rotation,filters, andzIndex. Previously a subclass that decoratedviewfrom a spin-lifecycle hook (e.g. attaching aBlurFilterinonReelSpinStart) had to remember to undo every property on its own. and any path that skipped a hook (a buffer cell that exited spin withoutonReelSpinEnd, a slam-stop that bypassed the lifecycle) left a recycled symbol carrying stale state into its next life. The most visible symptom was a “blurred” cell appearing after a cascade refill once a symbol had been pooled mid-spin.ReelSymbol.destroy()now inlines the lifecycle hooks (stopAnimation,onDeactivate) instead of going throughdeactivate(), so it doesn’t try to reset transform / filter state on a view that was already torn down by a parentcontainer.destroy({ children: true }).The same-id early-return path inside
Reel._setSymbolAtbypasses the deactivate/activate cycle, so the matching reset has been added there too.No public API change. Subclasses that already cleared their own filter / transform state continue to work and just do a few redundant assignments.
-
#102
a2be4b8Thanks @feddorovich! -SpinController.skip()now firesonReelSpinEndandonReelLandedon every reel that hadn’t already landed, regardless of which phase was active when the slam-stop arrived. Previously these symbol-level hooks fired only when the active phase happened to beStopPhaseorDropStopPhase(theironSkip()called the notifications); a skip duringStartPhase/SpinPhase/AnticipationPhase/AdjustPhaseleft visible symbols without an end-of-spin signal. most visibly, motion blur (or any other decoration attached inonReelSpinStart) stayed on the cell after the slam.The notifications moved out of
StopPhase.onSkip/DropStopPhase.onSkipinto the controller so there’s a single source of truth and no double-fire. Natural-stop flow is unchanged. those phases still fire the hooks themselves before the bounce.
0.3.2
Patch Changes
b86dad7Thanks @igaming-bulochka! - Fix: shipCONTRIBUTING.mdin the npm tarball so the npmjs.com “Contributing” sidebar link resolves. npmjs builds that link fromrepository.directory(packages/pixi-reels) and a standard filename, but the file previously only existed at the monorepo root. the link 404’d. The build script now syncsCONTRIBUTING.mdinto the package alongsideREADME.mdandLICENSE, and the package’sfilesarray includes it.
0.3.1
Patch Changes
93aa66cThanks @igaming-bulochka! - Update: packagehomepagenow points at the canonical docs site,https://pixi-reels.schmooky.dev. No code or runtime change. npm metadata and the docs site URL only.
0.3.0
Minor Changes
-
#61
28551caThanks @schmooky! - Add: per-reel geometry, MultiWays, big symbols, and expanding wilds.- Per-reel static shape (pyramids):
builder.visibleRowsPerReel([3, 5, 5, 5, 3]), optionalreelPixelHeights,reelAnchor: 'top' | 'center' | 'bottom'. Reels can now have non-uniform row counts at build time. - MultiWays (per-spin row variation):
builder.multiways({ minRows, maxRows, reelPixelHeight })plusreelSet.setShape(rowsPerReel)mid-spin. A newAdjustPhase(inserted only when.multiways(...)is called) reshapes reels between SPIN and STOP. Pin migration follows: pins gain a frozenoriginRowand migrate back toward it on each reshape. - Big symbols (
N×Mblocks):register('bonus', SymbolClass, { size: { w: 2, h: 2 } }). The result grid staysstring[][]. the engine paints OCCUPIED across the block.getSymbolFootprint(col, row)resolves any cell to the anchor. - Expanding wilds: unchanged from the existing pin API; reaffirmed via tests as a degenerate big-symbol case.
New events:
shape:changed,adjust:start,adjust:complete,pin:migrated. They only fire on MultiWays slots. non-MultiWays event surfaces are unchanged.New runtime:
reelSet.setShape(),reelSet.getSymbolFootprint(),reelSet.getVisibleGrid(),reelSet.isMultiWaysSlot. New builder fluents:.visibleRowsPerReel(),.reelPixelHeights(),.reelAnchor(),.multiways(),.pinMigrationDuration(),.pinMigrationEase(). Pin gains optionaloriginRow.AdjustPhase animates the reshape: every visible symbol tweens its height + Y from the old shape to the new one over
pinMigrationDurationms with the configurablepinMigrationEase. Pin overlays tween in lock-step so a sticky wild visibly slides to its migrated row. SetpinMigrationDuration(0)for an instant snap.Constraints: big symbols and MultiWays are mutually exclusive per slot in v1. Cascade mode + MultiWays throws at build.
Breaking (debug-only, not protected by semver but called out):
DebugSnapshot.visibleRowswidens fromnumbertonumber[]so jagged shapes are representable. Adapt downstream code that deep-reads the snapshot. - Per-reel static shape (pyramids):
Patch Changes
-
#61
4b22c00Thanks @schmooky! - Fix and harden a handful of follow-ups from the per-reel-geometry / MultiWays / big-symbols PR:Reel.reshape()now keeps_reelHeightin sync with the new geometry so the field doesn’t go stale after a reshape. Previously a direct external call leftreelHeightreporting the construction-time value. The method is also marked@internalin JSDoc.ReelSet.setShape()is the supported entry point.ReelSetBuilder.maskStrategy()now validates its argument synchronously: passingnull,undefined, or an object missingbuild()/update()methods throws with a grep-able error instead of crashing later insideReelViewport.- Added a comment in
SpinController.skip()documenting the reshape-on-skip contract. pin overlays migrate instantly on slam-stop regardless ofpinMigrationDuration, and the rationale (overlays are destroyed at land anyway).
No new public API; behaviour for existing well-formed callers is unchanged.
0.2.0
Minor Changes
-
3fd806a- Backfill for three engine PRs merged without changesets after0.1.0:- Cascade drop-in mechanic and anticipation recipe (#51).
- Engine primitives:
CellPin,movePin, andreelSet.frameexposure (#52). ReelSet.getCellBoundsfor overlays, paylines, and hit areas (#53).
All three are additive, so this bundles them into a single minor bump.
-
555c9f0- Add:WinPresenter. a minimal win-presentation layer that animates winning cells and fires events. Paylines, cluster pops, scatter splashes all use the same shape. The library never draws lines or overlays; user code does that by reacting to events.WinPresenter.show(wins: Win[]). animates each win’s cells, one by one.stagger: 0flashes simultaneously,stagger > 0sweeps left-to-right in cell order.Win. one shape:{ cells: SymbolPosition[]; value?: number; kind?: string; id?: number }. Covers paylines, clusters, cascade pops, scatters.dimLosers(default 0.35 alpha) fades non-winning cells during each win; restored onwin:end.symbolAnim:'win'(default, callsplayWin()), a named spine animation, or(symbol, cell, win) => Promise<void>for a custom callback.- Events fire on
ReelSet.events:win:start(full list),win:group(per-win),win:symbol(per-cell),win:end(complete/aborted). Subscribe withreelSet.getCellBoundsto draw any overlay you want. - Cascades: call
presenter.show([{ cells: winners }])fromrunCascade’sonWinnersVanishhook. same API. - Helper:
sortByValueDescexported for convenience. - Types:
Win,SymbolPosition(canonicalised toconfig/types, re-exported from events). - Reels now have an explicit
container.zIndex = reelIndexso the viewport’s sortedmaskedContainerdraws reels deterministically. same order as before, but callers can flip it for bottom-left diagonal overflow.
No existing API is changed or removed.
Patch Changes
-
7792142- Fix: TwoAnimatedSpriteSymbolbugs that only manifest on symbols with non-trivial win animations:resize()now positions the sprite according to its configured anchor, soanchor: { x: 0.5, y: 0.5 }renders the symbol centred in its cell instead of with its centre pinned to the cell’s top-left corner (which clipped three quarters of the symbol under the reel mask).anchor: (0, 0). the prior default and only combination that worked. is unchanged.playWin()now returns the animation to frame 0 (gotoAndStop(0)) when the sequence completes, so the idle visible state settles on the neutral base frame. Previously the sprite held its last animation frame indefinitely. fine for symmetric pulses that happen to end where they started, a visible glitch for anything else (AI-generated or keyframe sequences that end mid-action).