Moving Is a Lean Plus a Wiggle

One of the Moshpit Games projects is a fruit merging game, the kind where dropped fruit combines into bigger fruit. The character at the top who carries and drops the fruit has no animation data at all. No skeleton, no keyframes, no animation player. It's a few sprites and about eighty lines of arithmetic, and it has more life in it than most rigged characters.

Taking the whole approach apart shows that none of it needed an animator.

One Multiplier Is a Whole Effect

The character has a head and a hat, and this is the entire relationship between them:

Head.RotationDegrees = headAngle;
float hatAngle = headAngle * HatWobbleMultiplier;
Hat.RotationDegrees = hatAngle;

HatWobbleMultiplier is 0.9. The hat turns nine tenths as far as the head does.

That one number is the difference between a hat that's part of the character and a hat that's sitting on the character. Rotating them together makes the hat read as painted on, because nothing in the world moves in perfect lockstep with the thing under it. Ninety percent means the hat is always slightly behind, always slightly wrong, and the eye reads that as a separate object with its own weight resting on a head.

This is what animators call overlapping action, and it usually costs a second set of keyframes on a second bone. Here it's a multiply.

Moving Is a Lean Plus a Wiggle

While the character is moving there are two contributions:

float moveDir = Mathf.Sign(input);
float fruitAngle = moveDir * FruitDangleAmplitude;
float headAngle  = moveDir * HeadWobbleAmplitude;

if (moveDir != 0)
{
    SpeedWiggleAccumulator += (float)delta * SpeedWiggleFrequency;
    float amplitude = Mathf.Sin(SpeedWiggleAccumulator) * SpeedWiggleAmplitude;
    headAngle  += amplitude;
    fruitAngle += amplitude;
}

A constant lean in the direction of travel, which is the pose, plus a fast small sine on top, which is the bounce of walking. Amplitude 2 degrees at frequency 32, so it's a rapid shudder rather than a swing.

The lean amplitudes carry the effect. HeadWobbleAmplitude is negative twelve and FruitDangleAmplitude is positive sixteen. Moving right, the head tips back into the direction of travel and the carried fruit swings the opposite way, trailing behind. Two signs is all it takes to say that the head is being driven and the fruit is being dragged.

The accumulator pattern is the part to copy. Rather than sampling a sine of total elapsed time, the code keeps its own phase and advances it by delta * frequency. That means frequency can change between frames without the wave jumping, because the phase is continuous by construction. Sampling sin(time * frequency) and then changing frequency teleports the wave to a different part of its cycle, which is a discontinuity people usually discover as a visible pop and fix by never changing frequency.

Stopping Is a Wobble That Slows Down

Releasing the stick is where the good part is:

if (DangleTimer < FruitDangleTime)
{
    float amplitude = 1.0f - Mathf.Min(1.0f, DangleTimer / FruitDangleTime);
    fruitAngle = Mathf.Sin(FruitDangleAccumulator) * amplitude * FruitDangleAmplitude * LastMoveDir * -1;
    DangleTimer += (float)delta;
    FruitDangleAccumulator += (float)delta * FruitDangleFrequency * amplitude;
}

The amplitude decays linearly to zero over FruitDangleTime, which is the obvious part. The oscillation starts big and fades out.

The swing begins in the direction opposite to travel, from LastMoveDir * -1. Something being carried keeps going when the carrier stops, so it swings forward first and then back. Getting that sign right is what makes it read as momentum rather than as a shiver.

And the third one is the line I'd point at. The accumulator advances by delta * frequency * amplitude, so the frequency is scaled by the decaying amplitude too. As the wobble dies, it also slows down.

A real pendulum doesn't do that. Its period is set by its length, and a dying swing keeps the same rhythm at smaller and smaller amplitude. But a wobble that slows as it fades reads as friction, as something coming to rest, and it looks better than the correct behavior does. It also solves a practical problem: an oscillation that keeps its frequency while its amplitude approaches zero spends its last half second doing many tiny invisible cycles, which is wasted time in an animation the player is waiting on.

The head and hat get the same treatment on separate timers, with HatWobbleTime at 0.75 seconds and FruitDangleTime at 4 seconds. The dangling fruit keeps swinging for five times as long as the head does, so the two motions fall out of sync almost immediately and never resolve into a single visible rhythm. Sharing one timer would make the whole character pulse.

Both accumulators and both timers reset to zero whenever movement resumes:

LastMoveDir = moveDir;
DangleTimer = 0;
FruitDangleAccumulator = 0;
HatWobbleTimer = 0;
HatWobbleAccumulator = 0;

So a stop and start mid-wobble restarts cleanly rather than resuming a stale phase. It's a hard cut, and at these speeds nobody sees it, and the alternative is blending state that would double the complexity of the whole system.

The Juggle Is Two Quarter Waves

Dropping a fruit tosses the next one from the left hand to the right, and the arc is built without a physics body:

// Dropping
float t = AnimTimer / AnimDropTime;
float theta = Mathf.Pi * 0.5f * t;
var newPos = LeftSocket.GlobalPosition.Lerp(midpoint, t);
NextFruitRight.GlobalPosition = newPos with { Y = newPos.Y - (Mathf.Sin(theta) * JuggleHeight) };
// Catching
float t = AnimTimer / AnimDropTime;
float theta = Mathf.Pi * 0.5f * t;
var newPos = midpoint.Lerp(RightSocket.GlobalPosition, t);
NextFruitRight.GlobalPosition = newPos with { Y = newPos.Y - (Mathf.Cos(theta) * JuggleHeight) };

The first half moves left socket to midpoint with a height of sin over a quarter turn, so height goes from zero to full. The second half moves midpoint to right socket with cos over a quarter turn, so height goes from full back to zero.

Sine and cosine over their first quarter are mirror images, and at the join both are at their flat part, where the slope is zero. So the two halves meet at the apex with matching height and matching gradient, and the toss has a smooth top instead of a corner. That's a continuous arc assembled from two independent phases, each of which can have its own duration.

Which matters, because they do:

[Export] public float AnimDropTime { get; set; } = 0.25f;
[Export] public float AnimCatchTime { get; set; } = 0.25f;

Two separate exports, so the throw and the catch can be timed differently. They're equal right now, and a slower catch than throw is the classic way to make a catch land heavily.

There's a real bug in it. The catch phase computes its progress with AnimTimer / AnimDropTime and then checks completion against AnimTimer >= AnimCatchTime. If somebody sets those to different values, the catch either finishes before its arc completes or overshoots past the socket. The parameter exists, is exported, and is wired up wrong, so it works only while the two numbers stay equal.

The hands are two sprites each with a flip:

LeftClosed.Visible = false;
RightClosed.Visible = false;
LeftOpen.Visible = true;
RightOpen.Visible = true;
LeftOpen.FlipV = true;
RightOpen.FlipV = false;

Open and closed variants, vertically flipped per phase to get four poses out of two drawings. A two frame hand animation driven by the state machine rather than a timeline.

The Fruit Are Alive Before They Are Dropped

The fruit themselves have idle behavior:

LookTimer += (float)delta;
if (LookTimer >= LookTime)
{
    LookTimer -= LookTime;
    Eyes.FlipH = RNG.Randf() < 0.25f ? !Eyes.FlipH : Eyes.FlipH;
}

Once a second, a one in four chance of flipping which way the eyes face. Blinking is a separate timer with its own probability, its own duration, and a scale rather than a sprite swap:

Eyes.Scale = Eyes.Scale with { Y = IsBlinking ? EyeScale * BlinkScale : EyeScale };

Squashing the eyes to a tenth of their height for 0.3 seconds. One scale value instead of a closed-eye sprite.

Two independent random timers mean no two fruit in a pile are ever synchronized, so a screen of twenty fruit has twenty things blinking and glancing at different moments. Any shared clock would make them a chorus, which is the thing that immediately reads as fake.

The merge has the guard that every game of this type needs:

if (!IsQueuedForDeletion() && !Freeze && body is Fruit other)
{
    if (other.SceneFilePath == SceneFilePath && !other.Freeze)

Two touching fruit both receive a collision callback, so without the check both would spawn a replacement and the pile would double. Checking IsQueuedForDeletion on itself and Freeze on both sides is what stops one collision becoming two merges.

Identity is compared by SceneFilePath, so two fruit are the same kind when they came from the same scene file. That removes a tier enum and the possibility of it disagreeing with the scene assignment, and it costs a string comparison on a rare event.

The new fruit inherits amplified momentum:

nextFruit.LinearVelocity = (LinearVelocity + other.LinearVelocity) * 1.25f;
nextFruit.AngularVelocity = (AngularVelocity + other.AngularVelocity) * 1.25f;

Summing the two velocities conserves momentum. Multiplying by 1.25 does not, and that's the point. The merge gives back more energy than went in, so the pile jumps and settles rather than absorbing the event silently, and a chain of merges builds rather than damping out.

What Transfers

Drive secondary motion from primary motion with a multiplier. One number under one produces overlapping action on any attached thing, and it costs no data.

Keep a phase accumulator rather than sampling a function of total time. It's the same number of lines and it lets frequency vary without the wave jumping.

Decay the frequency along with the amplitude. It's wrong and it looks better, and it stops an oscillation wasting its last moments on cycles nobody can see.

Give every repeated element its own random timers. Shared timing is what makes a crowd of things look like one thing.

And when an effect should feel good rather than be correct, put the number that breaks conservation somewhere obvious and give it a name. The 1.25 on a merge is a design decision, not a bug, and it's the reason merging feels like a reward.