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 (flapper, stopper, clapper) is the fixed thing the wheel is read against, and the one part of a bonus wheel that is allowed to be floppy. This recipe covers all of it; the disc it rides on has its own.

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: 45, 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()                                      // the default: size 6, just inside the first tongue's tip
.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 finds the smallest swing that keeps the circle outside the triangle and gives the tongue exactly that. Four things follow:

  • It yields as far as the peg needs and no further. The angle is the geometry, not a curve fitted to look about right.
  • It never cuts through a peg, at any setting, speed or frame rate.
  • It does not fall until the peg has gone. The spring 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 the release is one clean swing home, not a flung overshoot and a chatter against the peg it just cleared.

The two softening knobs are padding on the peg. 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. A wheel standing still with a peg under the tongue leans the tongue off it, to whichever side it was already on.

How far it swings comes from how deep the tip dips. A peg whose shoulder bites 2 px into the blade, the default, asks about 13 degrees of a 60 px tongue. Pegs out past the tip make the blade lift clear over them entirely, 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, 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. 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; at the default 45 the blade spends part of every peg pinned against its limit, riding through the last few pixels of it, and the engine says so once, naming the angle it needs. The readout above shows the largest lift of the last spin; set the cap a little above it.

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, so the tongue is flicked straight to the angle that peg would have held it at: a fast wheel keeps it pinned and jittering, a crawling one bends slowly over every peg and falls all the way back. Three tongues against one set of pegs: a stiff one at the top, a floppy one with heavy friction at four o’clock, a rigid triangle at eight.

Loading recipe…

Drag: the tongue slowing the ring#

A real flapper is in the way: while a peg shoves it aside, 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…

The hold is drawn, not planned. rotationDeg, the ticks and the landing never see it, and it always relaxes to zero, so the wheel comes to rest drawn exactly on its result. ring.visualRotationDeg is where the disc actually is, ring.dragDeg how much is being held; the debug HUD prints the second while it is non-zero. A wheel that should genuinely take longer to stop is a spin profile, 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)

All four share the pin and the tip edge, so swapping one for another moves nothing else. 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 required: the engine cannot guess how far the tip is from the pin in a skeleton. Set flapRotates: false when the studio’s tick animation already swings the whole stopper, or the spring and the animation fight. 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 soundtrack of a wheel slowing down, and the easiest thing to get wrong: a click 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