Interactive Newton's Cradle

A step-by-step tutorial on simulating perfect momentum transfer using Lua and the LÖVE framework.

Screen recording of the program:

Introduction

Physics engines are usually built to handle soft bodies or standard rigid collisions. A Newton's Cradle is a unique challenge because it requires perfectly elastic collisions and simultaneous momentum transfer across multiple objects.

In this tutorial, we will use LÖVE's Box2D wrapper (love.physics) to trick the engine into creating a flawless Newton's Cradle.

Download the complete code here: main.lua or follow the steps below.

Step 1: The Physics Setup

First, we need to initialize our physics world. Box2D uses the MKS (meters, kilograms, and seconds) system, so we must define a meter scale to map pixels to physics units.

local world
local balls = {}
local joints = {}
local anchor
local mouseJoint = nil

-- Configuration
local numBalls = 5
local ballRadius = 25
local stringLength = 250
local screenWidth = 800
local startY = 150

function love.load()
    love.window.setTitle("Interactive Newton's Cradle")
    love.window.setMode(screenWidth, 600)
    
    -- 64 pixels equals 1 meter in our world
    love.physics.setMeter(64)
    world = love.physics.newWorld(0, 9.81 * 64, true)
    
    -- Static ceiling anchor
    anchor = love.physics.newBody(world, screenWidth / 2, startY, "static")
    
    setupBalls()
end

Step 2: Creating the Pendulums

We generate the balls dynamically in a loop. To make them act like a Newton's cradle, we need three crucial physics properties:

function setupBalls()
    local totalWidth = numBalls * (ballRadius * 2)
    local startX = (screenWidth / 2) - (totalWidth / 2) + ballRadius

    for i = 1, numBalls do
        local bx = startX + (i - 1) * (ballRadius * 2)
        local by = startY + stringLength

        local body = love.physics.newBody(world, bx, by, "dynamic")
        body:setBullet(true) -- Prevents tunneling
        
        local shape = love.physics.newCircleShape(ballRadius)
        local fixture = love.physics.newFixture(body, shape, 1)

        -- Perfect elasticity and zero friction
        fixture:setRestitution(1.0)
        fixture:setFriction(0)

        local joint = love.physics.newDistanceJoint(anchor, body, bx, startY, bx, by)

        table.insert(balls, {body = body, shape = shape, fixture = fixture, startX = bx})
        table.insert(joints, joint)
    end
end
💡 Pro Tip: Without body:setBullet(true), the engine might process a high-speed collision one frame too late, causing the balls to overlap and the simulation to "mush" together instead of clicking cleanly.

Step 3: Adding Interaction

We want to be able to pick up the balls with our mouse. We use a MouseJoint, which pulls the physical body toward the cursor using applied force rather than teleporting it, keeping the physics stable.

function love.update(dt)
    world:update(dt)
    if mouseJoint then
        mouseJoint:setTarget(love.mouse.getPosition())
    end
end

function love.mousepressed(x, y, button)
    if button == 1 then
        for _, ball in ipairs(balls) do
            if ball.fixture:testPoint(x, y) then
                mouseJoint = love.physics.newMouseJoint(ball.body, x, y)
                mouseJoint:setMaxForce(5000 * ball.body:getMass())
                break
            end
        end
    end
end

function love.mousereleased(x, y, button)
    if button == 1 and mouseJoint then
        mouseJoint:destroy()
        mouseJoint = nil
    end
end

Step 4: Drawing to the Screen

Finally, we pull the physics data (positions) and render our visual graphics on top of them.

function love.draw()
    love.graphics.setBackgroundColor(0.1, 0.1, 0.15)

    -- Draw Ceiling
    love.graphics.setLineWidth(4)
    love.graphics.setColor(0.8, 0.8, 0.8)
    local frameStartX = balls[1].startX - ballRadius
    local frameEndX = balls[numBalls].startX + ballRadius
    love.graphics.line(frameStartX, startY, frameEndX, startY)

    -- Draw Strings and Balls
    for i = 1, numBalls do
        local ball = balls[i]
        local bx, by = ball.body:getPosition()
        local ax = ball.startX 

        -- String
        love.graphics.setLineWidth(2)
        love.graphics.setColor(0.5, 0.5, 0.5)
        love.graphics.line(ax, startY, bx, by)

        -- Ball
        love.graphics.setColor(0.2, 0.6, 1)
        love.graphics.circle("fill", bx, by, ballRadius)
        love.graphics.setLineWidth(2)
        love.graphics.setColor(0.9, 0.9, 0.9)
        love.graphics.circle("line", bx, by, ballRadius)
    end
    
    love.graphics.setColor(1, 1, 1)
    love.graphics.print("Click and drag a ball to start the simulation.", 10, 10)
end

More Love2d tutorials:

Fractal graphics, click to zoom

Animated plasma

Starfield flythrough