Aduok Code

Procedural Scorpion IK Cursor

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).

What you will learn

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.

01
Part One
The IK Engine

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.

JavaScript
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.

JavaScript
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

JavaScript
// 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

JavaScript
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

JavaScript
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.

JavaScript
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:

JavaScript
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;
}
02
Part Two
Procedural Walking with LegSystem

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.

JavaScript
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;
  }
}
03
Part Three
Building the Scorpion Body

Global Scale & Initialization

All sizes derive from a single S variable. Change one number to resize the entire creature.

JavaScript
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

JavaScript
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

JavaScript
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);
04
Part Four
Drawing the Scorpion

Drawing Helpers & Glow

JavaScript
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:

JavaScript
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);
}
Foot state indicator

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)'

05
Part Five
Click-to-Strike Interaction

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.

JavaScript
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;
  }
}
06
Part Six
The Main Loop

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.

JavaScript
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:

VariableTypeEffect
S = 7.5NumberChange this single number to resize the entire scorpion
tailAnglesArrayIncrease values for tighter curl, add more entries for a longer tail
stiffnessNumberNear 1.0 = floppy, above 3.0 = rigid. Hip segments use 3.0 to stay planted
fAccel / fFricNumberHigher fAccel = faster response, higher fFric = heavier, lazier feel
rAccel / rThreshNumberLower 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.

HTML — cursor-scorpion-animation.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/cursor-scorpion-animation.html

Happy coding.

Want to see it in action?Watch the full build on YouTube