Golfing a 2D physics engine in JS


November-December 2025



Introduction

I have a long love-hate story with physics in JS.

It all started in 2019, when I read this 134 pages book about 2D physics, and summarized its contents in just 9 pages on my little notepad.

I used that to create a 1.57kb lib called Mini2DPhysics, the JS1K entry Emojysics in 2019 and the JS13kGames entry O HIII BAD SKATEPARK in 2024.

The problem was that this book, as every book I could find at the time, didn't go beyond basic interactions between discs and rectangles (and it definitely didn't need so many pages to do that).

Then this 599 pages book released in 2022 by the same editor and some of the original authors, managed to be even more verbose in almost every aspect of 2D game mechanics, except one: advanced physics. And when I say "advanced physics", I'm not asking for really complex stuff, I just want to learn springs, fixed joints, hinge joints, and how to optimize for speed and stability.

In 2021, 2022, 2023, 2024 and 2025 I tried many times to learn 3D physics because a lot of good books actually cover this topic, but I haven't managed to completely implement any of them in JS (so far)... which was quite frustrating. (but stay tuned... I will be back!)

Finally, in October 2025, after completing a new edition of JS13KGames, I randomly typed "2D physics JS" on Google and stumbled upon this 2024 Reddit post announcing a complete Youtube playlist dedicated to learning 2D physics in JS, with complete source code on Gitlab... I may have found the holy grail!

In this page, I will follow each chapter of the playlist, show how I implemented my own mini 2D engine based on its source code, and how I enhanced a few things. My code will be published in public domain on the 2DPhysics Github repo, and a copy of the original Gitlab repo is also present there for reference..

I also ended up buying a copy of the physical book released in August 2025, which is (sadly) just a line-by-line code explanation written by an AI, with a lot of mistakes. I won't mention it here, but I still really encourage the author to pursue his work (and teach us how to do 3D physics in JS next!), because my 2Dphysics engine mostly exists thanks to him! And if he makes a new edition of his book with better page layout, more legible text and some illustrations, I will gladly buy it too! <3



CODING MY OWN ENGINE




Chapter 1: Setting up a Canvas

Here we'll keep the HTML code to the absolute minimum: doctype, canvas and script tags.

The canvas is called a and its 2D context c.

<!doctype html>
<canvas id=a width=800 height=600 style="background:#eee"></canvas>
<script>

// Canvas setup
c = a.getContext("2d");

</script>

Links:
- Original: video, demo, source code (0.4kb)
- Mine: demo (nothing to see yet), source code (0.1kb)


From now on, the code snippets in each chapter will only show what was added or modified in my Javascript code.


Chapter 2: Main loop

Let's create a fixed 60fps loop with setInterval, for simplicity.

Indeed, many 2D game engines use requestAnimationFrame and deltaTime, but it's more verbose, non-deterministic, and tends to be unpredictable on screens with high refresh rates.

A fixed loop will allow a consistent and reproductible behavior on all kinds of screens, with just a little lisk of lag on low-end devices if the game is not optimized enough.

// Main loop
setInterval(() => {

  // TODO

}, 16);

Links:
- Original: video, demo, source code (1.3kb)
- Mine: demo (nothing to see yet), source code (0.2kb)


Chapter 3: Mouse and keyboard events

User inputs and states are reduced to the bare minimum: left and right mouse buttons and mouse position. Two functions are added in the loop: update and draw.

// Globals
m = null, mp = 0; // mouse position, pressed

// Event listeners
onmousemove = e => {
  m = {x: e.pageX - a.offsetLeft, y: e.pageY - a.offsetTop};
}

onmousedown = e => {
  mp = 1;
}

onmouseup = e => {
  mp = 0;
}

// Update the physics engine
update = () => {
  console.log(m); // Test logs
}

// Render the scene
draw = () => {

  // Reset canvas
  a.width = a.width;

  // Test: draw a black square
  c.fillRect(100, 100, 20, 20);
}

// Main loop
setInterval(() => {
  update();
  draw();
}, 16);

Links:
- Original: video 1, 2, demo, source code (2.8kb)
- Mine: demo (only shows a rectangle), source code (0.7kb)


Chapter 4: 2D vectors

2D vectors are objects containing two values (x and y).

They can represent a point (with x and y coordinates) or a movement (with x and y offsets).

I will reuse the mini vector lib I created for Mini2DPhysics, adapted to this new tutorial.

The mouse position can now be stored as a Vec2.

// 2D vectors
Vec2 = (x, y) => ({x, y});
normalize = v => scale(v, 1 / len(v));
len = v => dot(v, v) ** .5;
len2 = v => dot(v, v);
normal = v => Vec2(v.y, -v.x);
dot = (v, w) => v.x * w.x + v.y * w.y;
copy = v => Vec2(v.x, v.y);
add = (v, w) => Vec2(v.x + w.x, v.y + w.y);
sub = (v, w) => add(v, scale(w, -1));
scale = (v, n) => Vec2(v.x * n, v.y * n);

// Event listeners
onmousemove = e => {
  m = Vec2(e.pageX - a.offsetLeft, e.pageY - a.offsetTop);
}

Links:
- Original: video demo, source code (3.8kb)
- Mine: demo (nothing more to see), source code (1.0kb)


Chapter 5: Drawing points and arrows

Thie tuto explains how do draw bullets (filled arcs), discs (stroked arcs), lines and arrows, for debug purposes.

We'll settle with just two methods: drawPoint and drawLine.

// Debug
drawPoint = (center, radius = 5, color = "#888") => {
  c.beginPath();
  c.fillStyle = color;
  c.arc(center.x, center.y, radius, 0, 7);
  c.fill();
  c.closePath();
}

drawLine = (start, end, color = "#888") => {
  c.beginPath();
  c.lineWidth = 3;
  c.strokeStyle = color;
  c.moveTo(start.x, start.y);
  c.lineTo(end.x, end.y);
  c.stroke();
  c.closePath();
}

// Render the scene
draw = () => {

  // Reset canvas
  a.width = a.width;

  // Test: draw some shapes
  c.fillRect(100, 100, 20, 20);
  drawPoint(Vec2(200,200));
  drawLine(Vec2(300, 50), Vec2(400, 150));
}

Links:
- Original: video 1, 2, 3, demo, source code (5.6kb)
- Mine: demo, source code (1.4kb)



Chapter 6: Shapes

This chapter introduces a generic Shape class for circles and polygons.

We'll make our own version in Chapter 8.

Links:
Original: video demo, source code (7.2 kb)


Chapter 7: Compute the centroid of a polygon

We don't need this here: as all our shapes will be based on circles and rectangles, which already have a defined center point.

Links:
Original: video 1, 2, demo, source code (7.2 kb)


Chapter 8: Rotating and moving shapes

Here, we start implementing our shape objects (circles and rectangles) and the methods that allow them to translate (move) and rotate.

Rectangles have 4 vertices (points) and 4 edges (sides).

- Translations are performed by adding an offset vector to the shape's center (and to all its vertices, if it's a rectangle).

- 2D rotations (around the origin [0,0]) are performed by applying a 2D rotation matrix on 2D vectors.

- 2D rotations around a pivot point are done by subtracting the pivot point's coordinates from the point, rotating the point around the origin, and adding the pivot point back. A rotate function is added to perform these kinds of rotations.

A horizontal radius is drawn on the discs to make their rotations visible.

Realistic rotations based on gravity, friction and inertia will be introduced in chapter 24.

// Globals
shapes = [];

// 2D vectors
rotate = (v, center, angle) => {
  var x = v.x - center.x;
  var y = v.y - center.y;
  return Vec2(
    x * Math.cos(angle) - y * Math.sin(angle) + center.x,
    x * Math.sin(angle) + y * Math.cos(angle) + center.y
  );
}

// Shapes
circle = (center, radius, angle = 0, color = "#888") => {
  shape = {
    type: "circle",
    center: center,
    radius: radius,
    angle: angle,
    color: color
  };
  
  shapes.push(shape);
  return shape;
}

rectangle = (center, width, height, angle = 0, color = "#888") => {
  shape = {
    type: "rectangle",
    center: center,
    width: width,
    height: height,
    angle: angle,
    color: color,

    // Rectangle vertices
    vertices: [
      Vec2(center.x - width / 2, center.y - height / 2), // top left
      Vec2(center.x + width / 2, center.y - height / 2), // top right
      Vec2(center.x + width / 2, center.y + height / 2), // bottom left
      Vec2(center.x - width / 2, center.y + height / 2)  // bottom right
    ]
  };
  
  if(angle) {
    rotateShape(shape, angle);
  }
  shapes.push(shape);
  return shape;
};

// Translate a shape
translateShape = (shape, offset) => {

  // Move center
  shape.center = add(shape.center, offset);
  
  // Rectangle (move vertices)
  if(shape.type == "rectangle"){
    for(var i in shape.vertices){
      shape.vertices[i] = add(shape.vertices[i], offset);
    }
  }
}

// Rotate a shape around its center
rotateShape = (shape, angle) => {

  // Update angle
  shape.angle += angle;
  
  // Rectangle (rotate vertices)
  if(shape.type == "rectangle"){
    for(var i in shape.vertices){
      shape.vertices[i] = rotate(shape.vertices[i], shape.center, angle);
    }
  }
}

// Update the physics engine
update = () => {

  // Test: translate and rotate the shapes
  for(var i of shapes){
    rotateShape(i, .01);
    translateShape(i, Vec2(.2, .2));
  }
}

// Render the scene
draw = () => {

  // Reset canvas
  a.width = a.width;

  // Draw shapes
  for(var i of shapes){
    c.save();
    c.beginPath();
    
    if(i.type == "circle"){
      c.fillStyle = i.color;
      c.translate(i.center.x, i.center.y);
      c.rotate(i.angle);
      c.arc(0, 0, i.radius, 0, 7);
      c.lineTo(0,0);
      c.closePath();
      c.fill();
      c.stroke();
    }
    
    else if(i.type == "rectangle"){
      c.fillStyle = i.color;
      c.moveTo(i.vertices[0].x, i.vertices[0].y);
      c.lineTo(i.vertices[1].x, i.vertices[1].y);
      c.lineTo(i.vertices[2].x, i.vertices[2].y);
      c.lineTo(i.vertices[3].x, i.vertices[3].y);
      c.closePath();
      c.fill();
      c.stroke();
    }

    c.restore();
  }
}

// Demo
circle(Vec2(100, 100), 60);
rectangle(Vec2(250, 100), 60, 100);

Links:
- Original: video 1, 2, demo, source code (10.8 kb)
- Mine: demo, source code (3.9 kb)



Chapter 9: Circle-circle collision detection

The real work starts here!

A physics engine has two main tasks: collision detection and collision response. Let's start with detection.

All our shapes will be either circles or rectangles, so we need to detect three kinds of collisions: circle-circle, circle-rectangle and rectangle-rectangle.

We'll start by the easiest collision detection, between two circles: if the distance between their centers is smaller than the sum of their radii, they are colliding.

In the code below, two circles are created (c1 and c2), a distance function dist is added and when the circles collide, they get colored in red. The collision detection is just one line in the update method, but it will be more complete in the next chapters.

// 2D vectors
dist = (v, w) => len(sub(v, w));

// Update the physics engine
update = () => {

  // Test: rotate all shapes
  for(var i of shapes){
    rotateShape(i, .01);
  }
  
  // Test: translate each shape
  translateShape(c1, Vec2(-.2, .2));
  translateShape(c2, Vec2(.2, .2));
  
  // Test: detect circle-circle collisions
  if(c1.type == "circle" && c2.type == "circle"){
    if(dist(c1.center, c2.center) < (c1.radius + c2.radius)){
      c1.color = "red";
      c2.color = "red";
    }
    else {
      c1.color = "#888";
      c2.color = "#888";
    }
  }
}

// Demo
c1 = circle(Vec2(220, 140), 60);
c2 = circle(Vec2(80, 100), 60);

Links:
- Original: video, demo, source code (11.8 kb)
- Mine: demo, source code (4.3 kb)



Chapter 10: Collision manifolds

Physics engines use temporary objects called manifolds to store information about a collision between two shapes. They include:

- a penetration depth (overlap distance)

- a collision normal (the force's direction, from one shape's contact point to the other shape's center)

- and a contact point (where the collision takes place on the surface of the first shape)

For each pair of shapes that collide, a single manifold object is created. Both shapes will respond to the same manifold: shape A will use the collision normal to move away from shape B, and B will use the inverse of the collision normal to move away from A.

Here, our debug methods drawPoint and drawLine are used to show the manifold information, in blue.

// Globals
manifold = null;

// Update the physics engine
update = () => {

  // (...)
  
  // Test: detect circle-circle collisions
  if(c1.type == "circle" && c2.type == "circle"){
    direction = sub(c2.center, c1.center);
    distance = dist(c1.center, c2.center);
    if(distance < (c1.radius + c2.radius)){
    
      manifold = {
        depth: (c1.radius + c2.radius) - distance,
        normal: normalize(direction),
        contactPoint: add(c1.center, scale(normalize(direction), c1.radius))
      };
      
      c1.color = "red";
      c2.color = "red";
    }
    else {
      c1.color = "#888";
      c2.color = "#888";
      manifold = null;
    }
  }
}

// Render the scene
draw = () => {

  // (...)

  // Debug: draw manifold
  if(manifold){
    drawPoint(manifold.contactPoint, 5, "#04F");
    drawLine(manifold.contactPoint, add(manifold.contactPoint, scale(manifold.normal, 50)), "#04F");
  }
}

// Demo (inversed the circles temporarily to display c1 on top of c2)
c2 = circle(Vec2(80, 100), 60);
c1 = circle(Vec2(220, 140), 60);

Links:
- Original: video, demo, source code (12.2 kb)
- Mine: demo, source code (4.9 kb)



Chapter 11: Pushing circles apart

Pushing overlapping shapes apart is the first mechanism that takes action in a collision response.

It's possible to have just one shape being pushed, or both, depending on their mass or mobility.

For example here, only one shape (c2) is pushed, while the other (c1) keeps moving diagonally.

The pushing mechanism will be implemented more rigorously in chapter 23.

// Update the physics engine
update = () => {

  // (...)
  
  // Test: translate one shape
  translateShape(c1, Vec2(.2, .2));
  
  // Test: detect circle-circle collisions
  if(c1.type == "circle" && c2.type == "circle"){
    direction = sub(c2.center, c1.center);
    distance = dist(c1.center, c2.center);
    if(distance < (c1.radius + c2.radius)){
    
      manifold = {
        depth: (c1.radius + c2.radius) - distance,
        normal: normalize(direction),
        contactPoint: add(c1.center, scale(normalize(direction), c1.radius))
      };
      
      // Push c2
      c2.center = add(c2.center, scale(manifold.normal, manifold.depth));
      
      c1.color = "red";
      c2.color = "red";
    }
    else {
      c1.color = "#888";
      c2.color = "#888";
      manifold = null;
    }
  }
}

Links:
- Original: video 1, 2 demo, source code (13 kb)
- Mine: demo, source code (4.9 kb)



Chapter 12: Compute polygons normals

For rectangles (and other polygons), each edge (side) needs a normal vector, perpendicular to it and facing outwards, to compute collisions.

The normals stay the same when the rectangle is translated, but they must be recomputed when it's rotated.

In the code below, a rectangle r1 is introduced. Its normals are represented in green.

// shapes
rectangle = (center, width, height, angle = 0, color = "#888") => {
  
  // (...)
  
  // Normals
  calcNormals(shape);
  
  shapes.push(shape);
  return shape;
};

// Rotate a shape around its center (angle in radians)
rotateShape = (shape, angle) => {

  // Update angle
  shape.angle += angle;
  
  // Rectangle (rotate vertices and recompute normals)
  if(shape.type == "rectangle"){
    for(var i in shape.vertices){
      shape.vertices[i] = rotate(shape.vertices[i], shape.center, angle);
    }
    calcNormals(shape);
  }
}

// Compute the normals of a polygon
calcNormals = shape => {
  shape.normals = [];
  for(var i = 0; i < shape.vertices.length; i++){
    shape.normals.push(normal(normalize(sub(shape.vertices[(i + 1) % shape.vertices.length], shape.vertices[i]))));
  }
}

// Render the scene
draw = () => {

  // Reset canvas
  a.width = a.width;

  // Draw shapes
  for(var i of shapes){
    c.save();
    c.beginPath();
    
    if(i.type == "circle"){
      
      // (...)
      
    }
    
    else if(i.type == "rectangle"){
      
      // (...)
      
      // Test: draw normals
      for(j = 0; j < 4; j++){
        middle = scale(sub(i.vertices[(j + 1) % i.vertices.length], i.vertices[j]), .5);
        drawLine(add(i.vertices[j], middle), add(add(i.vertices[j], middle), scale(i.normals[j], 30)), "green");
      }
    }

    c.restore();
  }

  // (...)

}

Links:
- Original: video 1, 2 demo, source code (13.6 kb)
- Mine: demo, source code (4.7 kb)



Chapter 13: Rectangle-rectangle collisions

Collisions between rectangles (or any convex polygons) are detected using the Seprating-Axis Theorem (SAT):

The idea is to project a pair of rectangles on different axis (one axis for each edge of each polygon to test).

- If the projections are separated on at least one axis, the shapes are definitely not colliding.

- If the projections overlap on every axis, the shapes collide. We use the axis with the smallest overlap to build the collision manifold.

Contrary to circle-circle collisions, the collision normal does not point to the center of the other shape: it is perpendicular to the rectangle edge that has the smallest penetration depth.

In this chapter, we create three rectangles, and for each pair of rectangles, if a collision is detected, both shapes get pushed away from each other with the same force.

The manifolds are drawn on the first vertex of the axis that detects the collision.

// Globals
manifolds = [];

// SAT: test two rectangles against each other's axis and choose the minimal penetration depth
rectangleRectangleCollision = (r1, r2) => {

  var contact = null;
  
  // Check all axes from rectangle A against rectangle B
  var contactA = getContactPoint(r1, r2);
  if(contactA == null) return null;
  
  // Check all axes from rectangle B against rectangle A
  var contactB = getContactPoint(r2, r1);
  if(contactB == null) return null;
  
  // Choose the smallest penetration depth (most restrictive axis)
  if(contactA.depth < contactB.depth){
    contact = contactA;
  }
  else {
    contact = contactB;
    // Reverse the normal direction to ensure it points from A to B
    contact.normal = scale(contact.normal, -1);
  }
  
  // Return the final contact information (depth, normal, contact point)
  return contact;
}

// SAT: Test all normals of rectangle 1 as potential separating axes
getContactPoint = (r1, r2) => {

  var contact = null;
  var minDepth = Number.MAX_VALUE;
  var supportPoint = null;

  // For each normal of rectangle 1
  for(var i = 0; i < r1.normals.length; i++){
    // Find which vertex of rectangle 2 penetrates deepest along this normal
    supportPoint = findSupportPoint(r1.normals[i], r1.vertices[i], r2.vertices);

    // If there is no overlap along this normal, there is no collision
    if(supportPoint == null) return null;

    // Keep the smallest penetration depth found so far
    if(supportPoint.depth < minDepth){
      minDepth = supportPoint.depth;

      // Store the contact information for this axis
      contact = {
        depth: minDepth,              // penetration distance
        normal: r1.normals[i],        // collision normal (axis of penetration)
        contactPoint: supportPoint.vertex // point of contact on rectangle 2
      };
    }
  }
  // Return the axis with the minimum overlap
  return contact;
}

// SAT: Find which vertex from the other rectangle penetrates the deepest along a given axis
findSupportPoint = (normalOnEdge, pointOnEdge, otherPolygonVertices) => {

  var maxDepth = 0;
  var supportPoint = null;
  var depth = null;

  // For each vertex of the other rectangle
  for(var i = 0; i < otherPolygonVertices.length; i++){
    // Project the vertex onto the axis defined by the current normal
    // The 'depth' here represents how far the vertex is behind the edge (penetration distance)
    depth = dot(sub(otherPolygonVertices[i], pointOnEdge), scale(normalOnEdge, -1));

    // Keep the vertex that penetrates the most (maximum depth)
    if(depth > maxDepth){
      maxDepth = depth;
      supportPoint = {
        vertex: otherPolygonVertices[i], // the vertex with the deepest penetration
        depth: depth                     // its penetration distance
      };
    }
  }

  // Return the vertex that is deepest along this normal
  return supportPoint;
}

// Update the physics engine
update = () => {

  var push, manifold;
  
  // Reset manifolds
  manifolds = [];
  
  // Reset shapes colors
  for(var i of shapes){
    i.color = "#888";
  }

  // Test: rotate a shape
  rotateShape(r1, -.01);
  
  // Test: translate a shape
  translateShape(r1, Vec2(.2, .2));
  
  // Consider all pairs of shapes
  for(var i = 0; i < shapes.length; i++){
    for(var j = i; j < shapes.length; j++){
      if(i != j){
        
        // Detect rectangle-rectangle collisions
        if(shapes[i].type == "rectangle" && shapes[j].type == "rectangle"){
          manifold = rectangleRectangleCollision(shapes[i], shapes[j]);
          if(manifold){
            shapes[i].color = "red";
            shapes[j].color = "red";
            push = scale(manifold.normal, manifold.depth * 0.5);
            translateShape(shapes[j], push);
            translateShape(shapes[i], scale(push, -1));
            manifolds.push(manifold);
          }
        }
      }
    }
  }
}

// Render the scene
draw = () => {

  
  // (...)
  
  // Debug: draw manifolds
  for(var i of manifolds){
    drawPoint(i.contactPoint, 5, "#04F");
    drawLine(i.contactPoint, add(i.contactPoint, scale(i.normal, 50)), "#04F");
  }
}

// Demo
r1 = rectangle(Vec2(100, 60), 100, 50);
r2 = rectangle(Vec2(180, 170), 75, 75);
r3 = rectangle(Vec2(240, 150), 20, 50);

Links:
- Original: video 1, 2 3 demo, source code (16.4 kb)
- Mine: demo, source code (8.3 kb)



Chapter 14: Circle-rectangle collisions

To detect a collision between a rectangle and a circle, we need to find which point of the rectangle is closest to the center of the circle.

This point can be a corner, or any point on one edge of the rectangle.

// Detect collision between a circle and a rectangle
circleRectangleCollision = (circle, rectangle) => {

  // First, try to detect a collision between the circle and the rectangle's edges
  var contact = circleVsRectangleEdges(circle, rectangle);

  // If no edge collision was found, test against the rectangle's corners
  if(!contact){
    contact = circleVsRectangleCorners(circle, rectangle);
  }

  // Return the resulting contact information (if any)
  return contact;
}

// Test collision between a circle and the edges of a rectangle
circleVsRectangleEdges = (circle, rectangle) => {

  var nearestEdgeVertex = null;
  var nearestEdgeNormal = null;
  var dirToNext, circleDirToNextProjection, circleDirToNormalProjection;

  // Loop over each edge of the rectangle
  for(var i = 0; i < rectangle.vertices.length; i++){
    // Vector from the current vertex of the rectangle to the circle center
    vertToCircle = sub(circle.center, rectangle.vertices[i]);

    // Direction vector along the current edge
    dirToNext = sub(rectangle.vertices[(i + 1) % rectangle.vertices.length], rectangle.vertices[i]);

    // Project the circle center direction onto the edge direction
    circleDirToNextProjection = dot(vertToCircle, normalize(dirToNext));

    // Project the circle center direction onto the edge normal
    circleDirToNormalProjection = dot(vertToCircle, rectangle.normals[i]);

    // Check if the circle center is within the edge segment bounds
    // (projection along the edge is between 0 and edge length)
    // and on the "inner" side of the edge (normal projection ≥ 0)
    if(circleDirToNormalProjection >= 0 && circleDirToNextProjection > 0 && circleDirToNextProjection < len(dirToNext)){
      // This edge is the nearest potential collision candidate
      nearestEdgeNormal = rectangle.normals[i];
      nearestEdgeVertex = rectangle.vertices[i];
    }
  }

  // If no valid edge was found, no collision is possible along edges
  if(nearestEdgeNormal == null || nearestEdgeVertex == null){
    return null;
  }

  // Compute how far the circle center is from the edge along the normal
  var projectionToEdgeNormal = dot(nearestEdgeNormal, sub(circle.center, nearestEdgeVertex));

  // If the circle overlaps the edge (distance < radius), report a collision
  if(projectionToEdgeNormal - circle.radius < 0){
    return {
      // Penetration depth (how far the circle intrudes)
      depth: -(projectionToEdgeNormal - circle.radius),

      // Normal points from rectangle toward the circle
      normal: scale(nearestEdgeNormal, -1),

      // Approximate contact point on the circle’s perimeter
      contactPoint: add(circle.center, scale(nearestEdgeNormal, -circle.radius))
    };
  }

  // No edge collision
  return null;
}

// Test collision between a circle and the corners of a rectangle
circleVsRectangleCorners = (circle, rectangle) => {

  var dirToCircleCenter;

  // Loop over all rectangle vertices
  for(var i = 0; i < rectangle.vertices.length; i++){
    // Vector from the circle center to the current corner
    dirToCircleCenter = sub(rectangle.vertices[i], circle.center);

    // If the corner is inside the circle’s radius, a collision occurs
    if(len2(dirToCircleCenter) < (circle.radius ** 2)){
      return {
        // Penetration depth (difference between radius and distance)
        depth: circle.radius - len(dirToCircleCenter),

        // Normal pointing from circle center toward the corner
        normal: normalize(dirToCircleCenter),

        // Contact point is the rectangle corner itself
        contactPoint: rectangle.vertices[i]
      };
    }
  }

  // No corner inside the circle → no collision
  return null;
}

// Update the physics engine
update = () => {

  var push, manifold, circle, rectangle, shape1, shape2;
  
  // Reset manifolds
  manifolds = [];
  
  // Reset shapes colors
  for(var i of shapes){
    i.color = "#888";
  }

  // Test: rotate a shape
  rotateShape(r1, -.01);
  
  // Test: translate a shape
  translateShape(r1, Vec2(.2, .1));
  
  // Consider all pairs of shapes
  for(var i = 0; i < shapes.length; i++){
    for(var j = i; j < shapes.length; j++){
      if(i != j){
      
        shape1 = shapes[i];
        shape2 = shapes[j];
        
        // Detect circle-rectangle collisions
        if(shapes[i].type == "rectangle" && shapes[j].type == "circle"){
          rectangle = shapes[i];
          circle = shapes[j];
          manifold = circleRectangleCollision(circle, rectangle);
        }
        
        else if(shapes[i].type == "circle" && shapes[j].type == "rectangle"){
          circle = shapes[i];
          rectangle = shapes[j];
          manifold = circleRectangleCollision(circle, rectangle);
        }
        
        // Collision response
        if(manifold){
          shape1.color = "red";
          shape2.color = "red";
          push = scale(manifold.normal, manifold.depth * 0.5);
          translateShape(rectangle, push);
          translateShape(circle, scale(push, -1));
          manifolds.push(manifold);
        }
      }
    }
  }
}

// Demo
r1 = rectangle(Vec2(80, 60), 120, 50);
c2 = circle(Vec2(180, 130), 50);

Links:
- Original: video 1, 2 3 demo, source code (19.1 kb)
- Mine: demo, source code (12.2 kb)



Chapter 15: RigidBody

Rigid bodies add new peoperties to our shapes: mass, invMass, velocity and forceAccumulator.

Mass is a number representing the object's weight.

Velocity is a Vec2 representing the object's speed (evolution of position over time).

Acceleration is the evolution of velocity over time.

According to Newton's 2nd law of motion (F = ma or a = F/m), the acceleration of an object is equal to the sum of the forces applied to it (pushes, gravity, friction...), divided by its mass.

The forceAccumulator Vec2 attribute is introduced to represent this sum of forces. It will be reset after each frame.

An invMass (1/mass) attribute is introduced specifically to avoid doing the "F/m" division repeatedly.

// Shapes
circle = (center, radius, mass = 1, angle = 0, velocity = Vec2(0, 0), color = "#888") => {
  shape = {
    type: "circle",
    center: center,
    radius: radius,
    mass: mass,
    invMass: 1 / mass,
    velocity: velocity,
    angle: angle,
    forceAccumulator: Vec2(0, 0),
    color: color
  };
  
  shapes.push(shape);
  return shape;
}

rectangle = (center, width, height, mass = 1, angle = 0, velocity = Vec2(0, 0), color = "#888") => {
  shape = {
    type: "rectangle",
    center: center,
    width: width,
    height: height,
    mass: mass,
    invMass: 1 / mass,
    velocity: velocity,
    angle: angle,
    forceAccumulator: Vec2(0, 0),
    color: color,

    // (...)
};

Links:
- Original: video, demo, source code (19.4 kb)
- Mine: demo (nothing new to see yet), source code (12.6 kb),


Chapter 16: Combining RigidBody and Shapes

We've already done that in chapter 15.

Links:
Original: video, demo, source code (18.9 kb)


Chapter 17: Integration

Integration consists in computing the shapes' acceleration, velocity and position at the end of a frame.

The code below uses the semi-implicit Euler method, which is a good compromise between simplicity and stability.

According to Newton's first law of motion, objects keep the same trajectory and speed as long as they don't encounter another force.

Until gravity and friction get implemented, the shapes will keep a constant speed after a collision.

In the original code, acceleration and velocity are proportional to deltaTime. Here, with setInterval, it can remain constant.

// Shape integration
integrate = (shape) => {

  // compute acceleration
  var acceleration = scale(shape.forceAccumulator, shape.invMass);
  
  // apply acceleration to velocity
  shape.velocity = add(shape.velocity, acceleration);
  
  // apply velocity to position
  translateShape(shape, shape.velocity);
  
  // reset force accumulator
  shape.forceAccumulator = Vec2(0, 0);
}

// Update the physics engine
update = () => {

  // (...)
        
  // Collision response
  if(manifold){
    shape1.color = "red";
    shape2.color = "red";
    push = scale(manifold.normal, manifold.depth * 0.5);
    rectangle.forceAccumulator = add(rectangle.forceAccumulator, push);
    circle.forceAccumulator = add(circle.forceAccumulator, scale(push, -1));
    manifolds.push(manifold);
  }

  // (...)
  
  // Integration
  for(i of shapes){
    integrate(i);
  }
}

Links:
- Original: video, demo, source code (19.1 kb)
- Mine: demo, source code (13.0 kb)



Chapter 18: Other integration methods

This chapter explores other ways to integrate rigid bodies. We can ignore it.

Links:
Original: video 1, 2 demo, source code (21.1 kb)


Chapter 19: Bouncing balls

Let's make balls behave realistically with bouncing and damping (velocity attenuation over time)

The bouncing on the ground is currently implemented by inverting the ball's velocity when it reaches a certain height threshold.

// Shape integration
integrate = (shape) => {

  // compute acceleration
  var acceleration = scale(shape.forceAccumulator, shape.invMass);

  // apply acceleration to velocity
  shape.velocity = add(shape.velocity, acceleration);

  // apply velocity to position
  translateShape(shape, shape.velocity);

  // reset force accumulator
  shape.forceAccumulator = Vec2(0, 0);

  // velocity damping
  shape.velocity = scale(shape.velocity, .99);
}

// Update the physics engine
update = () => {

  // (...)
  
  // Ball update
  for(i of shapes){
  
    // Apply gravity
    i.forceAccumulator = add(i.forceAccumulator, gravity);
    
    // Integration
    integrate(i);

    // Bouncing (temp)
    if(i.center.y > 300){
      i.center.y = 300;
      i.velocity = scale(i.velocity, -1);
    }
  }
}

// Demo
c1 = circle(Vec2(100, 100), 20);

Links:
- Original: 1, 2 demo 1, 2, source code 1 (20.6 kb), 2 (20.8 kb)
- Mine: demo, source code (12.1 kb)



Chapter 20: Linear impulses

Linear impulses (pushes) can help us create realistic collision responses according to the shapes' velocity, mass, fricion and restitution.

- Restitution (bounciness) represents how much energy is conserved after a collision (0: no elasticity - 1: perfect elastic).

- Friction (slidyness) will be used later in tangential collision responses (0: frictionless - 1: maximum friction).

In this chapter, the default values (friction = 0, restitution = 1) are used, and the masses are ignored.

Also, the update method now handles all three kinds of collisions, and the gravity is temporarily disabled.

The red coloring on collisions is also disabled.

// Shapes
circle = (center, radius, mass = 1, friction = 0, restitution = 1, angle = 0, velocity = Vec2(0, 0), color = "#888") => {
  shape = {
    type: "circle",
    center: center,
    radius: radius,
    mass: mass,
    invMass: 1 / mass,
    friction: friction,
    restitution: restitution,
    velocity: velocity,
    
    // (...)
}

rectangle = (center, width, height, mass = 1, friction = 0, restitution = 1, angle = 0, velocity = Vec2(0, 0), color = "#888") => {
  shape = {
    type: "rectangle",
    center: center,
    width: width,
    height: height,
    mass: mass,
    invMass: 1 / mass,
    friction: friction,
    restitution: restitution,
    velocity: velocity,
    
    // (...)
};

// Correct positioning
positionalCorrection = (shape1, shape2, manifold) => {

  // todo
  
}

// Resolve collision
resolveCollision = (shape1, shape2, manifold) => {
  var relativeVelocityAlongNormal = dot(sub(shape2.velocity, shape1.velocity), manifold.normal);
  if(relativeVelocityAlongNormal > 0) return; // shapes are already going away from each other
  var e = 1; // total elasticity (currently 100%)
  var j = -(1 + e) * relativeVelocityAlongNormal; // impulse magnitude
  var impulseVector = scale(manifold.normal, j);
  shape1.velocity = add(shape1.velocity, scale(impulseVector, -.5));
  shape2.velocity = add(shape2.velocity, scale(impulseVector, .5));
}

// Update the physics engine
update = () => {

  var push, manifold, shape1, shape2, direction, distance;
  
  // Reset manifolds
  manifolds = [];
  
  // Reset shapes colors
  for(var i of shapes){
    i.color = "#888";
  }
  
  // Consider all pairs of shapes
  for(var i = 0; i < shapes.length; i++){
    for(var j = i; j < shapes.length; j++){
      if(i != j){
      
        shape1 = shapes[i];
        shape2 = shapes[j];

        // Reset manifold
        manifold = null;
        
        // Detect circle-circle collisions
        if(shape1.type == "circle" && shape2.type == "circle"){
          direction = sub(shape2.center, shape1.center);
          distance = dist(shape1.center, shape2.center);
          if(distance < (shape1.radius + shape2.radius)){
            manifold = {
              depth: (shape1.radius + shape2.radius) - distance,
              normal: normalize(direction),
              contactPoint: add(shape1.center, scale(normalize(direction), shape1.radius))
            };
          }
        }
      
        // Detect rectangle-rectangle collisions
        else if(shape1.type == "rectangle" && shape2.type == "rectangle"){
          manifold = rectangleRectangleCollision(shape1, shape2);
        }

        // Detect circle-rectangle collisions
        else if(shape1.type == "rectangle" && shape2.type == "circle"){
          tmp = shape1; shape1 = shape2; shape2 = tmp;
          manifold = circleRectangleCollision(shape1, shape2);
        }
        
        else if(shape1.type == "circle" && shape2.type == "rectangle"){
          manifold = circleRectangleCollision(shape1, shape2);
        }
        
        // Collision response
        if(manifold){
          //shape1.color = "red";
          //shape2.color = "red";
          push = scale(manifold.normal, manifold.depth * 0.5);
          positionalCorrection(shape1, shape2, manifold);
          resolveCollision(shape1, shape2, manifold);
          manifolds.push(manifold);
        }
      }
    }
  }
  
  for(i of shapes){
    // Apply gravity
    //i.forceAccumulator = add(i.forceAccumulator, gravity);
    
    // Integration
    integrate(i);
  }
}

// Demo
c1 = circle(Vec2(190, 100), 20, 1, 0, 1, 0, Vec2(1, 1));
c2 = circle(Vec2(320, 30), 20, 1, 0, 1, 0, Vec2(-2, 2));
r1 = rectangle(Vec2(200, 220), 100, 20);
r2 = rectangle(Vec2(200, 280), 50, 50, 1, 0, 1, 0, Vec2(0, -1));

Links:
- Original: video 1, 2 3 4 demo 1, 2, 3, 4, source code 1 (22.2 kb), 2 (22.2 kb), 3 (22.4 kb), 4 (22.8 kb)
- Mine: demo, source code (14.8 kb)



Chapter 21: weight

As we saw earlier, objects react to impulses proportionally to their inverse masses.

Fixed objects (also called kinematic or immovable) have an infinite mass and an invMass equal to 0, hence no reaction to impulses.

For simplicity, the shapes constructors will consider "mass = 0" as infinite mass.

The demo is updated to include light, heavy and fixed shapes.

// Shapes
circle = (center, radius, mass = 1, friction = 0, restitution = 1, angle = 0, velocity = Vec2(0, 0), color = "#888") => {
  
  shape = {
    type: "circle",
    center: center,
    radius: radius,
    mass: mass,
    invMass: mass == 0 ? 0 : 1 / mass,
    
    // (...)

rectangle = (center, width, height, mass = 1, friction = 0, restitution = 1, angle = 0, velocity = Vec2(0, 0), color = "#888") => {
  shape = {
    type: "rectangle",
    center: center,
    width: width,
    height: height,
    mass: mass,
    invMass: mass == 0 ? 0 : 1 / mass,
    
    // (...)

};

// Resolve collision
resolveCollision = (shape1, shape2, manifold) => {

  var relativeVelocityAlongNormal = dot(sub(shape2.velocity, shape1.velocity), manifold.normal);
  if(relativeVelocityAlongNormal > 0) return; // shapes are already going away from each other
  var e = 1; // total elasticity
  var invMassSum = shape1.invMass + shape2.invMass;
  var j = (-(1 + e) * relativeVelocityAlongNormal) / invMassSum; // impulse magnitude
  var impulseVector = scale(manifold.normal, j);
  shape1.velocity = add(shape1.velocity, scale(impulseVector, -shape1.invMass));
  shape2.velocity = add(shape2.velocity, scale(impulseVector, shape2.invMass));
}

// Demo
c1 = circle(Vec2(190, 100), 20, 1, 0, 1, 0, Vec2(1, 1));
c2 = circle(Vec2(250, 120), 20, 0, 0, 1);
r1 = rectangle(Vec2(200, 220), 100, 20, .2);
r2 = rectangle(Vec2(200, 280), 50, 50, 10, 0, 1, 0, Vec2(0, -1));

Links:
- Original: video, demo, source code (23.6 kb)
- Mine: demo, source code (14.9 kb)



Chapter 22: kinematic bodies

We can return early from collision resolution if both shapes are kinematic (if their invMass is 0).

// Resolve collision
resolveCollision = (shape1, shape2, manifold) => {

  var relativeVelocityAlongNormal = dot(sub(shape2.velocity, shape1.velocity), manifold.normal);
  if(relativeVelocityAlongNormal > 0) return; // shapes are already going away from each other
  if(shape1.invMass == 0 && shape2.invMass == 0) return; // both shapes are kinematic
  
  // (...)
}

Links:
Original: video, demo, source code (24.9 kb)


Chapter 23: Positional corrections

Here we implement positionalCorrection prevent objects from overlapping: they are instantly moved away from each other proportionally to their invMass.

The restitution (bounciness) of the shapes is included in the impulse magnitude.

Finally, the gravity is restored and a fixed ground is added to the demo.
In physics, gravity is a constant (all objects fall at the same speed).
However, our integration method makes all the forces applied to an object proportional to their inverse mass.
To compensate that, in the update method, each object will receive a gravity force proportional to its mass ((G x m) / m = G).

// Correct positioning
positionalCorrection = (shape1, shape2, manifold) => {

  var correction = 0.7;
  var correctionAmount = manifold.depth / (shape1.invMass + shape2.invMass) * correction;
  var correctionVector = scale(manifold.normal, correctionAmount);
  if(shape1.invMass > 0){
    translateShape(shape1, scale(correctionVector, -shape1.invMass));
  }
  if(shape2.invMass > 0){
    translateShape(shape2, scale(correctionVector, shape2.invMass));
  }
}

// Resolve collision
resolveCollision = (shape1, shape2, manifold) => {

  
  // (...)
  
  var e = Math.min(shape1.restitution, shape2.restitution); // total elasticity
  
  // (...)
}

// Update the physics engine
update = () => {

  // Apply gravity
  for(var i of shapes){
    console.log(i.mass);
    i.forceAccumulator = add(i.forceAccumulator, scale(gravity, i.mass));
  }

  // (...)
  
  // Integration
  for(i of shapes){
    integrate(i);
  }
}

Links:
- Original: video, demo, source code (25.9 kb)
- Mine: demo, source code (15.5 kb)



Chapter 24: Inertia

Inertia represents the resistance of an object to rotational forces.

In this chapter, we add the inertia, inverseInertia and angularVelocity properties for circles and rectangles.

Like the velocity, the angular velocity is damped over time, and applied to the shape's angle during integration.

Contrary to velocity, angularVelocity is a Number (because the angle is a Number too).

The inertia is a constant computed in each shape's constructor:

- Circles inertia is equal to (mass * radius²) / 2.

- Rectangles inertia is equal to (mass * (width² + height²)) / 12.

- Other polygons' inertia is detailed in the original code but not used here.

The shapes now have all they need to rotate naturally after a collision, and the next two chapters will implement that.

// Shapes
circle = (
  center,
  radius,
  mass = 1,
  friction = 0,
  restitution = 1,
  angle = 0,
  velocity = Vec2(0, 0),
  angularVelocity = 0,
  color = "#888"
) => {

  var inertia = (mass * (radius ** 2)) / 2;
  
  shape = {
    type: "circle",
    center: center,
    radius: radius,
    mass: mass,
    invMass: mass == 0 ? 0 : 1 / mass,
    friction: friction,
    restitution: restitution,
    angle: angle,
    velocity: velocity,
    angularVelocity: angularVelocity,
    inertia: inertia,
    invInertia: inertia == 0 ? 0 : 1 / inertia,
    forceAccumulator: Vec2(0, 0),
    color: color
  };
  
  shapes.push(shape);
  return shape;
}

rectangle = (
  center,
  width,
  height,
  mass = 1,
  friction = 0,
  restitution = 1,
  angle = 0,
  velocity = Vec2(0, 0),
  angularVelocity = 0,
  color = "#888"
) => {

  var inertia = (mass * ((width ** 2) + (height ** 2))) / 12;
  
  shape = {
    type: "rectangle",
    center: center,
    width: width,
    height: height,
    mass: mass,
    invMass: mass == 0 ? 0 : 1 / mass,
    friction: friction,
    restitution: restitution,
    angle: angle,
    velocity: velocity,
    angularVelocity: angularVelocity,
    inertia: inertia,
    invInertia: inertia == 0 ? 0 : 1 / inertia,
    forceAccumulator: Vec2(0, 0),
    color: color,

    // Rectangle vertices
    vertices: [
      Vec2(center.x - width / 2, center.y - height / 2), // top left
      Vec2(center.x + width / 2, center.y - height / 2), // top right
      Vec2(center.x + width / 2, center.y + height / 2), // bottom left
      Vec2(center.x - width / 2, center.y + height / 2)  // bottom right
    ]
  };
  
  // Rotation
  if(angle) {
    rotateShape(shape, angle);
  }
  
  // Normals
  calcNormals(shape);
  
  shapes.push(shape);
  return shape;
};

// Shape integration
integrate = (shape) => {

  // compute acceleration
  var acceleration = scale(shape.forceAccumulator, shape.invMass);
  
  // apply acceleration to velocity
  shape.velocity = add(shape.velocity, acceleration);

  // apply velocity to position
  translateShape(shape, shape.velocity);

  // apply angular velocity to angle
  rotateShape(shape, shape.angularVelocity * 1);

  // reset force accumulator
  shape.forceAccumulator = Vec2(0, 0);

  // velocity damping
  shape.velocity = scale(shape.velocity, .99);

  // angular velocity damping
  shape.angularVelocity *= 0.99;
}

// Demo
rectangle(Vec2(350, 350), 300, 20, 0, 0.5, .9, 0.2);
circle(Vec2(190, 100), 30, 10, 1, .1);
circle(Vec2(290, 100), 30, 10, .1, .1);
circle(Vec2(390, 100), 30, 10, .01, .1);
rectangle(Vec2(500, 100), 100, 50, 10, 1, .1);
rectangle(Vec2(290, -700), 100, 50, 10, .01, .1);

Links:
- Original: video, demo, source code (27.0 kb)
- Mine: demo (without rotation), source code (16.1 kb)



Chapter 25: Rotational impulses

Just like position has velocity and acceleration, angle has angular velocity and torque (angular acceleration).

Angular impulses can affect an object's torque every time a force is not applied towards its center of gravity.

A cross function is added, to compute a "cross-product" (-ish) between two 2D vectors.
It returns a value between -1 and 1 representing the angle between two vectors (from 1: parallel, to 0: perpendicular, to -1: opposed).

The resolveCollision method is rewritten below to allow rectangles to rotate after a collision.

Circles are not affected by this code because the can only rotate after tangential impulses, caused by friction. Chapter 26 will cover this.

cross = (v, w) => v.x * w.y - v.y * w.x;

// Resolve collision
resolveCollision = (shape1, shape2, manifold) => {

  // If both objects are kinematic, skip
  if(shape1.invMass == 0 && shape2.invMass == 0) return;

  // Vectors from each object's center to the contact point
  var penetrationToCentroidA = sub(manifold.contactPoint, shape1.center);
  var penetrationToCentroidB = sub(manifold.contactPoint, shape2.center);

  // Linear velocity contribution at the contact point due to angular velocity
  var angularVelocityPenetrationCentroidA = Vec2(
    -shape1.angularVelocity * penetrationToCentroidA.y,
     shape1.angularVelocity * penetrationToCentroidA.x
  );
  var angularVelocityPenetrationCentroidB = Vec2(
    -shape2.angularVelocity * penetrationToCentroidB.y,
     shape2.angularVelocity * penetrationToCentroidB.x
  );

  // Total velocity at the contact point = linear + rotational
  var relativeVelocityA = add(shape1.velocity, angularVelocityPenetrationCentroidA);
  var relativeVelocityB = add(shape2.velocity, angularVelocityPenetrationCentroidB);

  // Relative velocity between both contact points
  var relativeVel = sub(relativeVelocityB, relativeVelocityA);

  // Project relative velocity onto the collision normal
  var velocityInNormal = dot(relativeVel, manifold.normal);

  // If the objects are moving apart (positive relative velocity along the normal), skip
  if(velocityInNormal > 0) return;

  // Restitution coefficient (bounciness)
  var e = Math.min(shape1.restitution, shape2.restitution);

  // Compute the "lever arm" cross product (how far from the center the impulse acts)
  var pToCentroidCrossNormalA = cross(penetrationToCentroidA, manifold.normal);
  var pToCentroidCrossNormalB = cross(penetrationToCentroidB, manifold.normal);

  // Effective inverse mass including both linear and rotational contributions
  var invMassSum = shape1.invMass + shape2.invMass;
  var rigiAInvInertia = shape1.invInertia;
  var rigiBInvInertia = shape2.invInertia;
  var crossNSum =
    pToCentroidCrossNormalA * pToCentroidCrossNormalA * rigiAInvInertia +
    pToCentroidCrossNormalB * pToCentroidCrossNormalB * rigiBInvInertia;

  // Compute the scalar impulse magnitude (j)
  var j = -(1 + e) * velocityInNormal;
  j /= (invMassSum + crossNSum);

  // Convert scalar impulse into a vector along the collision normal
  var impulseVector = scale(manifold.normal, j);

  // Apply impulse to linear velocities (in opposite directions)
  shape1.velocity = sub(shape1.velocity, scale(impulseVector, shape1.invMass));
  shape2.velocity = add(shape2.velocity, scale(impulseVector, shape2.invMass));

  // Apply rotational impulse based on lever arm and inverse inertia
  shape1.angularVelocity += -pToCentroidCrossNormalA * j * rigiAInvInertia;
  shape2.angularVelocity +=  pToCentroidCrossNormalB * j * rigiBInvInertia;
}

Links:
Original: video 1, 2, 3, 4, 5, demo 1, 2, source code 1 (29.6kb) 2 (29.5 kb)
- Mine: demo, source code (18.2 kb)



Chapter 26: Frictional impulses

The last step needed to make balls roll on a slope or after a collision, is to implement friction.

// Resolve collision
resolveCollision = (shape1, shape2, manifold) => {

  // If both objects are kinematic, skip
  if(shape1.invMass == 0 && shape2.invMass == 0) return;
  
  // Normal impulse

  // (...)

  // Frictional impulse

  // Compute the component of the relative velocity along the collision normal
  var velocityInNormalDirection = scale(manifold.normal, dot(relativeVel, manifold.normal));

  // Subtract that from the total relative velocity to get the tangential direction
  var tangent = sub(relativeVel, velocityInNormalDirection);

  // Reverse tangent direction (optional, depending on sign convention)
  tangent = scale(tangent, -1);

  // Use the smallest friction coefficient between the two shapes
  var minFriction = Math.min(shape1.friction, shape2.friction);

  // Compute lever arms (distance vectors crossed with tangent direction)
  // Used to determine rotational influence for each body
  var pToCentroidCrossTangentA = cross(penetrationToCentroidA, tangent);
  var pToCentroidCrossTangentB = cross(penetrationToCentroidB, tangent);

  // Effective inverse mass for the tangential direction (accounts for rotation)
  var crossSumTangent =
    pToCentroidCrossTangentA * pToCentroidCrossTangentA * rigiAInvInertia +
    pToCentroidCrossTangentB * pToCentroidCrossTangentB * rigiBInvInertia;

  // Compute the tangential (frictional) impulse magnitude
  // This resists sliding motion along the tangent direction
  // Original tuto used the formula -(1+e) * dot(...) but it makes no sense to use (1+e) here
  var frictionalImpulse = -dot(relativeVel, tangent) * minFriction;

  // Divide by total effective mass (linear + rotational)
  frictionalImpulse /= (invMassSum + crossSumTangent);

  // Limit frictional impulse so it doesn’t exceed the normal impulse
  if(frictionalImpulse > j){ frictionalImpulse = j; }

  // Convert scalar friction impulse into a vector along the tangent
  var frictionalImpulseVector = scale(tangent, frictionalImpulse);

  // Apply friction impulse to linear velocities of both bodies
  shape1.velocity = sub(shape1.velocity, scale(frictionalImpulseVector, shape1.invMass));
  shape2.velocity = add(shape2.velocity, scale(frictionalImpulseVector, shape2.invMass));

  // Apply angular (rotational) impulse due to friction
  shape1.angularVelocity += -pToCentroidCrossTangentA * frictionalImpulse * rigiAInvInertia;
  shape2.angularVelocity +=  pToCentroidCrossTangentB * frictionalImpulse * rigiBInvInertia;
}

A little warning here: when I showed this code to ChatGPT, it suggested a few modifications (to take with a pinch of salt):

- "(1+e) * " has no physical sense in friction. We should do this instead:

var frictionalImpulse = -dot(relativeVel, tangent) * minFriction;

- Coulomb friction must be clamped:
if (Math.abs(frictionalImpulse) > j * minFriction)
    frictionalImpulse = j * minFriction * Math.sign(frictionalImpulse);

We will consider these ideas later.

Links:
- Original: video 1, 2, 3, demo 1, 2, source code 1 (33.6 kb), 2 (36.1 kb)
- Mine: demo, source code (20.4 kb)



We are now at the same level as my 2019 library mini2Dphysics. The next chapters will finally cover the last features that interest me.

Chapter 27: Anchor points

To start implementing joints between rigid bodies, we need a way to define anchor points.

These anchor points are defined relatively to a shape's center, and are recomputed in world coordinates when the shape is rotated or translated.

For debug purposes, they are drawn in green.

// Shapes
circle = ( /* ... */ ) => {
  
  var shape = {
    // (...)
    color: color,
    anchorPoints: []
  };
  
  shapes.push(shape);
  return shape;
}

rectangle = ( /* ... */ ) => {
 
  var shape = {
    
    // (...)
    
    color: color,
    anchorPoints: [],

    // (...)
  };
};

// Add anchor point
// (anchor is in local coordinates)
addAnchor = (anchor, shape) => {
  var anchor = add(anchor, shape.center);
  shape.anchorPoints.push(anchor);
  return shape.anchorPoints.length - 1;
}

// Translate a shape
translateShape = (shape, offset) => {

  // (...)
  
  // Move anchor points
  for(var i in shape.anchorPoints){
    shape.anchorPoints[i] = add(shape.anchorPoints[i], offset);
  }
}

// Rotate a shape around its center (angle in radians)
rotateShape = (shape, angle) => {

  // (...)
  
  // Rotate anchor points
  for(var i in shape.anchorPoints){
    shape.anchorPoints[i] = rotate(shape.anchorPoints[i], shape.center, angle);
  }
}

// Render the scene
draw = () => {

  // Reset canvas
  a.width = a.width;

  // Draw shapes
  for(var i of shapes){
    
    // (...)
    
    // Debug: draw anchor points
    for(var j of i.anchorPoints){
      drawPoint(j, 5, "green");
    }
  }

  // (...)
}

// Demo
rectangle(Vec2(350, 350), 300, 20, 0, 0.5, .9, 0.2);
circle(Vec2(190, 100), 30, 10, 1, .1);
c1 = circle(Vec2(290, 100), 30, 10, .1, .1);
circle(Vec2(390, 100), 30, 10, .01, .1);
rectangle(Vec2(500, 100), 100, 50, 10, 1, .1);
r1 = rectangle(Vec2(290, -700), 100, 50, 10, .01, .1);
addAnchor(Vec2(15, 15), c1);
addAnchor(Vec2(-30, 15), r1);

Links:
- Original: video, demo, source code (37.2 kb)
- Mine: demo, source code (21.1 kb)



Chapter 28: Apply a force outside the center

Anchor points are used to apply forces on objects. When the anchor is not on the center of a shape, the force provokes a linear and a rotational motion.

A torqueAccumulator (Number) is added to the shapes constructors, and corresponds to a rotational acceleration.

It's important to note here that impulses and forces are different: impulses are used to instantly modify a velocity, while forces are used to instantly modify an acceleration.

It's possible to replace forces by impulses proportional to deltaT (the time between two frames), but we'll see that later.

Here, mouse inputs are used to apply forces to any shape.

An isPointInside function is created to check if a shape is clicked.

The integrate method is completed too.

// Globals
selectedRigidBody = null;
selectedPosition = null;
selectedAnchorId = null;

// Is point inside a shape
isPointInside = (point, shape) => {

  // Circle
  if(shape.type == "circle"){
		var distanceToCenter = len(sub(shape.center, point));	
		return shape.radius > distanceToCenter;
	}
  
  // Rectangle
  else {
    var isInside = false;
		for(var i = 0; i < shape.vertices.length; i++){
			var vertex = shape.vertices[i];
			var normal = shape.normals[i];
			var vertToPoint = sub(point, vertex);
			var d = dot(vertToPoint, normal);
			if(d > 0) return false;
			else isInside = true;
		}
		return isInside;
  }
}

// Add force on a point
// (point is in world coordinates)
addForceAtPoint = (point, shape, force) => {
  var direction = sub(point, shape.center);
  shape.forceAccumulator = add(shape.forceAccumulator, force);
  shape.torqueAccumulator += cross(direction, force);
}

// Shape integration
integrate = (shape) => {

  // compute acceleration and rotational acceleration
  var acceleration = scale(shape.forceAccumulator, shape.invMass);
  var rotationalAcceleration = shape.torqueAccumulator * shape.invInertia;
  
  // apply acceleration to velocity
  shape.velocity = add(shape.velocity, acceleration);

  // apply velocity to position
  translateShape(shape, shape.velocity);
  
  // apply rotational acceleration to angular velocity
  shape.angularVelocity += rotationalAcceleration * 1;

  // apply angular velocity to angle
  rotateShape(shape, shape.angularVelocity * 1);

  // velocity damping
  shape.velocity = scale(shape.velocity, .99);

  // angular velocity damping
  shape.angularVelocity *= 0.99;

  // reset force accumulator
  shape.forceAccumulator = Vec2(0, 0);
  
  // reset torque accumulator
  shape.torqueAccumulator = 0;
}

// Mouse interactions
handleMouseObjectInteraction = () => {
  var mousePos = Vec2(mx, my);

  // Left button pressed
  if(mlb){
    for(var i = 0; i < shapes.length; i++){
      var mouseInside = isPointInside(mousePos, shapes[i]);
      if(mouseInside && selectedRigidBody == null){
        selectedRigidBody = shapes[i];
        selectedPosition = copy(mousePos);
        var localPos = sub(mousePos, shapes[i].center);
        addAnchor(localPos, shapes[i]);
        selectedAnchorId = shapes[i].anchorPoints.length - 1;
      }
    }
  }
  
  // Left button released
  else {
    if(selectedRigidBody != null){
      selectedRigidBody.anchorPoints.pop();
      selectedRigidBody = null;
    }
    selectedAnchorId = null;
    selectedPosition = null;
  }
  
  // Apply force
  if(selectedRigidBody && selectedPosition){
    var start = selectedRigidBody.anchorPoints[selectedAnchorId];
    var force = sub(mousePos, start);
    addForceAtPoint(start, selectedRigidBody, scale(force, 0.01));			
  }
}

// Update the physics engine
update = () => {

  // (...)
  
  // Mouse interactions
  handleMouseObjectInteraction();
}

// Demo
rectangle(Vec2(350, 350), 400, 20, 0, 0.5, .9); // not tilted

Links:
- Original: video 1, 2, demo, source code (39.6 kb)
- Mine: demo, source code (23.3 kb)



Chapter 29: Force joints

With anchor points that can apply forces, we can now implement joints.

The simplest kind of joint, the force joint, attracts two anchor points with a defined strength.

A forceJoint constructor and a handleJoints are created.

// Globals
joints = [];

// Update the physics engine
update = () => {

  // (...)
  
  // joints
  handleJoints();
}

// Render the scene
draw = () => {

  // (...)
  
  // Debug: draw joints
  for(var i of joints){
    drawLine(i.rigiA.anchorPoints[i.anchorA], i.rigiB.anchorPoints[i.anchorB], "orange");
  }
}

// Joints

// Force joint
forceJoint = (rigiA, anchorA, rigiB, anchorB, strength) => {
	var joint = {
    type: "force",
    rigiA: rigiA,
		anchorA: anchorA,
		rigiB: rigiB,
		anchorB: anchorB,
    strength: strength
	};
  
  joints.push(joint);
  return joint;
}

// Joint handler
handleJoints = () => {
  for(var i = 0; i < joints.length; i++){
    
    var joint = joints[i];
    var forceHalving = (joint.rigiA.invMass == 0 || joint.rigiB.invMass == 0) ? 1 : 0.5;
    var anchorAPos = joint.rigiA.anchorPoints[joint.anchorA];
    var anchorBPos = joint.rigiB.anchorPoints[joint.anchorB];
    var direction = sub(anchorBPos, anchorAPos);
  
    // Force joint
    if(joint.type == "force"){
      
      // Update connection A
      addForceAtPoint(anchorBPos, joint.rigiA, scale(direction, joint.strength * forceHalving));
      
      // Update connection B
      addForceAtPoint(anchorAPos, joint.rigiB, scale(direction, -joint.strength * forceHalving));
    }
  }
}

// Demo
rectangle(Vec2(350, 550), 500, 20, 0, 0.5, .9);
r1 = rectangle(Vec2(200, 200), 200, 100, 100);
a1 = addAnchor(Vec2(50,25), r1);
c1 = circle(Vec2(500, 100), 60, 100);
a2 = addAnchor(Vec2(-60,0), c1);
j1 = forceJoint(r1, a1, c1, a2, .1);

Links:
- Original: video, demo, source code (41.9 kb)
- Mine: demo, source code (24.4 kb)



Chapter 30: Spring joints

Spring joints simulate realistic springs behavior, with oscillatory movement and proportional force:

The force is equal to the inverse of the spring displacement (from its rest length) times the spring's stiffness constant (according to the simplified Hook's law: F = -x * k).

// Spring joint
springJoint = (rigiA, anchorA, rigiB, anchorB, stiffness, restLength) => {
  var joint = {
    type: "spring",
    rigiA: rigiA,
		anchorA: anchorA,
		rigiB: rigiB,
		anchorB: anchorB,
    stiffness: stiffness,
    restLength: restLength
	};
  
  joints.push(joint);
  return joint;
}

// Joint handler
handleJoints = () => {
  for(var i = 0; i < joints.length; i++){
    
    var joint = joints[i];
    var forceHalving = (joint.rigiA.invMass == 0 || joint.rigiB.invMass == 0) ? 1 : 0.5;
    var anchorAPos = joint.rigiA.anchorPoints[joint.anchorA];
    var anchorBPos = joint.rigiB.anchorPoints[joint.anchorB];
    var direction = sub(anchorBPos, anchorAPos);
    var distance = len(direction);
  
    // Force joint
    if(joint.type == "force"){
      
      // Update connection A
      addForceAtPoint(anchorBPos, joint.rigiA, scale(direction, joint.strength * forceHalving));
      
      // Update connection B
      addForceAtPoint(anchorAPos, joint.rigiB, scale(direction, -joint.strength * forceHalving));
    }
    
    // Spring joint
    else if(joint.type == "spring"){

      var restDistance = distance - joint.restLength;
      var forceMagnitude = restDistance * joint.restLength * joint.stiffness * forceHalving;
      var force = scale(normalize(direction), forceMagnitude);
    
      // Update connection A
      if(joint.rigiA.invMass != 0){
        addForceAtPoint(anchorAPos, joint.rigiA, force);
      }

      // Update connection B
      if(joint.rigiB.invMass != 0){
        addForceAtPoint(anchorBPos, joint.rigiB, scale(force, -1));	
      }
    }
  }
}

// Demo
j1 = springJoint(r1, a1, c1, a2, .005, 50);

Links:
- Original: video, demo, source code (43.6 kb)
- Mine: demo, source code (25.3 kb)



Chapter 31: Reverse force joints

Reverse force joints are used to create a repulsion and maintaining a minimum distance between two objects.

They introduce the concept of distance-based force activation.

They have no attractive or repulsive effect when the distance is bigger than the minimum effect distance.

// Joint handler
handleJoints = () => {
  for(var i = 0; i < joints.length; i++){
    
    // (...)
    
    // Reverse force joint
    else if(joint.type == "reverseForce"){

      var forceMagnitude = Math.max(0, joint.maxEffectDistance - distance);

      // Update connection A
      if(joint.rigiA.invMass != 0){
        addForceAtPoint(anchorBPos, joint.rigiA, scale(normalize(direction), -forceMagnitude * joint.strength * forceHalving));
      }

      // Update connection B
      if(joint.rigiB.invMass != 0){
        addForceAtPoint(anchorAPos, joint.rigiB, scale(normalize(direction), forceMagnitude * joint.strength * forceHalving));	
      }
    }
  }
}

// Demo
j1 = reverseForceJoint(r1, a1, c1, a2, .05, 200);

Links:
- Original: video, demo, source code (45.3 kb)
- Mine: demo, source code (26.2 kb)



Chapter 32: Control which objects can collide with each other

For now we simply add a noCollision array in each shape's constructor, and we will use it in the next two chapters.

Links:
Original: video, demo, source code (46.0 kb)


Chapter 33: Fixed joints

Fixed joints and hinge joints (that we will see in next chapter) are the main reasons that made me follow this tutorial from scratch. Fixed joints are particularly important because they allow to create compound shapes.

Sadly, the original demo, source code and video didn't fulfill their mission: they correctly introduce the iterative approach (to fix the shapes' position and angle many times per frame) and disable collisions between jointed shapes, but the joint they implement is not firm enough: the shapes wobble!

Unable to fix it myself, I had ChatGPT fix it for me and explain to me what was wrong:
The angle correction's force was too weak, and applied using angularVelocity tweaks instead of impulses.

The fixed code below adds direct angle projection at each iteration for a more precise result.

// Shapes
circle = (/* ... */) => {

  // (...)
  anchorPoints: [],
  noCollision: [],
}

rectangle = (/* ... */) => {

  // (...)
  anchorPoints: [],
  noCollision: [],
}

// Update the physics engine
update = () => {

  var push, manifold, shape1, shape2, direction, distance, i, j;
  
  // Reset manifolds
  manifolds = [];
  
  // Reset shapes colors, apply gravity
  for(i of shapes){
    i.color = "#888";
    i.forceAccumulator = add(i.forceAccumulator, scale(gravity, i.mass));
  }
  
  // Consider all pairs of shapes
  for(i = 0; i < shapes.length; i++){
    for(j = i; j < shapes.length; j++){
      
      shape1 = shapes[i];
      shape2 = shapes[j];
    
      // Ignore self-collisions, pairs of immobile shapes and collisions in fixed joints
      if(i != j && !(shape1.invMass == 0 && shape2.invMass == 0) && !shape1.noCollision.includes(j)){
        
        // (...)
      
      }
    }
  }

  // Joints (must be enforced before integration)
  handleJoints();
  
  // Integration
  for(i of shapes){
    integrate(i);
  }
  
  // Mouse interactions
  handleMouseObjectInteraction();
}

// Render the scene
draw = () => {

  // (...)

  // Debug: draw manifolds
  for(var i of manifolds){
    drawPoint(i.contactPoint, 5, "#04F");
    //drawLine(i.contactPoint, add(i.contactPoint, scale(i.normal, 50)), "#04F");
  }
  
  // (...)
}

// Fixed joint
fixedJoint = (rigiA, anchorA, rigiB, anchorB, iterations = 20) => {
  var joint = {
    type: "fixed",
    rigiA: rigiA,
		anchorA: anchorA,
		rigiB: rigiB,
		anchorB: anchorB,
    iterations: iterations,
    rigiABounce: rigiA.restitution,
		rigiBBounce: rigiB.restitution,
		rigiAFriction: rigiA.friction,
		rigiBFriction: rigiB.friction,
    initialLength: len(sub(rigiA.anchorPoints[anchorA], rigiB.anchorPoints[anchorB])),
    relativeAngle: rigiB.angle - rigiA.angle
	};
  
  rigiA.noCollision.push(shapes.indexOf(rigiB));
  rigiB.noCollision.push(shapes.indexOf(rigiA));
  joints.push(joint);
  return joint;
}

// Enable/disable friction and restitution in fixed/hinge joints 
setMaterialZero = joint => {
  joint.rigiA.restitution = 0;
  joint.rigiA.friction = 0;
  joint.rigiB.restitution = 0;
  joint.rigiB.friction = 0;
}

restoreMaterial = joint => {
  joint.rigiA.restitution = joint.rigiABounce;
  joint.rigiA.friction = joint.rigiAFriction;
  joint.rigiB.restitution = joint.rigiBBounce;
  joint.rigiB.friction = joint.rigiBFriction;
}

// Joint handler
handleJoints = () => {

  var joint, forceHalving, anchorAPos, anchorBPos, direction, distance, restDistance, forceMagnitude, force, normal, contact, currentOrientationDiff, fixedOrientationVel, angleError, stiffness, avgAngVel, i, j;
  
  for(i = 0; i < joints.length; i++){
    
    joint = joints[i];
    forceHalving = (joint.rigiA.invMass == 0 || joint.rigiB.invMass == 0) ? 1 : 0.5;
    anchorAPos = joint.rigiA.anchorPoints[joint.anchorA];
    anchorBPos = joint.rigiB.anchorPoints[joint.anchorB];
    direction = sub(anchorBPos, anchorAPos);
    distance = len(direction);
  
    // Force joint
    if(joint.type == "force"){
      
      // (...)
    }
    
    // Spring joint
    else if(joint.type == "spring"){
    
     // (...)
    }
    
    // Reverse force joint
    else if(joint.type == "reverseForce"){

      // (...)
    }
    
    // Fixed joint
    else if(joint.type == "fixed"){
    
      setMaterialZero(joint);

      // Move shapes in order to make the anchor points superposed, proportionally to their inverse masses
      delta = sub(anchorBPos, anchorAPos);
      totalInvMass = joint.rigiA.invMass + joint.rigiB.invMass;
      if(totalInvMass > 0){
          correctionA = scale(delta, joint.rigiA.invMass / totalInvMass);
          correctionB = scale(delta, joint.rigiB.invMass / totalInvMass);

          // Move A and B
          translateShape(joint.rigiA, correctionA);
          translateShape(joint.rigiB, scale(correctionB, -1));
      }

      // Update connection A
      for(j = 0; j < joint.iterations; j++){
        
        direction = sub(anchorBPos, anchorAPos);
        distance = len(direction);
        if(distance < 0.00001) break;
        normal = copy(normalize(direction));
        contact = {
          depth: 0,
          normal: normal,
          contactPoint: anchorBPos,
          rigiA: joint.rigiB,
          rigiB: joint.rigiA
        };
        if(distance > joint.initialLength){
          contact.depth = distance - joint.initialLength;
        }
        else {
          contact.depth = joint.initialLength - distance;
          contact.normal = scale(contact.normal, -1);
        }
        
        positionalCorrection(joint.rigiA, joint.rigiB, contact);
        resolveCollision(joint.rigiA, joint.rigiB, contact);
      }
      
      // Fix orientation strongly
      currentOrientationDiff = joint.rigiB.angle - joint.rigiA.angle - 0.01;
      angleError = joint.relativeAngle - currentOrientationDiff;
      
      // Apply symmetric angular correction
      stiffness = 1; // try 1.0 for absolutely rigid
      joint.rigiA.angle -= angleError * stiffness * 0.5;
      joint.rigiB.angle += angleError * stiffness * 0.5;
      
      // Update connection B
      for(j = 0; j < joint.iterations; j++){
        direction = sub(anchorBPos, anchorAPos);
        distance = len(direction);
        if(distance < 0.00001) break;
        normal = copy(normalize(direction));
        contact = {
          depth: 0,
          normal: normal,
          contactPoint: anchorBPos,
          rigiA: joint.rigiA,
          rigiB: joint.rigiB
        };
        
        if(distance > joint.initialLength){
          contact.depth = distance - joint.initialLength;
        }
        else{
          contact.depth = joint.initialLength - distance;
          contact.normal = scale(contact.normal, -1);
        }
        
        positionalCorrection(joint.rigiB, joint.rigiA, contact);
        resolveCollision(joint.rigiB, joint.rigiA, contact);
      }	

      // Fix orientation strongly
      currentOrientationDiff = joint.rigiA.angle - joint.rigiB.angle;
      angleError = joint.relativeAngle - currentOrientationDiff;
      
      stiffness = 1;
      joint.rigiA.angle += angleError * stiffness * 0.5;
      joint.rigiB.angle -= angleError * stiffness * 0.5;
      
      avgAngVel = (joint.rigiA.angularVelocity + joint.rigiB.angularVelocity) * 0.5;
      joint.rigiA.angularVelocity = avgAngVel;
      joint.rigiB.angularVelocity = avgAngVel;
		
      restoreMaterial(joint);
    }
  }
}

// Demo
for(i = 0; i < 40; i++){
  circle(Vec2(i*20, -i*20-500), 10, 10);
}
rectangle(Vec2(380, 450), 600, 20, 0, 0.5, .9);
r1 = rectangle(Vec2(300, 200), 200, 100, 100);
a1 = addAnchor(Vec2(75, 0), r1);
r2 = rectangle(Vec2(600, 200), 300, 25, 100);
a2 = addAnchor(Vec2(-125, 0), r2);
c1 = circle(Vec2(300, 320), 60, 100);
j1 = fixedJoint(r1, a1, r2, a2);

Links:
- Original: video, demo, source code (53.3 kb)
- Mine: demo, source code (30.9 kb)



Chapter 34: Hinge joints

Hinge joints are almost identical to fixed joints (they also have iterative solving, collision disabling and strong positional solving), but differ in terms of angles: the shapes are attached firmly together but they rotate freely.

// Hinge joint
hingeJoint = (rigiA, anchorA, rigiB, anchorB, iterations = 20) => {
  var joint = {
    type: "hinge",
    rigiA: rigiA,
		anchorA: anchorA,
		rigiB: rigiB,
		anchorB: anchorB,
    iterations: iterations,
    rigiABounce: rigiA.restitution,
		rigiBBounce: rigiB.restitution,
		rigiAFriction: rigiA.friction,
		rigiBFriction: rigiB.friction,
    initialLength: len(sub(rigiA.anchorPoints[anchorA], rigiB.anchorPoints[anchorB])),
	};
  
  rigiA.noCollision.push(shapes.indexOf(rigiB));
  rigiB.noCollision.push(shapes.indexOf(rigiA));
  joints.push(joint);
  return joint;
}

// Update the physics engine
update = () => {

  // (...)
    
      // Ignore self-collisions, pairs of immobile shapes and collisions in fixed/hinge joints
      if(
        i != j && !(shape1.invMass == 0 && shape2.invMass == 0) && !shape1.noCollision.includes(j)
      ){

        // (...)
        
      }
    }
  }

  // (...)
}

// Joint handler
handleJoints = () => {

  // (...)
  
  for(i = 0; i < joints.length; i++){
    
    // (...)
    
    // Hinge joint
    else if(joint.type == "hinge"){
    
      setMaterialZero(joint);

      // Move shapes in order to make the anchor points superposed, proportionally to their inverse masses
      var delta = sub(anchorBPos, anchorAPos);
      var totalInvMass = joint.rigiA.invMass + joint.rigiB.invMass;
      if(totalInvMass > 0){
        correctionA = scale(delta, joint.rigiA.invMass / totalInvMass);
        correctionB = scale(delta, joint.rigiB.invMass / totalInvMass);

        // Move A and B
        translateShape(joint.rigiA, correctionA);
        translateShape(joint.rigiB, scale(correctionB, -1));
      }

      // Update connection A
      for(var j = 0; j < joint.iterations; j++){
        
        direction = sub(anchorBPos, anchorAPos);
        distance = len(direction);
        if(distance < 0.00001) break;
        normal = copy(normalize(direction));
        contact = {
          depth: 0,
          normal: normal,
          contactPoint: anchorBPos,
          rigiA: joint.rigiB,
          rigiB: joint.rigiA
        };
        if(distance > joint.initialLength){
          contact.depth = distance - joint.initialLength;
        }
        else {
          contact.depth = joint.initialLength - distance;
          contact.normal = scale(contact.normal, -1);
        }
        
        positionalCorrection(joint.rigiA, joint.rigiB, contact);
        resolveCollision(joint.rigiA, joint.rigiB, contact);
      }
      
      // Update connection B
      for(j = 0; j < joint.iterations; j++){
        direction = sub(anchorBPos, anchorAPos);
        distance = len(direction);
        if(distance < 0.00001) break;
        normal = copy(normalize(direction));
        contact = {
          depth: 0,
          normal: normal,
          contactPoint: anchorBPos,
          rigiA: joint.rigiA,
          rigiB: joint.rigiB
        };
        
        if(distance > joint.initialLength){
          contact.depth = distance - joint.initialLength;
        }
        else{
          contact.depth = joint.initialLength - distance;
          contact.normal = scale(contact.normal, -1);
        }
        
        positionalCorrection(joint.rigiB, joint.rigiA, contact);
        resolveCollision(joint.rigiB, joint.rigiA, contact);
      }
		
      restoreMaterial(joint);
    }
  }
}

// Demo
j1 = hingeJoint(r1, a1, r2, a2);

Links:
- Original: video, demo (fixed), source code (fixed) (48.9 kb)
- Mine: demo, source code (33.9 kb)



Chapter 35 (bonus): Collision masks and groups

This chapter shows an advanced technique for telling which objects should and should not collide with each other. We don't need that.

Links:
Original: video, demo, source code (50.1 kb)


Chapter 36 (bonus): Collision matrix

An even more advanced collision filtering system, that we also won't use.

Links:
Original: video, demo, source code (63.1 kb)



Yayyy, I completed my dream 2D physics engine in two weeks and a little less than 34kb! It's now time to golf it!



GOLFING MY ENGINE


Phase 1: line-by-line minification and simplification



The canvas 2D context is retrieved using a template literal (implied parentheses).
2D vector functions and globals are renamed.
ES6 shorthand property names are used in V().
len2 and copy are removed.
repetitions are added in rotate for better zip compression.
null is replaced with 0.
Minified size: 579b → 395b.

// Canvas setup
var c=a.getContext`2d`,

// 2D vectors
V=(x,y)=>({x,y}),             // Vec2 constructor
n=v=>S(v,1/l(v)),             // normalize
l=v=>d(v,v)**.5,              // length
N=v=>V(v.y,-v.x),             // normal
d=(x,y)=>x.x*y.x+x.y*y.y,     // dot
A=(x,y)=>V(x.x+y.x,x.y+y.y),  // add
s=(x,y)=>A(x,S(y,-1)),        // sub
S=(x,y)=>V(x.x*y,x.y*y),      // scale
D=(x,y)=>l(s(x,y)),           // dist
r=(x,y,c)=>(V((x.x-y.x)*Math.cos(c)-(x.y-y.y)*Math.sin(c)+y.x,(x.x-y.x)*Math.sin(c)+(x.y-y.y)*Math.cos(c)+y.y)), // rotate
C=(x,y)=>x.x*y.y-x.y*y.x,     // cross

// Globals
m=0,p=0,        // mouse position, pressed
sr=0,sp=0,sa=0, // selected rigidbody, position, anchor
H=[],J=[],M=[], // shapes, joints, manifolds
G=V(0,.1),      // Gravity

Event listeners are golfed using the good old "e.type[7]" trick.
e.type is either "mouseup" or "mousedown", so e.type[7] is either trueish ("w") or falseish (undefined).
Minified size: 108b → 101zb.

onmousemove=o=>m=V(o.pageX-a.offsetLeft,o.pageY-a.offsetTop),
onmousedown=onmouseup=o=>p=!!o.type[7],

Circle and rectangle are replaced with a single shape constructor.
radius, width and height are replaced with size1 and size2.
angle and velocity parameters are removed.
ES6 shorthand properties are used.
attributes names are not minified (yet) to keep objects readable.
temp vars are added to the parameters instead of using var or let.
== is replaced with === to improve performances.
Minified size: 834b → 450b

// Shapes
shape=(
  type,
  center,
  size1, // radius (circle) or width (rectangle)
  size2, // height (rectangle only)
  mass=1,
  friction=0,
  restitution=1,
  color,

  // inertia
  inertia=(type==="circle"?mass*size1**2/2:mass*(size1**2+size2**2)/12), 

  // shape
  s={
    type,
    center,
    size1,
    size2,
    mass,
    invMass:mass===0?0:1/mass,
    friction,
    restitution,
    angle:0,
    velocity:V(0,0),
    angularVelocity:0,
    inertia,
    invInertia:n?0:1/n,
    forceAccumulator:V(0,0),
    torqueAccumulator:0,
    color,
    anchorPoints:[],
    noCollision:[],
    normals:[],
    vertices:type==="circle"?[]:[
      V(center.x-size1/2,center.y-size2/2),
      V(center.x+size1/2,center.y-size2/2),
      V(center.x+size1/2,center.y+size2/2),
      V(center.x-size1/2,center.y+size2/2)
    ]
  }
)=>(
  cn(s),    // compute normals (rectangles only)
  H.push(s) // add shape in shapes list and return shape
);

Functions involving points and shapes are renamed (two letters each).
vertices.length and normals.length are replaced with 4 (because we only deal with rectangles).
Temp vars are removed when possible.
Logic is improved
All the comments are rewritten.
Minified size: 1329b → 1030b.

// Check if a point is inside a shape
// params: point (world coordinates), shape
pi=(point,shape,t=1,i=4)=>{

  // circles: return true if radius is bigger than distance to center
  if(shape.type==="circle")t=shape.size1>l(s(shape.center,point));
  
  // rectangles: return false if dot(sub(point,vertex),normal) > 0 for any edge
  else for(;i--;)if(d(s(point,shape.vertices[i]),shape.normals[i])>0)t=0;
  
  return t
},

// Add an anchor to a shape
// params: anchor (local coordinates), shape
anchor=(anchor,shape)=>(
  
  // add anchor in the point's anchors list (converted in world coordinates)
  shape.anchorPoints.push(A(anchor,shape.center)),
  
  // return anchor index
  shape.anchorPoints.length-1
),

// Add force on a point
// params: point (world coordinates), shape, force (2D vector)
af=(point,shape,force)=>{

  // translation force
  shape.forceAccumulator=A(shape.forceAccumulator,force),
  
  // rotation force
  shape.torqueAccumulator+=C(s(point,shape.center),force)
},

// Translate a shape
// params: shape, offset (2D vector)
ts=(shape,offset,n)=>{

  // add offset to the shape's center
  shape.center=A(shape.center,offset);
  
  // add offset to the shape's vertices (if any)
  for(n in shape.vertices)
    shape.vertices[n]=A(shape.vertices[n],offset);
    
  // add offset to the shape's anchor points
  for(n in shape.anchorPoints)
    shape.anchorPoints[n]=A(shape.anchorPoints[n],offset)
},

// Rotate a shape around its center
// params: shape, angle (in radians)
rs=(shape,angle,t)=>{

  // update angle
  shape.angle+=angle;

  // rectangles
  if(shape.type==="rectangle"){
    
    // rotate rectangle vertices
    for(t in shape.vertices)
      shape.vertices[t]=r(shape.vertices[t],shape.center,angle);
      
    // compute rectangle normals
    cn(shape);
  }
    
  // rotate anchor points
  for(t in shape.anchorPoints)
    shape.anchorPoints[t]=r(shape.anchorPoints[t],shape.center,angle)
},

// Shape integration
si=shape=>{

  // skip fixed objects
  if(shape.invMass!==0){
    
    // add force accumulator to velocity (proportionally to inverse mass)
    shape.velocity=A(shape.velocity,S(shape.forceAccumulator,shape.invMass)),
    
    // add velocity to position
    ts(shape,shape.velocity),
    
    // damp velocity
    shape.velocity=S(shape.velocity,.99),
    
    // reset force accumulator
    shape.forceAccumulator=V(0,0),
    
    // add torque to angular velocity (proportionally to inverse inertia)
    shape.angularVelocity+=shape.torqueAccumulator*shape.invInertia,
    
    // add angular velocity to angle
    rs(shape,shape.angularVelocity),
    
    // damp angular velocity
    shape.angularVelocity*=.99,
    
    // reset torque accumulator
    shape.torqueAccumulator=0
  }
},

// Compute the normals of a polygon
cn=(shape,t=0)=>{

  // rectangle only
  if(shape.type!=="circle"){
    
    // compute 4 normals
    for(;t<4;t++)
      shape.normals[t]=(N(n(s(shape.vertices[(t+1)%4],shape.vertices[t]))))
  }
},

Functions dedicated to collision detection are minified similarly.
Number.MAX_VALUE is replaced by 9e9.
Minified size: 1405b → 943b.

// Detect rectangle-rectangle collisions using separating-axis theorem (SAT)
// rectangles are tested against each other's axis and we choose the minimal penetration depth
// params: rect1, rect2
rr=(rect1,rect2,e,r)=>{

  // test rect2 against rect1's axis
  e=cp(rect1,rect2);
  
  // return null if no collision
  if(e===null)return e;
  
  // test rect1 against rect2's axis
  r=cp(rect2,rect1);
  
  // return null (if no collision) or the manifold with the smallest penetration depth (flip the normal if it's a rect2 axis)
  return r===null?r:(
    e.depth<r.depth
    ?e
    :(r.normal=S(r.normal,-1),r)
  )
},

// Get contact point
// all normals of rect1 are tested as potential separating axes
// params: rect1, rect2
cp=(t,n,r=null,e=9e9,l=null,o=0)=>{
  
  // for each axis
  for(;o<4;o++){
  
    // if no support point found (no collision), return null
    if((l=fs(t.normals[o],t.vertices[o],n.vertices))===null)return l;
    
    // create/replace the manifold r if this axis has the smallest penetration depth
    l.depth<e&&(
      r={
        depth:e=l.depth,
        normal:t.normals[o],
        contactPoint:l.vertex
      }
    )
  }
  
  // return the manifold that has the smallest penetration depth
  return r
},

// Find a support point
// find which vertex from the other rectangle penetrates the deepest in a given axis
// params: normal, point (from rect1), vertices (from rect2)
fs=(normal,point,vertices,e=0,l=null,u=null,o=0)=>{

  // for each vertex
  for(;o<4;o++)
    
    // if penetration depth is higher than max depth e, save it in u and save the support point in l
    (u=d(s(vertices[o],point),S(normal,-1)))>e&&(
      e=u,
      l={
        vertex:vertices[o],
        depth:u
      }
    );
    
  // return the contact point that has the biggest penetration depth
  return l
},

// Detect collision between a circle and a rectangle
// params: circle, rect
cr=(circle,rect)=>{

  // try to detect a collision between the circle and the rectangle's edges
  // if no edge collision is found, test against the rectangle's corners
  // return the resulting contact information (if any)
  return cre(circle,rect)||crc(circle,rect);
},

// Detect a collision between a circle and a rectangle's edges
// params: circle, rectangle
cre=(circle,rect,t,c,i=null,v=null,o=0,z,u)=>{
  
  // for each edge
  for(;o<4;o++)
  
    // create a vector z from first vertex of edge to the center of the circle
    z=s(circle.center,rect.vertices[o]),
    
    // create a vector t from first vertex to the second 
    t=s(rect.vertices[(o+1)%4],rect.vertices[o]),
    
    // project z on t
    c=d(z,n(t)),
    
    // project z on the edge's normal
    // if the circle's center is within the edge's bounds and inside the rectangle, save the normal in v and the vertex in i
    d(z,rect.normals[o])>=0&&c>0&&c<l(t)&&(v=rect.normals[o],i=rect.vertices[o]);
  
  // no collision if v or i is null
  if(v===null||i===null)return null;
  
  // compute distance between edge and circle's center
  u=d(v,s(circle.center,i));
  
  // return null (if no collision) or a manifold
  return u-circle.size1<0
  ?{
    depth:-(u-circle.size1),
    normal:S(v,-1),
    contactPoint:A(circle.center,S(v,-circle.size1))
  }
  :null
},

// Detect a collision between a circle and a rectangle's vertices
// params: circle, rectangle
crc=(circle,rect,t,c=0)=>{
  for(;c<4;c++)
    if(t=s(rect.vertices[c],circle.center),l(t)<circle.size1)
      return{
        depth:circle.size1-l(t),
        normal:n(t),
        contactPoint:rect.vertices[c]
      };
  return null
}

Functions related to collision resolutions:
All the variables are factorized or put in the parameters list.
A "scalar cross" helper function is added.
Minified size: 1177b → 959b.

// Correct positioning
// Move shapes away from each other according to a collision manifold and proportionally to their inverse mass
// params: shape1, shape2, manifold
oc=(shape1,shape2,manifold,v)=>{
  
  // compute total correction amount
  v=S(manifold.normal,manifold.depth/(shape1.invMass+shape2.invMass));
  
  // tanslate shapes (if they're not fixed)
  shape1.invMass>0&&ts(shape1,S(v,-shape1.invMass)),
  shape2.invMass>0&&ts(shape2,S(v,shape2.invMass))
},

// "Scalar cross" helper function
// performs a scalar cross between an angular velocity and penetration vector
// params: shape, penetration
sc=(shape,penetration)=>V(-shape.angularVelocity*penetration.y,shape.angularVelocity*penetration.x),

// Resolve collision
// params: shape1, shape2, manifold
rc=(shape1,shape2,manifold,a,o,v,y,m,u,M,g,f,h,x,F,P,b,j,p,q)=>{

  // skip if both shapes are fixed
  shape1.invMass===0&&shape2.invMass===0||(
  
    // if the objects are already moving apart from each other, return
    // else, compute relative velocity (v: linear and a: rotational)
    (y=d(v=s(A(shape2.velocity,sc(shape2,o=s(manifold.contactPoint,shape2.center))),A(shape1.velocity,sc(shape1,a=s(manifold.contactPoint,shape1.center)))),manifold.normal))>0||(
    
      // Normal impulse:
      
      // total restitution
      m=Math.min(shape1.restitution,shape2.restitution),
      
      // "lever arm" cross product
      u=C(a,manifold.normal),
      M=C(o,manifold.normal),
      
      // compute scalar impulse magnitude F
      F=S(manifold.normal,x=(-(1+m)*y)/((g=shape1.invMass+shape2.invMass)+(u*u*(f=shape1.invInertia)+M*M*(h=shape2.invInertia)))),
      
      // apply linear impulses (in opposite directions)
      shape1.velocity=s(shape1.velocity,S(F,shape1.invMass)),
      shape2.velocity=A(shape2.velocity,S(F,shape2.invMass)),
      
      // apply rotational impulses (also opposite)
      shape1.angularVelocity+=-u*x*f,
      shape2.angularVelocity+=M*x*h,
      
      // Frictional impulse:
      
      // compute reverse tangent direction
      P=S(s(v,S(manifold.normal,d(v,manifold.normal))),-1),
      
      // compute frictional impulse and don't let it exceed normal impulse
      (p=(-(1+m)*d(v,P)*Math.min(shape1.friction,shape2.friction))/(g+((b=C(a,P))*b*f+(j=C(o,P))*j*h)))>x&&(p=x),
      
      // apply linear and rotational impulses
      shape1.velocity=s(shape1.velocity,S(q=S(P,p),shape1.invMass)),
      shape2.velocity=A(shape2.velocity,S(q,shape2.invMass)),
      shape1.angularVelocity+=-b*p*f,
      shape2.angularVelocity+=j*p*h
    )
  )
}

Debug functions and mouse interactions are barely simplified (they will not be present in the final release).
Minified size: 781b → 435b.

// Debug: Draw a point
dp=(center,color= "#888")=>(
  c.beginPath(),
  c.fillStyle=color,
  c.arc(center.x,center.y,5,0,7),
  c.fill(),
  c.closePath()
),

// Debug: draw a line
dl=(start,end,color="#888")=>(
  c.beginPath(),
  c.strokeStyle=color,
  c.moveTo(start.x,start.y),
  c.lineTo(end.x,end.y),
  c.stroke(),
  c.closePath()
),

// Mouse interactions
mi=(r,a,e,n,o)=>{
  if(p)
    for(r=0;r<H.length;r++)pi(m,H[r])&&0==sr&&(
      sr=H[r],
      sp=V(m.x,m.y),
      e=s(m,H[r].center),
      sa=anchor(e,H[r])
    );
  else 0!=sr&&(
    sr.anchorPoints.pop(),
    sr=0
  ),
  sa=0,
  sp=0;
  sr&&sp&&(
    n=sr.anchorPoints[sa],
    o=s(m,n),
    af(n,sr,S(o,.01))
  )
}

The update function is a bit simpler.
Minified size: 919b → 618b.

// Update the physics engine
up=(e,r,c,t,o,j=0,i)=>{
  
  // reset manifolds list
  M=[];

  // consider all distinct pairs of shapes
  for(;j<H.length;j++){
  
    // r: shape1 (index j from 0 to H.length)
    r=H[j];
    
    // add (gravity * mass) to shape1
    r.forceAccumulator=A(r.forceAccumulator,S(G,r.mass));
    
    for(i=j;i<H.length;i++){
    
      // c: shape2 (index i from j to H.length)
      c=H[i],
      
      // ignore self-collisions, pairs of fixed shapes and collisions in fixed/hinge joints
      j===i||r.invMass===0&&c.invMass===0||r.noCollision.includes(i)||
      (
        
        // reset manifold
        e=null,
        
        // circle-circle collision
        r.type==="circle"&&c.type==="circle"
        ?(
          
          // vector between circles centers
          t=s(c.center,r.center),
          
          // if distance between centers is smaller than sum of radii: collision
          (o=l(t))<r.size1+c.size1&&(
            
            // create manifold
            e={
              depth:r.size1+c.size1-o,
              normal:n(t),
              contactPoint:A(r.center,S(n(t),r.size1))
            }
          )
        )
        
        // rectangle-rectangle-rectangle collision
        :r.type==="rectangle"&&c.type==="rectangle"
        ?e=rr(r,c)
        
        // rectangle-circle collision
        :r.type==="rectangle"&&c.type==="circle"
        
        // inverse r and c and check circle-rectangle collision
        ?(t=r,r=c,c=t,e=cr(r,c))
        
        // circle-rectangle collision
        :r.type==="circle"&&c.type==="rectangle"&&(e=cr(r,c)),
        
        // if a manifold is present: there's a collision
        e&&(
        
          // correct positioning
          pc(r,c,e),
          
          // resolve collision
          rc(r,c,e),
          
          // add manifold to manifolds list (debug only)
          M.push(e)
        )
      );
    }
  }

  // integrate all shapes
  for(j of H)si(j);

  // handle mouse interactions
  mi();
  
  // handle joints
  hj();  
}

The draw function is barely touched (it will not be part of the final release).
Minified size: 705b → 655b.

// Render the scene
dr=(e,r)=>{
  for(e of(a.width^=0,H))
    for(r of(
      c.save(),
      c.beginPath(),
      "circle"===e.type
      ?(
        c.fillStyle=e.color,
        c.translate(e.center.x,e.center.y),
        c.rotate(e.angle),
        c.arc(0,0,e.size1,0,7),
        c.lineTo(0,0),
        c.closePath(),
        c.fill(),
        c.stroke()
      ):"rectangle"===e.type&&(
        c.fillStyle=e.color,
        c.moveTo(e.vertices[0].x,e.vertices[0].y),c.lineTo(e.vertices[1].x,e.vertices[1].y),c.lineTo(e.vertices[2].x,e.vertices[2].y),c.lineTo(e.vertices[3].x,e.vertices[3].y),c.closePath(),
        c.fill(),
        c.stroke()
      ),
      c.restore(),
    e.anchorPoints))
      dp(r,"green");
  for(e of M)
    dp(e.contactPoint,"#04F");
  for(e of J)
    dl(e.rigiA.anchorPoints[e.anchorA],e.rigiB.anchorPoints[e.anchorB],"orange")
};

Joints and main loop are also quiclky minified for now. They will be optimized later.
Joints constructors avoid using a temp var for the return, as "J.push()" returns the last item pushed.
Minified size: 4294b → 3794b

// Force joint
// params: rigiA, anchorA, rigiB, anchorB, strength
forceJoint=(rigiA,anchorA,rigiB,anchorB,strength)=>{
  return J.push({
    type:"force",
    rigiA,
    anchorA,
    rigiB,
    anchorB,
    strength
  })
},

// Spring joint
// params: rigiA, anchorA, rigiB, anchorB, stiffness, restLength
springJoint=(rigiA,anchorA,rigiB,anchorB,stiffness,restLength)=>{
  return J.push({
    type:"spring",
    rigiA,
    anchorA,
    rigiB,
    anchorB,
    stiffness,
    restLength
  });
},

// Reverse force joint
// params: rigiA, anchorA, rigiB, anchorB, strength, maxEffectDistance
reverseForceJoint=(rigiA,anchorA,rigiB,anchorB, strength, maxEffectDistance)=>{
  return J.push({
    type:"reverseForce",
    rigiA,
    anchorA,
    rigiB,
    anchorB,
    strength,
    maxEffectDistance
  })
},

// Fixed joint
// params: rigiA, anchorA, rigiB, anchorB, iterations
fixedJoint=(rigiA,anchorA,rigiB,anchorB,iterations=20)=>{
  return rigiA.noCollision.push(H.indexOf(rigiB)),rigiB.noCollision.push(H.indexOf(rigiA)),J.push(e={
    type:"fixed",
    rigiA,
    anchorA,
    rigiB,
    anchorB,
    iterations,
    rigiABounce:rigiA.restitution,
    rigiBBounce:rigiB.restitution,
    rigiAFriction:rigiA.friction,
    rigiBFriction:rigiB.friction,
    initialLength:l(s(rigiA.anchorPoints[anchorA],rigiB.anchorPoints[anchorB])),
    relativeAngle:rigiB.angle-rigiA.angle
  })
},

// Hinge joint
// params: rigiA, anchorA, rigiB, anchorB, iterations
hingeJoint=(rigiA,anchorA,rigiB,anchorB,iterations=20)=>{
  return rigiA.noCollision.push(H.indexOf(rigiB)),rigiB.noCollision.push(H.indexOf(rigiA)),J.push(e={
    type:"hinge",
    rigiA,
    anchorA,
    rigiB,
    anchorB,
    iterations,
    rigiABounce:rigiA.restitution,
    rigiBBounce:rigiB.restitution,
    rigiAFriction:rigiA.friction,
    rigiBFriction:rigiB.friction,
    initialLength:l(s(rigiA.anchorPoints[anchorA],rigiB.anchorPoints[anchorB]))
  })
},

// Enable/disable friction and restitution in fixed/hinge joints 
setMaterialZero=i=>{
  i.rigiA.restitution=i.rigiA.friction=i.rigiB.restitution=i.rigiB.friction=0
},

restoreMaterial=i=>{
  i.rigiA.restitution=i.rigiABounce,
  i.rigiA.friction=i.rigiAFriction,
  i.rigiB.restitution=i.rigiBBounce,
  i.rigiB.friction=i.rigiBFriction
},

// Joint handler
hj=(i,r,g,t,e,a,o,h,A,B,c,f,p,v,M,m,L,d,y)=>{
  for(m=0;m<J.length;m++){
    r=0==(i=J[m]).rigiA.invMass||0==i.rigiB.invMass?1:.5,g=i.rigiA.anchorPoints[i.anchorA],
    t=i.rigiB.anchorPoints[i.anchorB],
    e=s(t,g),
    a=l(e);
    if("force"==i.type)
      af(t,i.rigiA,S(e,i.strength/2)),
      af(g,i.rigiB,S(e,-i.strength/2));
    else if("spring"==i.type)
      o=(a-i.restLength)*i.restLength*i.stiffness*r,h=S(n(e),o),
      0!=i.rigiA.invMass&&af(g,i.rigiA,h),
      0!=i.rigiB.invMass&&af(t,i.rigiB,S(h,-1));
    else if("reverseForce"==i.type)
      o=Math.max(0,i.maxEffectDistance-a),
      0!=i.rigiA.invMass&&af(t,i.rigiA,S(n(e),-o*i.strength*r)),
      0!=i.rigiB.invMass&&af(g,i.rigiB,S(n(e),o*i.strength*r));
    else if("fixed"==i.type){
      setMaterialZero(i),
      d=s(t,g),(L=i.rigiA.invMass+i.rigiB.invMass)>0&&(v=S(d,i.rigiA.invMass/L),
      M=S(d,i.rigiB.invMass/L),
      ts(i.rigiA,v),
      ts(i.rigiB,S(M,-1)));
      for(y=0;y<i.iterations&&(e=s(t,g),!((a=l(e))<1e-5));y++)
        A={
          depth:0,
          normal:n(e),
          contactPoint:t,
          rigiA:i.rigiB,
          rigiB:i.rigiA
        },
        a>i.initialLength?A.depth=a-i.initialLength:(
          A.depth=i.initialLength-a,
          A.normal=S(A.normal,-1)
        ),
        pc(i.rigiA,i.rigiB,A),
        rc(i.rigiA,i.rigiB,A);
      B=i.rigiB.angle-i.rigiA.angle-.01,
      c=i.relativeAngle-B,
      f=1,
      i.rigiA.angle-=c*f*.5,
      i.rigiB.angle+=c*f*.5;
      for(y=0;y<i.iterations&&(e=s(t,g),!((a=l(e))<1e-5));y++)
        A={
          depth:0,
          normal:n(e),
          contactPoint:t,
          rigiA:i.rigiA,
          rigiB:i.rigiB
        },
        a>i.initialLength?A.depth=a-i.initialLength:(
          A.depth=i.initialLength-a,
          A.normal=S(A.normal,-1)
        ),
        pc(i.rigiB,i.rigiA,A),
        rc(i.rigiB,i.rigiA,A);
      B=i.rigiA.angle-i.rigiB.angle,
      c=i.relativeAngle-B,
      f=1,
      i.rigiA.angle+=c*f*.5,
      i.rigiB.angle-=c*f*.5,
      p=.5*(i.rigiA.angularVelocity+i.rigiB.angularVelocity),
      i.rigiA.angularVelocity=p,
      i.rigiB.angularVelocity=p,
      restoreMaterial(i)
    }
    else if("hinge"==i.type){
      setMaterialZero(i);
      d=s(t,g);
      (L=i.rigiA.invMass+i.rigiB.invMass)>0&&(
        v=S(d,i.rigiA.invMass/L),
        M=S(d,i.rigiB.invMass/L),
        ts(i.rigiA,v),
        ts(i.rigiB,S(M,-1))
      );
      for(y=0;y<i.iterations&&(e=s(t,g),!((a=l(e))<1e-5));y++)
        A={
          depth:0,
          normal:n(e),
          contactPoint:t,
          rigiA:i.rigiB,
          rigiB:i.rigiA
        },
        a>i.initialLength?A.depth=a-i.initialLength:(
          A.depth=i.initialLength-a,
          A.normal=S(A.normal,-1)
        ),
        pc(i.rigiA,i.rigiB,A),
        rc(i.rigiA,i.rigiB,A);
      for(y=0;y<i.iterations&&(e=s(t,g),!((a=l(e))<1e-5));y++)
        A={
          depth:0,
          normal:n(e),
          contactPoint:t,
          rigiA:i.rigiA,
          rigiB:i.rigiB
        },
        a>i.initialLength?A.depth=a-i.initialLength:(
          A.depth=i.initialLength-a,
          A.normal=S(A.normal,-1)),
          pc(i.rigiB,i.rigiA,A),
          rc(i.rigiB,i.rigiA,A);
      restoreMaterial(i)
    }
  }
};

// Main loop
setInterval(() => {
  up();
  dr();
}, 16);

So far, the full code without demo went from 12133b to 9252b (minified) and 3537b to 3002b (zipped)



THE PLAYROOM



Before continuing, I coded a little demo with 6 different parts showcasing each joint plus a "stress test" with a lot of shapes that collide with each other.

The performance was good (no slowdowns), but some bugs appeared:
- The stress test never reaches an equilibrium state (the shapes keep mobing and rotating infinitely).
- The stress test shapes tend to interpenetrate a bit instead of pushing each other away.
- The fixed joint isn't stiff enough (the circle slowly falls toward the floor).

With the help of ChatGPT and other 2D physics libraries, I learned how to fix these problems, at least in theory:
- The solver can be run many (usually around 10) times per frame to reduce interpenetrations.
- We can use angular impulses to enforce the fixed joint's relative angle.
- We can also remove rc (resolveCollision) from the fixed joint code to avoid applying too many impulses to the neighbour shapes.

This looks better, but the code is hacky and too verbose.

So, let's try putting all these fixes aside and recoding the "update" and "handle joints" methods cleanly, from scratch, based on all that I've seen so far.



OPTIMIZATION



Let's focus on the stress test first:
To make it more stable with minimal extra code, we can:
- remove pc (positional correction) entirely.
- solve the collisions many times per frame in up (update).
- add a Baumgarte-like bias and clamp the friction in rc (resolve collision).
- pseudo-sleep in si (shape integration): set tiny movement/rotations to zero.

In the update function, a reference to both shapes is stored in each manifold, for simplicity.

An ai (apply impulse, linear and angular) method is also added, because it will be necessary at any different places.

// Apply normal impulse (linear + angular)
ai=(sA, sB, impulseScalar, normal, raCrossN, rbCrossN, doLinear=1, Imp) => {

  // linear
  if(doLinear){
    Imp = S(normal, impulseScalar);
    if(sA.invMass !== 0) sA.velocity = s(sA.velocity, S(Imp, sA.invMass));
    if(sB.invMass !== 0) sB.velocity = A(sB.velocity, S(Imp, sB.invMass));
  }
  
  // angular
  sA.angularVelocity -= raCrossN * impulseScalar * sA.invInertia;
  sB.angularVelocity += rbCrossN * impulseScalar * sB.invInertia;
},

// Shape integration
si=shape=>{

  // skip fixed objects
  if(shape.invMass!==0){
    
    // add force accumulator to velocity (proportionally to inverse mass)
    shape.velocity=A(shape.velocity,S(shape.forceAccumulator,shape.invMass));
    
    // add velocity to position
    ts(shape,shape.velocity);
    
    // damp velocity
    shape.velocity=S(shape.velocity,.99);
    
    // zero tiny velocity
    if(Math.abs(l(shape.velocity)) < .0001) shape.velocity = V(0,0);
    
    // reset force accumulator
    shape.forceAccumulator=V(0,0);
    
    // add torque to angular velocity (proportionally to inverse inertia)
    shape.angularVelocity+=shape.torqueAccumulator*shape.invInertia;
    
    // add angular velocity to angle
    rs(shape,shape.angularVelocity);
    
    // damp angular velocity
    shape.angularVelocity*=.99;
    
    // zero tiny rotation
    if(Math.abs(shape.angularVelocity) < .0001 || Math.abs(l(shape.velocity)) < .0001) shape.angularVelocity = 0;
    
    // reset torque accumulator
    shape.torqueAccumulator=0;
  }
},


// Resolve collision
// params: shape1, shape2, manifold
rc=(manifold,shape1,shape2,a,o,v,u,M,B,x,F,P,b,j,m,p,q)=>{

  shape1 = manifold.A;
  shape2 = manifold.B;

  // Movement impulses
  
  // skip if both shapes are fixed
  if(shape1.invMass!==0 || shape2.invMass!==0){

    // relative velocity at contact point
    v = s(
      A(
        shape2.velocity,
        sc(
          shape2, 
          o = s(manifold.contactPoint, shape2.center)
        )
      ),
      A(
        shape1.velocity, 
        sc(
          shape1,
          a = s(manifold.contactPoint, shape1.center)
        )
      )
    );

    // effective mass for normal direction
    u = C(a,manifold.normal);
    M = C(o,manifold.normal);

    // compute Baumgarte-like bias from penetration to reduce positional drift
    B = manifold.depth * .5;

    // normal impulse scalar
    x = (-(1 + Math.min(shape1.restitution, shape2.restitution)) * d(v, manifold.normal) + B) / (shape1.invMass + shape2.invMass + u*u*shape1.invInertia + M*M*shape2.invInertia);

    // apply impulses
    ai(shape1, shape2, x, manifold.normal, u, M);

    // friction impulses
    
    // compute tangent
    P = S(s(v, S(manifold.normal, d(v, manifold.normal))), -1);
    
    // if zero tangent, skip
    if(l(P) > 0) {
 
      // effective mass along tangent
      b = C(a, P = n(P));
      j = C(o, P);

      // friction coefficient
      m = Math.min(shape1.friction, shape2.friction);
      
      // desired friction impulse
      p = (-d(v, P)) / (shape1.invMass + shape2.invMass + b*b*shape1.invInertia + j*j*shape2.invInertia);
      
      // clamp friction by normal impulse magnitude
      if(Math.abs(p) > Math.abs(x) * m) p = Math.sign(p) * Math.abs(x) * m;
      
      // apply impulses
      ai(shape1, shape2, p, P, b, j);
    }
  }
},

// Update the physics engine
up=(i,j,body1,body2,manifold,diff,dist)=>{

  // clear manifolds
  M = [];
  
  // reset acceleration and apply gravity (F = m * g)
  for(j of H){
    if(j.invMass !== 0) j.forceAccumulator = S(G, j.mass);
  }

  // collision detection
  for(j=0;j<H.length;j++){
    for(i=j+1;i<H.length;i++){
      
      body1 = H[j],
      body2 = H[i];
      
      // skip pairs of static bodies and bodies with fixed/hinge joints
      if((body1.invMass !== 0 || body2.invMass !== 0) && !body1.noCollision.includes(i)){

        // reset manifold
        manifold = null;

        // circle-circle
        if(body1.type=== "circle" && body2.type === "circle"){
          diff = s(body2.center, body1.center);
          dist = l(diff);
          if(dist < body1.size1 + body2.size1){
            manifold = {
              depth: body1.size1 + body2.size1 - dist,
              normal: n(diff),
              contactPoint: A(body1.center, S(n(diff), body1.size1)),
              A: body1,
              B: body2
            };
          }
        }
        
        // rect-rect
        else if(body1.type === "rectangle" && body2.type === "rectangle"){
          manifold = rr(body1, body2);
        }
        
        // rect-circle (circle as first)
        else if(body1.type === "rectangle" && body2.type === "circle"){
          manifold = cr(body2, body1);
        }
        
        // circle-rect
        else { //if(body1.type === "circle" && body2.type === "rectangle"){
          manifold = cr(body1, body2);
        }

        // add manifold to list
        if(manifold) M.push(manifold);
      }
    }
  }

  // resolve contact impulses and joint impulses multiple times
  for(i = 20; i--;){
  
    // resolve contacts
    for(j of M){
      rc(j);
    }
    
    // resolve joints as contact-like constraints
    hj();
  }

  // integrate shapes
  for(j of H) si(j);
},

It's not perfect, but it's more stable than ever and there are almost no residual interpenetrations.

For the joints, we can use a unified constructor function instead of five, and rewrite the hj (handle joints) function like this:
solve the joints with impulses, many times per frame.
after each resolution, apply a slight positional correction to help the anchors stay attached for hinge and fixed joints.
get rid of material enabling/disabling code and the D (dist) Vec2 function.

// Joints
// type: force/spring/repulse/hinge/fixed
// length: restLength (spring), minDistance (repulse) 
joint=(type,rigiA,anchorA,rigiB,anchorB,strength,length,relativeAngle)=>{
  
  // hinge/fixed: disable collisions
  if(type === "hinge" || type === "fixed"){
    rigiA.noCollision.push(H.indexOf(rigiB));
    rigiB.noCollision.push(H.indexOf(rigiA));
    relativeAngle=rigiB.angle-rigiA.angle;
  }
  
  return J.push({
    type,
    rigiA,
    anchorA,
    rigiB,
    anchorB,
    strength,
    length,
    relativeAngle
  });
},

// Handle joints
hj = () => {

  var baumgarte = 0.4,  // rigidity (0.1–0.4)
  softness  = 0.01,     // stabilization
  j, sA, B, Apos, Bpos, ra, rb, dpos, dist, normal, raCrossN, rbCrossN, invMassSum, err, angMass, totalInvMass;
  
  for(j of J){

    sA = j.rigiA;
    B = j.rigiB;

    // world anchors
    Apos = sA.anchorPoints[j.anchorA];
    Bpos = B.anchorPoints[j.anchorB];

    // relative vectors
    ra = s(Apos, sA.center);
    rb = s(Bpos, B.center);

    // separation
    dpos = s(Bpos, Apos);
    dist = l(dpos);
    normal = dist > 0 ? n(dpos) : V(1,0);
    
    // effective mass
    raCrossN = C(ra, normal);
    rbCrossN = C(rb, normal);
    invMassSum = sA.invMass + B.invMass + raCrossN * raCrossN * sA.invInertia + rbCrossN * rbCrossN * B.invInertia + softness;

    if(invMassSum !== 0){

      // force joint
      if(j.type === "force"){
        ai(sA, B, (-(j.strength * dist) / 10000), normal, raCrossN, rbCrossN);
      }

      // spring joint
      else if(j.type === "spring"){
        ai(sA, B, ((-(j.strength * (dist - j.length)) / 1000) / invMassSum), normal, raCrossN, rbCrossN);
      }

      // repulsion joint
      else if(j.type === "repulse"){
        ai(sA, B, ((j.strength * Math.max(0, j.length - dist)) / 10000), normal, raCrossN, rbCrossN);
      }

      // hinge / fixed
      else {

        // linear impulses
        ai(sA, B, (-(d(s(A(B.velocity, sc(B, rb)), A(sA.velocity, sc(sA, ra))), normal)) + (-(baumgarte * dist))) / invMassSum, normal, raCrossN, rbCrossN)

        // angular correction (fixed only)
        if(j.type === "fixed"){
          err = (B.angle - sA.angle) - (j.relativeAngle);
          angMass = sA.invInertia + B.invInertia;
          if(angMass > 0){
            ai(sA, B, (-(B.angularVelocity - sA.angularVelocity) + (-(baumgarte * err)) ) / angMass, 0, 1, 1, 0);
          }
        }

        // position correction (anchors superposed)
        if(dist > 0){
          totalInvMass = sA.invMass + B.invMass;
          if(sA.invMass !== 0) ts(sA, S(dpos, (sA.invMass / totalInvMass)));
          if(B.invMass !== 0) ts(B, S(dpos, -(B.invMass / totalInvMass)));
        }
      }
    }
  }
},

// Demo

// Grid
shape("rectangle", V(400, 20), 780, 20, 0, .5, .5);
shape("rectangle", V(400, 300), 780, 20, 0, .5, .5);
shape("rectangle", V(400, 580), 780, 20, 0, .5, .5);
shape("rectangle", V(20, 300), 20, 580, 0, .5, .5);
shape("rectangle", V(280, 300), 20, 580, 0, .5, .5);
shape("rectangle", V(540, 300), 20, 580, 0, .5, .5);
shape("rectangle", V(780, 300), 20, 580, 0, .5, .5);

// Stress test
for(i=35;i--;){
  shape("circle", V(65 - i/2 + (i % 8) * 25, 55+ (i / 8) * 40), 10, 15, 1, .5, .5);
  shape("rectangle", V(50 + i/2 + (i % 8) * 25, 35+ (i / 8) * 40), 20, 15, 1, .5, .5, );
}

// Force joint
r1 = shape("rectangle", V(400, 80), 90, 50, 0, .5, .5);
a1 = anchor(V(20,15), r1);
c1 = shape("circle", V(450, 250), 30, 30, .5);
a2 = anchor(V(-20,10), c1);
j1 = joint("force", r1, a1, c1, a2, .95);

// Spring joint
r1 = shape("rectangle", V(630, 80), 90, 50, 0, .5, .5);
a1 = anchor(V(20,15), r1);
c1 = shape("circle", V(590, 250), 30, 30, 1);
a2 = anchor(V(0,-10), c1);
j2 = joint("spring", r1, a1, c1, a2, .2, 80);

// repulse force joint
for(i=10;i--;){
  shape("circle", V(180+Math.random() * 30, 520+Math.random() * 20), Math.random()*5+5, 10, 10, .5, .5);
}
r1 = shape("rectangle", V(100, 520), 90, 50, 0, .5, .5);
a1 = anchor(V(0,-25), r1);
c1 = shape("circle", V(120, 480), 30, 0, 1);
a2 = anchor(V(-20,0), c1);
j1 =joint("repulse", r1, a1, c1, a2, .2, 200);

// Fixed joint
for(i=20;i--;){
  shape("circle", V(300+Math.random() * (780/3 - 50), 520+Math.random() * 20), Math.random()*5+5, 10,10, .5, .5);
}
r1 = shape("rectangle", V(400, 420), 90, 50, 10, .5, .5);
a1 = anchor(V(0,-15), r1);
c1 = shape("circle", V(400, 400), 30, 30, 10);
a2 = anchor(V(0,20), c1);
j4 = joint("fixed", r1, a1, c1, a2);

// Hinge joint
r1 = shape("rectangle", V(630, 460), 90, 50, 0, .5, .5);
a1 = anchor(V(20,15), r1);
c1 = shape("circle", V(665, 465), 30, 30, 100);
a2 = anchor(V(-20,10), c1);
j4 = joint("hinge", r1, a1, c1, a2);

With this rewrite (made with a lot of trial and error), all the joints behave correctly: no wobble, no shaking, no exaggerated collisions with the other shapes in the scene!

In terms of size, we are also very good: 7609b minified, 2734b minified !

Here's a new demo testing every possible interaction between joints, mobile and immobile shapes, and multi-jointed shapes:

// Grid
shape("rectangle", V(400, 20), 780, 20, 0, .5, .5);
shape("rectangle", V(280, 300), 500, 20, 0, .5, .5);
shape("rectangle", V(400, 580), 780, 20, 0, .5, .5);
shape("rectangle", V(20, 300), 20, 580, 0, .5, .5);
shape("rectangle", V(280, 300), 20, 580, 0, .5, .5);
shape("rectangle", V(540, 300), 20, 580, 0, .5, .5);
shape("rectangle", V(780, 300), 20, 580, 0, .5, .5);

// Hinge
s1 = shape("rectangle", V(140, 90), 90, 50, 100, .5, .5);
a1 = anchor(V(20,15), s1);
s2c = shape("circle", V(135, 90), 30, 30, 0);
a2 = anchor(V(-20,10), s2c);
j1 = joint("hinge", s1, a1, s2c, a2);

s1 = shape("rectangle", V(150, 270), 20, 50, 0, .5, .5);
a1 = anchor(V(0,-20), s1);
s2 = shape("rectangle", V(150, 240), 160, 20, 100, .5, .5);
a2 = anchor(V(0,0), s2);
j1 = joint("hinge", s1, a1, s2, a2);

s1 = shape("circle", V(170, 150), 20, 20, 10, .5, .5);
a1 = anchor(V(0,-15), s1);
s2 = shape("circle", V(165, 140), 10, 10, 10, .5, .5);
a2 = anchor(V(10,0), s2);
j1 = joint("hinge", s1, a1, s2, a2);

// Multi hinge
s1b = shape("rectangle", V(410, 70), 200, 30, 0, .5, .5);
a1a = anchor(V(-60,10), s1b);
a1b = anchor(V(70,10), s1b);
s2 = shape("circle", V(390, 90), 30, 30, 10, .5, .5);
a2a = anchor(V(20,-20),s2);
a2b = anchor(V(-20,20),s2);
s3 = shape("rectangle", V(450, 110), 30, 60, 10, .5, .5);
a3a = anchor(V(10,-20),s3);
a3b = anchor(V(-10,20),s3);
j1 = joint("hinge", s1b, a1a, s2, a2a);
j2 = joint("hinge", s1b, a1b, s3, a3a);
s6 = shape("circle", V(400, 200), 50, 50, 10, .5, .5);
s4 = shape("rectangle", V(370, 150), 30, 100, 10, .5, .5);
a4a = anchor(V(0,-40),s4);
a4b = anchor(V(0,40),s4);
s5 = shape("rectangle", V(470, 150), 30, 100, 10, .5, .5);
a5a = anchor(V(0,-40),s5);
a5b = anchor(V(0,40),s5);
j3 = joint("hinge", s2, a2b, s4, a4a);
j4 = joint("hinge", s3, a3b, s5, a5a);
a6a = anchor(V(-40,-0),s6);
a6b = anchor(V(40,0),s6);
j5 = joint("hinge", s6, a6a, s4, a4b);
j6 = joint("hinge", s6, a6b, s5, a5b);

// Fixed
s1 = shape("rectangle", V(140, 90+280), 90, 50, 100, .5, .5);
a1 = anchor(V(20,15), s1);
s2d = shape("circle", V(135, 90+280), 30, 30, 0);
a2 = anchor(V(-20,10), s2d);
j1 = joint("fixed", s1, a1, s2d, a2);

s1 = shape("rectangle", V(150, 270+280), 20, 50, 0, .5, .5);
a1 = anchor(V(0,-20), s1);
s2 = shape("rectangle", V(150, 240+280), 160, 20, 100, .5, .5);
a2 = anchor(V(0,0), s2);
j1 = joint("fixed", s1, a1, s2, a2);

s1 = shape("circle", V(170, 150+280), 20, 20, 10, .5, .5);
a1 = anchor(V(0,-15), s1);
s2 = shape("circle", V(165, 140+280), 10, 10, 10, .5, .5);
a2 = anchor(V(10,0), s2);
j1 = joint("fixed", s1, a1, s2, a2);

// Multi fixed
s1a = shape("rectangle", V(410, 70+280), 200, 30, 0, .5, .5);
a1a = anchor(V(-60,10), s1a);
a1b = anchor(V(70,10), s1a);
s2 = shape("circle", V(390, 90+280), 30, 30, 10, .5, .5);
a2a = anchor(V(20,-20),s2);
a2b = anchor(V(-20,20),s2);
s3 = shape("rectangle", V(450, 110+280), 30, 60, 10, .5, .5);
a3a = anchor(V(10,-20),s3);
a3b = anchor(V(-10,20),s3);
j1 = joint("fixed", s1a, a1a, s2, a2a);
j2 = joint("fixed", s1a, a1b, s3, a3a);
s6 = shape("circle", V(400, 200+280), 50, 50, 10, .5, .5);
s4 = shape("rectangle", V(370, 150+280), 30, 100, 10, .5, .5);
a4a = anchor(V(0,-40),s4);
a4b = anchor(V(0,40),s4);
s5 = shape("rectangle", V(470, 150+280), 30, 100, 10, .5, .5);
a5a = anchor(V(0,-40),s5);
a5b = anchor(V(0,40),s5);
j3 = joint("fixed", s2, a2b, s4, a4a);
j4 = joint("fixed", s3, a3b, s5, a5a);
a6a = anchor(V(-40,-0),s6);
a6b = anchor(V(40,0),s6);
j5 = joint("fixed", s6, a6a, s4, a4b);
j6 = joint("fixed", s6, a6b, s5, a5b);

// Hinge + fixed
s1p = shape("rectangle", V(650, 70), 100, 30, 0, .5, .5);
a1 = anchor(V(0,15), s1p);
s2 = shape("circle", V(650, 90), 30, 30, 10, .5, .5);
s3 = shape("rectangle", V(650, 110), 50, 50, 10, .5, .5);
s4 = shape("rectangle", V(590, 180), 100, 50, 10, .5, .5);
s5 = shape("circle", V(650, 270), 30, 30, 10, .5, .5);
a2a = anchor(V(20,-20),s2);
a2b = anchor(V(0,20),s2);
a3a = anchor(V(20,-20),s3);
a3b = anchor(V(20,20),s3);
a4a = anchor(V(40,-20),s4);
a4b = anchor(V(-40,20),s4);
a5a = anchor(V(-20,-20),s5);
j1 = joint("hinge", s1p, a1, s2, a2a);
j2 = joint("fixed", s2, a2b, s3, a3a);
j3 = joint("hinge", s3, a3b, s4, a4a);
j4 = joint("fixed", s4, a4b, s5, a5a);

Good enough!



GOLFING MY ENGINE


Phase 2: final obfuscation and API



Now that the engine works as I expected, I can finish golfing it by merging functions, removing useless code, renaming long variables & attributes, and adding / optimizing a ton of tiny things.

Functions merged in run(): N (Vec2 normal), si (shape integration), rr (rectangle-rectangle collision detection), cp (contact point) fs (find a support point), cr (circle vs rectangle), cre (circle vs rectangle edges), crc (circle vs rectangle corners), rc (resolve collision), dp (draw point), dl (draw line), hj (handle joints), in (integrate)...

Stuff put in the global scope: G (gravity), O, E, R (stability parameters), H, J, M (shapes, joints, manifolds)...

Stuff removed: normalscale (in circle-rectangle detection), force joints (just a special case of spring joint), ...

Stuff renamed / modified:
- null → 0
- a === 1 → a, a !== 0 → a, ...
- the API (shape(), anchor(), joint(), run()).
- No global leak for the other methods (IIFE).
- shapes types and joints types are now number constants
- all attributes in shape, joint, manifold, contact point are now one letter long.
- Multi-purpose variables in run()
- Better for-oops everywhere

Features added: shapes initial angle, velocity, angularVelocity, custom gravity, custom color (or any other data)

Minified size: 1448 bytes gzipped (1152b without joints)

//  API
// -----

// - shape(type, center, mass, width, height, {f: friction, r:restitution, a: angle, v: velocity, a: angularVelocity, g: gravity, d: customData)
// - anchor(shape, position)
// - joint(type, shape1, anchor1, shape2, anchor2, strength, length)
// - run(): run simulation

//  Globals
// ---------

// Constants
G = [0,.1];     // Gravity
O = .4;         // Shapes overlap correction
E = .01;        // Fixed joints elasticity
R = 20;         // Resolutions per frame

// Arrays
H = [];    // shapes (circles, rectangles)
J = [];    // joints (spring, repulsive, hinge, fixed)
M = [];    // manifolds (collision info)

// Shapes types
RECTANGLE = 0;
CIRCLE = 1;

// Joints types
SPRING = 0;
REPULSIVE = 1;
HINGE = 2;
FIXED = 3;

((n=x=>S(x,1/l(x)),l=x=>d(x,x)**.5,d=(x,y)=>x[0]*y[0]+x[1]*y[1],p=(x,y)=>[x[0]+y[0],x[1]+y[1]],s=(x,y)=>p(x,S(y,-1)),S=(x,y)=>[x[0]*y,x[1]*y],C=(x,y)=>x[0]*y[1]-x[1]*y[0],r=(x,y,l)=>[(x[0]-y[0])*Math.cos(l)-(x[1]-y[1])*Math.sin(l)+y[0],(x[0]-y[0])*Math.sin(l)+(x[1]-y[1])*Math.cos(l)+y[1]],x=(x,y)=>[-x.A*y[1],x.A*y[0]],X=(c,f,n,i=4)=>{if(c.c=p(c.c,f),!c.t)for(;i--;)c.n[i]=r(c.n[i],[0,0],n),c.V[i]=r(p(c.V[i],f),c.c,n);for(i in c.p)c.p[i]=r(p(c.p[i],f),c.c,n)},I=(c,t,l,e,a,n,r=1,o)=>{r&&(o=S(e,l),c.v=s(c.v,S(o,c.M)),t.v=p(t.v,S(o,t.M))),c.A-=a*l*c.I,t.A+=n*l*t.I})=>{shape=(t,c,m=1,w=9,h=9,g,n=t?m*w*w/2:m*(w*w+h*h)/12,s={t,m,w,h,c:[0,0],f:.5,r:.5,a:0,v:[0,0],A:0,g:G,F:[0,0],T:0,p:[],N:[],n:[[0,-1],[1,0],[0,1],[-1,0]],M:m?1/m:0,I:n?1/n:0,V:[[-w/2,-h/2],[w/2,-h/2],[w/2,h/2],[-w/2,h/2]],e:H.length,...g})=>(X(s,c,s.a),H.push(s),s),anchor=(t,c)=>(t.p.push(p(c,t.c)),t.p.length-1),joint=(t,A,a,B,b,s,l)=>{if(t>1){A.N.push(B.e);B.N.push(A.e);l=B.a-A.a}J.push({t,A,a,B,b,s,l})},run=(a,f,t,c,e,h,o,r,A,v,w,i,V,b,B,m,g)=>{for(M=[],f=H.length;f--;)for(a=f-1;a>=0;a--)if(c=H[f],e=H[a],c.F=S(c.g,c.m),c.M+e.M&&!c.N.includes(a)){if(h=0,c.t&&e.t)o=s(e.c,c.c),(r=l(o))=0&&w>0&&wA&&(A=w,v={v:e.V[i],d:w});v||(h=0),h&&v.d0&&(b=C(V,w=n(w)),B=C(i,w),m=Math.min(c.f,e.f),g=-d(h,w)/(c.M+e.M+b*b*c.I+B*B*e.I),Math.abs(g)>Math.abs(v)*m&&(g=Math.sign(g)*Math.abs(v)*m),I(c,e,g,w,b,B));for(t of J)c=t.A,e=t.B,h=c.p[t.a],o=e.p[t.b],v=s(h,c.c),w=s(o,e.c),A=s(o,h),i=(r=l(A))>0?n(A):[1,0],V=C(v,i),b=C(w,i),(B=c.M+e.M+V*V*c.I+b*b*e.I+E)&&(t.t?t.t<2?I(c,e,t.s*Math.max(0,t.l-r)/1e4,i,V,b):(I(c,e,(-d(s(p(e.v,x(e,w)),p(c.v,x(c,v))),i)+-O*r)/B,i,V,b),t.t>2&&(m=e.a-c.a-t.l,(g=c.I+e.I)&&I(c,e,(-(e.A-c.A)+-O*m)/g,0,1,1,0)),r>0&&(B=c.M+e.M,X(c,S(A,c.M/B),0),X(e,S(A,-e.M/B),0))):(I(c,e,-t.s*(r-t.l)/1e3/B,i,V,b),I(c,e,-t.s*(r-t.l)/1e3/B,i,V,b)))}for(f of H)f.M&&(f.v=p(f.v,S(f.F,f.M<0?-f.M:f.M)),f.F=[0,0],f.A+=f.T*f.I,f.a+=f.A,X(f,f.v,f.A),f.v=S(f.v,.99),Math.abs(l(f.v))<1e-4&&(f.v=[0,0]),f.A*=.99,Math.abs(f.A)<1e-4&&(f.A=0),f.T=0)}})()

Here's my updated demo after 2 weeks of golfing (and 6 weeks of work in total):