# pixi-wheels pixi-wheels 0.1.0 is a bonus wheel engine for PixiJS v8. Fluent builder, typed events, velocity-matched planned stops, anticipation (creep / stutter / stall), dynamic sectors, rings, pointers with flap physics, idle spin, texture and Spine skins, a headless testing harness. Outcome math, RTP and audio live in consumer code: the wheel lands where `setResult()` says. Site: https://pixi-wheels.schmooky.dev Repo: https://github.com/schmooky/pixi-wheels Package: https://www.npmjs.com/package/pixi-wheels ## Quick start ```ts import { WheelBuilder, SpinPresets } from 'pixi-wheels'; const wheel = new WheelBuilder() .radius(240, 36) .sections([{ id: 'x2', value: 2, weight: 3 }, { id: 'x5', value: 5, weight: 2 }, { id: 'x10', value: 10 }]) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); app.stage.addChild(wheel); const spin = wheel.spin(); wheel.setResult({ value: 5 }, { anticipation: { bait: 'x10' } }); await spin; ``` ## Guides ### Server adapters URL: https://pixi-wheels.schmooky.dev/guides/adapters/ `setResult()` takes a `WheelTarget`: ```ts { section: 'grand' } // by id { index: 3 } // by position { value: 8, pick: 'random' } // any section with that value { angle: 123 } // wheel-local degrees { position: 0.34 } // fraction of the way round { section: 'grand', offset: 0.2 } // plus an exact spot inside ``` For a real response, describe how to read it once: ```ts import { createTargetAdapter } from 'pixi-wheels'; const toTarget = createTargetAdapter({ by: 'value', path: 'bonus.wheel.multiplier', pick: 'random' }); wheel.setResult(toTarget(response)); ``` `by` is `section | index | value | angle | position`; `path` is dotted; `indexBase: 1` handles backends that count from one; `offsetPath` reads an exact offset. The config is JSON, so the studio stores it next to the wheel (`config.adapter`) and the exported project reads it back. `resolveTarget(geometry, target, { mode, margin, rng })` is the pure function behind `setResult()`, for pre-computing a landing. ### Anticipation URL: https://pixi-wheels.schmooky.dev/guides/anticipation/ Anticipation baits the player with one section and lands on another. It is planned from the result, so it is deterministic, testable and always lands where the server said. One rule holds for every style: **the wheel comes to rest exactly once, on the result.** It never stops on the bait and moves on, and it never rolls back. ```ts wheel.setResult({ value: 2 }, { anticipation: { bait: 'x50', // section id, or { index } style: 'auto', // 'creep' | 'stutter' | 'stall' | 'auto' rest: 0.22, // where the pointer rests: this far into the target from the shared line ('keep' to leave it) creepSpeed: 40, // deg/s at which the crawl begins hesitateSpeed: 2, // stutter: the near-stall speed, deg/s dwellMs: 600, // stutter: how long the near-stall lasts pushMs: 700, // stutter: the slip over the line approachDeg: 45, // stall: crawl length before the rest (default the target's arc, at most 45) maxDistanceDeg: 150, // how far the bait may be from the landing protectSkip: false, }, }); ``` Set `.landing({ anticipation: { bait: 'x50' } })` on the builder to bait every spin. The defaults are tuned; `bait` alone gives the beat below. ## The near-miss beat The feeling to hit is "it is going to be the jackpot... it is the one before it". Three things make it read: 1. **The approach is a crawl.** Whatever the style, the wheel comes down to `creepSpeed` and crawls its last stretch with a constant deceleration, the way a real wheel dies against friction. The pointer takes its time reaching the line between the result and the bait. 2. **It never stops early.** `creep` crawls through the bait and barely crosses the line. `stutter` all but stalls a hair short of the line, still moving at `hesitateSpeed`, then slips over it. `stall` enters the result and crawls toward the bait's line as if it will cross, and dies just short of it. In every case the first moment the wheel is still is the rest, and the segment under the pointer is the result. No dwell inside the bait, no roll back. 3. **It rests by the line.** Whatever the landing mode says, a tease puts the landing angle just inside the target, `rest` of its arc from the divider it shares with the bait: 0.22 after crossing it, 0.15 when it died short of it. The pointer ends up hugging the line it almost crossed, which is where the eye expects it. Set `rest: 'keep'` to let the landing mode place it, or add `settle: 'center'` to glide to the middle once the beat has landed. A target with its own `offset`, an `angle` / `position` target, or `mode: 'exact'` is never moved. The crawls pass the divider under the pointer, so `pointer:tick` fires for them too, at crawl speed. Wire the flapper's kick and its click to that event and the miss sounds right as well. ## Geometry A clockwise wheel sweeps decreasing local angles under the pointer, so it meets the section *after* the landing (in layout order) first. In the spin direction: - **bait before landing** (the pointer passes the bait, then reaches the landing): `creep` or `stutter`. - **bait after landing** (the pointer reaches the landing first): `stall`. `'auto'` picks by distance. If the bait is farther than `maxDistanceDeg` on both sides the tease is dropped with a warning (`bait-too-far`) and the wheel lands plainly. An explicit style with the wrong geometry warns (`bait-order`) and is dropped too. Nothing ever moves the landing off the result. ## Events | Event | When | |---|---| | `anticipation:start` | The stop was planned with a tease. Carries `bait` and `style`. | | `anticipation:bait` | The crawl begins: the pointer enters the bait (`creep`, `stutter`) or the result (`stall`). | | `anticipation:end` | The tease resolved, just before `spin:landing`. | ## Protected skip With `protectSkip: true` (or `.skip({ protectAnticipation: true })`) the first press fast-forwards to the start of the crawl and lets the tease play; the second press lands. `skip:requested` carries `protectedByAnticipation` so audio can tell the two apart. ### Configs and templates URL: https://pixi-wheels.schmooky.dev/guides/configs/ ```ts const cfg = builder.toConfig(); // WheelConfig, plain JSON const wheel = WheelBuilder.fromConfig(cfg, { assets }).ticker(app.ticker).build(); ``` A `WheelConfig` holds the rings (sections, radii, pointers, skin config, dynamic steps), the speed profiles, landing and skip defaults, idle, and the server adapter. Skins given as instances are recorded as `{ type: 'custom' }`; give them as configs to stay serialisable. Textures and Spine files are referenced by key and resolved through an `AssetResolver`. `assertWheelConfig(json)` checks a config from a file or a form before `fromConfig()`. ## Templates `WheelTemplates.gamble()`, `gambleDynamic()`, `multipliers()`, `jackpots()`, `dynamicJackpot()`, `twoRing()`, `debug()` return configs to start from. `WHEEL_TEMPLATE_NAMES` lists them. ## Studio The [studio](/studio/) edits a `WheelConfig` in forms, rebuilds the wheel on every change, shows the fluent code the config stands for, takes your uploads, and exports the JSON or a runnable Vite project with the assets under `public/assets/`. ### Debugging URL: https://pixi-wheels.schmooky.dev/guides/debugging/ PixiJS draws to a canvas; the debug tools describe the wheel in words and lines. ```ts import { enableDebug, debugOverlay, debugArc, DebugRingSkin } from 'pixi-wheels'; enableDebug(wheel); // window.__PIXI_WHEELS_DEBUG debugOverlay(wheel, { live: true, ticker: app.ticker, hud: 'bottom-left', screen: app.screen }); console.log(debugArc(wheel)); ``` ``` main rot 123.4 state stopping speed -210.3 deg/s | x2 |x5| x3 |x10| x2 |x8| x3 |x50| ^ pointer -> x3 ``` In the console: ``` __PIXI_WHEELS_DEBUG.log() // arc + state __PIXI_WHEELS_DEBUG.snapshot() // plain JSON: rings, sections, pointers, target, planned legs __PIXI_WHEELS_DEBUG.trace(true) // log every event (ticks included) __PIXI_WHEELS_DEBUG.overlay() // dividers, angles, pointer lines, landing marker, HUD __PIXI_WHEELS_DEBUG.land('x50') // setResult on the current spin ``` `DebugRingSkin` is the plain look for wiring a wheel before the art exists. Every recipe on this site has a Debug button that toggles the overlay. The overlay's `pegs` layer draws the pegs the tongues touch, fills the one being ridden, and marks each tongue's contact zone on the rim; the HUD prints the first tongue's deflection. Tune `elasticity`, `friction`, `stiffness` and `damping` while watching it. ## Reading the overlay Everything the overlay writes is drawn at a constant screen size, on dark pills, whatever scale the wheel is shown at: - cyan lines on the dividers, and a pill just inside the rim with each section's id and start angle (every other one past 24 sections), so a canvas fitted to the wheel never crops them; - a red marker at each pointer with the local angle under it; - the pegs, the one being ridden filled pink, and each tongue's contact zone; - the landing angle in yellow with a `target` pill once the result is in; - the HUD panel: one per ring, in a corner of the canvas. `hud` takes `'top-left'` (default), `'top-right'`, `'bottom-left'`, `'bottom-right'`, an exact `{ x, y }`, or `false`. Corners on the right or bottom need `screen: app.screen`. Pick the layers with `layers: ['sections', 'target']` or `handle.setLayers([...])`; `fontSize` scales the text. ## Poking at the demos on this site Every live demo on this site registers itself on `window.__pixiWheels` under its recipe name, so the console can reach it: ```js const { wheel, spin } = __pixiWheels['playson-spine-wheel']; wheel.events.on('spin:landing', (e) => console.log(e.section.id, e.landingAngle)); spin(); // the same action as the Spin button debugArc(wheel); // via __PIXI_WHEELS_DEBUG, enabled on every demo ``` Each demo's frame carries `data-recipe=""`, which is what the end-to-end sweep uses to spin, skip and re-spin every recipe and compare where the wheel says it landed with what sits under the pointer. ### Dynamic sections URL: https://pixi-wheels.schmooky.dev/guides/dynamic-sections/ Some features change the wheel's look as a round progresses: a jackpot sector grows, a gamble's green half shrinks. In pixi-wheels a section's arc is its weight, and weights can change any time. Labels and skins re-lay out on every frame of the transition; the spin's landing angle is fixed when the result arrives and does not move. ## Direct ```ts await wheel.setWeights({ grand: 2, mini: 3 }, { durationMs: 600, ease: 'sine.inOut' }); ``` Unnamed sections keep their weights. `durationMs: 0` snaps. ## Steps ```ts .dynamic({ steps: [ { mini: 5, major: 1.5, grand: 0.6 }, { mini: 4, major: 2, grand: 1 }, { mini: 3, major: 3, grand: 1.6 }, ], durationMs: 700, initialStep: 0, }) await wheel.setStep(2); await wheel.nextStep(); // wraps around wheel.step; // current index, or null without steps ``` ## Quantised Think of the rim as N slots and use integer weights: a step moves a border by exactly one slot and the static sections never drift. ## Events `sections:transition:start`, `sections:transition:end`, `sections:changed` (also fires for a snap). The payload names the ring and the step. ## Skins `GraphicsRingSkin` and `TextureRingSkin` follow weights (wedges, labels and per-section decorations move). Authored art (`PlaysonWheelSkin` on this site, `SpineRingSkin`) is painted for fixed arcs and does not. ### Events and audio URL: https://pixi-wheels.schmooky.dev/guides/events-and-audio/ ```ts wheel.events.on('spin:start', ({ ring, fromIdle }) => ...); ``` | Order | Event | Payload | Cue | |---|---|---|---| | 1 | `spin:start` | `ring, fromIdle, direction` | wind-up, start the loop | | 2 | `spin:resultSet` | `ring, target` | (the server answered) | | 3 | `spin:cruise` | `ring, speed` | whoosh | | 4 | `spin:stopping` | `ring, turns, duration, anticipation` | slow-down, riser | | 4a | `anticipation:start` | `ring, bait, style` | tension loop | | 4b | `anticipation:bait` | `ring, bait` | heartbeat, hold breath | | 4c | `anticipation:end` | `ring, bait` | release | | 5 | `spin:landing` | `ring, section, landingAngle` | sector win, reveal | | 6 | `spin:settle:start` / `spin:settle:end` | `ring, mode` | click | | 7 | `spin:complete` | `WheelSpinResult` | present, hand back | | any | `pointer:tick` | `ring, pointer, from, to, speed, direction` | ratchet click | | any | `skip:requested` / `skip:completed` | `ring, protectedByAnticipation` | swish | | any | `idle:start` / `idle:stop` | `ring, speed, direction` | ambient | | any | `sections:transition:start` / `:end`, `sections:changed` | `ring, step` | state change | | any | `speed:changed` | `ring, name, profile, previous` | | | end | `destroyed` | | | `resultSet` comes before `cruise` when the server is faster than the wind-up; it does not matter, the stop begins only once both the result and the minimum times are in. Wire audio to events, never to method calls: a skip, a slam and a normal stop all reach `spin:landing`. ## Wiring a real audio engine The [sound hooks recipe](/recipes/integration/#sound-hooks) uses [`@schmooky/zvuk`](https://zvuk.schmooky.dev), a small Web Audio engine with buses, voices and fades. The shape is the same for any engine: load once, listen to events, play voices, keep the handles you need to stop. ```ts import { createEngine } from '@schmooky/zvuk'; const engine = createEngine({ buses: { music: { level: 0.4 }, sfx: { level: 1 } } }); await engine.loadSound('spin', '/audio/wheel_spin.mp3'); await engine.loadSound('tick', '/audio/click.mp3'); await engine.loadSound('win', '/audio/sector_win.mp3'); let spinVoice; wheel.events.on('spin:start', () => { spinVoice = engine.sound('spin').play(); }); wheel.events.on('pointer:tick', ({ speed }) => engine.sound('tick').play({ volume: Math.min(1, speed / 900), pitch: { base: 0.9 + speed / 1400, jitter: 0.04 } })); wheel.events.on('spin:landing', () => { spinVoice?.stop({ fade: 0.25 }); engine.sound('win').play(); }); wheel.events.on('destroyed', () => engine.close()); // Browsers need a gesture before sound: unlock from the click that starts the spin. button.onclick = async () => { await engine.unlock(); await wheel.spin(); }; ``` Three habits that keep audio honest: - **Stop by voice, not by name.** `spin:landing` can arrive from a skip or a slam; keep the voice handle and stop that. - **Drive the ratchet from `pointer:tick`'s `speed`**, never from a timer. A creep, a stutter and a bounce all tick at their own rate. - **Close on `destroyed`.** A wheel that is torn down should not leave a music loop behind. ### Getting started URL: https://pixi-wheels.schmooky.dev/guides/getting-started/ pixi-wheels is a bonus wheel engine. You drop it into a PixiJS v8 app. ## You need - Node 20+, and pnpm, npm or yarn - A PixiJS v8 app. The engine advances on `app.ticker` - Spine skins? Also install `@esotericsoftware/spine-pixi-v8` ## Install ```bash pnpm add pixi-wheels pixi.js # only if you want Spine skins or pointers pnpm add @esotericsoftware/spine-pixi-v8 ``` ## Boot Pixi, build a wheel ```ts import { Application } from 'pixi.js'; import { WheelBuilder, SpinPresets } from 'pixi-wheels'; const app = new Application(); await app.init({ width: 800, height: 600, background: '#0b0d12' }); document.body.appendChild(app.canvas); const wheel = new WheelBuilder() .radius(240, 36) .sections([ { id: 'x2', label: 'x2', value: 2, weight: 3 }, { id: 'x5', label: 'x5', value: 5, weight: 2 }, { id: 'x10', label: 'x10', value: 10, weight: 1 }, { id: 'x2b', label: 'x2', value: 2, weight: 3 }, ]) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.position.set(400, 300); app.stage.addChild(wheel); const spin = wheel.spin(); const response = await fetch('/api/wheel').then((r) => r.json()); // your server wheel.setResult({ value: response.multiplier }); const result = await spin; console.log(result.section.id, result.offset); ``` ## Did it work The wheel winds up, cruises until the result is in, decelerates without a jerk and stops with the pointer on a section carrying the value you passed. Nothing happened? One of these: | You see | Do this | |---|---| | A blank canvas | The wheel sits at (0, 0) with its centre there. Set `wheel.position`. | | `ticker(app.ticker) must be called` | You forgot `.ticker(app.ticker)`. | | It spins and never stops | Nothing called `setResult()`. The wheel waits for its server. Pass `resultTimeoutMs` to `spin()` if you want a guard. | | `Unknown ease "..."` | A typo in a profile's `stopEase`. The message lists every name. | ## Where next - **Build a real one** -> [Your first wheel](/guides/your-first-wheel/) - **Understand the spin** -> [Spin lifecycle](/guides/spin-lifecycle/) - **Tease the player** -> [Anticipation](/guides/anticipation/) - **See what is possible** -> [Recipes](/recipes/) - **Click instead of type** -> [Studio](/studio/) ### Idle spin URL: https://pixi-wheels.schmooky.dev/guides/idle/ ```ts .idle({ speed: 14, direction: 'cw', autoStart: true, rampMs: 800 }) wheel.idle.start(); // or start({ speed }) without builder config wheel.idle.stop(); // ramps down ``` - `spin()` from idle ramps from the idle speed; `spin:start` reports `fromIdle: true`. - Idle resumes on its own after the spin completes while `idle.start()` is in effect. - `idle:start` and `idle:stop` events bracket it. Placement is yours: scale the wheel down and park it in a corner, then tween it over the reels for the feature. The [side wheel recipe](/recipes/sections-rings-idle/#a-wheel-beside-the-reels) shows the whole move. ### Landing and settle URL: https://pixi-wheels.schmooky.dev/guides/landing/ ## Where inside the section ```ts .landing({ mode: 'center' }) // the middle (default) .landing({ mode: 'random', margin: 0.12 }) // anywhere, kept off the edges wheel.setResult({ section: 'x5', offset: 0.9 }) // exact: 0 = clockwise start edge, 1 = end edge wheel.setResult({ angle: 123.4 }) // exact: an absolute wheel-local angle wheel.setResult({ position: 0.25 }) // a quarter of the way round from the layout start ``` The per-spin options in `setResult(target, options)` override the builder defaults. ## Settle What happens once the pointer has reached the landing angle: ```ts settle: 'none' // stay put (default) settle: { mode: 'center', delayMs: 300, durationMs: 700, ease: 'sine.inOut' } settle: { mode: 'bounce', bounceDeg: 5, durationMs: 500 } ``` `spin:landing` fires when the pointer reaches the landing angle, `spin:settle:start` / `spin:settle:end` bracket the move, `spin:complete` fires after everything. The result's `landingAngle` is the pre-settle angle. ## Skip ```ts .skip({ allowed: true, minimumSpinTime: 800, protectAnticipation: false }) ``` `wheel.skip()` returns `true` when it did something. It throws before `setResult()`; `wheel.requestSkip()` queues the press for the moment the result lands. `wheel.slamStop()` snaps to the final position synchronously. The profile's `skipDuration` is the length of the fast-forward. ### Pointers URL: https://pixi-wheels.schmooky.dev/guides/pointers/ ```ts .pointer({ id: 'top', // default 'pointer' for the first angle: -90, // screen degrees: -90 top, 0 right, 90 bottom facing: 'inward', // sits on the rim, tip toward the hub; 'outward' sits at the hub tipInset: 18, // how far the tip reaches into the sections flap: { elasticity: 1, friction: 0.35, stiffness: 420, damping: 14, maxAngle: 28, tipWidth: 14 }, skin: new GraphicsPointerSkin({ shape: 'tongue' | 'triangle' | 'needle', length: 72, width: 36, color: 0xffffff }), }) ``` Results are read against the first pointer. Add more for decoration or a second read position; each one ticks. ## Pegs and the tongue Every ring has pegs: small circles on the disc, one per divider, 9 px inside the rim, 6 px across. `.pegs({ size, inset, angles })` changes them, `.pegs(false)` removes them (a flapping pointer then stays at rest). The debug overlay draws them (`pegs` layer) and `GraphicsRingSkin` will too with `pegs: true`. Dynamic sections move their pegs with their lines. The tongue tip is a point `tipWidth` wide at the peg ring. As a peg comes toward it, the peg pushes the tip aside along its own rim, exactly as far as the geometry demands times `elasticity`. At the peg's crown the push is largest; the peg then carries the tongue flat until it is through, plus `friction` of the contact width, and lets go. From there a spring (`stiffness`, `damping`) brings the tongue back, ringing if the damping is low. At speed a peg goes through within one frame and the tongue is flicked to the crown deflection instead, so a fast wheel keeps it pinned and jittering while a crawling one bends it slowly over every peg and lets it fall back. | Knob | Feel | |---|---| | `elasticity` | how far the tongue yields: 0.6 a stiff stub, 1 the geometry, 1.5 a floppy strip | | `friction` | extra carry once the peg is through: 0 lets go at once, 1 drags a whole contact width more | | `stiffness` | how hard it snaps back | | `damping` | how quickly the ringing dies | | `maxAngle` | the hard limit | | `tipWidth` | how early a peg starts pushing | | pegs `size` / `inset` | bigger or deeper pegs push earlier and harder | `flap: false` keeps a pointer rigid. Read `pointer.deflection` and `pointer.engagedPeg` for your own reactions; the HUD prints both. ## Ticks ```ts wheel.events.on('pointer:tick', ({ pointer, from, to, speed, direction }) => { audio.play('tick', { volume: Math.min(1, speed / 700) }); }); ``` One event per divider, in order, however fast the ring turns; a single frame that sweeps several dividers reports each of them. ## Texture and Spine pointers ```ts new TexturePointerSkin({ texture, artDirection: 'up', pin: { x: 0.5, y: 0.85 }, scale: 1 }) ``` The pin is the point the art pivots around; `artDirection` says which way the tip points in the source image. `SpinePointerSkin` (from `pixi-wheels/spine`) plays a `tick` animation on every crossing and an `idle` loop otherwise. ### Rings and subwheels URL: https://pixi-wheels.schmooky.dev/guides/rings/ ```ts const wheel = new WheelBuilder() .radius(280, 190) // the main ring: outer 280, inner 190 .sections(OUTER) .ring('inner', (r) => r.radius(175, 50) .direction('ccw') .pointer({ angle: 0, facing: 'inward', tipInset: 12 }) .sections(INNER) .skin({ type: 'graphics', rim: { width: 6 } }), ) .ticker(app.ticker) .build(); const outer = wheel.spin(); const inner = wheel.spin({ ring: 'inner' }); wheel.setResult({ section: 'major' }); wheel.setResult({ section: 'grand' }, { ring: 'inner' }); await Promise.all([outer, inner]); ``` - Rings may touch but not overlap; `build()` checks the radii. - Every event payload carries `ring`. One `wheel.events` serves them all. - `wheel.ring('inner')` is the `Ring` itself: `spin()`, `setResult()`, `skip()`, `setWeights()`, `idle`, `pointers`, `skin`. - Methods on `Wheel` without a `ring` option address the main ring. ## Pointers for an inner ring `facing: 'inward'` seats the pointer on the ring's outer edge with the tip pointing at the hub: on an inner ring that puts it on the outer ring's inner edge, the "special inward arrow". `facing: 'outward'` seats it at the ring's inner radius pointing out. ## One ring triggering another Listen for the outer landing and start the inner spin from it: ```ts wheel.events.on('spin:landing', ({ ring, section }) => { if (ring === 'main' && section.id === 'spin') { const inner = wheel.spin({ ring: 'inner' }); wheel.setResult(serverInnerTarget, { ring: 'inner' }); } }); ``` ### Sections and arcs URL: https://pixi-wheels.schmooky.dev/guides/sections/ A ring is a list of sections laid out clockwise from `startAngle` (default -90, twelve o'clock). Each section's arc is its `weight` share of 360 degrees. ```ts .startAngle(-90) .sections([ { id: 'mini', label: 'MINI', value: 'mini', weight: 5 }, { id: 'grand', label: 'GRAND', value: 'grand', weight: 0.5 }, ]) ``` ## Ids, labels, values - `id` names the section in `setResult({ section })`, in events and in the debug output. Unique per ring. - `label` is what a label-drawing skin writes. Defaults to the id; `''` draws nothing. - `value` is the payload the section stands for. Several sections may share one, and `setResult({ value })` lands on one of them. - `tags` are yours. Skins on this site use them to pick authored plates. ## Weights Relative, positive, finite. `weight: 0.5` is half a default section. Change them at run time with `wheel.setWeights()`; see [Dynamic sections](/guides/dynamic-sections/). ## Styles Per-section overrides for the painted skin and for labels on any skin: ```ts style: { fill: 0xf1c40f, alpha: 1, stroke: 0xffffff, labelColor: 0x2b1d00, labelSize: 30, labelFont: 'Roboto Condensed', labelWeight: '800', labelOrientation: 'radial' | 'tangential' | 'upright', labelRadius: 0.68, // fraction of the outer radius } ``` Sections without a `fill` cycle the ring's palette (`.palette([...])`). ## Reading the geometry ```ts const s = wheel.geometry.byId('grand'); // { startAngle, endAngle, midAngle, arc, ... } wheel.geometry.sectionAt(localAngle); // which section covers a wheel-local angle wheel.sectionUnderPointer(); // right now wheel.main.localAngleUnderPointer(); // wheel-local degrees under the pointer ``` Angles are wheel-local: they turn with the disc. A point at local angle `a` is on screen at `a + rotation`, so the pointer at screen angle `p` reads local `p - rotation`. A clockwise spin therefore sweeps *decreasing* local angles under the pointer: it enters a section through its `endAngle` and leaves through its `startAngle`. `geometry.entryAngle(section, direction)` spells this out; the anticipation planner relies on it. ## Rich labels A label is not only a string. Give a section `content`: any container, or a factory that builds one. The label layer of every label-drawing skin (`GraphicsRingSkin`, `TextureRingSkin` with `labels: true`, `SpineRingSkin` with `labels: true`, the studio skins on this site) places it at the label radius, rotates it by `labelOrientation` and fits it into the section's room. ```ts import { Sprite, Text, Container } from 'pixi.js'; .sections([ // A ready-made object. { id: 'x2', value: 2, content: new Sprite(coinTexture) }, // A factory: built once, from the section and the room it has. { id: 'jackpot', weight: 2, content: (ctx) => { const view = new Container(); const icon = new Sprite(crownTexture); const text = new Text({ text: 'JACKPOT', style: { fontSize: 40, fontWeight: '800', fill: 0xffd23f } }); text.anchor.set(0.5); text.position.y = icon.height / 2 + 24; view.addChild(icon, text); ctx.fit(view, { padding: 0.1 }); // scale into the slot, 10% free around return view; }, style: { labelOrientation: 'tangential', labelRadius: 0.62 } }, // A Spine instance works the same way: it keeps animating after placement. { id: 'bonus', content: () => Spine.from({ skeleton: 'bonusSkeleton', atlas: 'fxAtlas' }) }, ]) ``` The factory receives a `LabelContext`: the resolved `section`, `outerRadius`, `innerRadius`, the `slot` (see below) and a bound `fit()`. Content is built once per section and rebuilt only when the section is given a different `content`. `toConfig()` keeps the text `label` and drops `content`; rich labels are code. Orientation applies to content as to text. `'tangential'` puts the top of the content toward the rim (upright at twelve o'clock); `'tangential-in'` toward the hub (upright at six o'clock, for a wheel read from below, like the Pragmatic recipe); `'upright'` counter-rotates every frame. ## Fitting content The room a section offers is a **slot**: the chord across the wedge at the label radius, and the radial room around that point inside the ring band. `labelSlot()` computes it; the label layer fits every label into it with `labelFit` (`'contain'` by default, also `'cover'`, `'width'`, `'height'`, `'none'`). ```ts import { labelSlot, scaleToFit, fitContainer, fitText } from 'pixi-wheels'; const slot = labelSlot(section, outerRadius, innerRadius, { radius: 0.68, orientation: 'radial' }); // slot.width / slot.height: the box, oriented like the label. slot.chord, slot.radial: the raw room. fitContainer(view, slot); // sets view.scale so its local bounds fit; returns the factor fitContainer(view, slot, { mode: 'width', max: Infinity }); // match the width, allowed to grow fitText(text, { width: slot.width * 0.8, height: slot.height * 0.8 }); // lowers fontSize instead: crisp glyphs scaleToFit({ width: 400, height: 80 }, slot); // the pure number, no PixiJS ``` Content is re-fitted whenever the geometry changes, so dynamic sections shrink and grow their labels with their arcs. Scaling never enlarges by default (`max: 1`): author content at the size it should have on a full-size wedge and let the fit only take it down. ### Skins, textures and Spine URL: https://pixi-wheels.schmooky.dev/guides/skins/ A ring owns the motion; a skin owns the pixels. The ring hands the skin two containers - `disc`, which rotates, and `overlay`, which does not - plus the geometry, and calls `layout()` whenever weights change. ## Built-in | Skin | Config type | What it draws | |---|---|---| | `GraphicsRingSkin` | `graphics` | Wedges, dividers, rim, hub, bulbs, fitted labels, shading. The default. | | `DebugRingSkin` | `debug` | Flat fills, ids, start angles, degree marks, pointer lines. | | `TextureRingSkin` | `texture` | One painted face texture, an optional fixed frame, per-section decorations that follow the geometry. | | `SpineRingSkin` | `spine` | A skeleton; the wheel rotates a bone (or the whole skeleton) and plays `spin` / `win` animations. | | `HeadlessRingSkin` | `headless` | Nothing. Tests. | Pass an instance or a config: ```ts .skin(new TextureRingSkin({ face, frame, decorations: [{ section: 'x50', texture: plate, radius: 0.7 }] })) .skin({ type: 'texture', face: 'wheel-face.webp', frame: 'bezel.webp' }) // keys resolved by .assets(resolver) ``` ## Spine ```ts import 'pixi-wheels/spine'; // registers the 'spine' config types import { SpineRingSkin, SpinePointerSkin } from 'pixi-wheels/spine'; .skin(new SpineRingSkin({ skeleton: 'wheelData', atlas: 'wheelAtlas', // Assets aliases bone: 'wheel', // the bone the engine turns; omit to turn the whole skeleton animations: { idle: 'idle', spin: 'spin', win: 'win', winBySection: { grand: 'win_grand' } }, })) .pointer({ skin: new SpinePointerSkin({ skeleton: 'stopperData', atlas: 'wheelAtlas', length: 80, tickAnimation: 'tick' }) }) ``` `labels: true` on `SpineRingSkin` draws the sections' text or rich `content` on the disc above the skeleton, for values the art cannot know (server-driven multipliers). `winBySection` maps section ids to landing animations, so a skeleton can carry one celebration per plate. Spine 3.8 exports need converting to 4.2 first: `tools/spine-3.8-to-4.2/` in the repo does binary `.skel` and JSON; `tools/spine-3.7-to-4.2/` does 3.7 JSON. When a game packs a skeleton's images into its UI atlases instead of a Spine atlas, `tools/pragmatic-wheel/build_fx_atlas.py` rebuilds a Spine atlas from the sprite rectangles. When the skeleton itself is lost but the atlas survives, `tools/playson-wheel/build_spine.py` shows how to author one over the atlas, sector effects included. The [Playson Spine recipe](/recipes/skins/#playson-the-super-wheel-as-a-spine-skeleton) and the [Pragmatic recipe](/recipes/skins/#pragmatic-play-wheel-of-happiness) run on those outputs. ## Your own ```ts class MySkin implements RingSkin { attach(ctx: RingSkinContext) { /* add children to ctx.disc / ctx.overlay */ } layout() { /* re-draw for ctx.geometry.sections */ } syncRotation?(deg: number) {} highlight?(id: string | null) {} onSpinStart?() {} onSpinStop?() {} onLanded?(section) {} destroy() {} get isDestroyed() { return false; } } registerRingSkin('mine', (config, assets) => new MySkin(config)); ``` The Playson wheel on the recipes page is exactly this: authored plates composed from an atlas, in ~150 lines. ### Spin lifecycle URL: https://pixi-wheels.schmooky.dev/guides/spin-lifecycle/ ``` idle -> starting -> cruising -> stopping -> [settling] -> idle ^ | ^ | setResult() the plan: decel [+ bait legs] [+ settle legs] idling (optional slow rotation while nothing happens) ``` ## States | State | What the ring does | Allowed calls | |---|---|---| | `idle` | Nothing. | `spin()`, `idle.start()`, `setWeights()` | | `idling` | Turns slowly at the idle speed. | `spin()` (ramps from the idle speed), `idle.stop()` | | `starting` | Accelerates to `spinSpeed` over `accelerationMs`. | `setResult()`, `requestSkip()` | | `cruising` | Constant speed, waiting for the result and the minimum times. | `setResult()`, `skip()`, `requestSkip()` | | `stopping` | Plays the planned legs: deceleration, anticipation, landing. | `skip()`, `slamStop()` | | `settling` | Plays the settle legs (centre glide or bounce). | nothing; wait | `spin()` while spinning throws. `setResult()` twice throws. `setResult()` after the stop began throws. Read `wheel.main.state` if you need to branch. ## The planned stop The moment the stop may begin, the engine plans it as a list of legs and plays them back. The first leg starts at the cruise speed: its ease's initial slope is matched to the speed, so the distance fixes the duration and the planner picks the number of full turns that gets closest to the profile's `stopDuration`. There is no velocity step when the result arrives. - Plain stop: one leg to the landing angle. - Creep: a leg to the bait's entry edge ending at `creepSpeed`, then a crawl over the line to a rest just inside the landing section. - Stutter: the same crawl, slowing to a near-stall a hair short of the line, then a slip over it to the rest. - Stall: a leg to the landing section's entry edge ending at `creepSpeed`, then a crawl toward the bait's line that dies just short of it. No plan ever contains a reverse leg or a stop before the rest: the segment the wheel stops on is the result. - Settle: a dwell and a glide to the centre, or a bounce out and back. `spin:stopping` reports the turn count and total duration; the debug overlay draws the current leg. ## Landing angle `setResult()` resolves the target to a wheel-local angle at once: the section's middle, a random spot inside it, or the exact offset or angle you passed. A tease moves that angle next to the divider the target shares with the bait (`rest`), so the miss reads as "by a hair". The stop is planned against the final angle, so a dynamic-section change during the spin does not move the landing. ## Skip and slam `skip()` replaces the remaining legs with a short fast-forward to the landing (at least a third of a turn) and keeps the settle. With `protectAnticipation`, the first press fast-forwards to the bait instead and the second lands. `slamStop()` snaps to the final position and completes synchronously; it is what the test harness uses. ## Result ```ts interface WheelSpinResult { ring: string; // 'main' on a single-ring wheel section: ResolvedSection; landingAngle: number; // wheel-local degrees under the pointer offset: number; // 0..1 inside the section wasSkipped: boolean; duration: number; // ms from spin() to complete turns: number; // full turns made } ``` ### Studio URL: https://pixi-wheels.schmooky.dev/guides/studio/ The [studio](/studio/) is a live wheel with its config beside it. - **Sections**: ids, labels, values, weights, colours, label orientation; reorder; snapshot the current weights as a dynamic step. - **Wheel**: radii, start angle, direction; the pointer's angle, facing, flap and skin; the ring skin (graphics, debug, texture, Spine) and its options. - **Spin**: the speed profile fields, landing mode and settle, skip rules, idle. - **Assets**: upload textures or a Spine bundle. They stay in your browser and are referenced by file name. - **Code**: the fluent code the config stands for. Run it to take over the canvas with anything a recipe can do. Every recipe's "Studio" button lands here. - **Export**: the config JSON (import it back later), the fluent code, or a runnable Vite project with your assets. On the canvas: pick the section the next spin lands on and the bait, switch speed profiles, step dynamic sections, toggle the debug overlay. ### Testing URL: https://pixi-wheels.schmooky.dev/guides/testing/ ```ts import { createTestWheel, expectPointerOn, captureEvents, seededRng } from 'pixi-wheels/testing'; const h = createTestWheel({ sections: [{ id: 'x2', weight: 3 }, { id: 'x50', weight: 0.5 }, { id: 'x5', weight: 2 }], direction: 'cw', landing: { mode: 'random' }, rng: seededRng(7), }); const log = captureEvents(h.wheel, ['anticipation:bait', 'spin:landing']); const result = await h.spinAndLand({ section: 'x2' }, { anticipation: { bait: 'x50' } }); expect(result.section.id).toBe('x2'); expectPointerOn(h.wheel, 'x2'); expect(log.map((e) => e.event)).toEqual(['anticipation:bait', 'spin:landing']); h.destroy(); ``` - `spinAndLand()` runs frames until the spin completes. `advance(ms)` steps time by hand; `runUntilIdle()` finishes whatever is in flight. - `HeadlessRingSkin` and `HeadlessPointerSkin` draw nothing, so no renderer or DOM is needed. - `FakeTicker` is duck-compatible with `PIXI.Ticker`; pass it to `WheelBuilder.ticker()` directly for a custom setup. - `planStop()` and `resolveTarget()` are pure; test them without a wheel. ### Your first wheel URL: https://pixi-wheels.schmooky.dev/guides/your-first-wheel/ This is the shape of a shipped feature: a multiplier wheel that opens when the base game awards it, spins while the server answers, baits with the top prize, lands, presents, and hands control back. ## 1. Sections with unequal arcs Weights are relative. The big prize gets a sliver. ```ts const SECTIONS = [ { id: 'x2a', label: 'x2', value: 2, weight: 3 }, { id: 'x50', label: 'x50', value: 50, weight: 0.6, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'x5', label: 'x5', value: 5, weight: 2 }, { id: 'x3', label: 'x3', value: 3, weight: 2.5 }, { id: 'x10', label: 'x10', value: 10, weight: 1 }, { id: 'x2b', label: 'x2', value: 2, weight: 3 }, ]; ``` Ids must be unique; values may repeat. `setResult({ value: 2 })` picks one of the `x2` wedges at random, so the same result does not always land on the same wedge. ## 2. Build ```ts import { WheelBuilder, SpinPresets } from 'pixi-wheels'; const wheel = new WheelBuilder() .radius(260, 40) // outer radius, hub radius .sections(SECTIONS) .pointer({ angle: -90 }) // twelve o'clock, the default .skin({ type: 'graphics', bulbs: { count: 24 } }) .landing({ mode: 'random', margin: 0.12, settle: 'none' }) .skip({ minimumSpinTime: 800 }) .speed('normal', SpinPresets.NORMAL) .speed('turbo', SpinPresets.TURBO) .ticker(app.ticker) .build(); ``` `build()` validates everything and throws a message that names the fix: a missing ticker, an inner radius larger than the outer, an ease that does not exist, a bait that is not a section. ## 3. Spin, ask the server, land ```ts async function playWheel(): Promise { const spin = wheel.spin(); const response = await api.spinWheel(); // { multiplier: 5 } wheel.setResult( { value: response.multiplier }, { anticipation: { bait: 'x50' } }, // almost the big one ); const result = await spin; return result.section.value as number; } ``` `spin()` returns immediately with a promise; the wheel is already cruising while the request is in flight. `setResult()` may arrive any time before the stop; the stop begins once the profile's minimum times have passed. If the bait does not sit next to the landing the tease is skipped with a console warning and the wheel just lands. ## 4. Wire the events ```ts wheel.events.on('spin:start', () => audio.play('windup')); wheel.events.on('pointer:tick', ({ speed }) => audio.play('tick', { volume: speed / 700 })); wheel.events.on('anticipation:bait', () => audio.play('heartbeat')); wheel.events.on('spin:landing', ({ section }) => hud.showPrize(section.value)); wheel.events.on('spin:complete', () => game.resumeBaseGame()); ``` Every exit path fires the right events: a normal stop, a skip, a slam. Listen once and audio stays correct. ## 5. Skip ```ts skipButton.on('pointerdown', () => { try { wheel.skip(); } // throws before the result is in... catch { wheel.requestSkip(); } // ...so queue the press instead }); ``` ## 6. Test it ```ts import { createTestWheel, expectPointerOn } from 'pixi-wheels/testing'; it('lands the server result', async () => { const h = createTestWheel({ sections: SECTIONS }); const result = await h.spinAndLand({ value: 5 }, { anticipation: { bait: 'x50' } }); expect(result.section.value).toBe(5); expectPointerOn(h.wheel, result.section.id); h.destroy(); }); ``` No renderer, no timers. The whole spin runs on a fake ticker in a few milliseconds. ## API guides ### Builder URL: https://pixi-wheels.schmooky.dev/docs/api-builder/ `new WheelBuilder()` configures the main ring through its own methods and extra rings through `ring()`. `build()` validates and returns a `Wheel`. ## Main ring (also on `RingBuilder`) | Method | Meaning | Default | |---|---|---| | `radius(outer, inner = 0)` | Outer and hub radius in px. Inner must be smaller than outer. | required | | `sections(list)` / `section(cfg)` | Replace / append `WheelSectionConfig`s. At least two, unique ids, positive weights. | required | | `startAngle(deg)` | Where section 0 begins, wheel-local. | -90 | | `pointer(spec)` | Add a pointer: `PointerConfig` plus `skin` (instance or config). Repeatable. | one at -90 | | `skin(skin)` | `RingSkin` instance or `RingSkinConfig`. | `GraphicsRingSkin` | | `dynamic({ steps, durationMs, ease, initialStep })` | Dynamic-section steps. Step ids must exist. | none | | `palette(colors)` | Fill colours cycled by index. | built-in palette | `RingBuilder` adds `direction(dir)`, `outerRadius(r)`, `innerRadius(r)`. ## Wheel level | Method | Meaning | Default | |---|---|---| | `direction('cw' \| 'ccw')` | Spin direction for rings without their own. | `'cw'` | | `ring(id, (r) => ...)` | Add a ring around the same centre. Rings may not overlap. | | | `speed(name, profile)` | Register a `SpinProfile`. Validated: positive speeds, `minTurns <= maxTurns`, eases exist. | `normal: SpinPresets.NORMAL` | | `initialSpeed(name)` | Active profile at build. | first registered | | `landing(options)` | Defaults for `setResult()`: `mode`, `margin`, `settle`, `anticipation`. | center, none | | `skip({ allowed, minimumSpinTime, protectAnticipation })` | Skip rules. | allowed | | `idle({ speed, direction, autoStart, rampMs })` | Idle rotation. | none | | `rng(fn)` | Random source for random landings and `pick: 'random'`. | `Math.random` | | `ticker(ticker)` | The PixiJS ticker. | required | | `assets(resolver)` | Where skin configs find textures. | throws on use | | `name(str)` / `adapter(cfg)` | Kept in `toConfig()`. | | | `toConfig()` | The builder as `WheelConfig`. | | | `WheelBuilder.fromConfig(cfg, { assets, ticker })` | A builder from a config. | | ## Validation `build()` throws on: no ticker; no radius; fewer than two sections; duplicate section or pointer ids; non-positive weights; inner radius not below outer; overlapping rings; unknown `initialSpeed`; a profile field out of range; an unknown ease anywhere; a dynamic step naming an unknown section; an idle speed of zero. Each message names the call to fix. ### Events URL: https://pixi-wheels.schmooky.dev/docs/api-events/ `wheel.events` is an `EventEmitter`. `on(name, fn)`, `once`, `off`, `removeAllListeners`, `listenerCount`. A listener that throws stops the remaining listeners for that emit and propagates into the engine; wrap anything that can throw. ```ts interface WheelEvents { 'spin:start': [{ ring; fromIdle; direction }]; 'spin:cruise': [{ ring; speed }]; 'spin:resultSet': [{ ring; target: ResolvedTarget }]; 'spin:stopping': [{ ring; turns; duration; anticipation: AnticipationStyle | null }]; 'spin:landing': [{ ring; section: ResolvedSection; landingAngle }]; 'spin:settle:start': [{ ring; mode: 'center' | 'bounce' }]; 'spin:settle:end': [{ ring }]; 'spin:complete': [WheelSpinResult]; 'anticipation:start': [{ ring; bait: ResolvedSection; style }]; 'anticipation:bait': [{ ring; bait }]; 'anticipation:end': [{ ring; bait }]; 'pointer:tick': [{ ring; pointer; from: ResolvedSection; to: ResolvedSection; speed; direction }]; 'skip:requested': [{ ring; protectedByAnticipation }]; 'skip:completed': [{ ring }]; 'idle:start': [{ ring; speed; direction }]; 'idle:stop': [{ ring }]; 'sections:changed': [{ ring; sections; step: number | null }]; 'sections:transition:start': [{ ring; durationMs; step }]; 'sections:transition:end': [{ ring; step }]; 'speed:changed': [{ ring; name; profile; previous }]; 'destroyed': []; } ``` Order within a spin: `spin:start`, `spin:resultSet` and `spin:cruise` in whichever order the server and the wind-up finish, `spin:stopping`, `anticipation:start`, `anticipation:bait`, `anticipation:end`, `spin:landing`, `spin:settle:start`, `spin:settle:end`, `spin:complete`. A skip inserts `skip:requested` when pressed and `skip:completed` right before `spin:landing`. ### Spin profiles URL: https://pixi-wheels.schmooky.dev/docs/api-profiles/ ```ts interface SpinProfile { spinSpeed: number; // deg/s at cruise accelerationMs: number; // rest to cruise accelerationEase?: Ease; // default 'power2.in' minimumSpinTime: number; // the stop may not begin before this many ms since spin() minCruiseMs: number; // nor before this long at cruise stopDuration: number; // what the deceleration should take (target) stopEase?: Ease; // an ease-out; default 'power3.out' minTurns: number; // fewest full turns while stopping maxTurns: number; // most skipDuration: number; // the fast-forward on skip } ``` | Preset | spinSpeed | accel | min spin | stop | turns | |---|---|---|---|---|---| | `NORMAL` | 540 | 900 | 1400 | 4200 `power3.out` | 1-8 | | `TURBO` | 720 | 400 | 600 | 2000 `power3.out` | 1-4 | | `CINEMATIC` | 450 | 1400 | 2200 | 8000 `power4.out` | 1-6 | | `QUICK` | 900 | 250 | 400 | 1300 `power2.out` | 1-3 | ## How the stop duration is chosen The planner matches the ease's start slope to the cruise speed so the hand-off is smooth. That fixes the duration for any given distance: `T = distance * slope / speed`. It then tries every turn count from `minTurns` to `maxTurns` and keeps the one whose `T` is closest to `stopDuration`. The actual duration is therefore near the target, not equal to it; `spin:stopping` reports it. `power3.out` has slope 4, `power2.out` 3, `power1.out` 2, `expo.out` about 7. An `inOut` ease starts at slope 0 and is clamped with a one-time warning; use an `out` curve. ## Eases Names follow the GSAP vocabulary without depending on it: `none`, `linear`, `power1..4.in|out|inOut` (`quad`, `cubic`, `quart`, `quint` are aliases), `sine.*`, `expo.*`, `circ.*`, `back.in|out(1.7)`. Or pass a `(t) => number`. `EASE_NAMES` lists them; `resolveEase(name)` throws on a typo with the list. A stop leg whose ease starts at the cruise speed lasts `turns * 360 * slope / spinSpeed` seconds (`slope` is the ease's start slope: 3 for `power2.out`, 4 for `power3.out`, 5 for `power4.out`), so the planner can only choose among whole-turn durations. Keep `stopDuration` inside the range `minTurns..maxTurns` spans, or the nearest turn count wins silently; the presets are tested for it. ### Wheel and Ring URL: https://pixi-wheels.schmooky.dev/docs/api-wheel/ `Wheel` is a `Container` holding one or more `Ring`s. Methods without a `ring` argument address the main ring. ## Spin ```ts wheel.spin({ ring?, direction?, resultTimeoutMs? }): Promise wheel.setResult(target: WheelTarget, { ring?, mode?, margin?, settle?, anticipation? }): void wheel.skip(ring?): boolean // throws before setResult(); false when disabled / too early wheel.requestSkip(ring?): void // queue a press until the result is in wheel.slamStop(ring?): void // snap to the final position, complete synchronously wheel.isSpinning: boolean ``` ## Speed ```ts wheel.setSpeed(name, ring?) // 'speed:changed' wheel.activeSpeed; wheel.speedNames ring.addSpeed(name, profile) ``` ## Idle ```ts wheel.idle.start(config?, ring?) // config from the builder when omitted wheel.idle.stop(ring?) ``` ## Dynamic sections ```ts wheel.setWeights({ id: weight }, { durationMs?, ease?, ring? }): Promise wheel.setStep(index, options?): Promise wheel.nextStep(options?): Promise wheel.step: number | null ``` ## Reading ```ts wheel.sections; wheel.geometry // main ring wheel.sectionUnderPointer(pointerId?) wheel.rotationDeg // settable while not spinning wheel.radius // largest outer radius wheel.main; wheel.ring(id); wheel.rings; wheel.hasRing(id) wheel.highlight(sectionId | null, ring?) ``` ## Ring Everything above without the `ring` argument, plus: ```ts ring.id; ring.outerRadius; ring.innerRadius; ring.direction ring.state: 'idle' | 'idling' | 'starting' | 'cruising' | 'stopping' | 'settling' ring.speed // signed deg/s, last frame ring.disc; ring.overlay // containers a custom skin or effect can draw into ring.pointers; ring.skin; ring.controller ring.localAngleUnderPointer(pointerId?) ring.update(deltaMS) // called by the ticker; call by hand with a fake one ``` ## Teardown `wheel.destroy()` destroys every ring (skins, pointers, ticker callbacks), emits `destroyed`, drops all listeners, and destroys the container tree. ### Glossary URL: https://pixi-wheels.schmooky.dev/docs/glossary/ Terms are grouped by what they describe. Within a group they are alphabetical. Code names are in `monospace`; the guide that covers a term is linked at the end of its entry. ## The wheel and its parts **Arc**: a section's angular size in degrees, its weight's share of 360. Arcs need not be uniform. [Sections](/guides/sections/) **Bezel**: the decorated frame around the rim. Authored art (the Playson wheel has twelve bezel pieces); the graphics skin draws a plain rim instead. **Bulbs**: the lights on a bezel. Skins animate them (alternate at rest, chase while spinning, strobe on a win); the engine knows nothing about them. **Disc**: the container of a ring that rotates. Sections, labels and a face texture live here. The **overlay** is the ring's fixed container: rim decoration, hub caps and pointers. **Divider**: the boundary between two sections. Every divider a pointer crosses fires `pointer:tick`. [Pointers](/guides/pointers/) **Face**: a single painted texture of the whole disc, for `TextureRingSkin`. [Skins](/guides/skins/) **Hub**: the centre of the wheel. `innerRadius` is its radius; sections start there. **Inner radius / outer radius**: the ring band, from the hub edge to the rim. `radius(outer, inner)` on the builder. Labels and pointers are placed relative to both. **Main ring**: the ring the wheel's shortcut methods (`spin`, `setResult`, `sections`) address. `'main'` by default. [Rings](/guides/rings/) **Plate**: an authored wedge image for one section, seated with its apex on the hub. Studio art usually ships one plate per prize type. **Rim**: the outer edge of the disc. **Ring**: one spinning disc with its sections, pointers and skin. A wheel has one or more; rings share one event stream. Also **subwheel** when it sits inside another ring. [Rings](/guides/rings/) **Section**: one wedge of a ring: `id`, `label`, `value`, `weight`, `style`, `tags`, optional rich `content`. Also called a sector or a segment. [Sections](/guides/sections/) **Start angle**: where section 0 begins, wheel-local degrees. Default -90, twelve o'clock. Sections lay out clockwise from there. **Wheel**: the `Container` the builder returns: its rings, its event emitter, the shortcuts to the main ring. ## Labels and content **Content**: a section's rich label: any `Container` (a `Text`, a `Sprite`, a `BitmapText`, a Spine instance, a group), or a factory that builds one from a `LabelContext`. Placed, rotated and fitted like a text label. [Sections](/guides/sections/#rich-labels) **Fit / fit mode**: how a label meets the room it has. `'contain'` (default), `'cover'`, `'width'`, `'height'`, `'none'`. Helpers: `scaleToFit`, `fitContainer`, `fitText`. [Sections](/guides/sections/#fitting-content) **Label**: the text a label-drawing skin writes for a section. Defaults to the id; `''` draws nothing. **Label context**: what a `content` factory receives: the resolved section, the radii, the slot, and a bound `fit()`. **Label orientation**: `'radial'` reads from the hub to the rim; `'tangential'` follows the arc with the top toward the rim (upright at twelve o'clock); `'tangential-in'` follows the arc with the top toward the hub (upright at six o'clock, for wheels read from below); `'upright'` stays upright on screen as the disc turns. **Label radius**: where along the radius a label sits, as a fraction of the outer radius. Default 0.68. **Slot**: the box a section offers a label at its label radius: the chord across the wedge there and the radial room around it. `labelSlot()` computes it. **Chord**: the straight-line width across an arc at a radius. The tangential room a label has. ## Sizes, weights and states **Dynamic sections**: weights that change at run time, animated, with labels and plates re-centring. Purely cosmetic: the server's outcome is applied by `setResult`, never by the geometry. [Dynamic sections](/guides/dynamic-sections/) **Step**: one authored set of weights in a `dynamic` config; `setStep(i)` / `nextStep()` move between them. **Weight**: a section's relative arc share. Default 1. `{ weight: 0.5 }` is half a default section. **Weight transition**: the animation from one weight set to another: `durationMs`, `ease`, and `sections:transition:start` / `:end` around it. ## Spinning **Cruise**: the constant-speed phase between the wind-up and the stop, while the result is awaited. `spin:cruise` marks its start. **Direction**: `'cw'` (clockwise, the default) or `'ccw'`. Per wheel or per ring. **Ease**: the curve a leg follows. GSAP-style names (`power3.out`, `back.out`, `sine.inOut`) or a function. The stop's ease is matched to the cruise speed so the wheel never jerks. [Spin lifecycle](/guides/spin-lifecycle/) **Idle**: a slow continuous spin while nothing happens, for a wheel parked beside the reels. `startIdle()` / `stopIdle()`; a real `spin()` takes over from it. [Idle](/guides/idle/) **Leg**: one piece of a planned stop: a distance, a duration, an ease, forwards or reverse. Kinds: decel, creep, hesitate, push, dwell, skip, settle, bounce, bounce-return. **Plan / planned stop**: the list of legs the engine computes the moment the result is known. Everything after that is playback, so the landing is exact and deterministic. [Spin lifecycle](/guides/spin-lifecycle/) **Profile**: a `SpinProfile`: cruise speed, wind-up, stop duration, ease, turn range. Registered under a name (`speed('turbo', ...)`) and switched with `setSpeed`. Presets: `NORMAL`, `TURBO`, `CINEMATIC`, `QUICK`. [Profiles](/docs/api-profiles/) **Result timeout**: how long a spin cruises without a result before rejecting (`resultTimeoutMs`), so a lost server response does not spin forever. **Speed**: cruise speed in degrees per second. `pointer:tick` reports the instantaneous speed for sound. **Spin**: `spin()` starts one; it resolves with a `WheelSpinResult` once the wheel has landed and settled. **Turns**: full rotations in the stop leg. `minTurns` / `maxTurns` bound them; the planner picks the count that best matches `stopDuration`. **Wind-up**: the acceleration from rest to cruise speed at the start of a spin. ## Landing **Entry edge / exit edge**: the boundary of a section a pointer meets first and last for a given spin direction. Clockwise, the entry edge is the section's `endAngle`. **Landing angle**: the wheel-local angle that ends under the pointer. Fixed when the result arrives and never changed by skips or slams. [Landing](/guides/landing/) **Landing mode**: where inside the section the pointer stops: `'center'`, `'random'` (with `margin` from the dividers), or `'exact'` at an `offset` (0..1 across the section). [Landing](/guides/landing/) **Margin**: the fraction of a section kept clear at both dividers in `'random'` mode, so a landing never looks like a divider. **Settle**: the move after landing: `'none'`, `'center'` (glide to the middle after `delayMs`), or `'bounce'` (overshoot and spring back). `spin:settle:start` / `:end` around it. [Landing](/guides/landing/) **Target**: a `WheelTarget`: where a spin lands, as the server decides it. `{ section }`, `{ index }`, `{ value }`, `{ angle }`, each with an optional `offset` or `position`. [Adapters](/guides/adapters/) **Resolved target**: a target turned into a section and a landing angle. ## Anticipation **Anticipation**: a planned near-miss. The pointer heads for the bait, lands on the target, and rests by the line the two share. The wheel comes to rest once, on the result. `anticipation:start`, `anticipation:bait`, `anticipation:end`. [Anticipation](/guides/anticipation/) **Bait**: the section the player is meant to hope for during a tease. Must be reachable within the anticipation's arc; the builder throws when it is not. **Creep**: the pointer crawls through the bait and barely crosses the line into the target. **Crawl**: the slow constant-deceleration stretch every tease ends with. **Stall**: the pointer enters the target, crawls toward the bait's line as if it will cross, and dies just short of it. For a bait that follows the target. **Rest**: where the pointer ends after a tease: a fraction of the target's arc from the divider it shares with the bait (0.22 after crossing it, 0.15 when it died short). `rest: 'keep'` leaves the landing mode in charge. **Stutter**: the crawl all but stalls a hair short of the line, still moving, then slips over it onto the target. **Hesitation**: that near-stall, at `hesitateSpeed` for `dwellMs`. **Auto**: picks creep or stall from where the bait sits relative to the target. **Near-miss**: what the player sees. Anticipation is how the engine plans it. ## Skipping and stopping early **Protected skip**: a skip that arrives during an anticipation and is delayed until the bait moment has played (`protectSkip: true`). `skip:completed` reports `protectedByAnticipation`. **Skip**: `skip()`: a short fast-forward to the landing, over `skip.durationMs`, at least `skip.minArc` degrees so it still reads as motion. Needs the result to be known. [Landing](/guides/landing/) **Slam**: `slamStop()`: snap to the final position now. For turbo modes and tests. **Skip config**: `skip({ durationMs, minArc, protectSkip, ease })` on the builder. ## Pointers **Art direction**: which way a pointer texture or skeleton points in its own pixels (`'up'`, `'right'`, `'down'`, `'left'`), so the ring can turn it toward the hub or the rim. **Facing**: `'inward'` seats a pointer on the rim with the tip toward the hub; `'outward'` seats it at the hub pointing out (the inner ring's arrow in a two-ring wheel). [Pointers](/guides/pointers/) **Flap**: the tongue's deflection against the pegs: pushed aside as a peg comes through (`elasticity`), carried past its crown (`friction`), sprung back (`stiffness`, `damping`), never past `maxAngle`. [Pointers](/guides/pointers/) **Peg**: a small circle on the disc the tongue touches, one per divider by default (`.pegs({ size, inset, angles })`). The debug overlay draws them; `GraphicsRingSkin` can too. [Pointers](/guides/pointers/) **Contact width**: the stretch of rim over which a peg is in touch with the tongue: the peg radius plus half the `tipWidth`, either side of the tongue's axis. **Crown**: the moment a peg's centre is under the tongue's axis, where the push is largest. **Pin**: the point a pointer pivots around. A skin's local origin. For a texture, `pin: { x, y }` as fractions of the image. **Pointer**: the fixed mark a ring is read against. Also tongue, flapper, stopper, needle. A ring can have several; each is named. [Pointers](/guides/pointers/) **Pointer angle**: the screen angle a pointer sits at. Default -90, twelve o'clock. The Pragmatic wheel reads at 90, six o'clock. **Tick**: a divider passing under a pointer. `pointer:tick` carries the pointer, the sections either side, the speed and the direction. Pointer skins with a `tick` animation play it here; the tongue itself is moved by the pegs, not by the event. **Tip inset**: how far the pointer's tip reaches past the rim into the disc, px. ## Skins and art **Asset resolver**: `{ texture(key) }`: what `fromConfig` uses to turn texture keys in a serialised skin config into loaded textures. [Configs](/guides/configs/) **Atlas**: a Spine texture atlas: one text file naming regions on one or more sheets. The Playson wheel ships as its atlas plus the skeletons rebuilt over it. **Bone**: in `SpineRingSkin`, the skeleton bone the engine rotates (`bone: 'wheel'`). The rest of the skeleton stays fixed and keeps animating. [Skins](/guides/skins/) **Debug skin**: `DebugRingSkin`: numbered wedges with their angles written on them. **Graphics skin**: `GraphicsRingSkin`: the default painted look: wedges, dividers, rim, hub, fitted labels. [Skins](/guides/skins/) **Headless skin**: `HeadlessRingSkin`: draws nothing. For tests and servers. **Highlight**: a skin's emphasis on one section, usually the winner. `highlight(id)` / `highlight(null)`. **Region**: one image inside an atlas, named by path (`wheel/mini/mini_sector`). **Skin**: what a ring looks like. The ring owns motion; the skin owns pixels. Graphics, debug, texture, Spine, headless, or your own class implementing `RingSkin`. [Skins](/guides/skins/) **Skin config**: the serialisable form of a skin, `{ type: 'graphics', ... }`, kept by `toConfig()`. Skin instances are recorded as `{ type: 'custom' }`. **Spine skin**: `SpineRingSkin` and `SpinePointerSkin`, from `pixi-wheels/spine`. The skeleton's animations (`idle`, `spin`, `win`, per-section `winBySection`, `tick`) are wired to the events by name. [Skins](/guides/skins/) **Texture skin**: `TextureRingSkin`: a painted face texture, optional decorations, optional labels. [Skins](/guides/skins/) **VFX / effects**: the frame sequences and glows a skeleton plays on a win: sector sweep, sector glow, gold sparkle, shockwave, the Pragmatic selection frames. Authored in the skeleton, triggered by the skin's hooks. ## Angles and coordinates **Clockwise positive**: the library's angle convention, matching PixiJS: degrees increase clockwise on screen. Spine's counter-clockwise rotation is converted at the bone. **Local angle**: an angle on the disc, rotating with it. Screen angle = local angle + rotation. The pointer at screen angle `p` reads local angle `p - rotation`. **Normalised angle**: an angle folded into 0..360 with `normalizeDeg`. Sections keep unnormalised, increasing angles so `endAngle` of section i equals `startAngle` of section i+1. **Rotation**: the disc's current turn in degrees, clockwise positive. **Screen angle**: an angle in the wheel's fixed frame. Pointers have screen angles. ## Integration **Adapter**: `createTargetAdapter(...)`: turns a server response into a `WheelTarget` (by id, index or value, read from a path). Recorded in `toConfig()` as documentation. [Adapters](/guides/adapters/) **Config**: `WheelConfig`, the JSON twin of a builder: `toConfig()` / `fromConfig()`. Versioned; validated by `assertWheelConfig`. [Configs](/guides/configs/) **Event**: a typed, colon-namespaced notification (`spin:landing`) on `wheel.events`. Every payload names its `ring`. [Events and audio](/guides/events-and-audio/) **Hook**: an event listener that drives something outside the wheel: a sound, a particle burst, a Spine reaction. **Template**: a ready-made builder for a common wheel: `gamble`, `gambleDynamic`, `multipliers`, `jackpots`, `dynamicJackpot`, `twoRing`, `debug`. **Ticker**: the PixiJS `Ticker` that drives the wheel. `ticker(app.ticker)` on the builder. Everything moves by `deltaMS`, so a fake ticker makes spins deterministic. ## Testing and debugging **ASCII arc**: `debugArc(wheel)`: the ring as a text strip with the pointer marked. For logs and agents. **Debug overlay**: `debugOverlay(wheel)`: section boundaries, pointer angles, pegs with the one being ridden and each tongue's contact zone, the target and a HUD drawn over the wheel, live. **Fake ticker**: `FakeTicker` from `pixi-wheels/testing`: advance time by hand and land a spin in one call. **Harness**: `createTestWheel` and friends: a headless wheel, seeded randomness, `expectPointerOn`, `captureEvents`. [Testing](/guides/testing/) **Notice**: a one-time warning the library prints with a code (`bait-too-far`, `ease-slope`, `config-skin-instance`) when a config is legal but suspicious. `setLogLevel` silences them. **Snapshot**: `debugSnapshot(wheel)`: the whole state as plain JSON. ## Recipes ### Anticipation and near-miss URL: https://pixi-wheels.schmooky.dev/recipes/anticipation/ Bait the player with one section and land on another - creep, stutter, stall, and a tease that survives a skip. #### bait-creep ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // The classic near-miss. The server says x2; the wheel slows to a crawl as // the pointer enters the x50 sliver right before it, keeps slowing across // it, and barely crosses the line into x2, resting right next to it. Bait // must sit just BEFORE the landing in the spin direction; the engine checks // and tells you if not. const wheel = new WheelBuilder() .radius(240, 34) .sections([ { id: 'x2a', label: 'x2', value: 2, weight: 3 }, { id: 'x50', label: 'x50', value: 50, weight: 0.8, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'x5', label: 'x5', value: 5, weight: 2 }, { id: 'x3', label: 'x3', value: 3, weight: 2.5 }, { id: 'x10', label: 'x10', value: 10, weight: 1 }, { id: 'x2b', label: 'x2', value: 2, weight: 3 }, { id: 'x8', label: 'x8', value: 8, weight: 1.2 }, ]) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('anticipation:start', ({ bait, style }) => console.log(`[bait] teasing ${bait.id} (${style})`)); wheel.events.on('anticipation:bait', () => console.log('[bait] pointer on the bait...')); wheel.events.on('anticipation:end', () => console.log('[bait] ...and past it')); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); // Clockwise, the pointer meets x50 just before x2a: creep. wheel.setResult({ section: 'x2a' }, { anticipation: { bait: 'x50', creepSpeed: 45 } }); await spin; }, }; ``` #### bait-stutter ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Stutter: like creep, but the crawl all but stalls a hair short of the line, // still moving at two degrees a second while everyone leans in, then slips // over it onto the result. The wheel never stops until it rests: the segment // it stops on is the result. const wheel = new WheelBuilder() .radius(240, 34) .sections([ { id: 'mini', label: 'MINI', weight: 4, style: { fill: 0x2e86de } }, { id: 'grand', label: 'GRAND', weight: 0.9, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'minor', label: 'MINOR', weight: 3, style: { fill: 0x10ac84 } }, { id: 'mini2', label: 'MINI', weight: 4, style: { fill: 0x2e86de } }, { id: 'major', label: 'MAJOR', weight: 1.5, style: { fill: 0xee5253 } }, { id: 'minor2', label: 'MINOR', weight: 3, style: { fill: 0x10ac84 } }, ]) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); wheel.setResult({ section: 'mini' }, { anticipation: { bait: 'grand', style: 'stutter', dwellMs: 700 } }); await spin; }, }; ``` #### bait-stall ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Stall: the bait sits just AFTER the landing. The pointer enters the result, // crawls toward the jackpot's line as if it will cross, and dies just short // of it, resting on the result right by the line. One deceleration, one stop: // the segment it stops on is the result. "It was going to be GRAND." const wheel = new WheelBuilder() .radius(240, 34) .sections([ { id: 'mini', label: 'MINI', weight: 4, style: { fill: 0x2e86de } }, { id: 'grand', label: 'GRAND', weight: 0.9, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'minor', label: 'MINOR', weight: 3, style: { fill: 0x10ac84 } }, { id: 'mini2', label: 'MINI', weight: 4, style: { fill: 0x2e86de } }, { id: 'major', label: 'MAJOR', weight: 1.5, style: { fill: 0xee5253 } }, { id: 'minor2', label: 'MINOR', weight: 3, style: { fill: 0x10ac84 } }, ]) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); // Clockwise the pointer meets minor, then grand: the bait comes AFTER the // landing, so 'auto' picks stall. Land minor, bait grand. wheel.setResult({ section: 'minor' }, { anticipation: { bait: 'grand' } }); await spin; }, }; ``` #### bait-auto ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // 'auto' (the default style) reads the geometry: bait before the landing // creeps, bait after it stalls, bait far away is dropped with a console // warning and the wheel just lands. Every spin here lands on a random // section and baits with the jackpot next to it. const wheel = new WheelBuilder() .radius(240, 34) .sections([ { id: 'x2', label: 'x2', value: 2, weight: 3 }, { id: 'jackpot', label: 'JACKPOT', weight: 0.9, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'x5', label: 'x5', value: 5, weight: 2 }, { id: 'x3', label: 'x3', value: 3, weight: 2.5 }, { id: 'x10', label: 'x10', value: 10, weight: 1 }, { id: 'x8', label: 'x8', value: 8, weight: 1.2 }, ]) .landing({ anticipation: { bait: 'jackpot' } }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('anticipation:start', ({ style }) => console.log('[auto] style:', style)); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); // x2 (before the jackpot, clockwise) creeps; x5 (after it) stalls; x3 is too far. const pick = ['x2', 'x5', 'x3'][Math.floor(Math.random() * 3)]; wheel.setResult({ section: pick }); await spin; }, }; ``` #### bait-protected-skip ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // A tease the player cannot skip past without seeing: the first press jumps // to the bait and lets the crawl play; the second press lands. const wheel = new WheelBuilder() .radius(240, 34) .sections([ { id: 'x2', label: 'x2', value: 2, weight: 3 }, { id: 'jackpot', label: 'JACKPOT', weight: 1, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'x5', label: 'x5', value: 5, weight: 2 }, { id: 'x3', label: 'x3', value: 3, weight: 2.5 }, { id: 'x10', label: 'x10', value: 10, weight: 1 }, ]) .skip({ protectAnticipation: true }) .speed('normal', SpinPresets.CINEMATIC) .ticker(app.ticker) .build(); wheel.events.on('skip:requested', ({ protectedByAnticipation }) => console.log(protectedByAnticipation ? '[skip] first press: jump to the bait' : '[skip] second press: land'), ); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); wheel.setResult({ section: 'x2' }, { anticipation: { bait: 'jackpot', style: 'creep', creepSpeed: 35 } }); await spin; }, }; ``` ### Server, events and debugging URL: https://pixi-wheels.schmooky.dev/recipes/integration/ Adapters for real server responses, every sound hook, the debug view, and configs that round-trip. #### server-adapter ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, createTargetAdapter, app // Three shapes of server response, one adapter each. An adapter is a small // serialisable config (`by` + dotted `path`), so the studio can export it // next to the wheel and the game never hand-parses the payload. const wheel = new WheelBuilder() .radius(230, 30) .sections([ { id: 'x2a', label: 'x2', value: 2 }, { id: 'x5', label: 'x5', value: 5 }, { id: 'x2b', label: 'x2', value: 2 }, { id: 'x10', label: 'x10', value: 10 }, { id: 'x3', label: 'x3', value: 3 }, { id: 'x2c', label: 'x2', value: 2 }, ]) .speed('normal', SpinPresets.TURBO) .ticker(app.ticker) .build(); const byIndex = createTargetAdapter({ by: 'index', path: 'bonus.wheel.sector', indexBase: 1 }); // 1-based on this backend const byValue = createTargetAdapter({ by: 'value', path: 'bonus.multiplier', pick: 'random' }); // three x2 wedges: any of them const byAngle = createTargetAdapter({ by: 'angle', path: 'bonus.wheel.stopDegrees' }); const responses = [ { name: 'index', body: { bonus: { wheel: { sector: 4 } } }, adapter: byIndex }, { name: 'value', body: { bonus: { multiplier: 2 } }, adapter: byValue }, { name: 'angle', body: { bonus: { wheel: { stopDegrees: 200 } } }, adapter: byAngle }, ]; let i = 0; return { wheel, onSpin: async () => { const r = responses[i++ % responses.length]; const spin = wheel.spin(); await new Promise((res) => setTimeout(res, 250)); const target = r.adapter(r.body); console.log(`[adapter] ${r.name} ->`, JSON.stringify(target)); wheel.setResult(target); const result = await spin; console.log(`[adapter] landed ${result.section.id}`); }, }; ``` #### sound-hooks ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, createEngine, app // Real game audio on the engine's events, through @schmooky/zvuk. The sounds // are the Super Wheel's own (Playson, used with permission): the activation // hit, the spin, the anticipation riser, the landing thud and one win sting // per prize tier. The wheel emits; the audio layer listens. A skip, a slam and // a normal stop all reach `spin:landing`, so nothing here calls a wheel method. const wheel = new WheelBuilder() .radius(230, 30) .sections([ { id: 'mini', label: 'MINI', weight: 1, tags: ['mini'], style: { fill: 0x2e8b57 } }, { id: 'x2', label: 'x2', value: 2, weight: 1.4, tags: ['coin'] }, { id: 'collect', label: 'COLLECT', weight: 1, tags: ['clover'], style: { fill: 0x1f6feb } }, { id: 'x5', label: 'x5', value: 5, weight: 1.1, tags: ['coin'] }, { id: 'minor', label: 'MINOR', weight: 0.9, tags: ['minor'], style: { fill: 0x9b59b6 } }, { id: 'x3', label: 'x3', value: 3, weight: 1.2, tags: ['coin'] }, { id: 'mystery', label: 'MYSTERY', weight: 1, tags: ['clover'], style: { fill: 0x1f6feb } }, { id: 'x10', label: 'x10', value: 10, weight: 0.8, tags: ['coin'] }, { id: 'major', label: 'MAJOR', weight: 0.6, tags: ['major'], style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, ]) .landing({ mode: 'random', margin: 0.15 }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); // One engine, two buses. Every time value in zvuk is in seconds. const engine = createEngine({ buses: { music: { level: 0.4 }, sfx: { level: 1 } }, master: { headroom: -3 } }); const base = '/playson-wheel/audio/'; await Promise.all([ engine.loadSound('music', base + 'wheel_music.mp3', { bus: 'music' }), engine.loadSound('activate', base + 'wheel_activate.mp3'), engine.loadSound('spin', base + 'wheel_spin.mp3'), engine.loadSound('tick', base + 'click.mp3'), engine.loadSound('riser', base + 'anticipation.mp3'), engine.loadSound('landing', base + 'landing.mp3'), engine.loadSound('skip', base + 'skip.mp3'), engine.loadSound('win_coin', base + 'sector_win_regular.mp3'), engine.loadSound('win_clover', base + 'sector_win_clover.mp3'), engine.loadSound('win_mini', base + 'win_mini.mp3'), engine.loadSound('win_minor', base + 'win_minor.mp3'), engine.loadSound('win_major', base + 'win_major.mp3'), ]); let music = null; let spinVoice = null; let riser = null; const e = wheel.events; e.on('spin:start', () => { music ??= engine.sound('music').play({ loop: true, fadeIn: 0.8 }); engine.sound('activate').play(); spinVoice = engine.sound('spin').play(); }); // The ratchet: louder and higher with speed, never twice the same. e.on('pointer:tick', ({ speed }) => { engine.sound('tick').play({ volume: Math.min(1, 0.25 + speed / 900), pitch: { base: 0.9 + Math.min(0.6, speed / 1400), jitter: 0.04 }, }); }); e.on('anticipation:start', () => { riser = engine.sound('riser').play(); }); e.on('anticipation:end', () => { riser?.stop({ fade: 0.3 }); riser = null; }); e.on('skip:requested', () => engine.sound('skip').play()); e.on('spin:landing', ({ section }) => { spinVoice?.stop({ fade: 0.25 }); engine.sound('landing').play({ volume: 0.8 }); const [tag] = section.tags; engine.sound(tag === 'coin' ? 'win_coin' : tag === 'clover' ? 'win_clover' : `win_${tag}`).play(); }); e.on('destroyed', () => engine.close()); return { wheel, onSpin: async () => { await engine.unlock(); // from the click: the browser needs a gesture before sound const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); const ids = wheel.sections.map((s) => s.id); const i = Math.floor(Math.random() * ids.length); wheel.setResult({ section: ids[i] }, { anticipation: { bait: ids[(i + ids.length - 1) % ids.length] } }); await spin; }, }; ``` #### debug-view ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, DebugRingSkin, debugArc, debugOverlay, app // The plain skin plus the live overlay: dividers with their angles on pills // outside the rim, the pointer marker with the local angle under it, the // pegs, the planned landing angle in yellow, and a HUD panel in the corner // of the canvas with the state, speed and current leg. Text stays readable // at any wheel scale. debugArc() prints the same as text; it is what an // agent reads when it cannot see the canvas. Open the console. const wheel = new WheelBuilder() .radius(230, 40) .sections([ { id: 'a', label: 'A', weight: 3 }, { id: 'b', label: 'B', weight: 1 }, { id: 'c', label: 'C', weight: 2 }, { id: 'd', label: 'D', weight: 1.5 }, { id: 'e', label: 'E', weight: 0.5 }, ]) .skin(new DebugRingSkin()) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); debugOverlay(wheel, { layers: 'all', live: true, ticker: app.ticker, hud: 'bottom-left', screen: app.screen }); wheel.events.on('spin:stopping', () => console.log(debugArc(wheel))); wheel.events.on('spin:complete', () => console.log(debugArc(wheel))); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); wheel.setResult({ section: 'e' }, { anticipation: { bait: 'd' }, mode: 'random' }); await spin; }, }; ``` #### config-round-trip ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Build with the fluent API, export the config, rebuild from it. The JSON is // what the studio saves and what a game can ship next to its assets; the // console shows it. const built = new WheelBuilder() .name('round trip') .radius(230, 30) .sections([ { id: 'x2', label: 'x2', value: 2, weight: 2 }, { id: 'x5', label: 'x5', value: 5 }, { id: 'x20', label: 'x20', value: 20, weight: 0.5, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, ]) .pointer({ angle: -90, skin: { type: 'graphics', shape: 'triangle', color: 0xffd166 } }) .skin({ type: 'graphics', bulbs: { count: 18 } }) .landing({ mode: 'random', settle: 'center' }) .speed('normal', SpinPresets.NORMAL) .adapter({ by: 'value', path: 'bonus.multiplier' }); const config = built.toConfig(); console.log('[config]', JSON.stringify(config, null, 2)); const wheel = WheelBuilder.fromConfig(config).ticker(app.ticker).build(); return { wheel }; ``` ### Dynamic sectors, rings and idle URL: https://pixi-wheels.schmooky.dev/recipes/sections-rings-idle/ Sector shares that move step by step, an inner ring spun by the outer one, and a wheel that idles beside the reels. #### dynamic-jackpot-steps ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // The studio brief: jackpot sectors whose size changes step by step, as a // purely cosmetic state. Solid fills, no decoration on the moving borders, // and the titles slide to the middle of whatever arc they own. The step // advances after every spin here; a real game drives it from its feature // state. The spin's landing is decided by the server and never by the arcs. const wheel = new WheelBuilder() .radius(250, 44) .sections([ { id: 'mini', label: 'MINI', style: { fill: 0x2e86de } }, { id: 'minor', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'mini2', label: 'MINI', style: { fill: 0x2e86de } }, { id: 'major', label: 'MAJOR', style: { fill: 0xee5253 } }, { id: 'mini3', label: 'MINI', style: { fill: 0x2e86de } }, { id: 'minor2', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'grand', label: 'GRAND', style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, ]) .dynamic({ steps: [ { mini: 5, minor: 3, mini2: 5, major: 1.5, mini3: 5, minor2: 3, grand: 0.6 }, { mini: 4, minor: 3.5, mini2: 4, major: 2, mini3: 4, minor2: 3.5, grand: 1 }, { mini: 3, minor: 4, mini2: 3, major: 3, mini3: 3, minor2: 4, grand: 1.6 }, { mini: 2, minor: 4, mini2: 2, major: 4, mini3: 2, minor2: 4, grand: 2.5 }, ], durationMs: 700, ease: 'sine.inOut', }) .skin({ type: 'graphics', dividers: { width: 2, color: 0xffffff, alpha: 0.5 }, shading: false, bulbs: { count: 28 } }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('sections:changed', ({ step }) => console.log('[dynamic] now on step', step)); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); const tiers = ['mini', 'minor', 'major', 'grand']; wheel.setResult({ section: tiers[Math.floor(Math.random() * tiers.length)] }); await spin; await wheel.nextStep(); }, }; ``` #### dynamic-quantized ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Steps on a grid: the rim is thought of as 24 slots, and each dynamic // sector owns a whole number of them. Integer weights ARE the slots, so a // step moves a border by exactly one slot width and the borders of the // static sectors never drift. Press spin to advance the purple sector. const SLOTS = 24; const wheel = new WheelBuilder() .radius(240, 30) .sections([ { id: 'a', label: '', weight: 6, style: { fill: 0xd98fa3 } }, { id: 'b', label: '', weight: 6, style: { fill: 0xb8b0b3 } }, { id: 'dyn', label: 'BONUS', weight: 2, style: { fill: 0x8e44ad } }, { id: 'c', label: '', weight: 5, style: { fill: 0xd98fa3 } }, { id: 'd', label: '', weight: 5, style: { fill: 0xb8b0b3 } }, ]) .dynamic({ // The bonus grows one slot at a time, taken from its neighbours. steps: [ { dyn: 2, b: 6, c: 5 }, { dyn: 3, b: 6, c: 4 }, { dyn: 4, b: 5, c: 4 }, { dyn: 5, b: 5, c: 3 }, { dyn: 6, b: 4, c: 3 }, ], durationMs: 450, }) .skin({ type: 'graphics', dividers: { width: 2, color: 0xffffff, alpha: 0.35 }, shading: false, hub: { radius: 30 } }) .speed('normal', SpinPresets.QUICK) .ticker(app.ticker) .build(); // A ring of tick marks, one per slot, so the grid is visible. const marks = new PIXI.Graphics(); for (let i = 0; i < SLOTS; i++) { const a = (-90 + (360 / SLOTS) * i) * Math.PI / 180; marks.moveTo(Math.cos(a) * 242, Math.sin(a) * 242).lineTo(Math.cos(a) * 254, Math.sin(a) * 254); } marks.stroke({ color: 0xffffff, width: 2, alpha: 0.6 }); wheel.main.overlay.addChild(marks); return { wheel, onSpin: async () => { await wheel.nextStep(); const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); wheel.setResult({ index: Math.floor(Math.random() * 5) }); await spin; }, }; ``` #### dynamic-from-state ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // No step list: the server sends a "wheel state" with every round and the // client sets the weights directly. Here the state is faked as a random // share for the bonus sector; the transition is animated by setWeights. const wheel = new WheelBuilder() .radius(240, 30) .sections([ { id: 'cash', label: 'CASH', weight: 4, style: { fill: 0x1e9e5a } }, { id: 'bonus', label: 'BONUS', weight: 1, style: { fill: 0x8e44ad } }, { id: 'lose', label: 'LOSE', weight: 4, style: { fill: 0x2f3542 } }, { id: 'cash2', label: 'CASH', weight: 4, style: { fill: 0x1e9e5a } }, ]) .speed('normal', SpinPresets.QUICK) .ticker(app.ticker) .build(); function applyServerState(state) { // state.bonusShare is 0..1 of the rim; everything else shares the rest. const rest = (1 - state.bonusShare) / 3; return wheel.setWeights({ bonus: state.bonusShare, cash: rest, lose: rest, cash2: rest }, { durationMs: 800, ease: 'power2.inOut' }); } return { wheel, onSpin: async () => { await applyServerState({ bonusShare: 0.05 + Math.random() * 0.4 }); const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); wheel.setResult({ index: Math.floor(Math.random() * 4) }); await spin; }, }; ``` #### two-ring-jackpot ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // An outer ring of tiers and an inner ring read by its own pointer at three // o'clock, facing inward. The two spin independently and share one event // stream; every payload says which ring it came from. const wheel = new WheelBuilder() .radius(280, 190) .sections([ { id: 'mini', label: 'MINI', style: { fill: 0x2e86de } }, { id: 'major', label: 'MAJOR', style: { fill: 0xee5253 } }, { id: 'minor', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'minor2', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'major2', label: 'MAJOR', style: { fill: 0xee5253 } }, { id: 'mini2', label: 'MINI', style: { fill: 0x2e86de } }, { id: 'minor3', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'major3', label: 'MAJOR', style: { fill: 0xee5253 } }, ]) .skin({ type: 'graphics', hub: false }) .ring('inner', (r) => r .radius(175, 50) .direction('ccw') .pointer({ angle: 0, facing: 'inward', tipInset: 12, skin: { type: 'graphics', shape: 'triangle', color: 0xffd166, length: 48, width: 30 } }) .sections([ { id: 'grand', label: 'GRAND', weight: 0.7, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'minor', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'major', label: 'MAJOR', style: { fill: 0xee5253 } }, { id: 'minor2', label: 'MINOR', style: { fill: 0x10ac84 } }, { id: 'major2', label: 'MAJOR', style: { fill: 0xee5253 } }, { id: 'minor3', label: 'MINOR', style: { fill: 0x10ac84 } }, ]) .skin({ type: 'graphics', rim: { width: 6 } }), ) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('spin:landing', ({ ring, section }) => console.log(`[rings] ${ring} landed ${section.id}`)); return { wheel, onSpin: async () => { const outer = wheel.spin(); const inner = wheel.spin({ ring: 'inner' }); await new Promise((r) => setTimeout(r, 300)); wheel.setResult({ index: Math.floor(Math.random() * 8) }); wheel.setResult({ index: Math.floor(Math.random() * 6) }, { ring: 'inner' }); await Promise.all([outer, inner]); }, }; ``` #### outer-triggers-inner ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // The outer ring decides whether the inner one spins at all. Landing on a // SPIN sector starts the inner ring from the landing event; anything else // ends the round. The inner pointer sits on the outer ring's inner edge // pointing inward. const wheel = new WheelBuilder() .radius(280, 190) .sections([ { id: 'spin', label: 'SPIN', style: { fill: 0x8e44ad } }, { id: 'c50', label: '50', value: 50, style: { fill: 0x1e9e5a } }, { id: 'c100', label: '100', value: 100, style: { fill: 0x10ac84 } }, { id: 'spin2', label: 'SPIN', style: { fill: 0x8e44ad } }, { id: 'c200', label: '200', value: 200, style: { fill: 0x2e86de } }, { id: 'c75', label: '75', value: 75, style: { fill: 0x1e9e5a } }, ]) .skin({ type: 'graphics', hub: false }) .ring('inner', (r) => r .radius(176, 40) .pointer({ angle: -90, facing: 'inward', tipInset: 10, skin: { type: 'graphics', shape: 'triangle', color: 0xffd166, length: 44, width: 28 } }) .sections([ { id: 'x2', label: 'x2', value: 2 }, { id: 'x5', label: 'x5', value: 5 }, { id: 'x3', label: 'x3', value: 3 }, { id: 'x10', label: 'x10', value: 10, weight: 0.6, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, ]) .skin({ type: 'graphics', rim: { width: 6 } }), ) .speed('normal', SpinPresets.TURBO) .ticker(app.ticker) .build(); return { wheel, onSpin: async () => { const outer = wheel.spin(); await new Promise((r) => setTimeout(r, 250)); const outcome = ['spin', 'c50', 'c100', 'spin2', 'c200', 'c75'][Math.floor(Math.random() * 6)]; wheel.setResult({ section: outcome }); const result = await outer; if (!result.section.id.startsWith('spin')) return; const inner = wheel.spin({ ring: 'inner' }); await new Promise((r) => setTimeout(r, 250)); wheel.setResult({ value: [2, 3, 5, 10][Math.floor(Math.random() * 4)] }, { ring: 'inner' }); await inner; }, }; ``` #### idle-side-wheel ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app, PIXI // A small wheel parked beside a reel panel, idling so the player knows it is // there. When the feature fires (the spin button here), it scales up over // the panel, spins from its idle speed, lands, and shrinks back to its seat. const wheel = new WheelBuilder() .radius(120, 18) .sections([ { id: 'x2', label: 'x2', value: 2, weight: 3 }, { id: 'x3', label: 'x3', value: 3, weight: 2 }, { id: 'x5', label: 'x5', value: 5, weight: 1.5 }, { id: 'x10', label: 'x10', value: 10, weight: 0.8, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, { id: 'x2b', label: 'x2', value: 2, weight: 3 }, { id: 'x3b', label: 'x3', value: 3, weight: 2 }, ]) .skin({ type: 'graphics', rim: { width: 5 }, hub: { radius: 18 } }) .idle({ speed: 14, autoStart: true, rampMs: 800 }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); // A stand-in reel panel so the layout reads. const stage = new PIXI.Container(); const panel = new PIXI.Graphics().roundRect(0, 0, 520, 320, 16).fill({ color: 0x14161c }).stroke({ color: 0x3a3f4b, width: 3 }); for (let r = 0; r < 5; r++) for (let c = 0; c < 3; c++) { panel.roundRect(24 + r * 96, 24 + c * 92, 80, 76, 10).fill({ color: 0x232733 }); } stage.addChild(panel, wheel); const SEAT = { x: 520 + 150, y: 160, scale: 1 }; const STAGE_POS = { x: 260, y: 160, scale: 1.25 }; wheel.position.set(SEAT.x, SEAT.y); function tween(target, to, ms) { return new Promise((resolve) => { const from = { x: target.x, y: target.y, s: target.scale.x }; let t = 0; const step = (ticker) => { t = Math.min(1, t + ticker.deltaMS / ms); const k = 1 - Math.pow(1 - t, 3); target.position.set(from.x + (to.x - from.x) * k, from.y + (to.y - from.y) * k); target.scale.set(from.s + (to.scale - from.s) * k); if (t >= 1) { app.ticker.remove(step); resolve(); } }; app.ticker.add(step); }); } return { wheel, stage, onSpin: async () => { await tween(wheel, STAGE_POS, 600); const spin = wheel.spin(); // ramps from the idle speed await new Promise((r) => setTimeout(r, 400)); // Bait with the section next to the target: a tease needs a neighbour, and never the target itself. const ids = wheel.sections.map((s) => s.id); const i = Math.floor(Math.random() * ids.length); wheel.setResult({ section: ids[i] }, { anticipation: { bait: ids[(i + 1) % ids.length] } }); await spin; await new Promise((r) => setTimeout(r, 900)); // present the win await tween(wheel, SEAT, 600); // idle resumes on its own }, }; ``` #### idle-controls ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Idle on demand. The wheel starts still; the first press starts the idle, // the second spins (from the idle speed) and the idle resumes after the // landing. idle.stop() ramps down instead of snapping. const wheel = new WheelBuilder() .radius(220, 30) .sections(Array.from({ length: 8 }, (_, i) => ({ id: `s${i}`, label: `${i + 1}` }))) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('idle:start', () => console.log('[idle] start')); wheel.events.on('idle:stop', () => console.log('[idle] stop')); wheel.events.on('spin:start', ({ fromIdle }) => console.log('[idle] spin from idle =', fromIdle)); let armed = false; return { wheel, onSpin: async () => { if (!armed) { wheel.idle.start({ speed: 20, direction: 'ccw', rampMs: 1200 }); armed = true; return; } const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 250)); wheel.setResult({ index: Math.floor(Math.random() * 8) }); await spin; }, }; ``` ### Skins and assets URL: https://pixi-wheels.schmooky.dev/recipes/skins/ Painted, textured and studio-art wheels, per-section styles, and pointers that flap. #### labels-and-styles ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Per-section styling: fills, label colours, tangential and upright labels, // a custom font size, and a section with no label at all. Labels auto-fit // the room their section has and re-centre when weights change. const wheel = new WheelBuilder() .radius(240, 36) .palette([0x264653, 0x2a9d8f, 0xe9c46a, 0xf4a261, 0xe76f51]) .sections([ { id: 'radial', label: 'RADIAL', weight: 2 }, { id: 'tangent', label: 'TANGENT', weight: 2, style: { labelOrientation: 'tangential', labelRadius: 0.8 } }, { id: 'upright', label: 'UP', weight: 1.5, style: { labelOrientation: 'upright', labelSize: 34 } }, { id: 'blank', label: '', weight: 1, style: { fill: 0x111111 } }, { id: 'gold', label: 'x100', weight: 0.7, style: { fill: 0xf1c40f, labelColor: 0x2b1d00, labelWeight: '900', labelSize: 30 } }, { id: 'thin', label: 'a long label that shrinks', weight: 1.2, style: { labelColor: 0xffffff } }, ]) .skin({ type: 'graphics', rim: { width: 10, color: 0x2b2b2b }, hub: { radius: 36, color: 0x2b2b2b, ringColor: 0xe9c46a } }) .speed('normal', SpinPresets.TURBO) .ticker(app.ticker) .build(); return { wheel }; ``` #### rich-labels ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, fitText, labelSlot, app, PIXI // Labels are not just strings. A section's `content` is any container: a // Text, a Sprite, a BitmapText, a Spine instance, a group of them. The label // layer places it at the label radius, rotates it like a text label, and fits // it into the room the section has, so a heavy jackpot wedge and a slim x2 // wedge each get content sized for them. The factory receives the slot and a // bound `fit()`; `fitText` shrinks a font size instead of a transform when // crispness matters. const GOLD = 0xffd23f; // A pill background sized from the slot, with a text fitted inside it. const pill = (label, color) => (ctx) => { const view = new PIXI.Container(); const w = ctx.slot.width * 0.92; const h = ctx.slot.height * 0.7; view.addChild(new PIXI.Graphics().roundRect(-w / 2, -h / 2, w, h, h / 2).fill({ color, alpha: 0.9 })); const text = new PIXI.Text({ text: label, style: { fontFamily: 'Roboto Condensed, Arial Narrow, sans-serif', fontSize: 40, fontWeight: '800', fill: 0x14151a } }); text.anchor.set(0.5); fitText(text, { width: w * 0.82, height: h * 0.8 }); view.addChild(text); return view; }; // An icon plus a value, stacked. `ctx.fit` does the final scale. const coin = (value) => (ctx) => { const view = new PIXI.Container(); const icon = new PIXI.Graphics().circle(0, 0, 26).fill(GOLD).circle(0, 0, 18).stroke({ color: 0xb8860b, width: 4 }); icon.position.set(0, -34); const text = new PIXI.Text({ text: `x${value}`, style: { fontFamily: 'Roboto Condensed, Arial Narrow, sans-serif', fontSize: 44, fontWeight: '900', fill: GOLD, stroke: { color: 0x3a2200, width: 5 } } }); text.anchor.set(0.5); text.position.set(0, 26); view.addChild(icon, text); ctx.fit(view, { padding: 0.08 }); return view; }; // A container that keeps animating after it is placed: the layer never touches its children. const star = () => (ctx) => { const view = new PIXI.Container(); const g = new PIXI.Graphics().star(0, 0, 5, 40, 18).fill(0xffffff).stroke({ color: GOLD, width: 4 }); view.addChild(g); app.ticker.add(() => { g.rotation += 0.02; }); ctx.fit(view); return view; }; const wheel = new WheelBuilder() .radius(240, 40) .sections([ { id: 'jackpot', weight: 2, content: pill('JACKPOT', GOLD), style: { fill: 0x7a1f1f, labelOrientation: 'tangential', labelRadius: 0.62 } }, { id: 'x2', value: 2, weight: 1, content: coin(2), style: { labelOrientation: 'radial' } }, { id: 'bonus', weight: 1.4, content: star(), style: { fill: 0x1f3d7a, labelOrientation: 'upright' } }, { id: 'x5', value: 5, weight: 0.7, content: coin(5), style: { labelOrientation: 'radial' } }, { id: 'free', weight: 1.6, content: pill('FREE SPINS', 0x8ee3a1), style: { fill: 0x1f6b3a, labelOrientation: 'tangential-in', labelRadius: 0.72 } }, { id: 'x10', value: 10, weight: 0.5, content: coin(10), style: { labelOrientation: 'radial' } }, { id: 'plain', label: 'TEXT', weight: 1 }, ]) .landing({ mode: 'random', margin: 0.15 }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); // The slot is also available outside the label layer, for your own placement. console.log('jackpot slot', labelSlot(wheel.sections[0], 240, 40, { radius: 0.62, orientation: 'tangential' })); return { wheel }; ``` #### pointer-flap ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // The tongue is physical. It touches pegs on the rim, one per divider: a peg // coming through pushes it aside, carries it on its crown for a moment, and // lets go into a spring. Three tongues against the same pegs, three feels: // a stiff short one at the top, a floppy one with lots of drag at four // o'clock, a rigid triangle at eight. The skin draws the pegs; the Debug // button shows each tongue's contact zone and the peg it is riding. Every // peg under any of them fires pointer:tick with the speed, the hook for the // ratchet click; the console shows the first tongue's ticks. const wheel = new WheelBuilder() .radius(230, 30) .sections(Array.from({ length: 16 }, (_, i) => ({ id: `s${i}`, label: '' }))) .pegs({ size: 7, inset: 10 }) .pointer({ id: 'stiff', angle: -90, flap: { elasticity: 0.8, friction: 0.2, stiffness: 700, damping: 18 } }) .pointer({ id: 'floppy', angle: 30, skin: { type: 'graphics', shape: 'needle', color: 0xffd166, length: 90, width: 26 }, flap: { elasticity: 1.5, friction: 0.9, stiffness: 140, damping: 5, maxAngle: 40, tipWidth: 20 }, }) .pointer({ id: 'rigid', angle: 150, skin: { type: 'graphics', shape: 'triangle', color: 0x64d2ff, length: 60, width: 34 }, flap: false }) .skin({ type: 'graphics', dividers: { width: 3 }, pegs: true }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); let ticks = 0; wheel.events.on('pointer:tick', ({ pointer, speed }) => { if (pointer === 'stiff' && ++ticks % 8 === 0) console.log(`[tick] ${ticks} pegs, ${Math.round(speed)} deg/s`); }); return { wheel }; ``` #### playson-wheel ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, TexturePointerSkin, loadPlaysonWheel, PlaysonWheelSkin, PLAYSON_SECTIONS, app // Playson's Four Charged Clovers: Super Wheel (used with permission). The // twelve authored wedge plates, the bezel, dividers, bulbs and hub come from // the game's own atlas; the stopper is the game's stopper. The engine only // knows twelve equal sections and where they are. const art = await loadPlaysonWheel(); const wheel = new WheelBuilder() .radius(250) .sections(PLAYSON_SECTIONS) .pointer({ angle: -90, tipInset: 30, skin: new TexturePointerSkin({ texture: art.textures['wheel/stopper'], artDirection: 'down', pin: { x: 0.5, y: 0.3 }, scale: 250 / 297 }), flap: { maxAngle: 18, stiffness: 600, damping: 16 }, }) .skin(new PlaysonWheelSkin({ art })) .landing({ mode: 'random', margin: 0.15 }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('spin:landing', ({ section }) => console.log('[playson] landed', section.id)); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); const i = Math.floor(Math.random() * PLAYSON_SECTIONS.length); // Bait with the plate just before the target, so the tease creeps past it or stalls just short of it. wheel.setResult({ section: PLAYSON_SECTIONS[i].id }, { anticipation: { bait: PLAYSON_SECTIONS[(i + 11) % 12].id } }); await spin; }, }; ``` #### playson-spine-wheel ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, SpineRingSkin, SpinePointerSkin, loadPlaysonSpine, PLAYSON_SECTIONS, loadPlaysonAudio, app, PIXI // The Super Wheel as a Spine skeleton (Playson, used with permission), with // the game's own effects: bulbs that alternate at rest and chase during the // spin, and on landing the sector sweep, the sector glow, the gold sparkle on // coin plates, the rainbow shockwave and a bulb strobe. The engine turns the // `wheel` bone; everything else is the skeleton's animation, picked by name // per section. The stopper is its own skeleton with a `tick` swing per divider. // The coin values the skeleton cannot know are rich labels on the disc. const [spine, audio] = await Promise.all([loadPlaysonSpine(), loadPlaysonAudio()]); const R = 250; const k = R / spine.radius; const gold = new PIXI.FillGradient({ type: 'linear', start: { x: 0, y: 0 }, end: { x: 0, y: 1 }, textureSpace: 'local', colorStops: [{ offset: 0, color: 0xfff1a8 }, { offset: 0.5, color: 0xffcf3d }, { offset: 1, color: 0xe08a12 }], }); const coinValue = (ctx) => { const t = new PIXI.Text({ text: `x${ctx.section.value}`, style: { fontFamily: 'Roboto Condensed, Arial Narrow, sans-serif', fontSize: 64, fontWeight: '900', fill: gold, stroke: { color: 0x3a2200, width: 7, join: 'round' } }, }); t.anchor.set(0.5); ctx.fit(t, { padding: 0.1 }); return t; }; const wheel = new WheelBuilder() .radius(R) .sections(PLAYSON_SECTIONS.map((s) => (s.tags.includes('coin') ? { ...s, content: coinValue, style: { labelOrientation: 'tangential', labelRadius: 0.5 } } : { ...s, label: '' }))) .pointer({ angle: -90, tipInset: 30, skin: new SpinePointerSkin({ skeleton: spine.stopper, atlas: spine.atlas, length: spine.stopperLength * k, scale: k, artDirection: 'down', flapRotates: false }), }) .skin(new SpineRingSkin({ skeleton: spine.skeleton, atlas: spine.atlas, bone: spine.bone, scale: k, labels: true, animations: { idle: 'idle', spin: 'spin', winBySection: spine.winBySection }, })) .landing({ mode: 'random', margin: 0.15 }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); audio.attach(wheel); return { wheel, onSpin: async () => { await audio.unlock(); const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); const i = Math.floor(Math.random() * PLAYSON_SECTIONS.length); // Bait with the plate just before the target, so the tease creeps past it or stalls just short of it. wheel.setResult({ section: PLAYSON_SECTIONS[i].id }, { anticipation: { bait: PLAYSON_SECTIONS[(i + 11) % 12].id } }); await spin; }, }; ``` #### pragmatic-wheel ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, TexturePointerSkin, loadPragmaticWheel, PragmaticWheelSkin, PRAGMATIC_SECTIONS, PRAGMATIC_RADIUS, pragmaticPlateLabel, app, PIXI // Pragmatic Play's Wheel of Happiness (used with permission), as the game // shows it: twelve gold-framed plates round the golden dragon hub, WILD WINS // in red and FREE SPINS in green, read from six o'clock by the gem-tipped // pointer. Each label is rich content: two lines of gold text with the top // toward the hub, plus the game's bitmap `+`, fitted into the plate. On // landing the losing plates dim and the game's own Spine effects play: the // ring flare round the hub at spin start, the selection frames pulsing on the // winner. const art = await loadPragmaticWheel(); const R = 262; const k = R / PRAGMATIC_RADIUS.outer; const wheel = new WheelBuilder() .radius(R, PRAGMATIC_RADIUS.hub * k) .sections(PRAGMATIC_SECTIONS.map((s) => ({ ...s, content: pragmaticPlateLabel(art), style: { labelOrientation: 'tangential-in', labelRadius: (PRAGMATIC_RADIUS.hub + PRAGMATIC_RADIUS.plate * 0.5) / PRAGMATIC_RADIUS.outer }, }))) .pointer({ angle: 90, tipInset: 40 * k, skin: new TexturePointerSkin({ texture: art.pointer, artDirection: 'up', pin: { x: 0.5, y: 0.86 }, scale: k * 1.35 }), flap: { maxAngle: 10, stiffness: 500, damping: 18 }, }) .skin(new PragmaticWheelSkin({ art })) // The tease rests by the line; the settle then centres the winning plate under the pointer. .landing({ mode: 'center', settle: { mode: 'center', delayMs: 450, durationMs: 700 } }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); const logo = new PIXI.Sprite(art.logo); logo.anchor.set(0.5, 1); logo.scale.set(0.5); logo.position.set(0, -R - 6); const stage = new PIXI.Container(); stage.addChild(wheel, logo); return { wheel, stage, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); const i = Math.floor(Math.random() * PRAGMATIC_SECTIONS.length); // Tease the neighbouring plate of the other colour, then land. const bait = PRAGMATIC_SECTIONS[(i + 1) % 12].id; wheel.setResult({ section: PRAGMATIC_SECTIONS[i].id }, { anticipation: { bait } }); await spin; }, }; ``` ### Starters URL: https://pixi-wheels.schmooky.dev/recipes/starters/ A wheel in ten lines, the two-colour gamble, and building from a template. #### basic-wheel ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app, mockWheelServer // Eight multipliers. Two of them are thin: the big wins take less of the rim. const wheel = new WheelBuilder() .radius(240, 34) .sections([ { id: 'x2a', label: 'x2', value: 2, weight: 3 }, { id: 'x5a', label: 'x5', value: 5, weight: 2 }, { id: 'x3a', label: 'x3', value: 3, weight: 2.5 }, { id: 'x10', label: 'x10', value: 10, weight: 1 }, { id: 'x2b', label: 'x2', value: 2, weight: 3 }, { id: 'x8', label: 'x8', value: 8, weight: 1.2 }, { id: 'x3b', label: 'x3', value: 3, weight: 2.5 }, { id: 'x50', label: 'x50', value: 50, weight: 0.5, style: { fill: 0xf1c40f, labelColor: 0x2b1d00 } }, ]) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); // A stand-in for the game server: it picks by odds, not by arc. const server = mockWheelServer(wheel, { odds: { x50: 0.2, x10: 0.6 } }); return { wheel, onSpin: async () => { const spin = wheel.spin(); // wind up, cruise... const response = await server.spin(); // ...while the server decides wheel.setResult({ value: response.value }); // any section carrying that value await spin; }, }; ``` #### gamble-red-green ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // The most common wheel there is: a coin flip with a rim. Two sections, // random landing so the pointer never sits on the divider, quick profile // because a gamble is spun over and over. const wheel = new WheelBuilder() .radius(200, 26) .sections([ { id: 'red', label: '', value: 'red', style: { fill: 0xd7263d } }, { id: 'green', label: '', value: 'green', style: { fill: 0x1e9e5a } }, ]) .skin({ type: 'graphics', dividers: { width: 5, color: 0xffffff }, hub: { radius: 26, color: 0x111111 } }) .landing({ mode: 'random', margin: 0.08 }) .speed('normal', SpinPresets.QUICK) .ticker(app.ticker) .build(); let balance = 100; wheel.events.on('spin:landing', ({ section }) => { balance = section.id === 'green' ? balance * 2 : 0; console.log(`[gamble] ${section.id} -> balance ${balance}`); }); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); wheel.setResult({ value: Math.random() < 0.5 ? 'red' : 'green' }); await spin; }, }; ``` #### gamble-dynamic ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Red / green whose split moves after every spin. The odds live on the // server; the arc is cosmetic and follows a step list. The pointer still // lands on whichever colour the server said. const wheel = new WheelBuilder() .radius(200, 26) .sections([ { id: 'red', label: '', value: 'red', style: { fill: 0xd7263d } }, { id: 'green', label: '', value: 'green', style: { fill: 0x1e9e5a } }, ]) .dynamic({ steps: [ { red: 1, green: 1 }, { red: 3, green: 2 }, { red: 2, green: 1 }, { red: 3, green: 1 }, { red: 5, green: 1 }, ], durationMs: 600, ease: 'sine.inOut', }) .skin({ type: 'graphics', dividers: { width: 5, color: 0xffffff }, hub: { radius: 26, color: 0x111111 } }) .landing({ mode: 'random', margin: 0.1 }) .speed('normal', SpinPresets.QUICK) .ticker(app.ticker) .build(); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); const won = Math.random() < 0.5; wheel.setResult({ value: won ? 'green' : 'red' }); await spin; // Each win shrinks the green: the next step is the next state. await wheel.nextStep(); }, }; ``` #### from-template ```ts // @ts-nocheck // Injected globals: WheelBuilder, WheelTemplates, SpinPresets, app // Templates are plain configs. Load one, add the ticker, build. Everything // the studio exports has this shape too. const config = WheelTemplates.jackpots(); // The jackpots template ships the cinematic profile (an eight-second stop); // a builder call after fromConfig() overrides any of it. NORMAL keeps the demo brisk. const wheel = WheelBuilder.fromConfig(config).speed('normal', SpinPresets.NORMAL).ticker(app.ticker).build(); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 300)); // The template records how its server answers: { jackpot: { tier } }. wheel.setResult({ section: ['mini', 'minor', 'major', 'grand'][Math.floor(Math.random() * 4)] }); await spin; }, }; ``` ### Stopping URL: https://pixi-wheels.schmooky.dev/recipes/stopping/ Where the pointer ends up - centre, random or an exact angle - and what happens after it lands. #### stop-modes ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, DebugRingSkin, app // Where inside the section the pointer ends up. Each spin cycles the mode: // center (the middle), random (anywhere, away from the edges), exact (the // offset the server sent: here 0.9, right by the edge). const wheel = new WheelBuilder() .radius(220, 30) .sections(Array.from({ length: 8 }, (_, i) => ({ id: `s${i}`, label: `${i}`, value: i }))) .skin(new DebugRingSkin()) .speed('normal', SpinPresets.TURBO) .ticker(app.ticker) .build(); const modes = ['center', 'random', 'exact']; let i = 0; return { wheel, onSpin: async () => { const mode = modes[i++ % modes.length]; const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); const target = mode === 'exact' ? { index: 3, offset: 0.9 } : { index: 3 }; wheel.setResult(target, { mode }); const result = await spin; console.log(`[stop-modes] ${mode}: offset ${result.offset.toFixed(2)} inside ${result.section.id}`); }, }; ``` #### settle-center ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Land exactly where the server said, pause, then glide to the middle of the // section so the prize presentation is centred. The result reports the // landing offset; the settle is presentation only. const wheel = new WheelBuilder() .radius(220, 30) .sections(Array.from({ length: 6 }, (_, i) => ({ id: `p${i}`, label: `${(i + 1) * 100}`, value: (i + 1) * 100 }))) .landing({ mode: 'random', margin: 0.06, settle: { mode: 'center', delayMs: 350, durationMs: 700, ease: 'sine.inOut' }, }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); wheel.events.on('spin:landing', ({ section, landingAngle }) => console.log('[settle] landed', section.id, 'at', landingAngle.toFixed(1))); wheel.events.on('spin:settle:start', () => console.log('[settle] centring...')); wheel.events.on('spin:settle:end', () => console.log('[settle] centred')); return { wheel }; ``` #### settle-bounce ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // A mechanical stop: the wheel overshoots by a few degrees and springs back. const wheel = new WheelBuilder() .radius(220, 30) .sections(Array.from({ length: 10 }, (_, i) => ({ id: `s${i}`, label: `${i * 5 + 5}` }))) .landing({ mode: 'center', settle: { mode: 'bounce', bounceDeg: 5, durationMs: 520, ease: 'power2.out' } }) .speed('normal', SpinPresets.NORMAL) .ticker(app.ticker) .build(); return { wheel }; ``` #### stop-exact-angle ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, DebugRingSkin, app // Some backends send the final angle, not a section. `{ angle }` is // wheel-local degrees; the engine reports which section that is. const wheel = new WheelBuilder() .radius(220, 30) .startAngle(0) .sections(Array.from({ length: 12 }, (_, i) => ({ id: `s${i}`, label: `${i * 30}`, value: i }))) .skin(new DebugRingSkin()) .speed('normal', SpinPresets.TURBO) .ticker(app.ticker) .build(); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); const angle = Math.round(Math.random() * 360); wheel.setResult({ angle }); const r = await spin; console.log(`[exact] asked for ${angle} deg -> ${r.section.id}, under pointer now ${wheel.main.localAngleUnderPointer().toFixed(1)}`); }, }; ``` #### skip-configs ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Press the button again while the wheel turns to skip. This wheel refuses // a skip in its first 1200 ms and takes 300 ms to land when it accepts one. // A press made before the server answered is queued with requestSkip(). const wheel = new WheelBuilder() .radius(220, 30) .sections(Array.from({ length: 8 }, (_, i) => ({ id: `s${i}`, label: `${i + 1}` }))) .skip({ allowed: true, minimumSpinTime: 1200 }) .speed('normal', { ...SpinPresets.CINEMATIC, skipDuration: 300 }) .ticker(app.ticker) .build(); wheel.events.on('skip:requested', () => console.log('[skip] accepted')); wheel.events.on('skip:completed', () => console.log('[skip] landed')); wheel.events.on('spin:complete', (r) => console.log('[skip] complete, skipped =', r.wasSkipped)); return { wheel, onSpin: async () => { const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 900)); // a slow server wheel.setResult({ index: Math.floor(Math.random() * 8) }); await spin; }, onSkip: () => { // Before the result: queue. After: land now. try { wheel.skip(); } catch { wheel.requestSkip(); } }, }; ``` #### speed-profiles ```ts // @ts-nocheck // Injected globals: WheelBuilder, SpinPresets, app // Four feels on one wheel. Each spin uses the next profile; the label under // the pointer says which. Profiles decide wind-up, cruise speed, turn count // and how long the stop takes. const names = ['normal', 'turbo', 'cinematic', 'quick']; const wheel = new WheelBuilder() .radius(220, 30) .sections(names.map((n, i) => ({ id: n, label: n.toUpperCase(), value: i })).concat([ { id: 'a', label: '', value: 4 }, { id: 'b', label: '', value: 5 }, { id: 'c', label: '', value: 6 }, { id: 'd', label: '', value: 7 }, ])) .speed('normal', SpinPresets.NORMAL) .speed('turbo', SpinPresets.TURBO) .speed('cinematic', SpinPresets.CINEMATIC) .speed('quick', SpinPresets.QUICK) .ticker(app.ticker) .build(); let i = 0; return { wheel, onSpin: async () => { const name = names[i++ % names.length]; wheel.setSpeed(name); const spin = wheel.spin(); await new Promise((r) => setTimeout(r, 200)); wheel.setResult({ section: name }); const r = await spin; console.log(`[speed] ${name}: ${r.duration} ms, ${r.turns} turns`); }, }; ```