pixi-wheels
All recipes

The disc, layer by layer

Every layer of art a wheel face is made of - the painted face, per-section plates, the bezel, the hub, rich labels, atlases and Spine discs - with the size, pivot and orientation each one has to match.

A ring owns the motion. A skin owns the pixels. The engine will happily spin a wheel that draws nothing at all, so every texture below is a choice you make about how the thing should look, never something the maths needs.

This is the long version: what the layers are, what each asset has to match, and where a real game’s art ends up. The tongue has its own recipe.

Take the textures#

Every PNG on this page is a real file, drawn to the requirements below, and yours: MIT like the library, no attribution, change them as you like. The demos load these exact bytes, so what you see in the prose is what runs in the canvas.

The whole set - discs, plates, bezels, hubs and the small parts

download all (1277 KB zip)

The tongues that go with them are on the tongue recipe, and the zip has the lot.

What a disc is made of#

The layers a wheel disc is built fromfaceplatesdividers and pegslabelsbezelhubpointerctx.disc turnsctx.overlay is fixed
Draw order from the bottom. The first four turn with the disc; the rest sit on the fixed overlay.

A skin is handed two containers and the geometry:

attach(ctx: RingSkinContext) {
  ctx.disc;        // turns with the wheel
  ctx.overlay;     // fixed: bezel, hub, pointers
  ctx.geometry;    // sections with startAngle, midAngle, arc
  ctx.outerRadius; // and innerRadius
  ctx.pegs;        // { size, radius, angles } or null
}
layout() { /* called again whenever a weight moves a boundary */ }

The demo assembles exactly that, one layer per second, from the five PNGs this page shows. Press Spin at any point.

Loading recipe…

Nothing at all: the painted default#

GraphicsRingSkin draws wedges, dividers, a rim, a hub, bulbs and fitted labels from the section styles alone. No files, no loader, no atlas. It is the right answer for a prototype, an internal tool, or a wheel whose colours come from the server.

.skin({
  type: 'graphics',
  dividers: { width: 3, color: 0xffffff },
  rim: { width: 10, color: 0xf0c040 },
  bulbs: { count: 24, radius: 5, inset: 14 },
  hub: { radius: 46, color: 0x121a2a, ringColor: 0xf0c040 },
  pegs: true,
  shading: true,
})

Everything below replaces some part of that with art.

One painted face#

The cheapest real art: the whole disc as a single texture that rotates with the ring.

What a face texture has to match0 degsection 0image centretransparent cornerswidth = 2 x outerRadius
Angles start at three o'clock and grow clockwise, so section 0 begins on the marked edge.
A twelve-section painted wheel face, crimson and navy wedges with gold dividers, on a transparent square
public/recipes/disc-art/face.png, 1024 x 1024. The demo below loads this exact file.
RequirementWhy
Square, transparent outside the circleThe sprite is anchored 0.5, 0.5 and scaled to 2 x outerRadius; corners would show
2x the biggest on-screen radiusA 300 px wheel on a 2x screen wants 1200 px of texture; 1024 is usually the sweet spot
Centre at the image centreHalf a pixel off and the wheel wobbles as it turns
Section 0 where you say it isPainted from three o’clock, used from twelve: faceRotation: -90
No text that can changeServer-driven values belong in the label layer, not in the paint
.skin(new TextureRingSkin({
  face,                     // the painted disc
  faceRotation: -90,        // line the painting up with startAngle
  fitToRadius: true,        // default: width becomes 2 x outerRadius
  frame: bezel,             // fixed, on the overlay
  labels: true,             // draw the section labels over the paint
}))

The trade is honest: one texture is one draw call and never lies about where a wedge is, but it cannot follow a weight change. Move a boundary and the painting stays put while the geometry underneath does not.

Faces: same rules, three densities

  • 12 wedges. Section 0 starts at three o'clock
    face.png

    1024 x 1024

    12 wedges. Section 0 starts at three o'clock

  • 8 wedges, four repeating colours
    face-8.png

    1024 x 1024

    8 wedges, four repeating colours

  • 24 wedges for a ratchet-heavy wheel
    face-24.png

    1024 x 1024

    24 wedges for a ratchet-heavy wheel

Loading recipe…

A plate per section#

When wedges move, or every wedge needs its own frame and glow, ship one plate and let the geometry place twelve.

How a plate sprite is authoredup is outwardanchor 0.5, 0.5outer chord = 2 R sin(arc / 2)
Author the plate upright, outward edge at the top, anchored dead centre.
Where the geometry puts each platemidAngleradius x Rrotation =midAngle + 90
The engine fans it out: one sprite per section, turned to face the rim.
A single crimson trapezoid plate with a gold frame, narrower at the bottom
public/recipes/disc-art/plate.png, 256 x 320. One file, twelve sprites.
RequirementValue
OrientationUp is outward. The engine sets rotation = midAngle + 90 deg
Anchor0.5, 0.5, dead centre, even when the art is not symmetric
ShapeTrapezoid: outer chord is 2 x R x sin(arc / 2), inner chord is the same at the hub radius
BleedA pixel or two of gold outside the intended edge hides seams between neighbours
VariantsShip the dim, the lit and the won state as separate files; swapping a texture is free
decorations: sections.map((s) => ({ section: s.id, texture: plate, radius: 0.63, scale: 0.38 }))

radius is a fraction of outerRadius, orientation: 'none' keeps the art upright instead of turning it, and rotationOffset nudges art that was exported off-axis. A skin of your own does the same thing with ctx.geometry.byId(id).midAngle and gets to keep per-section state - which plate is dim, which is celebrating.

Ship the states you need as separate files at the same size, and landing becomes a texture swap:

Plates: colours and states, all 256 x 320

This is what a real one looks like. Pragmatic Play’s Wheel of Happiness ships two plate colours and a dark variant of each, and dims the eleven losers on landing:

The WILD WINS plate from Wheel of Happiness: a gold-framed red trapezoid
plate_red.webp, 260 x 313, and its dim twin at the same size, so a swap never moves anything.
Assets provided by Pragmatic Play

The bezel, the bulbs and the hub#

Anything that must not turn goes on ctx.overlay: the frame round the rim, the chasing bulbs, the cap over the centre, the logo.

A gold ring with evenly spaced bulbs and a hollow centre
public/recipes/disc-art/bezel.png, 1152 x 1152 with a transparent middle: the disc shows through.
A dark disc with a gold rim and a gold star at its centre
public/recipes/disc-art/hub.png, 320 x 320. Drawn last, over the dividers' meeting point.

The small parts

PieceBelongs toSized against
Bezel / frameoverlayA little larger than 2 x outerRadius: it overlaps the rim
Bulbsoverlay, or the bezel artTheir own ring radius; animate alpha, never position
Hub capoverlayinnerRadius if the ring has one, else about 0.3 x outerRadius
Logo, jackpot readoutsoverlayWhatever the layout says: they are not wheel art

The bulbs are worth a word: alternating them at rest and chasing them during the spin is most of what makes a wheel feel alive, and it costs one alpha tween per bulb. Drive it from spin:start and spin:landing.

Labels are art too#

A section’s content is any container, so a label can be a texture, a bitmap-font number, an icon over a value, or a group that keeps animating after the wheel stops.

The room a label has inside its wedgechordAt(radius)the slot a label is fitted intolabelRadius
labelSlot gives you the box; fitContainer scales anything into it.
sections: [{
  id: 'grand',
  content: (ctx) => {                  // ctx: { section, radius, innerRadius, slot }
    const box = new Container();
    box.addChild(icon, valueText);
    fitContainer(box, ctx.slot, { mode: 'fit' });
    return box;
  },
  labelOrientation: 'tangential-in',   // top toward the hub: how slots read from six o'clock
  labelFit: 'fit',
}]
labelFitWhat it does
fitScale down only, keep the aspect. The safe default
fillScale up too, so a short label grows into its room
widthOnly the chord matters; let it be as tall as it likes
nonePlace it, do not touch the scale
labelOrientationReads
radialOut from the hub. The classic wheel label
tangentialAlong the arc, top facing out
tangential-inAlong the arc, top toward the hub: how a wheel read from six o’clock wants it
uprightNever rotates. For icons, faces, anything with a right way up

scaleToFit, fitContainer, fitText, labelSlot and chordAt are exported for your own layout code; the label layer uses the same five. Bitmap fonts are the usual answer for values that change every spin - Wheel of Happiness draws its + and its digits from one 512 x 512 page:

A bitmap font page: gold Impact glyphs on transparency
impact.png with impact.fnt beside it. One texture, any number, no layout cost.

Loading recipe…

Atlases: three things that move your pivot#

Wheel art is a handful of big sprites, so it almost always arrives packed.

Atlas regions: packed, trimmed, rotatedpacked as drawnoffsettrimmedrotated 90 deg CCW
All three are normal. All three break the pivot if you read the rectangle and stop there.
  • Trimmed. The packer cut the transparent border off. The frame rectangle and the trim offsets have to go back on, or every plate drifts toward the hub by however much was cut.
  • Rotated. Regions are stored 90 degrees counter-clockwise; PixiJS calls that rotate: 6, and width and height swap. A wheel built from rotated regions with the rotation ignored looks like a fan of tipped-over plates.
  • Padded. Padding a packer added is not part of the art. Carry it into the offsets or the piece sits a pixel or two off centre - invisible on a plate, obvious on a bezel.

Playson’s Super Wheel arrives as three atlas pages like this, sprite rectangles and all:

An atlas page of wheel parts: plates, bezel segments, glows and sweep frames on transparency
wheel.webp, 2048 x 2048, the first of three pages. Everything the Playson recipes draw comes off these.
Assets provided by Playson

When the disc is a skeleton#

SpineRingSkin turns a bone instead of a container, so the art keeps its own animation while the engine keeps the angles.

.skin(new SpineRingSkin({
  skeleton: 'wheelData', atlas: 'wheelAtlas',
  bone: 'wheel',                 // the bone the engine rotates; omit to turn the whole skeleton
  animations: {
    idle: 'idle', spin: 'spin',
    winBySection: { grand: 'win_11', mini: 'win_03' },
  },
  labels: true,                  // values the skeleton cannot know, on the overlay above it
}))

Rules that repay reading twice:

  • The engine sets the bone’s rotation every frame. An animation that also rotates that bone will fight it - animate its children instead.
  • winBySection is how one skeleton carries twelve celebrations. Without it, win plays for everything.
  • labels: true puts the label layer above the skeleton and rotates it by hand, so server-driven multipliers can sit on authored plates.
  • Spine 3.7 and 3.8 exports need converting first. tools/spine-3.8-to-4.2/ does binary .skel and JSON, tools/spine-3.7-to-4.2/ does 3.7 JSON.

The Super Wheel as a skeleton runs all of it, including a sector sweep per plate.

The checklist#

AssetSize that worksPivotNotes
FaceSquare, 2x the drawn diameterImage centreTransparent corners, no changing text
PlateOuter chord x wedge depth, plus bleedCentreUp is outward; ship dim and won variants
BezelA little over the faceImage centreTransparent middle, lives on the overlay
HubAbout 0.3 of the diameterImage centreDrawn over the dividers’ meeting point
Bulb16 - 32 pxCentreOne file, alpha-animated per bulb
PointerSee the tongue recipeThe pin, not the centreartDirection says which way the tip points
Bitmap fontOne page, 512 pxn/aFor values that change every spin

Two production wheels built from exactly this list are on the skins page: one composed from atlas sprites in about 150 lines of custom skin, one driven as a Spine skeleton.