pixi-wheels
All recipes

How to make a tongue

The pointer end to end - where it sits, the pegs it rides, elasticity, friction, springiness and drag, its texture and Spine art, the pointer:tick event, and how to make a ratchet that sounds right.

The tongue, the flapper, the stopper, the clapper, the ticker: the fixed thing the wheel is read against, and the only part of a bonus wheel that is allowed to be floppy. Get it right and a spin sounds and feels mechanical. Get it wrong and the wheel looks like a spinning picture.

This recipe is all of it. The disc it rides on has its own recipe.

Anatomy#

Where the tongue sits and what it touchesthe rimR - tipInsetpeg centresskin.lengththe lever the peg turnspin: the boss turns on itthe tip bites 2 px past the pegs
Every length is measured from the hub. The tongue pivots at its pin and never moves off it.
.pegs({ size: 6 })                      // studs on the disc, one per divider,
                                        // set just inside the tongue's tip
.pointer({
  id: 'top',                            // 'pointer' for the first one
  angle: -90,                           // -90 twelve o'clock, 0 three, 90 six
  facing: 'inward',                     // on the rim, tip toward the hub
  tipInset: 18,                         // how far the tip reaches past the rim
  skin: { type: 'graphics', shape: 'tongue', length: 72, width: 36, pinRadius: 8 },
  flap: { elasticity: 1, friction: 0.35, stiffness: 420, damping: 14, maxAngle: 28, tipWidth: 14 },
})

Results are read against the first pointer. Add more for decoration or a second read position; each one ticks on its own.

Eight sections, eight fat pegs, one long tongue, idling at 24 deg/s so every stage of the contact is visible:

Loading recipe…

The pegs#

Pegs are the wheel’s half of the contact: small circles on the disc that the tip has to climb over. Every ring has them by default, one per divider, so a tick always lands on a line.

.pegs({ size: 6, inset: 9 })                 // the default
.pegs({ size: 9, inset: 14, angles: [0, 45, 90, 135, 180, 225, 270, 315] })  // your own
.pegs(false)                                 // none: a flapping pointer stays at rest
  • Leave angles out and the pegs follow the dividers, so dynamic sections carry their pegs with them as the boundaries move.
  • Give angles and they are wheel-local degrees, fixed. Twice as many pegs as sections is a common trick: twice the ratchet, same landings.
  • inset defaults to tipInset + size - 2: the peg ring lands just inside the tongue’s tip, so a peg’s shoulder bites 2 px into the blade. Set it smaller and the pegs sit out past the tip, where the blade has to lift over them entirely - a much bigger flap, and maxAngle has to allow it.
  • size is the rest of the feel: a bigger peg starts pushing earlier and holds on longer.
  • GraphicsRingSkin draws them with pegs: true; a skin of your own reads ctx.pegs. The debug overlay’s pegs layer always draws them, plus the contact zone and the peg being ridden.

One peg, start to finish#

One peg going under the tonguefirst touchtip clears the pegthe peg is outriding the pegdeflectionthe peg's travel
Rides up the peg's face, holds while it passes underneath, drops once it is out.

The blade and the peg are two solids that may not overlap, and that is the whole model. The blade is a triangle hinged at the pin, tipWidth across at its base, tapering to a point at the tip; the peg is a circle. Every frame the engine asks one question - what is the smallest swing that keeps the circle outside the triangle - and gives the tongue exactly that.

Three things follow, and they are the three things a flapper does:

  • It yields as far as the peg needs and no further. Not a curve fitted to look about right: the angle is the geometry.
  • It never cuts through a peg. There is no setting, no speed and no frame rate at which it can.
  • It does not fall until the peg has gone. On the way down the spring does the work, but it may only descend into clear air, so a blade resting on a peg comes down as the peg leaves and not one degree before.
  • It rides, it is not thrown. While a peg carries it the blade has no velocity of its own, so when the peg finally lets go the spring starts from rest: one clean swing home instead of a flung overshoot and a chatter against the peg it just cleared.

Both knobs that soften the contact are padding on the peg rather than fudge on the angle. elasticity makes the blade treat every peg as fatter, so it swings wider; friction adds padding that has to clear as well, so the peg drags the blade further past itself before letting go. Either way the blade is measured against something at least as big as the peg, which is why it can never touch one.

A wheel standing still with a peg under the tongue leans the tongue off it, to whichever side it was already on. A blade resting inside a peg is the one arrangement that never happens.

How far it swings comes from how deep the tip dips. A peg whose shoulder bites 2 px into the blade - what .pegs() gives you by default - asks about 13 degrees of a 60 px tongue. Push the pegs out past the tip and the blade has to lift clear over them entirely, which is a much bigger flap; maxAngle caps it, and the engine says so once when the cap makes clearing impossible.

KnobFeelTry
elasticityhow far it yields: padding on the peg1 exactly enough, 1.4 swings wide and floppy. Under 1 is clamped
frictionhow long the peg drags it on: more padding0 lets go the moment the peg is through, 1 hangs on a long way
stiffnesshow hard it snaps back140 rubber, 420 default, 700 steel
dampinghow quickly the ringing dies5 rings audibly, 14 default, 18 dead
maxAnglethe hard limit45; set below what the geometry needs and the blade rides through the pegs
tipWidthhow wide the blade is14; a wider blade needs a bigger swing to get out of the way
draghow hard it holds the ring back0 weightless, 1 the wheel fights every peg

The lever the peg turns is pinRadius - pegs.radius, so a long tongue swings through a smaller angle for the same shove. flap: false keeps a pointer rigid: no contact, no spring, just a tick.

Pegs on the rim#

The other arrangement: studs right at the edge of the wheel and a short flapper flicking over them, the way a carnival wheel is built. The contact does not change - the blade still may not touch a peg - but the way out does. A peg under the tip cannot slip past it sideways, so the blade rides up until its tip lifts clear over the peg.

Keep the flapper small. The shorter the arm, the further it has to swing to lift the same few pixels, so a rim wheel wants a stubby flapper that flies rather than a long one that heaves: about a quarter of the radius reads right, and it is the arrangement’s whole character.

That is a big swing, and it is the right one: watch any real wheel and the flapper lifts a long way over every peg. The arrangement below asks for about 48 degrees, past the default cap of 45.

.radius(215)
.pegs({ size: 9, inset: 5 })        // centres 5 px inside the rim: on the edge
.pointer({
  tipInset: 17,                     // the tip dips a few px past the studs
  skin: { type: 'graphics', shape: 'tongue', length: 50, width: 22 },
  flap: { maxAngle: 62, stiffness: 240, damping: 9, friction: 0.25 },
})

Loading recipe…

maxAngle is the thing to get right. This geometry needs 48 degrees; leave the cap at the default 45 and the blade spends part of every peg pinned against its limit, riding through the last few pixels of it. The engine says so once and names the angle it needs. The readout above shows the largest lift of the last spin, so set the cap a little above that.

Between the two arrangements:

Pegs on the rimPegs inside the tip
pegs.insetsmall: 0 to a peg’s widththe default, tipInset + size - 2
Escapethe tip lifts clear over the pegthe peg’s shoulder slips past a shallow bite
Armshort, about a quarter of the radiuswhatever suits the art
Swing40 to 60 degrees10 to 20
maxAngleset it deliberately, well above the liftthe default has room
Reads asa carnival wheel, a big slow clacka tight ratchet

The release is a spring#

How the tongue comes back after a peg lets gostiff700 / 18default420 / 14floppy140 / 50.6 sdeg
Same push, three materials. Damping is what stops a floppy tongue looking like jelly.

At speed a peg crosses the whole contact inside a single frame. Nothing would ever be seen pushing, so the tongue is flicked straight to the angle that peg would have held it at - which is why a fast wheel keeps it pinned and jittering while a crawling one bends slowly over every peg and falls all the way back.

Three tongues against one set of pegs: a stiff short one at the top, a floppy one with heavy drag at four o’clock, a rigid triangle at eight.

Loading recipe…

Drag: the tongue slowing the ring#

A real flapper is in the way. The peg has to shove it aside, and while it is shoving, the wheel is not turning as freely as it wants to.

The arc a dragging tongue holds backpeg touchescrownthroughat most drag x c / pegRadiusarc held backreleased at dragRelease
Up the ramp the ring is drawn behind itself. Over the crown the hold lets go and it catches up.
.pointer({ flap: { drag: 1, dragRelease: 18 } })

drag is 0 by default. At 1 the tongue can hold the ring back by a whole contact width of arc - a couple of degrees on a normal wheel - and releases it when the peg wins. The two wheels below run the same spin to the same section at the same time; only the right one drags.

Loading recipe…

Worth being exact about what this is, because it would be easy to oversell: the hold is drawn, not planned. rotationDeg never sees it, the ticks never see it, and the landing never sees it. It always relaxes to zero, so the wheel comes to rest drawn exactly on its result - the one thing a bonus wheel may never get wrong. Read ring.visualRotationDeg for where the disc actually is and ring.dragDeg for how much is being held; the debug HUD prints the second one while it is non-zero.

If you want the wheel to genuinely take longer to stop, that is a spin profile and a stop plan, not the tongue.

The art#

What pointer art has to declarepin { x: 0.5, y: 0.85 }tip on the top edge: artDirection 'up'length
Two declarations do all the work: where the tip is in the image, and where the pin is.
A gold tongue-shaped pointer with a dark pin cap at its base, tip pointing up
public/recipes/tongue/tongue.png, 160 x 260, tip on the top edge. Loaded by the demo below.

Four tongues, one set of rules - all 160 x 260, tip up, pin at 0.5 / 0.85

download all (1277 KB zip)

Because all four share the pin and the tip edge, swapping one for another is a one-line change and nothing else moves. MIT, no attribution; the zip has the disc art too.

new TexturePointerSkin({
  texture,
  artDirection: 'up',          // where the tip is in the source image
  pin: { x: 0.5, y: 0.85 },    // fractions of the image: the pivot
  scale: 0.42,
  length: 96,                  // pin to tip after scaling; defaults to the edge distance
})
RequirementWhy
Tip on one edge, stated as artDirectionThe engine rotates the art so the tip faces the hub
Pin as fractions, not pixelsThe art can be re-exported at any size and still pivot correctly
A few transparent px at the tipA trimming packer will otherwise clip the point off
Authored 2xIt is the one sprite the player stares at while the wheel slows down
Nothing important near the pinIt is hidden under the bezel on most layouts

The gem-tipped stopper from Wheel of Happiness is declared exactly the same way:

A gold and green gem-tipped pointer from Wheel of Happiness
pointer.webp, 228 x 88. The game reads from six o'clock, so it is mounted at angle 90.
Assets provided by Pragmatic Play

Three tongues, three pipelines, one set of pegs - drawn with Graphics, a PNG, and the studio’s own art:

Loading recipe…

A Spine tongue#

import { SpinePointerSkin } from 'pixi-wheels/spine';

.pointer({
  angle: 90,
  skin: new SpinePointerSkin({
    skeleton: 'stopperData', atlas: 'wheelAtlas',
    length: 80,               // required: a skeleton has no obvious tip
    artDirection: 'up',
    idleAnimation: 'idle',
    tickAnimation: 'tick',    // one shot per divider
    flapRotates: true,        // false when the tick animation does the swinging
  }),
})

length is not optional here: the engine has no way to guess how far the tip is from the pin in a skeleton. flapRotates: false is the setting to reach for when the studio’s tick animation already swings the whole stopper - otherwise the spring and the animation fight and the result reads as a stutter. The Super Wheel recipe runs one against an authored skeleton.

Events#

wheel.events.on('pointer:tick', ({ ring, pointer, from, to, speed, direction }) => {
  // one event per divider, in order, however fast the ring turns
});
FieldWhat it carries
ringRing id, so a two-ring wheel can tell its tongues apart
pointerPointer id: every pointer ticks on its own
from, toThe resolved sections either side of the divider that just passed
speedRing speed at the crossing, deg/s, unsigned - the input to pitch and volume
directionWhich way it was actually turning at that moment

A single frame that sweeps four dividers reports four crossings, in order, so a counter is always right even at 900 deg/s. Between events, pointer.deflection and pointer.engagedPeg are live.

The sound#

The ratchet is the whole soundtrack of a wheel slowing down, and it is the easiest thing to get wrong: a peg event per divider at speed is sixty voices a second.

Loading recipe…

  • Rate-gate. One click per 45 ms at most. Past that the ear hears a buzz, and every extra voice is a real cost.
  • Ride the speed. Pitch and volume from speed, plus a few percent of jitter, so no two clicks are the same sample twice.
  • Swap low. Under about 120 deg/s, a heavier, lower sample. That single swap is most of what makes a wheel sound like it is running out of energy.
  • Let the wheel emit. The audio layer listens; nothing in a sound hook should call a wheel method. A skip and a slam both arrive at spin:landing on their own.

The events and audio guide has the full cue sheet, and the sound hooks recipe wires a whole game’s set through @schmooky/zvuk.

Debugging it#

Press Debug on any demo here. The pegs layer draws every peg, the contact zone either side of the tip, and fills the peg currently being ridden; the HUD prints flap <deg> on peg <n> and, when drag is on, how much arc is being held. In the console:

wheel.main.pointers[0].deflection     // degrees, live
wheel.main.pointers[0].engagedPeg     // index into ring.pegs.angles, or null
wheel.main.pegs                       // { size, radius, angles }
wheel.main.dragDeg                    // arc held back right now
debugSnapshot(wheel)                  // all of it as plain JSON

The checklist#

DecisionDefaultReach for
Where it readsangle: -9090 for a slot that reads from six o’clock
How deeptipInset: 18Deeper on a wheel with a thick bezel
Pegsone per dividerCustom angles for a ratchet finer than the sections
Feelelasticity 1, friction 0.35Softer and floppier for a big cartoon flapper
Springstiffness 420, damping 14140 / 5 for rubber, 700 / 18 for steel
Weightdrag: 00.6 - 1 when the stop should look like work
ArtGraphicsA PNG with pin and artDirection, or a skeleton with tick
Sound-One gated click per peg, pitched by speed