Building a Newton's Cradle

A step-by-step guide using Rust and Macroquad

In this tutorial, we will build an interactive physics simulation of a Newton's Cradle. We will cover rigid body constraints (the strings) and elastic collisions (the energy transfer).

Screen recording of the program:

1. Project Setup

First, ensure you have Rust installed. Create a new project and add the dependencies.

# Terminal
cargo new newtons_cradle
cd newtons_cradle

Open Cargo.toml and add Macroquad, a simple and fast game framework for Rust.

[dependencies]
macroquad = "0.4"

Download the full code here: main.rs or follow the steps below.

2. Defining the Physics Object

We need a struct to represent each ball. A pendulum consists of an anchor (where the string is tied), a position (where the ball is), and velocity.

use macroquad::prelude::*;

const BALL_RADIUS: f32 = 30.0;
const STRING_LENGTH: f32 = 300.0;

struct Pendulum {
    anchor: Vec2,
    pos: Vec2,
    vel: Vec2,
    is_dragged: bool, // Is the user holding this ball?
}

impl Pendulum {
    fn new(x: f32, y: f32) -> Self {
        Self {
            anchor: vec2(x, y),
            pos: vec2(x, y + STRING_LENGTH),
            vel: Vec2::ZERO,
            is_dragged: false,
        }
    }
}

3. Implementing Physics: The String Constraint

Normally, pendulums require trigonometry. However, in game physics, it's often easier to use Vector constraints.

  1. Apply Gravity to velocity.
  2. Move the position based on velocity.
  3. The Constraint: If the ball moves further away from the anchor than the string allows, pull it back instantly.
impl Pendulum {
    fn update(&mut self, dt: f32) {
        if self.is_dragged { 
            self.vel = Vec2::ZERO; 
            return; 
        }

        // 1. Apply Gravity
        self.vel.y += 900.0 * dt;
        self.vel *= 0.995; // Air resistance (Damping)
        self.pos += self.vel * dt;

        // 2. Enforce String Length
        let to_ball = self.pos - self.anchor;
        let dist = to_ball.length();

        if dist > 0.0 {
            // Reset position to exactly string_length away
            let dir = to_ball / dist;
            self.pos = self.anchor + dir * STRING_LENGTH;

            // Remove velocity that pulls away from the string (Tension)
            let vel_parallel = self.vel.dot(dir);
            if vel_parallel > 0.0 {
                self.vel -= dir * vel_parallel;
            }
        }
    }
}

4. The Secret Sauce: Elastic Collisions

A Newton's Cradle works because the collisions are nearly perfectly elastic and the masses are identical. In physics, when two objects of equal mass collide elastically, they simply exchange velocity along the normal vector of the collision.

Why loop multiple times? Because the balls are touching, a collision at the start of the chain needs to propagate to the end of the chain in a single frame. We run the collision solver 4 times per frame to allow this shockwave to travel.
fn resolve_collisions(balls: &mut [Pendulum]) {
    for i in 0..balls.len() {
        if i + 1 >= balls.len() { break; }

        // Get mutable references to two adjacent balls
        let (left, right) = balls.split_at_mut(i + 1);
        let b1 = &mut left[i];
        let b2 = &mut right[0];

        let delta = b2.pos - b1.pos;
        let dist = delta.length();
        let min_dist = BALL_RADIUS * 2.0;

        if dist < min_dist {
            let n = delta / dist; // Collision Normal

            // 1. Push them apart so they don't overlap
            let push = n * (min_dist - dist) * 0.5;
            if !b1.is_dragged { b1.pos -= push; }
            if !b2.is_dragged { b2.pos += push; }

            // 2. Velocity Exchange (Elastic Collision Logic)
            let v1_n = b1.vel.dot(n);
            let v2_n = b2.vel.dot(n);

            // Only swap if moving towards each other
            if v1_n - v2_n > 0.0 {
                let v1_t = b1.vel - n * v1_n; // Tangent component
                let v2_t = b2.vel - n * v2_n;

                // Swap normal components!
                b1.vel = v1_t + n * v2_n;
                b2.vel = v2_t + n * v1_n;
            }
        }
    }
}

5. The Main Loop

Finally, we wire everything together in the main function.

#[macroquad::main("Newton's Cradle")]
async fn main() {
    // Create 5 balls
    let mut balls: Vec<Pendulum> = (0..5)
        .map(|i| Pendulum::new(300.0 + i as f32 * 60.0, 100.0))
        .collect();

    loop {
        clear_background(LIGHTGRAY);

        // Handle Mouse Input (Dragging)
        let mouse_pos = Vec2::from(mouse_position());
        if is_mouse_button_pressed(MouseButton::Left) {
            for b in &mut balls {
                if b.pos.distance(mouse_pos) < BALL_RADIUS {
                    b.is_dragged = true;
                    break;
                }
            }
        }
        if is_mouse_button_released(MouseButton::Left) {
            for b in &mut balls { b.is_dragged = false; }
        }

        // Drag logic
        for b in &mut balls {
            if b.is_dragged {
                let dir = (mouse_pos - b.anchor).normalize();
                b.pos = b.anchor + dir * STRING_LENGTH;
                b.vel = Vec2::ZERO;
            }
        }

        // Physics Update
        let dt = get_frame_time();
        for b in &mut balls { b.update(dt); }
        
        // Sub-step collisions for stability
        for _ in 0..4 { resolve_collisions(&mut balls); }

        // Draw
        for b in &balls {
            draw_line(b.anchor.x, b.anchor.y, b.pos.x, b.pos.y, 2.0, BLACK);
            draw_circle(b.pos.x, b.pos.y, BALL_RADIUS, BLUE);
        }

        next_frame().await
    }
}

More Rust tutorials:

Interactive fractal graphics zoom

Animated particle constellations