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#
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.

| Requirement | Why |
|---|---|
| Square, transparent outside the circle | The sprite is anchored 0.5, 0.5 and scaled to 2 x outerRadius; corners would show |
| 2x the biggest on-screen radius | A 300 px wheel on a 2x screen wants 1200 px of texture; 1024 is usually the sweet spot |
| Centre at the image centre | Half a pixel off and the wheel wobbles as it turns |
| Section 0 where you say it is | Painted from three o’clock, used from twelve: faceRotation: -90 |
| No text that can change | Server-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
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.

| Requirement | Value |
|---|---|
| Orientation | Up is outward. The engine sets rotation = midAngle + 90 deg |
| Anchor | 0.5, 0.5, dead centre, even when the art is not symmetric |
| Shape | Trapezoid: outer chord is 2 x R x sin(arc / 2), inner chord is the same at the hub radius |
| Bleed | A pixel or two of gold outside the intended edge hides seams between neighbours |
| Variants | Ship 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 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.


The small parts
| Piece | Belongs to | Sized against |
|---|---|---|
| Bezel / frame | overlay | A little larger than 2 x outerRadius: it overlaps the rim |
| Bulbs | overlay, or the bezel art | Their own ring radius; animate alpha, never position |
| Hub cap | overlay | innerRadius if the ring has one, else about 0.3 x outerRadius |
| Logo, jackpot readouts | overlay | Whatever 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.
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',
}]
labelFit | What it does |
|---|---|
fit | Scale down only, keep the aspect. The safe default |
fill | Scale up too, so a short label grows into its room |
width | Only the chord matters; let it be as tall as it likes |
none | Place it, do not touch the scale |
labelOrientation | Reads |
|---|---|
radial | Out from the hub. The classic wheel label |
tangential | Along the arc, top facing out |
tangential-in | Along the arc, top toward the hub: how a wheel read from six o’clock wants it |
upright | Never 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:

Loading recipe…
Atlases: three things that move your pivot#
Wheel art is a handful of big sprites, so it almost always arrives packed.
- 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:

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.
winBySectionis how one skeleton carries twelve celebrations. Without it,winplays for everything.labels: trueputs 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.skeland 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#
| Asset | Size that works | Pivot | Notes |
|---|---|---|---|
| Face | Square, 2x the drawn diameter | Image centre | Transparent corners, no changing text |
| Plate | Outer chord x wedge depth, plus bleed | Centre | Up is outward; ship dim and won variants |
| Bezel | A little over the face | Image centre | Transparent middle, lives on the overlay |
| Hub | About 0.3 of the diameter | Image centre | Drawn over the dividers’ meeting point |
| Bulb | 16 - 32 px | Centre | One file, alpha-animated per bulb |
| Pointer | See the tongue recipe | The pin, not the centre | artDirection says which way the tip points |
| Bitmap font | One page, 512 px | n/a | For 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.












