Our fish slid sideways. Not badly — you had to watch for a second before it bothered you — but once you saw it you could not unsee it: the body was doing a swimming motion while the whole animal was being carried along a track, like a plank on a conveyor. This is what we had to change to fix it, and the three fixes we tried first that did nothing.
Throughout, sideslip means one thing: the angle between where the fish is going and where its body is pointing.
The model that slides
The usual way to draw a swimming fish is three independent steps:
// 1. steer: turn the heading toward where we want to go
fish.angularVelocity += (torque - drag) * dt;
fish.angle += fish.angularVelocity * dt;
// 2. move: push the body along that heading
fish.x += Math.cos(fish.angle) * speed * dt;
fish.y += Math.sin(fish.angle) * speed * dt;
// 3. decorate: run a wave down the spine so it looks alive
for (let i = 1; i < spine.length; i++) {
const tailward = i / (spine.length - 1);
const wave = Math.sin(fish.swimPhase - tailward * 4.8) * amp;
const angle = fish.spineAngles[i - 1] + wave;
spine[i].x = spine[i - 1].x - Math.cos(angle) * segmentLength;
spine[i].y = spine[i - 1].y - Math.sin(angle) * segmentLength;
}
Read step 3 against steps 1 and 2. The wave runs after the position has already been decided. The shape of the body and the track it travels are computed independently and never meet. That disconnect is the whole defect, and everything below follows from it.
Three patches that improved a number and changed nothing
Weld the momentum to the visible body axis. Take the body's actual axis, and rotate the velocity vector toward it every frame:
const bodyAxis = fish.spineAngles[0];
const speed = Math.hypot(fish.velocityX, fish.velocityY);
if (speed > 0.5) {
const course = Math.atan2(fish.velocityY, fish.velocityX);
const rot = angleDifference(bodyAxis, course) * Math.min(1, dt * 10);
fish.velocityX = speed * Math.cos(course + rot);
fish.velocityY = speed * Math.sin(course + rot);
}
The angle between the centre-of-mass velocity and the body axis drops to almost nothing, and the fish still visibly slides. In steady state that measurement cannot say anything else — the patch makes the centre of mass travel along its axis by construction. But nobody watches the centre of mass. What you watch is the body: eight spine points, each still free to translate however the wave feels like translating it. Instrumenting the one point that cannot express the defect is how you stay confidently wrong while the picture never changes.
Move the pivot forward, so turns rotate about the head instead of the middle. Add a rotation-carry factor, so stored joint angles rotate with the heading and the tail stops lagging. Both are real improvements to real numbers. Neither changed the picture.
The instrument that settled it
We stopped measuring scalars and started stamping the entire spine onto the canvas every third of a second, leaving the stamps behind:
// throwaway instrument in a standalone tuning page, not in the game
function captureSpineSnapshot(sampleTime) {
spineSnapshots.push({
t: sampleTime,
spine: fish.spine.map((p) => ({ x: p.x, y: p.y })),
});
if (spineSnapshots.length > 90) spineSnapshots.shift();
}
A swimming fish threads those outlines through each other: each part of the body passes near where the part ahead of it has been, so the stamps interleave into one corridor. A sliding fish leaves a ladder of parallel lines — the unmistakable picture of a shape being carried sideways. Unlike a scalar readout, that image cannot be argued with, and it kills a false "verified fixed" in about a second.
Force, not pose
We rewrote it under one rule: behaviour may only set muscle terms. It may never write position or heading.
There are three muscle terms — beat frequency, wave amplitude and a curvature bias for steering — plus a goal heading. Everything else is a consequence.
First the body has a shape. Segment depths give each piece its area against the water, and the mass sits forward, not in the geometric middle:
const DEPTH_PROFILE = [0.14, 0.22, 0.27, 0.28, 0.23, 0.14, 0.08, 0.18];
const MASS_PROFILE = (() => {
const weights = [0.16, 0.20, 0.20, 0.16, 0.12, 0.08, 0.05, 0.03];
const total = weights.reduce((a, b) => a + b, 0);
return weights.map((value) => value / total);
})();
const SWIM = {
cruiseHz: 1.5, dashHz: 4.6, waveLength: 1.0, tailAmp: 0.13,
normalDrag: 26, dragRatio: 52, bendGain: 1.0,
};
The depth profile peaks about a third of the way back — that deep forward body is what resists yaw, and flattening it makes the fish wag. The last entry is the caudal fin, not body depth.
The muscle terms produce a shape, in the fish's own frame:
function bodyShape(fish, phase) {
const body = bodyOf(fish);
const lambda = SWIM.waveLength * body.length;
const scale = SWIM.tailAmp * fish.waveGain;
const points = [{ x: 0, y: 0 }];
let bend = 0;
for (let i = 0; i < body.segments.length; i += 1) {
const s0 = body.arc[i] / body.length;
const s1 = body.arc[i + 1] / body.length;
const y0 = waveAmpAt(s0) * scale * Math.sin(phase - 2 * Math.PI * body.arc[i] / lambda);
const y1 = waveAmpAt(s1) * scale * Math.sin(phase - 2 * Math.PI * body.arc[i + 1] / lambda);
// steering is curvature accumulated joint by joint, so the body forms an
// arc — not a rigid rotation of the whole fish
bend += fish.bendBias * SWIM.bendGain * 0.34 * body.bendWeight[i];
const angle = Math.atan2((y1 - y0) * body.length, body.segments[i]) + bend;
points.push({
x: points[i].x + Math.cos(angle) * body.segments[i],
y: points[i].y + Math.sin(angle) * body.segments[i],
});
}
...
}
Then every segment meets the water. Its velocity is the rigid-body motion plus the muscle deformation, split into a component across the segment and one along it:
// cosine/sine rotate body frame -> world; q is the segment midpoint and dq its
// deformation velocity; r is that midpoint relative to the centre of mass.
const ux = fish.velocityX - fish.angularVelocity * ry + (cosine * dqx - sine * dqy);
const uy = fish.velocityY + fish.angularVelocity * rx + (sine * dqx + cosine * dqy);
const un = clamp(ux * nx + uy * ny, -400, 400); // across the segment
const ut = clamp(ux * tx + uy * ty, -400, 400); // along it
// resistance across the body is dragRatio times what it is along it: this is
// both where the thrust comes from and why the fish cannot move sideways
const kn = SWIM.normalDrag * body.areaWeight[i] / body.length;
const kt = kn / SWIM.dragRatio;
const fn = -kn * un * Math.abs(un);
const ft = -kt * ut * Math.abs(ut);
forceX += fn * nx + ft * tx;
forceY += fn * ny + ft * ty;
torque += rx * (fn * ny + ft * ty) - ry * (fn * nx + ft * tx);
Sum over segments, integrate, done. Mass is 1 by choice — only ratios matter here — so force is acceleration:
fish.velocityX += forceX * dt;
fish.velocityY += forceY * dt;
fish.angularVelocity += torque / bodyInertia(shape) * dt;
fish.x += fish.velocityX * dt;
fish.y += fish.velocityY * dt;
fish.angle += fish.angularVelocity * dt;
Sideslip is now impossible to author, because nothing authors motion at all. The lateral resistance is dragRatio times the forward resistance, so any sideways velocity dies almost as fast as it appears. The fish does not slide because the water will not let it — not because we constrained it.
Two honest notes. This is resistive-force theory: drag per segment, no added-mass term. A real fish gets a large part of its thrust from accelerating the water it pushes, and we do not model that at all, which is probably why the anisotropy has to be as high as 52 to look right. And dragRatio is not a measured property of anything — it is the knob that sets cruising speed, and we set it by watching koi cross the pond.
Run it on a fixed small step, not a whole frame:
const steps = Math.max(1, Math.ceil(dt / 0.004));
const stepDt = dt / steps;
for (let i = 0; i < steps; i += 1) stepSwimPhysics(fish, stepDt);
Quadratic drag plus a frame-rate dip is an explosion. Ask how we know.
Two ways we got it wrong
Counting the recoil twice. Published amplitude envelopes for swimming fish are measured in world coordinates — they describe how much each point of a real fish ends up moving, which already includes the body's recoil against its own tail. Feeding that curve in as the muscle command looks obviously right:
// wrong: this is the motion, not the command that produces it
function ampAt(x) { return 0.05 - 0.13 * x + 0.28 * x * x; }
The physics then adds recoil a second time and the fish waddles forward, head swinging. The command has to be quieter at the front than the resulting motion is, because the head's sway is a consequence, not an instruction:
// right: near zero at the head, growing tailward; the head's real sway
// appears on its own, as recoil
const waveAmpAt = (x) => Math.pow(Math.max(0, x), 1.9);
Using the wrong reference point. The shape has to be recentred each step, and we first recentred it on the geometric centroid of the spine. That puts a spurious rigid-body translation into what is supposed to be pure deformation, the drag is quadratic so it does not cancel, and it feeds yaw recoil. On a treadmill test — fish held in place, water flowing past, watching whether it holds station — it swims cleanly for several seconds and then wags in place while its speed collapses. The centroid has to be mass-weighted:
let cx = 0;
let cy = 0;
for (let i = 0; i < points.length; i += 1) {
cx += points[i].x * MASS_PROFILE[i];
cy += points[i].y * MASS_PROFILE[i];
}
return points.map((point) => ({ x: point.x - cx, y: point.y - cy }));
Check the sign of your plant
With the body working, the fish still would not turn toward anything. It cruised with the body permanently bent, held what amounted to full rudder, and pinned itself against the walls. We tried proportional gain, damping, a dead zone, ±180° tie-breaking, a commitment threshold. Some helped a little. Some made it worse in ways nothing explained.
Then we ran the plant open-loop: freeze the behaviour layer, hold a fixed bend, and read the yaw rate off the console. It takes two lines. A positive bend command yawed the fish negative — and the controller was feeding a positive command for a positive error. The steering loop had been positive feedback the whole time, and every parameter tuned above it was the gain of a runaway loop.
The sign was the bug. Fixing it also let us delete the damping term, the dead zone and the magnitude floor, because all three existed to fight the loop rather than to steer. Cruising bend dropped by more than half.
Measure the sign of your plant before you tune its controller.
Two more things the steering law needs once the body is a real body.
Aim the course, not the nose. A bent body sideslips, so pointing the head at something walks the fish past it in a widening spiral. While chasing a pellet, the error is measured against the direction of travel instead:
let error = angleDifference(fish.goalHeading, bodyAxis);
if (fish.targetPellet && fish.velocity > body.length * 0.25) {
const course = Math.atan2(fish.velocityY, fish.velocityX);
error = clamp(angleDifference(fish.goalHeading, course), -Math.PI, Math.PI);
}
Make the law nonlinear. A linear gain keeps feeding rudder for a 10° error, which keeps the body bent, which keeps the sideslip alive, which keeps the error. Shaping it decisive when far off and nearly straight once aligned breaks that loop:
const reference = fish.targetPellet ? 0.8 : 1.15;
const shaped = Math.pow(clamp(Math.abs(error) / reference, 0, 1), 1.7);
const steer = fish.turnSide ? fish.turnSide : -Math.sign(error) * shaped;
fish.bendBias += (steer - fish.bendBias) * Math.min(1, dt * bendRate);
turnSide is the one compensating rule that survived: past about 150° both directions are equally good, the shaped law has nothing to choose with, and a fish that dithers there looks broken. It picks a side and commits until the error is back under 85°.
Note what the controller writes: bendBias, a muscle term. It never touches fish.angle.
Burst and coast
A fish that undulates continuously reads as a machine however good the hydrodynamics are. Pond koi beat once or twice and then glide with the body straight:
fish.gaitTime -= dt;
if (fish.gaitTime <= 0) {
if (fish.gait === 'burst') {
fish.gait = 'coast';
fish.gaitTime = 1.2 + Math.random() * 1.8;
} else {
fish.gait = 'burst';
fish.gaitTime = (Math.random() < 0.5 ? 1 : 2) / Math.max(0.4, hz);
}
}
const needPower = Math.abs(angleDifference(fish.goalHeading, bodyAxis)) > 1.2
|| fish.velocity < body.length * 0.18;
if (fish.gait === 'coast' && !needPower) {
gain = 0.14; // muscles nearly off; the body straightens
hz = SWIM.cruiseHz * 0.75;
}
This is the payoff for building thrust out of a body meeting water: cutting the muscle command is the entire implementation. The fish coasts and decelerates on its own, the body straightens on its own, the wave phase keeps advancing so nothing freezes, and there is no glide animation to blend into. Under the old model this would have been a second animation state and a transition between them.
If you would rather watch than read: juju.games/cozy-games
What we would tell ourselves at the start
A shape decided independently of its track will not read as swimming. Not for anything that has to turn, chase and stop. You can shape the wave forever; the artefact lives in the seam between the two, and moving the wave upstream of the motion is what makes the artefact inexpressible rather than small.
Instrument the thing the complaint is about. The centre of mass was the easiest point to reach and the one point that could not show the problem. The ugliest debug view that draws what you are actually looking at beats the cleanest number that does not.
A pile of interacting rules means a missing mechanism. When the fix list grows a dead zone, a damping term, a magnitude floor and a tie-break, stop adding rules and go measure the thing they are all compensating for. Ours was a minus sign, and three of the four rules came out with it. The fourth turned out to be a real one.
Top comments (0)