Changelog
Latest published version: v3.0.0. Full per-release notes are generated frompackages/pixi-reels/CHANGELOG.md.
pixi-reels
3.0.0#
Major Changes#
-
#255
1d9bab9Thanks @igaming-bulochka! - Add: skip modes (requestSkip({ mode: 'slam' | 'quicken' })),onSkip(ctx)on every phase, the built-in phases as editable step lists, the press on the skip events and the results, and every built-in phase internal asprotected. Remove: the unpublished hurry API. Breaking for anyone with a custom phase or a listener on the skip events; additive for everyone else.Skip modes.
requestSkip(options?)andskipSpin(options?)take{ mode, speed, payload }.mode: 'slam'(the default) places each freed reel on its result now, as every press always has.mode: 'quicken'asks each freed reel for its landing sooner without changing what the landing looks like: a tease ends, a stop delay is cut, the spin-out and bounce still play, andspeednames a registered profile to finish on (the turbo bounce for a pressed reel, say). Which reels a press frees (tease protection,'stepwise', reel groups) is the same in both modes; a quicken press treats the reels it already quickened as down and walks on to the next group, a slam press cuts them.builder.skipMode('quicken')sets the default for a set,HoldAndWinBuilder.skipMode()for a board, andboard.skip(options?)takes the same object. Either mode is a skip:skipStageadvances,wasSkippedistrue, andSpinResult.skipModesays which.slamStop()stays a slam by name. In cascade mode a quicken slams and warns once with codequicken-cascade.Skip events carry the press.
skip:requestedandskip:completedareSkipInfo,{ reels, partial, mode, speed?, payload? }: the sameSkipContextevery phase’sonSkip(ctx)saw, plus the reels, so code hung off the event gets what the game attached to the press.completedfires in the same tick for a slam and as the last freed reel lands for a quicken, with the sameinfo. Keys the press did not set are absent; an engine slam (abort, timeout,slamStop()) is a baremode: 'slam'.SpinResult.skipContextis the last such press, besideskipMode.feature:skipon a board is{ inFlight, mode, speed?, payload? }with the press’s options as given. A'slam'listener written for 2.x keeps working; one that meant “about to be placed” now checksmode.onSkip(ctx).ReelPhase.skip(ctx?),forceComplete(ctx?)and theonSkip(ctx: SkipContext)hook receive{ mode, speed?, payload? }. Under'slam'the hook is the slam pose as before and the base completes the phase after it. Under'quicken'only the hook runs: cut a wait, never the landing, and call_complete()yourself when the natural end is reached (right away if the slam pose already is that end); ignore the mode and the phase runs its course, so the reel still lands. The built-in hooks defaultctxto a slam, so a subclass that still callssuper.onSkip()keeps its 2.x meaning; passctxthrough to take part in quickens.payloadis whatever the game attached to the press. A reel quickened before it reached its stop has the stop phase primed and asked as soon as it is created: itscutsteps are skipped from the first one on, andonSkip(ctx)followsonEnteras usual. This is the shape every phase hook will take;onEnterandupdateare unchanged for now.Steps. The built-in phases run a named list of steps, and a game edits that list by re-registering the same class with options:
f.register('stop', StopPhase, { steps: (steps) => insertAfter(steps, 'land', step('flash', (ctx) => ...)) }).StopPhaserunsdelay, spinOut, land, bounce;StartPhaserunsdelay, launch, pull, accelerate, announce;AnticipationPhaseruns oneteasestep. A step returns a gsap tween or timeline (killed on a slam), a promise (told to stop throughctx.signal), a cancellable such asctx.phase.bounce(), or nothing;ctxcarries the reel, the profile, the config, gsap, the container and its travel-axis property, the phase, andwait(ms)/until(predicate).insertBefore,insertAfter,replaceStep,removeStepandrunStepare exported. A quicken skips the steps markedcut(the delays and the tease).ReelPhase.runSteps()/tickSteps()let a custom phase run its own list;land()andbounce()are public, andbounce({ animation })takes a different shape while the base keeps carrying lifted unmask symbols along.quickenable. A quicken reachesonSkip(ctx)only on a phase that declaresreadonly quickenable = true. A phase without it is left alone by a quicken and runs its course, so a 2.8 phase whose slam pose kills its tweens keeps working. The built-ins declare it.More to listen to.
skip:queuedon the set bus whenrequestSkip()comes before the result, with the press on it.phase:steponreel.eventsfor every step of a phase onrunSteps():start,end,skipped(acutstep a quicken never started),cut(thecutstep a quicken stopped in flight),cancelled(the step a slam stopped).EventEmitter.onAny(fn)/offAny(fn)on every bus, name first, after the event’s own listeners;enableDebug().trace()now runs on it, covers every event, and returns the function that stops it.onNotice(fn)hears every engine notice whatever the console level, as{ kind, code, message, detail }.Open internals. Every
privatefield and method onStartPhase,SpinPhase,AnticipationPhase,StopPhase,AdjustPhaseand the three cascade phases isprotected, so a subclass can reach_beginSpinOut,_landAndBounce,_stage,_launch,_runSegmentand the rest instead of rewriting the phase.ReelPhase.speedis a public getter besidereel.Also.
skipStageis decided before a slam lands anything, so aspin:completelistener sees2on the press that ended the round (it saw0before).slamStop()beforesetResult()throws likeskipSpin()instead of warningslam-before-resultand landing on random fill; a spin whose result will never come is aborted through itsspin({ signal }).Removed.
requestHurry(),board.hurry(),BoardGrid.hurrySpinning(),HurryOptions, thehurry:requestedandfeature:hurryevents,ReelPhase.hurry()/onHurry()and theslam-before-resultnotice. None of these shipped to npm.
Minor Changes#
-
#251
bc2b5cbThanks @igaming-bulochka! - Add:PhaseCardSymbol- aCardSymbolthat is grey at rest and takes the colour of the phase its reel is running, so a spin can be read straight off the board: sky while the reel accelerates, blue at full speed, amber through a tease, violet while the stop spins the frame in, a green beat on landing, then grey again. Debug scaffolding with the same status asCardSymbol, not production art.The card does not know its reel.
PhaseCardSymbol.watch(reelSet.reels)paints every card on those reels from each reel’s ownphase:enter,landedandsymbol:createdevents (a card swapped in mid-phase is painted on arrival, other symbol classes are left alone) and returns the release. Custom phases are painted by their key,otherwhenPHASE_CARD_COLORShas no entry;colorsoverrides or extends the map per registration,landedMssets the landed beat, andsetPhase()is public for anything else that knows the phase. -
#250
34d3044Thanks @igaming-bulochka! - Add: a phase-authoring contract, so a phase written onReelPhasecan land a reel without a cast.pixi-reelsinvites custom phases (PhaseFactory.register,ReelPhaseexported as the base class) and then hid every method those phases need behind@internal; the library’s own recipe called them under@ts-nocheck.ReelPhase.land(cells?)(protected) brings the reel to rest on the frame it shows and announces the landing: drive halted, strip snapped to the grid,onReelSpinEnd()thenonReelLanded()on the symbols, unmask symbols lifted,spin:reelLandingraised. It is whatStopPhasedoes between its spin-out and its bounce, and the only way a phase lands a reel.ReelPhase.bounce(options?)(protected) plays the landing overshoot and returns aReelBounce(donepromise,cancel()), carrying lifted unmask views along the way asStopPhasealways had to by hand. Defaults come from the phase’s profile;BounceOptions(distance,duration,ease) override them per call.StopPhasenow lands and bounces through both helpers.Reel.placeStrip(),Reel.beginMotion(),Reel.notifySpinStart()andReel.forceSpeed()are public, documented as the phase contract and present in the published typings.haltDrive,snapToGrid,notifySpinEnd,notifyLandedandoffsetLiftedViewsstay internal:land()andbounce()cover them.ReelPhase<TConfig, TProfile extends SpeedProfile = SpeedProfile>: a phase that carries its own timing on the speed profile (interface InstantProfile extends SpeedProfile { slideMs: number }) declares it, andthis._speedis typed to see the extra fields.PhaseFactory.register/registerFactoryinfer the profile from the class, so the registration needs no cast either. The manager already handed every phase the registered profile instance by reference; this makes that first-class.
A consumer that writes no custom phase sees no change.
Patch Changes#
- #251
bc2b5cbThanks @igaming-bulochka! - Fix:ReelSet.destroy()no longer throws when a pooledCardSymbolis disposed after its reel. A released symbol’s view stays a child of the reel container, soReel.destroy()had already destroyed it by the time the symbol pool disposed the symbol, andCardSymbol.stopAnimation()reset the scale of a label that no longer had one.ReelSymbol.destroy()now skipsstopAnimation()andonDeactivate()for a symbol whose view is already destroyed;onDestroy()still runs.
2.8.0#
Minor Changes#
-
#241
0781855Thanks @igaming-bulochka! - Add:BoardGrid.lift(cell)/HoldAndWinBoard.lift(cell)- draw one cell’s lifted art in front of every other cell’s until the returned release is called, for a symbol whose one-shot must not be overlapped by a neighbour’s art.Promotion is a second render layer rather than a bigger
zIndex, so it is a separate channel from the at-rest order:refreshCellZIndex()keeps ordering the lifted cell underneath the lift - and the board keeps calling it on everyplaceand every landing - while the release restores the cell’s place exactly, recomputing nothing. WritingzIndexby hand cannot express this: any landing mid-animation drops the cell back into the pack.Lifts are reference-counted per cell, releases are idempotent, and
liftedCellsreports what is up.reset()anddestroy()drop every outstanding lift;respin()deliberately does not, so a presentation may span one. The lift covers the art the engine hoists out of a cell for a symbol registeredunmask: true; a masked cell is clipped to itself and cannot overlap a neighbour, so lifting one is a documented no-op.Add:
BoardGrid.dim({ except, amount, fade })/HoldAndWinBoard.dim(...)- push every cell but the named ones into the background for a beat, the partner oflift(). One rectangle per dimmed cell, drawn above the cells’ lifted art and below anything lifted, so a lifted cell stays out in front of the dim. It fades in and back out overfadems (DEFAULT_DIM_FADE_MS, 180) off the board’s own ticker, and an interrupted fade resumes from what is on screen over the proportional share of the duration, so nothing jumps;fade: 0cuts. Only one dim at a time is meaningful, so a second call throws instead of silently replacing the first.Add:
BoardGrid.dimSymbols({ except, amount, fade })/HoldAndWinBoard.dimSymbols(...)- the same shape asdim()on a different channel: it multiplies each symbol view’stinttowards black and leaves the cell alone, so the board’s chrome, backgrounds and gaps stay as bright as they were and only the art sinks. The tint carries down the whole view, a Spine skeleton included, without touching what a symbol class tints internally.amountis the strength (DEFAULT_DIM_AMOUNT, 0.5, which is the classictint: 0x808080);0leaves the art alone and1takes it to black. A dimmed cell that swaps its symbol keeps its dim: the arrival is tinted and the departure is cleaned. The two dims run on separate fade slots, so a game can sink the cells and the art at once.Fix:
ReelSymbol.activate()anddeactivate()now resetview.tintalongside alpha, scale, rotation and filters, and so does the same-id in-place swap inReel._replaceSymbol(which never touches the pool). Tint was the one mutable visual the recycle did not clear, so a symbol released while tinted - by the new board dim, or by a game tinting art of its own - carried that colour into whatever cell the pool handed it to next.Add:
ReelSet.promote(positions)- raise symbols above the mask and above every other symbol, with no dim, noplayWin()and nothing to await, which previously meant askingspotlight.show()for a whole presentation and switching every part of it off. Views attach to the newReelViewport.promotedLayerinstead of being reparented, so nothing moves and nothing has to be put back. A swap under a promoted symbol ends that symbol’s promotion, so the layer never holds a view the pool has taken back.Add:
HoldAndWinBoard.refreshCellZIndex()- already public onBoardGridbut unreachable from the board a game holds, which left a resolver depending on state the library does not watch with no way to say “ask me again”.A consumer that calls none of these sees no behaviour change.
2.7.0#
Minor Changes#
-
#239
64b3a0bThanks @igaming-bulochka! - Add: the landing frame as a signal, and a say for the symbol in what it plays there.spin:reelLanding(reelIndex, symbols) fires the frame a reel is on its result: strip snapped, every visible symbol told it landed, the bounce not yet started.spin:reelLandedstill fires when the reel is fully at rest, a wholebounceDurationlater on an animated stop and in the same tick on a slam. The new event fires on every landing path (animated stop, slam, each cascade refill stage), always after theonReelLanded()loop, so a listener that takes a landed symbol’s track over finds the engine’s own landing already set. EachReelraises the per-reellandingevent it is bridged from.ReelSymbol.onReelLanded(ctx)now receives aReelLandingContext-reelIndex,reelCount,cell,visibleCells,symbolId- so an override can play a different beat, or none, on a particular reel. Overrides written asonReelLanded()keep compiling.SpineReelSymbol’sautoPlayLandingaccepts a function of that context returningtrue(the landing one-shot),false(nothing) or an animation name to play as the landing beat on this reel and cell instead.StaticSpinSymbolforwards the context to its inner symbol, so the rule works behind a baked spin blur too.ReelSymbol.landingexposes the landing beat a symbol started on land (reported by subclasses through the newtrackLanding(promise)), kept until the reel moves again or the symbol is pooled, so a presenter can sequence after it instead of stomping it.HoldAndWinBuilder.lockAnimationuses it:'win'now waits for the coin’s own landing beat before the celebration (before, a Spine coin’s landing never showed under the default lock),'landing'no longer replays one the symbol already started, and the mode may be a function of the coin -(coin) => coin.id === 'collector' ? 'win' : 'landing'(HwLockAnimationRule).
-
#239
64b3a0bThanks @igaming-bulochka! - Add:inset(strategy, pixels)takes a per-side trim as well as one number:inset(new RoundedRectMaskStrategy({ radius: 43 }), { top: 4, bottom: 12 }). The sides are screen sides in every orientation, an omitted side is untouched, and a negative one grows. Uneven cross-axis sides are split into their symmetric half (throughbleed, as before) and a shift of the whole mask, so every built-in strategy honours them and curve bleed still composes. Replaces the hand-writtenPathMaskStrategya game needed for a frame whose top and bottom lips differ. -
#239
64b3a0bThanks @igaming-bulochka! - Add: consumer-owned symbol draw order.ReelSetBuilder.symbolZIndex(resolver)replaces the engine’ssymbolData.zIndex * 100 + cellStackingIndexwith the resolver’s answer, asked with aSymbolZIndexContext(symbolId,symbolData,reelIndex,reelCount,arrayIndex,visibleCell,visibleCells,atRest,defaultZIndex) and re-asked whenever a symbol’s id, cell, reel shape or rest state changes - including after the at-rest unmask lift and the re-mask on departure, which the old refresh did not cover. Omitted, nothing changes.HoldAndWinBuilder.symbolZIndexand theBoardGridoption pass it to every cell. For the order BETWEEN cells of a board, whose lifted art shares one layer,HoldAndWinBuilder.cellZIndex(resolver)/ theBoardGridcellZIndexoption make that layer sortable and ask for each cell’s value on every place and landing (BoardCellZIndexContext:symbolId,cell,cols,rows,atRest,attachOrder);liftedLayerexposes the layer itself.Z_INDEX_BUDGETpublishes the reserved values (symbolLayer: 100,pinOverlay: 10000) so a resolver can be bounds-checked against the library.
Patch Changes#
- #239
64b3a0bThanks @igaming-bulochka! - Fix:pixi-reels/spineandpixi-reels/testingresolve their types undermoduleResolution: node(node10) too. That resolver ignoresexports, so those subpaths had no declarations at all and a game on it had to declare the module by hand;typesVersionsnow maps them. Thetypescondition is also listed first in everyexportsentry, as TypeScript asks.
2.6.0#
Minor Changes#
- #221
c96cfddThanks @igaming-bulochka! - Add:RoundedRectMaskStrategygainsscope: 'outer'(one rect per reel, only the corners that sit on the set’s bounding box rounded, safe at a zero cross gap) and acornersoption ({ topLeft, topRight, bottomLeft, bottomRight }, screen-space) that limits which corners round in any scope.HoldAndWinBuilder.cellMaskand theBoardGridmaskoption now hand the factory(cell, { cols, rows, corners }), wherecornersare the board corners that cell sits on, so(_, { corners }) => new RoundedRectMaskStrategy({ radius, corners })clips a gapless board as one rounded window with a separate rect mask per cell. Zero-argument factories keep working.
Patch Changes#
- #220
767453aThanks @igaming-bulochka! - Perf:SpineReelSymboltakes its cached, hidden Spine instances off the ticker. It keeps one instance per symbol id for instant swaps, and each of those was created with spine-pixi’s defaultautoUpdate, so every parked skeleton kept updating its animation state and world transform every frame while invisible - hundreds of them on a Hold & Win board of 1x1 reels. Now only the instance on screen updates; a parked one is resumed the moment it is shown again.
2.5.0#
Minor Changes#
-
#218
e341a10Thanks @igaming-bulochka! - Add: named speed profiles for the whole Hold & Win board.HoldAndWinBuilder.speeds({ normal, turbo, superTurbo })registers each profile into every cell’s SpeedManager (speedProfile(p)is nowspeeds({ normal: p })),initialSpeed(name)picks the one active at build,board.setSpeed(name)switches every cell at once and firesspeed:changed,board.addSpeed(name, profile)registers another after build, andboard.speed/board.speedNamesread them back. As on a single reel set, a cell already in flight finishes on the profile it started with; the next wave (orskip()) shows the new one. Thestaggercallback now receives the active speed name as its third argument. -
#218
e341a10Thanks @igaming-bulochka! - Add: dormant Hold & Win cells for boards that grow mid-feature.HoldAndWinBuilder.inactive(cells, id?)builds the cells but keeps them out of the feature - they never spin, take no coin, showid(default: the empty id) and do not count towardcapacity/isFull- untilHoldAndWinBoard.activate(cells)wakes them, which fires the newcells:activatedevent.reset()puts them back to dormant.HoldAndWinStatetakes the inactive set as a third constructor argument and gainsactivate,inactiveCellsandisActive; the board mirrors them asactivate,inactiveCells. -
#218
e341a10Thanks @igaming-bulochka! - Add:HoldAndWinBuilder.lockAnimation('win' | 'landing' | 'none')picks what a coin’s symbol plays the moment it locks. The default stays'win'(the board’s existingplayWin()oncoin:locked);'landing'plays the newReelSymbol.playLanding()land beat only and'none'plays nothing, so a board can land every cell quietly and celebrate once.HoldAndWinBoard.playWin(cells?)is the explicit celebration: it plays the win on every locked coin (or justcells) and resolves when they finish.ReelSymbol.playLanding()is a new base-class one-shot that resolves at once by default;SpineReelSymbolalready implements it with the skeleton’slandingtrack. -
#218
e341a10Thanks @igaming-bulochka! - Add: rectangular Hold & Win cells and per-axis gaps.HoldAndWinBuilder.cellSizetakes{ width, height }as well as a number, and its options acceptcolumnGap/rowGapbeside the uniformgap.BoardGridgains the same (cellSize: number | { width, height },columnGap,rowGap) and exposescellWidth,cellHeight,columnGap,rowGap;cellSizeandgapremain as deprecated aliases.cellChrome/chromecallbacks now receive(graphics, width, height)- a square-board callback that reads one size argument keeps working.HoldAndWinBuilder.cellMask(factory)/BoardGridoptionmasksupply each cell’s mask strategy, for example aRoundedRectMaskStrategyso rounded cell corners match a rounded frame.
Patch Changes#
- #218
e341a10Thanks @igaming-bulochka! - Fix:BoardGrid(and soHoldAndWinBoard) now draws every cell’s chrome beneath every reel and renders the unmasked, at-rest symbols of all cells on oneRenderLayerabove the whole board. A coin withunmask: truewhose art overflows its cell used to be covered by the next cell’s chrome and blank symbol, cutting it along the neighbour’s edge.
2.4.0#
Minor Changes#
-
#215
002ded2Thanks @igaming-bulochka! - Fix and sharpen shaped anticipation, following review of the feature above.Fix: a
curveno longer scrolls a cascade reel. A tumble reel has already dropped its visible symbols and must tease at rest; the guard that pinned this only covered the legacy tease, so acurvedragged buffer symbols back through the empty window.curve/cellsare now dropped in cascade mode with a notice naming the reel.Fix: a travel anchor no longer deletes the legs before the last one.
cellsmeasured from the start of the tease, so a fast opening segment could reach the target before the segments after it ever played — silently. The anchor now applies to the final leg, which is what the docs always described.Fix: curve segments are validated at the call. A negative
speed, a non-positiveduration, a negativeholdor aNaNwere all accepted and played. The function form is now resolved (and validated) for every teasing reel whensetAnticipationis called, so a bad curve throws next to the caller’s own stack instead of being swallowed by the reel task.Fix: drive bounds are profile-relative.
motionModel('drive', { accelFrames: 20 })means “reach the ACTIVE profile’s full spin speed in 20 frames” and re-resolves per spin. The absoluteaccelform only suited a single-profile game: withspinSpeed30 / 50 / 80 across the presets, one fixed bound made SuperTurbo take 53 frames to reach speed where Normal took 20. Mixing the two forms throws. A drive that cannot meet a segment’s time budget now says so.Fix:
composeMasksno longer accumulates scene nodes. A member that owns its own Graphics — including any strategy wrapped ininset(...)— added a fresh child on every redraw, so each viewport resize and MultiWays reshape leaked a node.Fix: the new mask warnings go through the notice channel, so they carry a code and obey
setLogLevel('silent')like every other notice.Add:
anticipation:segmentfires once per curve leg ({ reelIndex, index, total, speed, targetSpeed }), so tease audio can hit the surge and the crawl separately instead of polling the speed to find the boundary.cellstakes the same function-of-tease-order form ascurve.stepDrivewrites into the state it is given rather than allocating one per reel per frame, and a parked drive is no longer stepped at all. -
#215
002ded2Thanks @igaming-bulochka! - Add:reelSet.setReelGroups([[0, 1], [2, 3], [4]])— reels stop and skip as blocks instead of individually.Reel index was the engine’s only ordering, which breaks as soon as a reel’s job is not tied to its neighbours. A filler reel meant to outlast a tease on the reels before it landed in the middle of that tease instead, because its flat
reelIndex * stopDelayoffset came due while they were still teasing, and a skip press landed “everything outside the tease” — including that filler reel — in one go.A group is a barrier in both directions. Stopping: no reel in a group starts its stop sequence (anticipation included) until every reel in the earlier groups has landed, and a reel waiting its turn keeps spinning at full speed, so the wait reads as “still going” rather than as a pause. Skipping: a press releases the next un-landed group, with tease protection still applying inside it —
protect: 'stepwise'brings a group of teasing reels down one press at a time, in tease order.Stop delays become group-relative, so the profile’s
stopDelaystaggers reels within a group rather than re-adding a whole-board offset on top of the barrier. An explicitsetStopDelays()is still taken as given. Every reel must be listed exactly once;nullclears. Sticky across spins, likesetStopDelays().Sets that never call it are unaffected.
-
#215
002ded2Thanks @igaming-bulochka! - Add: mask primitives beyond the rectangle, and anticipation you can shape.Masks.
RoundedRectMaskStrategyrounds the whole grid (scope: 'set') or each reel as its own card (scope: 'reel').SilhouetteMaskStrategyrounds the outline of a jagged pyramid / MultiWays set — every step of the staircase, concave corners included, with their own radius — instead of forcing you to pick between notched seams and a bounding box that hides the shape.PathMaskStrategytakes a(graphics, context) => voidso a one-off custom mask no longer needs a class.inset(strategy, px)shrinks any strategy’s output;composeMasks(...)unions several into one mask.Fix:
RectMaskStrategyignoredctx.bleed, so a warped set combiningcurveBleed(...)with an explicit.maskStrategy(new RectMaskStrategy())clipped the very overhang the bleed asked for.Anticipation.
setAnticipation(reels, { curve })replaces the fixed decelerate-then-hold with explicit speed legs, so a tease can surge above spin speed before it crawls, and its transitions ramp instead of stepping (segment eases default topower2.inOut). Pass a function of tease order to vary the curve per reel.{ cells: n }ends a tease after N symbols of travel instead of after a fixed time.reel.speedNormalizedexposes live speed as a fraction of spin speed, for tease audio that tracks the slow-down rather than just its start and end.motionModel('drive', { accel, decel, jerk })opts a set into acceleration-bounded motion: phases set a target speed and the reel integrates toward it, so every transition is shaped by the bounds instead of by a per-transition ease, and a mid-move retarget stays continuous. Opt-in; the default'tween'model is unchanged.Existing spins are byte-for-byte unaffected: the new eases and the drive apply only where you ask for them.
-
#215
002ded2Thanks @igaming-bulochka! - Add:ReelSymbol.playIn()/playOut(), andreelSet.swapSymbols(...)— the mystery-reveal and upgrade beat as one call.setSymbolAtalready swapped an identity, but instantly. A game that wants “the cells dissolve, the symbol underneath changes, the reveal arrives” had to hand-roll the ordering, the stagger, the zIndex bump so an overshooting entrance is not clipped, the re-hide after the swap (re-activation resets the view to fully visible, so the new art popped for a frame before its entrance began), and the abort handling — every time.playIn/playOutare the symbol-level hooks, with the same contract asplayDestroy:delay,signal, resolve when done, abort means “snap to the end” rather than “fail”. Defaults are a short scale-and-fade; override them for a Spinein/outtrack. They are separate fromplayDestroy, which stays tuned as the cascade’s “this cell was a winner and is being consumed” poof.swapSymbols(cells, opts)orchestrates the three beats — out, swap, in — with per-celloutDelay/inDelaystaggers, aholdMsand anonSwappedhook for the beat while the board is dark, andskipOut/skipInfor art that drives one side itself. Cells are validated up front, and an abort still performs the swap, so the board never disagrees with the result the server sent.Single-cell symbols only: a big symbol spans cells the frame layer has to reserve, so revealing one remains a
setResult/setShapejob.
Patch Changes#
- #215
002ded2Thanks @igaming-bulochka! -setReelGroups()now documents and enforces its window. A layout may be set any time up tosetResult()— including betweenspin()andsetResult(), so a round can be grouped from its own server response; the barrier is read as each reel’s SpinPhase resolves, which is exactly when the result lands. Changing the layout once reels have begun landing throws instead of half-applying: a reel that already passed the barrier cannot un-pass it, so the new layout would apply to some reels and not others, silently.
2.3.0#
Minor Changes#
-
#213
600ad7dThanks @igaming-bulochka! - Add: one console channel for everything the library says. Every warning and error now carries a stable CODE you can grep for, looks the same in the console, and obeys one volume knob via the newsetLogLevel(level)/getLogLevel()('silent' | 'error' | 'warn' | 'info', default'info'). Before this, ten call sites hand-rolled their own[pixi-reels] ...string - one had no prefix at all - and there was no way to quieten them in a production build.In a browser each notice prints as a styled badge (
pixi-reelspill, then the code, then the message); everywhere else it degrades to[pixi-reels] warn(code) message, because%cis a browser console feature and Node prints the directives literally. Notices keep going throughconsole.warn/console.error/console.inforather than a singleconsole.log, so devtools filtering, stack capture and the browser’s own warn/error styling all keep working, and detail arguments are passed through untouched so anErrorkeeps its stack.Fix:
slamStop()called beforesetResult()now says so. There is nothing to land on in that window, so the reels stop wherever the strip happens to be - random buffer fill in standard mode, and the alpha-0 residue of the fall-out in cascade mode, i.e. an invisible board. Nothing reported it; the reels just sat there showing the wrong thing. It stays a warning rather than a throw becauseslamStop()is the unconditional exit the engine’s own abort, timeout and error-recovery paths depend on, and those legitimately fire before a result -skipSpin()is the guarded entry point and still throws in this window.The default level is
'info'rather than'warn'on purpose: the mask-strategy auto-pick notices this replaced were unconditional, and anything lower would have silently deleted advice the engine used to give. -
#213
600ad7dThanks @igaming-bulochka! - Add: skip granularity. Skip used to be all-or-nothing.skipSpin()/requestSkip()/slamStop()force-completed every reel’s phase,AnticipationPhaseincluded, so a press on a teasing spin ended the tease before the player ever saw it. Three levers now open that up, plus the phase classes needed to build your own.setAnticipation(reels, { protect })guarantees the tease becomes visible before a press can end it.'once'(ortrue) makes the first press of the round land every NON-tease reel immediately and leave the tease reels running, so the trigger symbols are on screen and the build-up is under way; the next press ends it.'stepwise'then releases the NEXT tease reel on each press after that, in tease order (tease order, not reel index), so the player walks the tension forward one reel at a time and the press that releases the last one is the round-ending press.'always'never lets a press end a tease. For any other grouping, pair'always'with your ownslamStop({ reels })per press: protection keeps a press from ending a tease, and game code decides which group each press lands.skipStagereports1in that in-between state, which is what a UI should keep the button live on - gate onisSpinningfirst, since the stage is round-scoped and only resets on the nextspin(), so an'always'round (which never reaches2) ends parked at1. Protection applies toskipSpin()andrequestSkip()(including a press queued beforesetResult()arrived - callsetAnticipationBEFOREsetResultso the queued press can see the tease). BareslamStop()stays an unconditional land-now. Protection is inert when the effective hold is0ms, as in Turbo / SuperTurbo without adurationoverride: there is no tease to protect, so every press lands everything.slamStop({ reels })/slamStop({ except })is a per-reel slam. Those reels land now and every other reel keeps running its phase chain to a natural landing. It is the raw lever underprotect, exposed so a game can express its own rule. A partial slam does not touchskipStageand does not end the round; a partial slam with nothing left to land is a no-op rather than a skip.setMinimumSpinTime(ms | ms[])overrides theSpinPhasefloor per reel.minimumSpinTimelives on the speed profile, so it is one value shared by every reel, andsetStopDelays()- the only other per-reel lever - cannot go under it. The two missed each other: instant was only ever global, and per-reel could not go below the floor. Persists acrossspin()/refill()until cleared withnull, matchingsetStopDelays().There is a fairness reason to prefer
protectover raising the floor on teasing spins. If a scatterless skip lands instantly but a teasing skip settles at the floor, the response time itself tells the player a feature is coming before the reels have landed.protectkeeps the non-tease reels landing at the same instant either way and puts the tell on screen where it belongs.skip:requestedandskip:completednow carry{ reels, partial }so a listener can tell a partial slam from a round-ending one. Listeners written against the old zero-argument signature keep working.The built-in phase classes are exported:
StartPhase,SpinPhase,StopPhase,AnticipationPhase,AdjustPhase,CascadeFallPhase,CascadePlacePhase,CascadeDropInPhase.SpinPhaseConfig.minimumSpinTimedocumented an override that could not be reached, because registering a phase throughPhaseFactorymeant reimplementing it rather than subclassing it. Nowclass MyStop extends StopPhaseand register it.resolveTumbleConfigis exported alongside them: the three cascade phases take build-time config as extra constructor arguments, and a subclass registered throughregisterFactoryhas to forward the same resolved shape.tumble()would have passed. These are engine internals: the protected surface (onEnter/onSkip/updateand each phase’s private staging) can shift in a minor release, so a subclass may need to follow. The config TYPES remain the stable part.Fix: a
.phases(...)override of a cascade or MultiWays phase was silently discarded.phases()applied its configurator at call time, while.tumble()and.multiways()register their defaults later, insidebuild(), so any'cascade:fall'/'cascade:place'/'cascade:dropIn'/'adjust'registration was overwritten with no error - and the builder’s own doc comment advised calling.phases(...)after.tumble(...), which could not help, because chain position was never what decided the winner. Configurators are now deferred to the end ofbuild()’s phase wiring, so an override wins from anywhere in the chain and the last override of a key wins.spin:allStartedis now announced from a single place rather than only by a reel entering SPIN, so a partial slam that lands every reel still waiting to start no longer swallows it. It still fires at most once per round.Internally, a partial slam cannot use the spin generation as its abort switch - that is global, and bumping it would strand the surviving reels mid-chain - so slammed indices are tracked per reel and each chain checks them at its own await boundaries. A full slam behaves exactly as before.
Patch Changes#
-
#213
600ad7dThanks @igaming-bulochka! - Fix: a reel that teased during a tumble spin never came to rest.AnticipationPhasetweensreel.speedUP, and the tumble stop path never brings it back down -cascade:placeswaps symbol identities andcascade:dropIntweens views, and neither touchesreel.speedthe wayStopPhase._landAndBouncedoes. So in any cascade game callingsetAnticipation(), every teasing reel was left running at the tease speed after the round ended, drifting further off-grid every frame for the rest of the session while the untouched reels stayed put.Two changes. A tumble tease is now a pure hold: the reel has already dropped its visible symbols and is sitting at zero, so scrolling it would drag buffer symbols back through the empty window, and the multiplier is pinned to
0there whatever theslowdowncurve says. And a tumble reel is brought to rest and snapped to the grid before the place phase, which holds the invariant even when a custom'anticipation'phase is registered and does move the reel.Strip spins are unaffected:
StopPhasewas always resting the reel there, which is why this only ever showed up in cascades.
2.2.1#
Patch Changes#
- #209
8b6517cThanks @caesar-v! - Reset the symbol’s animation pose on a same-id refill. Reusing the instance withoutdeactivate()/activate()left it parked on the final frame of its last one-shot win, so a refilled cell could hold a symbol and draw nothing.
2.2.0#
Minor Changes#
-
#203
6012925Thanks @igaming-bulochka! - Add: reel curvature.builder.curve(0.45)projects the set onto a drum,builder.curvePerReel([...])gives each reel its own camera, andreelSet.setCurve(...)re-projects at runtime for tuning.amountis how far round the drum the window sees;depthis how strong the perspective is, capped below the angle at which cells would fold back over each other. A set with nocurve()builds no curve object and is unchanged.The cell facing the camera is drawn at 1:1 - authored size, both axes, no keystone - and everything else bends around it. That has a consequence worth planning for: a drum whose middle is 1:1 cannot also reach the window edges. Its ends fall short, the buffer cells fill that band compressed as they curve away, and you frame or mask it the way a real cabinet’s bezel does. Normalizing to the window edges instead would magnify the main axis at the centre while leaving the cross axis alone, i.e. a visibly stretched middle row.
Two ways to draw it.
curveMode('symbol')(default) projects each cell on its own - crisp, free, and a real keystone, but only for content that IS a texture, because aContainertransform is affine. The engine hands each symbol a projected quad through the newReelSymbol.applyCellQuad();SpriteSymbolandAnimatedSpriteSymboldraw it through a PixiJSPerspectiveMeshat no extra render pass. Everything else (Spine,Graphics, composite subtrees) takes the closest affine fit: a UNIFORM scale sized to fit inside the projected footprint, so art is never distorted along one axis and no cell overlaps its neighbour.PerspectiveCellandcanProjectTexture()are exported for custom texture-backed symbols.curveMode('warp')+renderer(app.renderer)bends the whole reel instead - each reel is rendered to a texture and drawn through a mesh whose VERTICES are displaced by the projection. Spine, atlas art, text and composites all bend, no symbol cooperates, and because the bend is on the rendered reel rather than in each cell, the spin, the stop bounce and cascade falls travel ALONG the curve instead of translating flat. Costs one render pass per reel per frame plus one resample;build()throws without a renderer.KNOWN LIMITATION (
'symbol'mode only): an ATLAS sub-frame does not take the mesh path. The mesh addresses its source with plain 0..1 UVs and remapping them onto the frame has not produced a correct draw, socanProjectTexture()refuses those and the symbol takes the affine fit - correct, but not keystoned. That is most production art.'warp'has no such limit, since a render texture owns its whole source.builder.curveFocus('reel' | 'set-lean' | 'set')picks where the camera stands across the strip: one per reel (default, five separate drums), one on the middle of the board (receding cells lean IN and the grid reads as one wide cylinder), or halfway. Anything but'reel'auto-selectsSharedRectMaskStrategy, since the lean crosses each reel’s own column.builder.curveBleed(px)gives the warp texture room across the strip for art wider than its cell - an overflowing mystery or scatter plate - so the overhang is captured, warped with everything else, and hangs over its neighbours instead of being sliced at the texture edge.MaskContextgains a matchingbleedsoSharedRectMaskStrategystops clipping it back to the board, which mattered most at the outermost reels where the overhang leaves the board entirely. Defaults to0; the field is read defensively so aMaskContextbuilt before it existed still yields a valid mask.ReelSet.getCellQuad(reel, cell)returns the four corners a curved cell is actually drawn on, ornullwhen flat -getCellBounds()has to return a rectangle and widens to the trapezoid’s bounding box. Outline with the quad, hit-test with the box. The debug overlay’scellsandbufferslayers use it, so the overlay shows the projection rather than a box around it.Art that does not fill its cell reports its real footprint through the new
ReelSymbol.cellInset, derived automatically from an atlas frame’s trim, so a small symbol is projected where it actually sits instead of being inflated to the cell’s edges.The projection never touches a view’s
position, so landing, wrapping, cascades, big symbols and MultiWays reshapes are unaffected.Also fixes
ReelSymbol.playDestroy()compensating its pivot move by the raw offset instead of the offset times scale, which made a scaled symbol jump on the first frame of the destroy animation.
2.1.0#
Minor Changes#
-
#204
9f10dd5Thanks @igaming-bulochka! - Add: symbol pools.builder.randomSymbols(pool, scope)and the runtimereelSet.randomSymbolsdecide what the engine may draw for cells the game does not name. A pool is{ weights?, exclude? }; its scope is{ reel?, slots?: 'spinning' | 'buffer' }.Until now
weights()was one table for the whole set, and the only levers past it.setExcludeSpinning/setExcludeBufferonRandomSymbolProviderwere unreachable from a built set (the provider is deliberately not exported, andReelkeeps its reference private), so “keep this symbol out of the buffer cells” meant writing aFrameMiddleware. Two things games actually ask for now have a call:// A coin may blur past mid-spin, but must never park half-visible // above or below the grid. reelSet.randomSymbols.set({ exclude: ["COIN"] }, { slots: "buffer" }); // Reel 2 runs hot on wilds for the feature, then back to the base table. reelSet.randomSymbols.set({ weights: { WILD: 40 } }, { reel: 2 }); reelSet.randomSymbols.set(null, { reel: 2 });The two ends of the strip can be governed separately:
slotstakes'bufferStart'and'bufferEnd'as well as'buffer'(both) and'spinning'(everything).bufferStartis the side at the smaller main coordinate - above on a vertical set, left on a horizontal one - the same endColumnTarget.bufferStartaddresses, whichever way the reel travels.// Nothing peeks in from above; the cell below the grid is left alone. reelSet.randomSymbols.set({ exclude: ["COIN"] }, { slots: "bufferStart" }); // ...on one reel only. reelSet.randomSymbols.set( { exclude: ["COIN"] }, { reel: 2, slots: "bufferEnd" } );Layers resolve base weights -> global spinning -> per-reel spinning -> global buffer -> per-reel buffer -> global side -> per-reel side. Weights override per symbol id, exclusions accumulate, and a narrower layer can never re-admit what a wider one banned. A weight of
0bans a symbol as surely asexcludedoes, in a pool and inweights()alike. Pools govern the RANDOM draw only. an explicitsetResult/initialFrametarget is the game speaking and always wins.weights(scope?)reports the effective table, so a game can assert its own configuration in a test; ask for'buffer'to see what both sides inherit, or for a side to see exactly what it draws from.Two failure modes now fail loud instead of quietly doing nothing: naming an unregistered symbol id in a pool throws (with the registered ids listed), and a pool that leaves some reachable scope with nothing to draw throws at the call that caused it, naming the scope, rather than mid-spin on whichever reel wraps first. The rejected pool is not installed.
setExcludeSpinning/setExcludeBufferkeep working as sugar over the global spinning / buffer pools.Reel.placeStripnow random-fills each empty slot from the pool that slot belongs to. it used to apply the buffer rules to visible cells too, which mattered the moment “buffer” stopped meaning “everything a skip places”.
Patch Changes#
-
#204
9f10dd5Thanks @igaming-bulochka! - Fix: anunmask: truesymbol in a BUFFER cell no longer renders above the mask, where it hung outside the grid in plain sight.unmasklifts a view out of the reel’s masked container. That is an at-rest presentation for a cell the player is looking at, andnotifyLanded()has always lifted visible cells only. But the lift decision itself was made from the symbol id alone, so any at-rest write to a buffer slot lifted it as well. and a buffer slot is parked outside the window precisely because the mask should hide it.The path that showed it in a real game was a skip.
StopPhase.onSkiplands the full strip (buffers included) throughplaceStrip, so a skip taken once the bounce has started. i.e. afternotifyLanded()put the reel back at rest. lifted every unmask symbol the target frame had inbufferStart/bufferEnd, and they stayed up there until the next spin pulled them back down.Reel.reshapegrowing a strip at rest did the same to its new tail cells.The lift now takes the slot into account, so a buffer cell never lifts. A second case needed the reverse: a symbol lifted while it was VISIBLE can still travel into a buffer slot without being replaced. a nudge rotates the array and only the wrapped symbol goes through
_replaceSymbol, so an unmask symbol nudged out of the window kept its seat above the mask. Every settle now re-masks any lifted view that ended up outside the window.
2.0.0#
Major Changes#
-
#197
847d9cdThanks @igaming-bulochka! - Fix: three public members no longer re-expose classes the package deliberately hides, andHoldAndWinBoardConfigis now exported.RandomSymbolProvider,StopSequencerandReelMotionwere hidden from the package entry in 1.0.0 (PR #140). Three public members were still typed with them —Reel.motion,Reel.stopSequencer,FrameBuilder.randomProvider— which put those classes back intodist/core/Reel.d.tsand would have semver-locked them into all of 2.x. All three are now@internal, sostripInternalkeeps them out of the published types. Nothing is lost: reel geometry is onReelSet.getCellBounds()/getBlockBounds()andReel.cellMain/.extent/.mainOffset, landing is driven bysetResult()/slamStop(), and symbol weights are configured viabuilder.weights({...}).HoldAndWinBoardConfigis now exported. The board’s own export block promises that a fork can “copy HoldAndWinBoard + HoldAndWinState, repoint their imports atpixi-reels, and everything they reach for is public” — but the config the constructor takes was not, so the first line of a forked board could not be typed.A new
check:api-surfaceguard fails the build on any public member typed with asrc/type no entry point exports, so this cannot silently regress. Constructor parameters are reported separately and waived by name: tagging a constructor@internalstrips the whole signature and leaves consumers an implicit zero-argnew Reel()that typechecks and then throws, which is worse than the leak.Fix:
destroySymbols()now names the reel and cell when a visible cell has no symbol. The coordinate range check already passed at that point, so a miss means the strip is short or holed — a reel torn down or reshaped while a cascade was in flight. It previously surfaced asCannot read properties of undefined (reading 'view')from inside anArray.map, naming neither the cell nor the reel. -
#197
847d9cdThanks @igaming-bulochka! - Fix: cascade grids are validated, the debug snapshot follows the travel axis, andGsapis exportable.refill()andrunCascade()’snextGridnow validate their grid, the same waysetResult()always has: shape, v1 option keys, and buffer counts that fit the reels. They previously validated nothing, so a cascade grid still carrying a v1bufferAbovereachedcolumnTargetToStrip, came backundefined, and was silently random-filled on every stage of the chain — the exact silent divergence the fail-loud guards exist to prevent. Astring[][]grid threw a bareTypeErrordeep in the pipeline instead of naming the call. Errors name their own entry point, so a badnextGridsaysrunCascade(): nextGridrather than surfacing as arefill()failure two frames later.The buffer-overflow message now reads
setResult()rather thansetResult, matching every other message from that call.DebugReelSnapshot.allSymbols[].yis now.main, the coordinate along that reel’s travel axis, and each reel reports itsorientationanddirection. The old field was hard-coded toview.y, so on a horizontal set every symbol reported a constant0— no positional information at all, in the one orientation 2.0 exists to add. This is the surface agents are pointed at precisely because the canvas is opaque to them.Gsapis exported. It is the second parameter ofdriveGsapWithTicker, the type ofReelConfig.gsap, and the return type of theReel.gsapaccessor, but it could not be named by a consumer.The v1 rename tables are no longer exported.
CODEMOD_HINT,V1_BUILDER_METHODS,V1_OPTION_KEYSandV1_OPTION_VALUESwere public, which would have semver-locked 1.x migration scaffolding into all of 2.x. The guards still read them internally and every throw still names the replacement; nothing a consumer writes needs the table.Fix: a
nudge()on a jagged layout no longer displaces symbols that render above the mask.ReelMotion.advance()derives positions from the array index and writes them absolutely (it accumulated with+=in 1.x), which dropped the reel offset baked into any view lifted intoviewport.unmaskedContainer. A nudge is the one path that moves the strip while the reel is at rest, so anunmask: truesymbol on a pyramid reel jumped a full cell out of its column for the whole tween and snapped back at the end. -
#197
847d9cdThanks @igaming-bulochka! - Remove: theexamples/directory. The standalone demo apps now live in a separate repo.Nothing in the published package changes —
examples/was never part of the tarball. This matters only if you cloned the repo to run a demo. Runnable demos live on the docs site under/recipes, about 130 of them, each with its source alongside;pnpm site:devserves the whole set.Keeping two parallel demo surfaces in one repo meant every API change had to be made twice, and the example half kept losing: two of the six apps were still passing
string[][]torunCascade’snextGrid, which throws on the first cascade, and nothing caught it becausevite buildonly transpiles.What survived the move, for anyone following a path from an older doc:
examples/shared/symbol classes and asset loaders are nowapps/site/src/runtime/CheatEngineandSeededRngare the private@pixi-reels/cheatspackage (still outside the library, per ADR 009)- the prototype sprite atlas is
apps/site/public/prototype-symbols/ examples/orientation-matrixistests/e2e/fixtures/orientation-matrix, unchanged in what it proves: browser coverage of all four orientation x direction combinations
-
#197
847d9cdThanks @igaming-bulochka! - Remove:ReelAxis.withDirection(), and with it the last trace of a per-spin direction override that never shipped.The method had zero call sites in
src. It existed only to serve ADR 016 section 3.5’sspin({ direction })/spin({ directionPerReel }), which is not implemented and is absent fromSpinOptions. Shipping it would have frozen a method into all of 2.x whose only justification was an unbuilt feature — the same trap as exporting the v1 rename tables.Direction is fixed at
build():.direction(d)and.directionPerReel([...]). Nothing else changes. The engine constructs one axis per reel viareelAxis(orientation, direction)and has never needed a sibling; if you were callingwithDirectionyourself, callreelAxis(axis.orientation, d)instead.Implementing the per-spin override is a feature PR after 2.0, not a freeze rider:
Reel._axisisreadonlyand is handed toReelMotion,ReelViewport, and every phase at construction, so a per-spin flip needs a re-injection path through all of them, plus the mid-spin-throw guard and the section 3.4 “both buffers >= 1” validation that only per-spin overrides force. Re-adding the method then is additive — consumers receive axes, they do not implement the interface. ADR 016 records this as decision 4 under Status, so it does not get re-proposed from the design doc. -
#197
847d9cdThanks @igaming-bulochka! - Change:string[][]is no longer accepted anywhere as a grid input.runCascade’snextGridmust returnColumnTarget[], and thepixi-reels/testinghelperspinAndLandtakesColumnTarget[]too — itsstring[][]convenience form is gone. Wrap withgrid.map((visible) => ({ visible })).One accepted shape means a grid read out of the engine can be handed back to it without a conversion step, and a wrong shape now names itself at the call site instead of failing later inside the frame pipeline.
-
#197
847d9cdThanks @igaming-bulochka! - Fix: anunmask: truesymbol now travels with the reel through the stop bounce instead of hanging still for it.StopPhaselifts landed unmask views intoviewport.unmaskedContainerinnotifyLanded()and only then tweensreel.containerthrough the two-leg overshoot. A lifted view carries the reel offset in its own coordinate rather than inheriting it from a parent, so it did not follow that tween: on the default profile a landed scatter or wild sat motionless for the full 600 ms while the rest of the reel bounced underneath it. The bounce now keeps lifted views pinned to the reel for every frame, and settles them on the exact resting position rather than the last tween sample.Skipping mid-bounce had the same fault from the other side.
onSkip()snapped to grid before resting the container, sosnapToGridbaked the current overshoot position into every lifted view and the container then moved out from under it — leaving the view off by however far the bounce had travelled. The container is rested first now.Fix:
nudge({ startDelay })no longer leaks anabortlistener per call. The listener was registered with{ once: true }, which only self-removes when the event actually fires, so every nudge that completed normally left one behind. The documented staggered pattern — one long-livedAbortControlleracrossPromise.all(reels.map(...))— accumulated them for the life of the controller. It is now removed on both paths.Fix:
StopSequencer.next()throws when the frame is exhausted instead of returning_frame[0], or''after areset(). Both fallbacks handed back a symbol id that resolves to nothing, so an over-consuming caller landed a silently wrong frame rather than failing where the bug was. Every caller already gates onhasRemaining.reset()also restores the feed cursor and step, not just the frame and count. -
#197
847d9cdThanks @igaming-bulochka! - Rename: the row/column vocabulary becomes orientation-neutral. A reel’s strip is made of cells, and the off-window slots either side are start and end (start = the smaller main coordinate: above for vertical, left for horizontal), independent of which way the reel travels.Run the
v1-to-v2codemod over your sources.build()throws a named error if it still sees a v1 key. The codemod is not on npm yet — the migration guide has the from-a-clone invocation.Core geometry:
v1 v2 visibleRows,visibleRowsPerReelvisibleCells,visibleCellsPerReelbufferSymbols({ above, below })bufferSymbols({ start, end })ColumnTarget.bufferAbove/.bufferBelow.bufferStart/.bufferEndReel.bufferAbove/.bufferBelow.bufferStart/.bufferEndreelPixelHeightsreelExtentsReel.spinSymbolHeightReel.spinCellSizeMotion:
v1 v2 ReelMotion.displace(deltaY).advance(travelDelta)ReelMotion.slotHeight.slotPitchReelMotion.getRowY(row).getCellMain(cell)Grid coordinates and payloads:
v1 v2 SymbolPosition.rowIndex.cellIndexcascade:*winnerRows,offsetRowswinnerCells,offsetCellsDropOffset.originalRow.originalCellTumbleConfig.rowStagger/.rowOrder.cellStagger/.cellOrderrowOrder: 'bottomToTop' | 'topToBottom'cellOrder: 'endFirst' | 'startFirst'pin:migrated { fromRow, toRow }{ fromCell, toCell }CellPin.originRow.originCellOffsets:
v1 v2 OffsetXModeCrossOffsetModeTrapezoidConfig.topWidthFactor/.bottomWidthFactor.startFactor/.endFactorSemantics, not just names:
v1 v2 bufferSymbols({ above, below })bufferSymbols({ start, end })reelAnchor: 'top' | 'center' | 'bottom''start' | 'center' | 'end'SymbolData.size { w, h }{ reels, cells }(andgetSymbolFootprint’ssize)NudgeOptions.direction: 'up' | 'down''forward' | 'reverse', relative to the reel’s own axis'symbol:created': [symbolId, row][symbolId, stripIndex]— it was always the strip index, never a visible rownudge()is now genuinely direction-relative: which edge feeds the reel is derived from the axis polarity, so a reel built withdirection('reverse')nudges upward on'forward'. A vertical/forward reel behaves exactly as'down'did.New:
builder.cellStacking(order)/builder.reelStacking(order)expose render order explicitly ('ascending'default = today’s behaviour: the cell/reel at the larger coordinate draws in front). Deliberately geometric —direction('reverse')does NOT flip stacking, so art lit from above keeps overlapping the way it was drawn.SymbolPosition.setId?for games composing more than one reel set. The engine never reads it.build()throws when a cross-reel big symbol (size.reels > 1) meets a mixeddirectionPerReel([...]). The coordinator assumes one shared feed edge across the reels a block covers.ReelMotion’s wrap callback drops its deadarrayIndex/directionarguments.
Fail-loud, no silent aliases:
visibleRows(),visibleRowsPerReel()andreelPixelHeights()are gone but still present as throwing stubs, and every renamed option key or string value throws from the builder method that received it (bufferSymbols({ above }),multiways({ minRows }),symbolData({ size: { w } }),tumble({ fall: { rowStagger } }),offsetConfig({ topWidthFactor }),reelAnchor('top'),initialFrame/setResultcolumns withbufferAbove). Each message names the v2 replacement and the codemod. The table itself stays internal: it is 1.x migration scaffolding, and exporting it would semver-lock it into all of 2.x.Codemod: the
v1-to-v2transform rewrites the API surface (AST-based, so it never touches your ownrow/collocals or your comments). Verified end-to-end against this repo’s 112 site recipes at their pre-rename revision: zero v1 API names left in code. It ships in the repo rather than on npm for now; see the migration guide for how to run it from a clone.Docs: a new “Migrating to 2.0” guide covers every rename with a before/after, including the three things the codemod deliberately leaves alone. ADRs, CHANGELOGs and the 1.0 migration guide keep their v1 vocabulary. they are records of what was true then.
-
#197
847d9cdThanks @igaming-bulochka! - Remove: the standalone HorizontalReel / HorizontalReelBuilder subtree - use orientation(‘horizontal’) on ReelSetBuilder instead. -
#197
847d9cdThanks @igaming-bulochka! - Internal: the motion contract (ADR 018) now runs in CI against the shipping engine, in all four orientation x direction combinations, and thecreateTestReelSetdefault symbol size is non-square (120x100) so a test can tell width from height.No engine API change, but
createTestReelSet’s default geometry is a breaking change to anyone writing tests againstpixi-reels/testing: passsymbolSizeexplicitly if you were relying on 100x100. Filed as major so it lands under Breaking Changes in the changelog, where a reader whose geometry assertions just started failing will actually look. -
#197
847d9cdThanks @igaming-bulochka! - Remove: the internal negative-index buffer encoding.ColumnTargetis now carried unchanged fromsetResult()/initialFrame()all the way down to the reel, so no stage of the pipeline materializesarr[-1]string properties on an array any more.What this changes for consumers:
Reel.placeSymbols(target)takes aColumnTargetinstead of astring[]. Wrap a visible-only array as{ visible: ids }.Reel.placeStrip(frame)is new: it lands a full strip frame (index0= furthest buffer-above cell), which is the shapeFrameBuilder.buildreturns. Custom stop/cascade phases should use this.FrameContext.targetSymbols?: string[]becomesFrameContext.target?: ColumnTarget. Middleware reads it with the newgetTargetSlot(target, cell)helper, or materializes it withcolumnTargetToStrip(target, bufferStart).FrameBuilder.build/.buildAlltakeColumnTarget/ColumnTarget[]in the target position.columnTargetToArrayis gone.getTargetSlot,setTargetSlot,columnTargetToStripandcloneColumnTargetare exported in its place.refill()now validates a column againstvisible.lengthrather than the materialized array length, so a refill grid may carrybufferStart/bufferEndentries. Previously a buffer-end entry made the column look too long and threw.
-
#197
847d9cdThanks @igaming-bulochka! - Change: gsap is held per reel set instead of in a module global.v1’s
utils/gsapRef.tsstored one instance process-wide, and its own docstring admitted “the lastsetGsapcall wins” - so building a secondReelSetsilently moved the first one’s tweens onto a different timeline. Harmless for a single-set game; a real footgun for a composed stage.builder.gsap(instance)now binds that set only, captured atbuild().driveGsapWithTicker(ticker)takes the instance as a second argument:driveGsapWithTicker(ticker, myGsap). Pass the same one you gave the builder; omit it only if you never called.gsap(...).- Custom
ReelSymbolsubclasses should animate on the new protectedthis.gsap, whichSymbolFactorybinds to the owning set. An importedgsapstill works when your app and the engine resolve to the same module;this.gsapis correct either way. Reel.gsapis exposed for custom phases (this._reel.gsap).- The internal
setGsap/getGsaphelpers are gone, replaced byDEFAULT_GSAPand theGsaptype.
Nothing changes for a single-set game that never calls
.gsap(...). -
#197
847d9cdThanks @igaming-bulochka! - Fix:StaticSpinSymbol’s motion blur now smears along the strip on a horizontal set.MotionBlurOptions.axisdefaulted to'y'and its docs told you to pass{ axis: 'x' }“for aHorizontalReel” - a class 2.0.0 deletes. So a horizontal set usingStaticSpinSymbolsmeared vertically, across the direction of travel, with no type error and no throw. The axis now defaults to the owning set’s orientation (ADR 016 section 5); an explicitblur.axisstill wins, for art that wants a deliberate cross-smear.ReelSymbolgains a protectedthis.mainAxis('x'or'y'), bound bySymbolFactoryat create time, for the few effects that genuinely follow travel.resize(width, height)stays screen-space. -
#197
847d9cdThanks @igaming-bulochka! - Add:orientation('horizontal')now supports pyramids, MultiWays, and big symbols. The uniform-only guard atbuild()is gone, so every layout the engine offers works on either axis.Reelstores its cell size axis-relative (cellMainalong the strip,cellCrossacross it) and projects back to screen(width, height)whenever art is resized. A jagged horizontal set therefore varies cell WIDTH where a vertical one varies height, from the same arithmetic. New accessors:Reel.cellMain,.cellCross,.mainGap,.crossGap.Breaking, beyond the v2 rename already listed:
reelExtents([...])andmultiways({ reelExtent })are MAIN-axis extents (pixel height for vertical, pixel width for horizontal). They were always the vertical reading; the name now means the same thing on both axes.getBlockBoundsprojects through the axis.size.reelsspans the cross axis andsize.cellsthe main axis in every orientation, so the screen width and height a block maps to invert under horizontal. The method name and return shape do not move.PinOverlayTween(part ofAdjustPhaseConfig) is axis-relative:cellWidth/oldCellHeight/newCellHeight/fromY/toY/xbecomecellCross/oldCellMain/newCellMain/fromMain/toMain/cross.
Fixed along the way: MultiWays reshape derived its new cell size and its pin-overlay slot pitch from
symbolGap.yunconditionally. On a horizontal set that is the CROSS gap, so reshaped reels came out the wrong length. Both now read the reel’s own main gap (ADR 016 section 6.6). -
#197
847d9cdThanks @igaming-bulochka! - Change:MaskStrategy.build/.updatetake a singleMaskContext({ rects, width, height, axis }) instead of positional arguments, and every strategy must declarereadonly version = MASK_STRATEGY_VERSION.Only affects custom strategies;
RectMaskStrategyandSharedRectMaskStrategyare unchanged to use.A
ReelMaskRectis screen-space, so which of its four numbers runs along the strip depends on the orientation: a vertical set puts the strip ony/height, a horizontal one onx/width. A strategy written for v1 receives an identically-shaped struct with transposed meaning and no compile error - and handed aMaskContextit would readrectsas an object, find no.length, and quietly draw a full-bleed rect that clips nothing.maskStrategy()now throws by name on any strategy that does not declare version 2.MaskContextandMASK_STRATEGY_VERSIONare exported.
Minor Changes#
-
#197
847d9cdThanks @igaming-bulochka! - Add:BoardGridandHoldAndWinBuildertake a travel axis, so a board’s cells can fill sideways or upward.ADR 016 section 7 listed sideways Hold & Win cells as unlocked by the axis work, but
BoardGridbuilt every cell with a bareReelSetBuilderand neither it norHoldAndWinBuilderexposed an orientation, so a coin always scrolled in from above.new HoldAndWinBuilder().grid(5, 3).axis("horizontal", "reverse");Cells are 1x1 reel sets, so this picks the edge a symbol scrolls in from. It does not touch the board layout:
colsandrowsstay board dimensions, andBoardGrid/HoldAndWinBoardkeep that vocabulary deliberately. Defaults to vertical / forward, unchanged. -
#197
847d9cdThanks @igaming-bulochka! - Add:CardSymbol,CARD_DECKandWILD_CARDship from the package. A playing-card tile drawn withGraphics— coloured body, glyph fitted to the cell, glyph-only win pulse — so a prototype runs with no art at all:import { CardSymbol, CARD_DECK, WILD_CARD } from 'pixi-reels'. It previously lived inexamples/sharedand could only be copy-pasted.It uses the reel set’s own gsap instance rather than importing gsap, so it is safe under a symlinked workspace.
-
#197
847d9cdThanks @igaming-bulochka! - Fix: the tumble cell stagger now follows gravity, so a reel that drains upward peels and refills from the top instead of the bottom.tumble({ fall, dropIn })’scellOrderresolved against the raw cell index and nothing else. Under the usual downward gravity that reads correctly — the bottom cell, the one at the exit edge, goes first — but on a reel draining the other way it staggered from the cell FURTHEST from the drain, so the cell about to leave first waited for the whole column to clear ahead of it. The geometry was already gravity-correct (symbols travelled and entered through the right edges); only the timing read backwards, which is why nothing caught it..direction('reverse')with the defaultgravity: 'auto'was the visible case.cellOrdernow accepts'auto'and defaults to it.'auto'starts at the gravity-EXIT end — the edge symbols are settling against — so the canonical “bottom-left first, top-right last” feel is unchanged for every downward-gravity reel, and inverts by itself when gravity does. Nothing changes for a set that does not override gravity or direction.'endFirst'and'startFirst'keep their meaning and are now explicitly geometric, like the buffers (ADR 016 section 3.4): they name an end of the strip and ignore gravity. Pass one to pin a screen edge regardless of which way the board drains. -
#197
847d9cdThanks @igaming-bulochka! - Add:tumble({ gravity })so cascades work on reverse and horizontal reels (ADR 016 section 3.6).Cascade refills used to be hard-coded to settle toward the larger cell index. On a reel built with
.direction('reverse')that meant the board drained one way and refilled through the edge it had just emptied, with survivors sliding against the reel’s own travel. The two halves disagreed internally too:distance: 'auto'applied the reel polarity while the default'perHole'did not, so changing one animation-tuning field flipped which edge symbols entered from.gravitydefaults to'auto', which follows each reel’s own direction, so a reverse or horizontal set now cascades correctly with no extra configuration:builder.direction("reverse").tumble({}); // drains upward, refills from below builder.orientation("horizontal").tumble({}); // drains right, refills from the left builder.tumble({ gravity: "reverse" }); // spin one way, drop the otherWhichever edge gravity exits by is the edge your server must pack survivors against in the grids it sends — the engine animates the result, it does not reorder it.
DropOffsetgains anisNewfield. Branch on that rather thanoriginalCell < 0, which only discriminates under forward gravity.computeDropOffsetstakes an optionalgravityand still defaults to'forward'.createTestReelSetgains atumbleoption so a cascade test can pick an orientation and direction without hand-rolling a builder. -
#197
847d9cdThanks @igaming-bulochka! - Add:PhaseConstructor,PhaseCreatorFn,PinOverlayTweenandTickerCallbackare now exported as types. Each appears in the signature of something already exported (PhaseFactory.register,AdjustPhaseConfig.pinOverlays,TickerRef.add), so a consumer could hold the value but never name it.Fix:
ReelSymbol.onReelSpinStart’s documented parameter name matches the signature again, and theSymbolSpotlightADR link no longer points at a path that does not exist. -
#197
847d9cdThanks @igaming-bulochka! - Add:ReelSet.getTargets(): ColumnTarget[]andReel.getTarget(). The whole board as the same shapesetResulttakes — buffers included, big-symbol anchors at their true positions — soreelSet.setResult(reelSet.getTargets())reproduces what is on screen.getVisibleGrid()is unchanged and still returnsstring[][]. It reports the visible window only, so it cannot be replayed: a block anchored inbufferStartwith just its tail showing reads as that id at visible cell 0, and feeding that back re-anchors the block there. UsegetVisibleGrid()to read the board for win logic, andgetTargets()to capture and replay one. -
#197
847d9cdThanks @igaming-bulochka! - Add:debugOverlaygains the axis-aware layers.axisdraws one arrow per reel along the travel axis, pointing the way it goes.feedmarks the strip edge new symbols arrive at.thresholdsdraws the two wrap lines, so contract laws L7 and L9 are watchable: drive a spin and no symbol should ever be drawn past one.hudnow reports orientation, direction and feed edge per reel (r0 VF feed=start spd=... cells=...).
Add:
overlay.describe()returns a plain-JSON summary of what those layers represent, per reel - orientation, direction, feed edge, the arrow’s signed main-axis span, the feed marker and both thresholds. PixiJS renders to a canvas that CI and AI agents cannot see; this is the same information in a formexpectcan read. A mirrored arrow has identical bounds, so the signed span is the only thing that can tell a reverse reel from a forward one.Fixed: the
buffersandhudlayers positioned themselves offcontainer.x/mainOffsetdirectly, so they drew in the wrong place on a horizontal set. Both now project through the reel’s axis, as does every new layer. Each layer’sGraphicscarries alabel(pixi-reels:debugOverlay:<layer>) for the Pixi devtools and for tests. -
#197
847d9cdThanks @igaming-bulochka! - Add: reverse and mixed per-reel travel direction now spin and land correctly on a vertical set.StopSequencerfeeds the target frame from the direction-appropriate edge (head-first for reverse reels, tail-first for forward), sodirection('reverse')(roll-up) anddirectionPerReel([...])(alternating columns) land the exact requested grid. Forward reels are unchanged. Horizontal orientation still fails loud until its set geometry lands. -
#197
847d9cdThanks @igaming-bulochka! - Add:orientation('horizontal')for uniform grids. A single horizontal reel is the banner - cells march along X, the strip travels on X, and it spins and lands through the same lifecycle as a vertical set. The builder projects viewport extents, cross-marching pitch and mask rects through the set axis,Reelderives its motion cell size / cross pitch from the axis (symbol art still sizes to screen width x height), andReelSet.getCellBoundsprojects to screen. Pyramid / MultiWays horizontal fail loud for now. -
#197
847d9cdThanks @igaming-bulochka! - Add:ReelSetBuilder.orientation()/direction()/directionPerReel()and per-reelReelAxisthreading (plus areel.axisaccessor). The axis is wired through the motion + phase layers. Vertical forward is fully supported.orientation('horizontal')and any reverse direction fail loud atbuild()for now - their set-level geometry and the StopSequencer feed edge (ADR 016 section 6.1) land in a later commit, so failing loud beats a mis-laid or non-landing spin. -
#197
847d9cdThanks @igaming-bulochka! - Add: fire the declared-but-unfiredspotlight:start(with the highlighted positions) andspotlight:endevents.SymbolSpotlightnow receives the ReelSet emitter and brackets each spotlight presentation; a teardown with nothing active stays silent. -
#197
847d9cdThanks @igaming-bulochka! - Add:debugOverlay(reelSet, { layers, live, ticker })- a layered visual debug overlay for the static / at-rest layers (mask,cells,buffers,bounds,blocks,pins,hud). It draws into aContaineradded to theReelSetitself, so it renders above the viewport (including the spotlight container) rather than under it likeshowMask. The handle exposessetLayers(...),redraw()anddestroy(), implementsDisposable, pools itsGraphics/Text(never recreated per frame), and whenlive: truedrives per-frame redraw of the live layers throughTickerRef(defaultTicker.shared, override viaticker). Static layers only redraw onshape:changed/adjust:complete. Also reachable as__PIXI_REELS_DEBUG.overlay(...). Dev-only, same caveat asenableDebug: it reads internals, is not semver-protected, and must not reach a production bundle. The axis / feed / thresholds layers arrive with A11b onceReelAxisis wired throughReel. -
#197
847d9cdThanks @igaming-bulochka! - Add:ReelAxisprojection value object (reelAxis(),VERTICAL_FORWARD) plusOrientation/Directiontypes. Unused for now - the foundation for orientation-generalized motion (ADR 016). No behavior change.
Patch Changes#
-
#197
847d9cdThanks @igaming-bulochka! - Docs: documentanticipation:reel,anticipation:reelEndandcascade:gravity:error, which the engine emitted but no page mentioned. -
#197
847d9cdThanks @igaming-bulochka! - Perf:build()no longer constructs and discards anOffsetCalculator.The instance was never read, but its constructor runs
_compute(), so everyReelSetBuilder.build()was laying out a full per-reel/per-cell offset table and throwing it away. Confirmed it contains nothrow, so it was not doubling as a validator. Also drops an unused local inStartPhase. No behaviour change. -
#197
847d9cdThanks @igaming-bulochka! - Fix: the big-symbol weight error said random fill “never enters random fill in v1”, which reads as a v1-only restriction on a v2 build. It is not version-scoped. -
#197
847d9cdThanks @igaming-bulochka! - Fix: an emptybufferStart/bufferEndno longer trips the buffer-range check.assertBufferCountsInRangecomparedhighestDefinedIndex(entries) >= capacity, and that helper returns-1for “no entries at all”. When a reel reports a NEGATIVE capacity — which happens transiently during a cascade, where the strip is briefly shorter thanbufferStart + visibleCells— the test became-1 >= -4and threw on a column that specified no buffer entries at all:runCascade(): nextGrid column 0: bufferEnd has a symbol at index -1, beyond engine bufferSymbols=-4The check only ever ran on
setResult, where reels are settled and capacity is never negative, so it stayed latent untilrefill()andrunCascade()began validating their grids in this release. A column that specifies nothing can never have an entry dropped, so it is always in range. -
#197
847d9cdThanks @igaming-bulochka! - Fix: a horizontal reel set laid out its initial strip with no gap between cells.Reel._setupSymbolPositionsstepped byspinCellSize + symbolGapY— the screen VERTICAL gap — instead of the travel-axis gap. On a vertical set the two are the same value, so this was invisible; on a horizontal one the main gap issymbolGapX, so symbols touched until the first spin handed positions toReelMotion(which projects correctly) and they silently snapped apart. -
#197
847d9cdThanks @igaming-bulochka! - Docs: the 2.0 migration guide subscribed withreelSet.on(...), which does not exist. Corrected toreelSet.events.on(...). -
#197
847d9cdThanks @igaming-bulochka! - Fix:movePin()flew the symbol to the wrong place on a horizontal reel set. It read_pinOverlayCellMain(a travel-axis coordinate, which isxwhenorientation('horizontal')) straight into.y, and the reel’s main offset into.x. Both are numbers, so nothing threw. Now routed throughaxis.toScreen, like every other pin-overlay site.Fix:
setShape()’s parameter and theshape:changedpayload label arecellsPerReel, not the v1rowsPerReel. The old name shipped in the.d.tsand in two runtime error messages.Fix: the big-symbol split error printed
anchor + h + distancewhile the predicate testedanchor + h - 1 + distance, so the number in the message was one off from the one that failed. -
#197
847d9cdThanks @igaming-bulochka! - Fix:debugOverlay’shudlayer is readable. It stacks its lines instead of overprinting them, and sits on a backing plate.Each line was anchored at its own reel’s top-left corner, which assumes a line fits inside a reel. It does not: roughly 40 characters at 11px monospace is ~230px against a cell that is typically ~100px wide. On any set past two reels every line ran across its neighbours into an unreadable smear, and it got worse the more reels you had — which is exactly when the hud is worth reading.
The lines are now one left-aligned column anchored inside the mask’s top-left, one per reel, so they read at any reel count and in either orientation. Stacking them outside the mask would keep the reels clear, but a host that framed its camera on the reel set before the overlay existed then renders the whole block off-screen, and an invisible hud is worse than a cluttered one. Drop
hudfromlayersif it covers art you need to see.Also: 10px on an 11px leading rather than 11/13, a translucent black plate behind the column so white text survives bright symbols, and
resolution = 1on the lines so small glyphs rasterize blocky instead of grey-smeared.The
r<n>prefix still ties a line to its reel, and thecellslayer still labels each cellreel,cell. Nothing about the reported fields changed. -
#197
847d9cdThanks @igaming-bulochka! - Fix:reelSet.destroy()left every in-flight spin-phase tween running.SpinController.destroy()dropped its active-phase map without skipping the phases first, andonSkip()is the only thing that kills the gsap timelines they own (start ramp, anticipation, stop bounce, cascade fall/drop-in). Those timelines outlived the set and kept writing reel speed and symbol view positions to display objectsdestroy()had already freed. It bites hardest in the setup the docs recommend — gsap driven off a PixiJS ticker — because the orphaned tweens do not stop when the set’s own app goes away: any other live ticker keeps advancing the shared root timeline. Destroying a reel set mid-spin now force-completes its active phases first, and bumps the spin generation so no already-awaiting phase chain starts a fresh phase on the way down. -
#197
847d9cdThanks @igaming-bulochka! - Fix: the auto-picked mask strategy’s console notice names the gap it actually keyed on. The auto-pick has read the CROSS-axis gap sinceorientation()landed, but the message still saidsymbolGap.x > 0verbatim — so on a horizontal set it pointed at the main-axis knob, and turning that one did nothing to the behaviour being explained. It now readssymbolGap.xon a vertical set andsymbolGap.yon a horizontal one. -
#197
847d9cdThanks @igaming-bulochka! - Fix:setResult()andinitialFrame()now reject a plainstring[][]with a message that names the fix. Previously the value reached a spread oftarget.visibledeep in the frame pipeline and threwTypeError: target.visible is not iterable— after the reels were already moving, so the spin promise never settled and the reel spun forever with no usable clue. -
#197
847d9cdThanks @igaming-bulochka! - Fix: the published tarball now actually containsREADME.mdandLICENSE. Both were listed inpackage.json’sfilesbut neither existed inside the package, and npm drops afilesentry that matches nothing without warning — so the npm page would have been blank and an MIT-licensed package would have shipped no licence text. -
#197
847d9cdThanks @igaming-bulochka! - Internal (docs site): recipes can return astagecontainer so a multi-set composition scales and centres as one. No library change. -
#197
847d9cdThanks @igaming-bulochka! - Fix:movePinplaced the flight symbol at the source cell’s bare reel-local Y, dropping the reel’s container offset and mixing the masked (reel-local) vs unmasked (viewport-space) coordinate conventions. Route flight placement through_pinOverlayCellYso it agrees with pin overlays on any layout with a nonzero reel offset. No API change. -
#197
847d9cdThanks @igaming-bulochka! - Docs: ADRs 016 / 017 / 018 move off Proposed and record where the implementation diverged from the plan;ROADMAP.mdandTODO.mdare reconciled (horizontal reels, mixed direction per reel and roll-up all close in 2.0.0). No code change. -
#197
847d9cdThanks @igaming-bulochka! - Refactor:ReelMotionnow projects through aReelAxisand derives symbol positions from array index (and rotation count from total travel) instead of accumulating deltas. Behavior is unchanged for the default vertical/forward axis; the derive model also fixes a latent float-residue wrap-skip at exact N-slot travel (motion contract L7). Internal - the axis defaults to vertical/forward, so callers are unaffected. -
#197
847d9cdThanks @igaming-bulochka! - Refactor:Reelroutes its own position writes through the injectedReelAxis- container placement (cross marches reels, main carries the offset),_placeSymbolView, the unmasked re-sync (absolute cross, incremental main), and every reel-local conversion. Behavior is unchanged for the default vertical/forward axis. Internal;ReelConfiggains an optionalaxis. -
#197
847d9cdThanks @igaming-bulochka! - Refactor: renameSpinningMode.computeDeltaY(symbolHeight, ...)tocomputeDelta(slotPitch, ...). The parameter was always the slot pitch (the caller passesmotion.slotHeight); the name now matches. Returns signed travel along the reel’s axis. The full-slot wrap-skip risk the old cap guarded (contract L7) is gone with the derive-from-index motion, so the cap is now only smoothing. -
#197
847d9cdThanks @igaming-bulochka! - Refactor: route the non-cascade spin phases’ GSAP position tweens throughreel.axisinstead of a hardcoded.y. StopPhase’s landing bounce now overshoots in the direction of travel viabase + axis.polarity * bounceDistanceonaxis.mainProp, and reads/restores the reel container’s base position throughaxis.getMain/setMain. AdjustPhase’s MultiWays pin-overlay squash and slide now writescale[axis.mainProp]and position viaaxis.setMain/setCross. StartPhase’s step-back is a speed tween (already direction-relative through the motion layer) and is unchanged. Vertical/forward is byte-identical. -
#197
847d9cdThanks @igaming-bulochka! - Refactor: the tumble cascade phases position symbols through the injectedReelAxis.CascadeFallPhaseandCascadeDropInPhaseread start positions viaaxis.getMain, write viaaxis.setMain, and build their GSAP tweens with a computedaxis.mainPropkey; fall/drop distances now carryaxis.polarityso gravity follows the reel’s travel axis. Grid origins (originalRow * cellHeight) stay direction-agnostic. Behavior is unchanged for the default vertical/forward axis (mainProp: 'y',polarity: 1).CascadePlacePhaseandtumbleAlgorithmwere unaffected (visibility/identity swap and cell-index math, no position writes). Internal only. -
#197
847d9cdThanks @igaming-bulochka! - Fix:ReelViewport.updateMaskSizenow resizes the dim overlay. A viewport resize (e.g. a MultiWays reshape growing the tallest reel) no longer leaves the spotlight dimming a stale rectangle. -
#197
847d9cdThanks @igaming-bulochka! - Docs: a guide for orientation and direction (the headline of 2.0.0), the new builder methods in the API reference, and the debug overlay’s axis layers plusdescribe()in the debugging guide. No code change. -
#197
847d9cdThanks @igaming-bulochka! - Internal: browser coverage for all four orientation x direction combinations, via a newtests/e2e/fixtures/orientation-matrixfixture and a Playwright spec wired into CI. No library change. -
#197
847d9cdThanks @igaming-bulochka! - Internal: cover the natural (non-slam) stop on reverse and mixed-direction reels. No API change - this closes a test gap, it does not change behaviour.
1.6.1#
Patch Changes#
- #194
1a9e258Thanks @igaming-bulochka! - Fix: commit a MultiWays reshape BEFORE the fall in cascade (classic-tumble) mode when the target shape is known at spin time.CascadeFallPhasedrops a reel’s current visible rows, and the reshape used to run only after the fall (between SPIN and STOP, where standard mode’s spin blur hides it), so in cascade mode a reel that changed height dropped its old, differently-sized board and then snapped to the new shape. a reel visibly changing height mid-tumble. Now, ifsetShape()is called BEFOREspin({ mode: 'cascade' }), the reshape commits before the fall so the reel falls at its target height. The legacyspin()thensetShape()ordering is unchanged (the reshape still lands after SPIN). For a clean per-spin reshape in a classic tumble, callsetShape()beforespin({ mode: 'cascade' }).
1.6.0#
Minor Changes#
-
#191
ff7658bThanks @igaming-bulochka! - Add:bufferSymbols({ above, below }). asymmetric buffer rows, includingbelow: 0for tumble-only reel sets. A pure tumble never scrolls the strip, so the below-window cells exist only to be hidden by the mask; dropping them means nothing can ever peek out under the grid. Requires.tumble(...)on the builder (validated atbuild()); strip spins (spin({ mode: 'standard' })) andnudge()throw on such a set because both move symbols through the below-window buffer. The number form keeps its exact legacy behavior (symmetric count, minimum 1 with a clamp warning). -
#191
ff7658bThanks @igaming-bulochka! - Add:RunCascadeOptions.presentWinners. a win-presentation hook awaited after detection and BEFOREdestroySymbols, while the winners are still on the board. This is the natural seat for aWinPresenterpass (play the authored win clip, dim losers, then let the library destroy the cells): a round’s presentation order is win → destroy → refill.onCascadekeeps its post-destroy timing unchanged.
Patch Changes#
-
#191
ff7658bThanks @igaming-bulochka! - Fix: re-mask liftedunmasksymbols through the cascade refill path. A purerefill()never passes throughStartPhase(strip spins) ornotifySpinStart(tumble fall), so a symbol withunmask: truearriving via drop-in stayed parented inviewport.unmaskedContainerand rendered its whole above-viewport approach outside the reel mask. floating over the page before landing.CascadePlacePhaseandCascadeDropInPhasenow callreel.beginMotion()on entry (idempotent, same rule asStartPhase._launch);notifyLandedre-lifts once the refill settles. -
#191
ff7658bThanks @igaming-bulochka! - Fix: cascade refills notifyonReelLanded()on MOVERS only. survivors that slid and new arrivals. Untouched survivors (offsetRows 0) no longer replay their landing animation on every cascade stage, which read as the whole board twitching after each pop.Reel.notifyLanded(landedRows?)gained an optional visible-row filter (strip-spin landings are unchanged. every visible symbol still lands); the gravity stage of two-stage refills now fires each slid survivor’s landing reaction the moment it settles.
1.5.0#
Minor Changes#
-
#188
a586390Thanks @igaming-bulochka! - Add: anticipation-aware spin presentation — newReelSymbol.onReelAnticipationStart()lifecycle hook, fired on every strip symbol when its reel enters the anticipation phase (and on symbols installed mid-tease).StaticSpinSymboluses it to crossfade the baked motion blur back to the crisp snapshot, so the slowed tease strip is readable instead of smeared. -
#188
a586390Thanks @igaming-bulochka! - Add:SpineReelSymbolmulti-skin skeleton support — spineMap entries accept an optionalskin, so several symbolIds can share one multi-skin skeleton (e.g. alowSymbolsskeleton carryinglow1..low5as skins) instead of shipping one skeleton per symbol. -
#188
a586390Thanks @igaming-bulochka! - Fix:symbolDataunmaskis now an at-rest presentation. While the reel spins, unmasked ids stay in the masked reel container like every other symbol — previously they scrolled visibly outside the grid and buffer-row instances sat parked beyond the mask edge, visually breaking the reels. On land, visible-row instances are lifted into the viewport-wideunmaskedContainer(above every reel and outside the mask), and re-masked the instant the reel begins to move on the next spin (at the start of the accel ramp, not once it reaches full speed — so a lifted symbol never floats above the mask while the strip scrolls under it). -
#188
a586390Thanks @igaming-bulochka! - Add:symbolDataunmask: truenow works on jagged / pyramid layouts (reels with a non-zerooffsetY). Previously the builder threw at config time, because the motion layer writes bare reel-local Y and would drop the reel offset from a lifted view on every snap. Since unmask is now an at-rest presentation (a view is only lifted while the reel is stopped),Reel._syncUnmaskedViewOffsets()re-bakescontainer.yafter each absolutemotion.snapToGrid(), and the frequent mid-spin snaps never touch a lifted view. Theunmask + pyramid layout is not supportedbuild-time throw is removed.
1.4.0#
Minor Changes#
- #186
b6d1649Thanks @igaming-bulochka! - Add: static / motion-blurred snapshot spinning — spin cached textures instead of live symbols. NewSpinTextureCachecaptures any symbol into a per-symbolIdRenderTexture(or accepts hand-authored textures viasetStatic/setBlurred, which always win and are never destroyed by the cache) and bakes a motion-blur variant in a one-timeBlurFilterpass — no filters run during the spin. The smear follows the reel’s travel axis: vertical by default,blur: { axis: 'x' }for aHorizontalReelstrip. NewStaticSpinSymbolwraps anyReelSymbol(Spine included): while the reel spins it deactivates the inner symbol and shows the cached snapshot, crossfading crisp→blurred overblurRampMs; symbols wrapping in mid-spin only retarget a sprite texture; on land the live symbol is reactivated.prewarmSpinTextures()bakes all ids up front so the first spin never hitches. Engine:onReelSpinStartnow also fires (with a newjoinedMidSpinarg) on symbols installed while a reel is already spinning, spin start/end notifications reach buffer rows, a slam-stoppedStartPhaseno longer skipsnotifySpinStart, andHorizontalReelnow fires the symbol spin hooks across its conveyor (onReelSpinStartatspin()and on mid-spin feeds;onReelSpinEndatsetResult()so the visible deceleration runs crisp and the landing window feeds in live — what lands is never blurred;onReelLandedon land) — fixing SpineautoPlayBlurgaps where mid-spin, slammed, or horizontal-strip symbols stayed onidle. Spin-state hooks must now be idempotent;SpineReelSymbol.playBlur()no longer restarts an already-running blur loop, andSpineReelSymbolapplies the idle pose immediately on activation (spine.update(0)) so same-frame renders and snapshot captures see the posed skeleton instead of nothing.
1.3.0#
Minor Changes#
-
#183
5bddccbThanks @igaming-bulochka! - Add: staggered / sequential anticipation so teasing reels build tension one after another instead of all slowing down at once.setAnticipation(reelIndices, stagger?)now takes a second argument controlling when each reel BEGINS its slow-down (offsets are by tease-order, not raw reel index):0(default) — every anticipation reel starts slowing together (unchanged behaviour).number— reel at tease-orderkstartsk * staggerms after the first.number[]— explicit per-tease-order offset in ms.'sequential'— each reel waits until the previous anticipation reel has fully landed before it starts.
Add: progressive slow-down. Pass
setAnticipation(reels, { stagger, slowdown })whereslowdown({ from, to, holdFrom, holdTo }) interpolates across the tease sequence, so each successive reel decelerates to a lower speed and/or holds longer than the last — the escalating “each reel crawls slower than the one before” build-up. Omit it for the previous flat 30%-and-hold tease.Add:
durationoverride —setAnticipation(reels, { duration })sets the tease hold in ms regardless of the active speed profile, so anticipation keeps playing in Turbo / SuperTurbo (whose profiles useanticipationDelay: 0and previously skipped it entirely).Add:
anticipation:reel({ reelIndex, order, total }) andanticipation:reelEnd({ reelIndex }) events — a dedicated per-reel tease start/end signal so games can drive tension SFX, pitch ramps (order / (total - 1)), and escalating visuals without re-deriving the tease set fromspin:stopping. Fired only for reels that actually tease.Add:
anticipationForScatters(grid, { symbol, trigger, mode })— derive the tease reel list straight from a result grid (gridis the sameColumnTarget[]you pass tosetResult). Anticipation begins on the reel after thetrigger-th scatter;mode: 'all-remaining'teases every following reel,'scatter-only'teases only reels that actually hold the symbol (so a 3-scatter result doesn’t slow the empty reels).Fix: after an anticipation tease the reel now carries its slow speed into the stop and crawls onto its landing frame, instead of snapping back to full spin speed and doing a fast re-spin into position.
spin:stoppingnow fires when a reel actually begins slowing (after its stagger offset), so tease SFX/VFX can sync to the real start. The stagger and slowdown reset at the start of everyspin().Also:
setStopDelays(null)/setDropOrder(null)now CLEAR a per-reel stop-delay override and restore the defaulti * speed.stopDelaystagger — distinct from passing all-zeros (which lands every reel simultaneously).
1.2.0#
Minor Changes#
- #176
01639b0Thanks @igaming-bulochka! - Add:HorizontalReel+HorizontalReelBuilder— a single one-row, sideways reel for the “these symbols pay this round” banner above the reels. It reuses the engine’s own contract, so there is nothing incompatible to learn:spin()returns a promise,setResult(symbols)takes the sameColumnTarget[]asReelSet(one entry — this reel is a single column), and the promise resolves with the engine’sSpinResult.skipSpin(),isSpinning, and thespin:start/spin:completeevents all mirrorReelSet.cascade(winners, newIds?)runs a real tumble one row wide: the winning symbols are removed, the survivors collapse to close the gaps, and new symbols slide in from the feed side to refill. Built on the shared symbol pool /TickerRef/EventEmitterprimitives, cleaned up viadestroy().
1.1.0#
Minor Changes#
-
#158
22f2b33Thanks @igaming-bulochka! - Add:BoardGrid— the generic “board of reels” primitive is now a public export. A grid of cells that each spin independently (cells,spinCells,symbolAt/reelAt,cellBounds/cellCenter,setProfile,place), with no game rules of its own.HoldAndWinBoardis one opinionated board built on it; build your own the same way.spinCells’ per-cellonLandedcallback may be async — return a promise andspinCellsresolves only once every cell has landed and its after-land work has finished. -
#158
22f2b33Thanks @igaming-bulochka! - Add: Hold & Win board.HoldAndWinBuilderbuilds aHoldAndWinBoard— a grid of independently spinning 1×1 cells with the full respin / lock / collect lifecycle (enter,respin,release,setSymbolAt,skip,reset), typed events (coin:locked,board:full,feature:end, …), per-cell geometry (cellBounds/cellCenter) and live symbol access (symbolAt/reelAt). Coins are opaque{ cell, id, data }, so value, multipliers, collectors and flights stay game-layer. Also exportsEmptySymbol(a render-nothing symbol), pluscellKeyand theHwEffecttype so you can forkHoldAndWinBoard+HoldAndWinStateand keep every import on public API.
Patch Changes#
-
#158
22f2b33Thanks @igaming-bulochka! - Fix: harden and complete the Hold & Win board public surface.HoldAndWinState(the pure reducer) is now exported from the barrel, so the documented “forkHoldAndWinBoard+HoldAndWinStateand keep every import on public API” path actually resolves.beginWave/respinnow throws on a duplicate hit targeting the same cell in one wave instead of silently dropping the first coin (a malformed result fails loud, matchingenter’s duplicate-seed guard). A failedplayWin()reaction tocoin:lockedis now logged viaconsole.warninstead of being swallowed silently, andsetSymbolAt’s JSDoc documents that it must not be called mid-wave. -
#158
22f2b33Thanks @igaming-bulochka! - Fix: hardenHoldAndWinBoardrecovery and mid-wave misuse. Ifrespin()throws between starting and closing a wave — most plausibly a game-layerrespin:start/cell:landed/coin:lockedlistener throwing — it now restores the reducer’s phase and slams any still-spinning cells before rethrowing, so a failed wave no longer strands the board inspinning(where every laterrespin()threw “wave in flight”) or leaves an orphaned reel (where the nextrespin()threw “already spinning”). The error still propagates to the caller. The reducer also ignores stray landings outside a wave, so a cell settling after areset()or a recovered error can no longer re-lock a coin into a cleared ledger or flip a finished feature back to active.release()andsetSymbolAt()still throw if called while a wave is in flight.respin()now returns a caller-ownedhitsarray (a copy of the wave’s landings) rather than a live reference into reducer state, so mutating the result can’t reach back into the board.
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).