Introduction
This article walks you through building a fully procedural scorpion cursor from scratch using nothing but vanilla JavaScript and the HTML5 Canvas API. Every body part - the curling tail, the snapping pincers, the walking legs - is driven by a real physics-based Inverse Kinematics engine. No libraries, no pre-baked animations.
The engine is built around three core classes: Segment (a single bone), LegSystem (procedural walking), and Creature (the physics root that follows the mouse).
How Inverse Kinematics works in 2D - how to build a parent-child segment skeleton - how to make legs walk procedurally with LegSystem - how to construct the scorpion body: abdomen, tail, pincers, head, eyes - how to draw with canvas: glow effects, tapering, ovals - how to add a click-to-strike stinger interaction.
What is Inverse Kinematics?
Inverse Kinematics (IK) calculates joint angles so the end of a chain of bones reaches a target. Instead of manually setting each angle, you give it a target position and it figures out all the angles automatically.
For a scorpion leg: hip -> femur -> tibia -> foot. IK figures out what angles the hip and femur need so the foot lands exactly where you want.
Step 1 — Setting Up Input
First we track the mouse position and button state. This is the data the creature will chase.
var Input = { keys: [], mouse: { left: false, x: 0, y: 0 } };
for (var i = 0; i < 230; i++) Input.keys.push(false);
document.addEventListener('mousemove', e => {
Input.mouse.x = e.clientX;
Input.mouse.y = e.clientY;
});
document.addEventListener('mousedown', e => {
if (e.button === 0) Input.mouse.left = true;
});
document.addEventListener('mouseup', e => {
if (e.button === 0) Input.mouse.left = false;
});Step 2 — Creating the Canvas
We create a full-screen canvas dynamically and grab its 2D context.
var canvas = document.createElement('canvas');
document.body.appendChild(canvas);
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
canvas.style.cssText = 'position:absolute;left:0;top:0;';
var ctx = canvas.getContext('2d');
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});Step 3 — The Segment Class
The Segment is the fundamental building block. Every tail ring, leg bone, and pincer arm is a Segment. It stores its size (length), default angle relative to its parent, allowed range of motion, and stiffness.
Constructor
// new Segment(parent, size, angle, range, stiffness)
class Segment {
constructor(parent, size, angle, range, stiffness) {
this.isSegment = true;
this.parent = parent;
if (typeof parent.children === 'object') parent.children.push(this);
this.children = [];
this.size = size;
this.relAngle = angle;
this.defAngle = angle;
this.absAngle = parent.absAngle + angle;
this.range = range;
this.stiffness = stiffness;
this.updateRelative(false, true);
}
}updateRelative() — recalculates world position
updateRelative(iter, flex) {
this.relAngle -= 2 * Math.PI * Math.floor(
(this.relAngle - this.defAngle) / 2 / Math.PI + 0.5);
if (flex) {
this.relAngle = Math.min(
this.defAngle + this.range / 2,
Math.max(this.defAngle - this.range / 2,
(this.relAngle - this.defAngle) / this.stiffness + this.defAngle));
}
this.absAngle = this.parent.absAngle + this.relAngle;
this.x = this.parent.x + Math.cos(this.absAngle) * this.size;
this.y = this.parent.y + Math.sin(this.absAngle) * this.size;
if (iter) this.children.forEach(c => c.updateRelative(iter, flex));
}follow() — the IK drag step
follow(iter) {
var px = this.parent.x, py = this.parent.y;
var d = Math.hypot(this.x - px, this.y - py);
this.x = px + this.size * (this.x - px) / d;
this.y = py + this.size * (this.y - py) / d;
this.absAngle = Math.atan2(this.y - py, this.x - px);
this.relAngle = this.absAngle - this.parent.absAngle;
this.updateRelative(false, true);
if (iter) this.children.forEach(c => c.follow(true));
}Step 4 — The Creature Class
The Creature is the skeleton root. It moves freely toward the mouse with smooth acceleration and rotation physics.
class Creature {
constructor(x,y,angle,fAccel,fFric,fRes,fThresh,rAccel,rFric,rRes,rThresh) {
this.x = x; this.y = y; this.absAngle = angle;
this.fSpeed = 0; this.fAccel = fAccel; this.fFric = fFric;
this.fRes = fRes; this.fThresh = fThresh;
this.rSpeed = 0; this.rAccel = rAccel; this.rFric = rFric;
this.rRes = rRes; this.rThresh = rThresh;
this.children = []; this.systems = [];
}
}The follow(x, y) method applies forward acceleration, rotation toward target, friction, then cascades updates to all child segments and leg systems:
follow(x, y) {
var dist = Math.hypot(this.x - x, this.y - y);
var angle = Math.atan2(y - this.y, x - this.x);
this.fSpeed += this.fAccel * (dist > this.fThresh);
this.fSpeed *= 1 - this.fRes;
this.speed = Math.max(0, this.fSpeed - this.fFric);
var dif = this.absAngle - angle;
dif -= 2 * Math.PI * Math.floor(dif / (2 * Math.PI) + 0.5);
if (Math.abs(dif) > this.rThresh && dist > this.fThresh)
this.rSpeed -= this.rAccel * (2 * (dif > 0) - 1);
this.rSpeed *= 1 - this.rRes;
this.absAngle += this.rSpeed;
this.x += this.speed * Math.cos(this.absAngle);
this.y += this.speed * Math.sin(this.absAngle);
this.absAngle += Math.PI;
this.children.forEach(c => c.follow(true));
this.systems.forEach(s => s.update());
this.absAngle -= Math.PI;
}How LegSystem Works
LegSystem extends LimbSystem and makes a leg walk on its own. Each foot has a goal position on the ground. When the creature moves far enough, the foot lifts and re-plants at a new position ahead.
Two states: step = 0 — standing, holding the current foothold. step = 1 — moving toward a new foothold target.
LimbSystem — the IK Solver
LimbSystem collects a chain of nodes from end to hip and solves IK using two passes.
moveTo(x, y) {
var dist = Math.hypot(x - this.end.x, y - this.end.y);
var len = Math.max(0, dist - this.speed);
for (var i = this.nodes.length - 1; i >= 0; i--) {
var n = this.nodes[i];
var ang = Math.atan2(n.y - y, n.x - x);
n.x = x + len * Math.cos(ang);
n.y = y + len * Math.sin(ang);
x = n.x; y = n.y; len = n.size;
}
for (var i = 0; i < this.nodes.length; i++) {
var n = this.nodes[i];
n.absAngle = Math.atan2(n.y - n.parent.y, n.x - n.parent.x);
n.relAngle = n.absAngle - n.parent.absAngle;
}
}Global Scale & Initialization
All sizes derive from a single S variable. Change one number to resize the entire creature.
var S = 7.5;
var scorpion = new Creature(
window.innerWidth / 2,
window.innerHeight / 2, 0,
S * 10,
S * 2.5,
0.5,
S * 5,
0.5,
0.08,
0.5,
0.25
);Body Segments, Tail & Stinger
var spine = scorpion, bodySegs = [];
for (var i = 0; i < 5; i++) {
spine = new Segment(spine, S*5, 0, Math.PI*0.55, 1.3);
bodySegs.push(spine);
}
var tailSegs = [];
var tailAngles = [0.25, 0.35, 0.45, 0.55, 0.65, 0.5, 0.3];
for (var i = 0; i < 7; i++) {
spine = new Segment(spine, S*4, tailAngles[i], Math.PI*0.7, 1.0);
tailSegs.push(spine);
}
var stingerSeg = new Segment(spine, S*5, -0.3, Math.PI*0.6, 1.0);
var stingerTip = new Segment(stingerSeg, S*3, -0.5, Math.PI*0.4, 1.0);Head, Pincers & Walking Legs
var head = new Segment(scorpion, S*6, 0, Math.PI*0.3, 1.5);
var headFwd = new Segment(head, S*4, 0, Math.PI*0.3, 1.5);
function makePincer(parent, side) {
var arm1 = new Segment(parent, S*7, side*0.5, Math.PI*0.6, 1.2);
var arm2 = new Segment(arm1, S*6, side*0.3, Math.PI*0.5, 1.2);
var f1 = new Segment(arm2, S*5, side*0.35, Math.PI*0.4, 1.2);
var f2 = new Segment(arm2, S*5, -side*0.1, Math.PI*0.4, 1.2);
return { arm1, arm2, f1, f2 };
}
var pincerL = makePincer(headFwd, 1);
var pincerR = makePincer(headFwd, -1);Drawing Helpers & Glow
function drawSegmentLine(seg, width, color) {
ctx.save();
ctx.strokeStyle = color || 'rgba(200,160,50,0.85)';
ctx.lineWidth = width || 1;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(seg.parent.x, seg.parent.y);
ctx.lineTo(seg.x, seg.y);
ctx.stroke();
ctx.restore();
}
ctx.shadowBlur = 8;
ctx.shadowColor = 'rgba(210,150,30,0.4)';
ctx.shadowBlur = 0;Tapering Tail
A taper variable reduces width and opacity from base to tip — creating a natural narrowing effect:
for (var i = 0; i < tailSegs.length; i++) {
var t = tailSegs[i];
var taper = 1 - i / tailSegs.length;
drawSegmentLine(t,
S * (1.2 + taper * 1.5),
`rgba(200,155,45,${0.55 + taper * 0.3})`);
ctx.arc(t.x, t.y, S * (0.5 + taper * 0.8), 0, Math.PI * 2);
}Each foot dot changes colour based on step state — amber when grounded, green when mid-step: ctx.fillStyle = leg.sys.step === 0 ? 'rgba(220,180,60,0.8)' : 'rgba(100,200,120,0.7)'
The Strike Mechanism
On click, the tail tightens its curl for 10 frames (the lunge), the stinger tip glows green, then everything relaxes back to rest over the remaining 20 frames.
var striking = false;
var strikeTimer = 0;
document.addEventListener('click', () => {
striking = true;
strikeTimer = 0;
});
if (striking) {
strikeTimer++;
if (strikeTimer > 30) { striking = false; strikeTimer = 0; }
if (strikeTimer < 10) {
tailSegs.forEach((t, i) => { t.defAngle = tailAngles[i] + 0.4; });
stingerSeg.defAngle = -0.8;
} else {
tailSegs.forEach((t, i) => { t.defAngle = tailAngles[i]; });
stingerSeg.defAngle = -0.3;
}
}Putting It All Together
Everything runs in a setInterval at 33ms (~30fps). Each tick: clear, draw vignette, handle strike, advance physics, draw all parts, draw mouse dot.
Input.mouse.x = window.innerWidth / 2;
Input.mouse.y = window.innerHeight / 2;
setInterval(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
var vgrd = ctx.createRadialGradient(
canvas.width/2, canvas.height/2, canvas.height*0.3,
canvas.width/2, canvas.height/2, canvas.height*0.8);
vgrd.addColorStop(0, 'rgba(0,0,0,0)');
vgrd.addColorStop(1, 'rgba(0,0,0,0.6)');
ctx.fillStyle = vgrd;
ctx.fillRect(0, 0, canvas.width, canvas.height);
scorpion.follow(Input.mouse.x, Input.mouse.y);
drawAll();
drawMouseDot();
}, 33);Tuning Reference
All the knobs you can turn to customise your scorpion:
| Variable | Type | Effect |
|---|---|---|
| S = 7.5 | Number | Change this single number to resize the entire scorpion |
| tailAngles | Array | Increase values for tighter curl, add more entries for a longer tail |
| stiffness | Number | Near 1.0 = floppy, above 3.0 = rigid. Hip segments use 3.0 to stay planted |
| fAccel / fFric | Number | Higher fAccel = faster response, higher fFric = heavier, lazier feel |
| rAccel / rThresh | Number | Lower rThresh = more sensitive rotation, higher rAccel = snappier turning |
Full Source Code
Save the following as cursor-scorpion-animation.html and open it in any browser. No dependencies, no build step.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/cursor-scorpion-animation.htmlHappy coding.