Interactive Lorenz Attractor in LÖVE2D

Screen recording of the program:


1. What is the Lorenz Attractor?

The Lorenz system is a set of three differential equations:


dx/dt = σ (y − x)
dy/dt = x (ρ − z) − y
dz/dt = x y − β z

With the classic parameters σ = 10, ρ = 28, β = 8/3, the system becomes chaotic. Small differences in initial conditions lead to wildly different trajectories.


2. Project Setup

Create a folder for your LÖVE project:


lorenz/
    main.lua

Then run it with:


love lorenz

3. Implementing the Lorenz System

We start by defining the Lorenz equations and an RK4 integrator. RK4 gives smooth, stable motion.

-- Lorenz equations
local function lorenz(x, y, z, sigma, rho, beta)
    local dx = sigma * (y - x)
    local dy = x * (rho - z) - y
    local dz = x * y - beta * z
    return dx, dy, dz
end

-- RK4 integration step
local function rk4_step(x, y, z, dt, sigma, rho, beta)
    local k1x, k1y, k1z = lorenz(x, y, z, sigma, rho, beta)
    local x2 = x + 0.5 * dt * k1x
    local y2 = y + 0.5 * dt * k1y
    local z2 = z + 0.5 * dt * k1z

    local k2x, k2y, k2z = lorenz(x2, y2, z2, sigma, rho, beta)
    local x3 = x + 0.5 * dt * k2x
    local y3 = y + 0.5 * dt * k2y
    local z3 = z + 0.5 * dt * k2z

    local k3x, k3y, k3z = lorenz(x3, y3, z3, sigma, rho, beta)
    local x4 = x + dt * k3x
    local y4 = y + dt * k3y
    local z4 = z + dt * k3z

    local k4x, k4y, k4z = lorenz(x4, y4, z4, sigma, rho, beta)

    local nx = x + (dt/6) * (k1x + 2*k2x + 2*k3x + k4x)
    local ny = y + (dt/6) * (k1y + 2*k2y + 2*k3y + k4y)
    local nz = z + (dt/6) * (k1z + 2*k2z + 2*k3z + k4z)

    return nx, ny, nz
end

4. Storing the State

We keep parameters, the current point, the trail, and camera controls in a single table:


local state = {
    sigma = 10, rho = 28, beta = 8/3,
    dt = 0.01,
    x = 0.1, y = 0, z = 0,
    trail = {},
    maxTrail = 2000,
    zoom = 8,
    rotX = 0,
    rotY = 0,
}

5. Updating the System

Each frame, we integrate several small steps for smoothness:


function love.update(dt)
    for i = 1, 4 do
        state.x, state.y, state.z =
            rk4_step(state.x, state.y, state.z,
                     state.dt, state.sigma, state.rho, state.beta)

        table.insert(state.trail, 1, {x=state.x, y=state.y, z=state.z})
        if #state.trail > state.maxTrail then
            state.trail[#state.trail] = nil
        end
    end
end

6. Projecting 3D to 2D

We rotate the 3D point around X and Y, then drop the Z coordinate:


local function project(px, py, pz)
    local cx, sx = math.cos(state.rotX), math.sin(state.rotX)
    local cy, sy = math.cos(state.rotY), math.sin(state.rotY)

    local ry = py * cx - pz * sx
    local rz = py * sx + pz * cx

    local rx = px * cy + rz * sy

    return rx * state.zoom, ry * state.zoom
end

7. Drawing the Trail

We draw line segments between consecutive points:


function love.draw()
    local w, h = love.graphics.getDimensions()
    love.graphics.push()
    love.graphics.translate(w/2, h/2)

    for i = 1, #state.trail - 1 do
        local p1 = state.trail[i]
        local p2 = state.trail[i+1]
        local x1, y1 = project(p1.x, p1.y, p1.z)
        local x2, y2 = project(p2.x, p2.y, p2.z)
        love.graphics.setColor(0.2 + i/#state.trail, 0.6, 1 - i/#state.trail)
        love.graphics.line(x1, y1, x2, y2)
    end

    love.graphics.pop()
end

8. Adding Interactivity

Mouse rotation


function love.mousemoved(x, y, dx, dy)
    if love.mouse.isDown(1) then
        state.rotY = state.rotY + dx * 0.005
        state.rotX = state.rotX + dy * 0.005
    end
end

Zoom


function love.wheelmoved(_, y)
    if y > 0 then state.zoom = state.zoom * 1.1
    else state.zoom = state.zoom / 1.1 end
end

Keyboard controls


function love.keypressed(key)
    if key == "up" then state.dt = state.dt * 1.2 end
    if key == "down" then state.dt = state.dt / 1.2 end
    if key == "r" then state.trail = {} end
end

9. Full main.lua

Here is the complete program assembled into one file:


-- Interactive Lorenz attractor for LÖVE2D (0.10+)
-- Drop into a folder and run `love .`

local state = {
    -- Lorenz parameters (classic chaotic values)
    sigma = 10.0,
    rho   = 28.0,
    beta  = 8.0/3.0,
    -- integration
    dt = 0.01,
    paused = false,
    -- point and trail
    x = 0.1, y = 0.0, z = 0.0,
    trail = {},
    maxTrail = 2000,
    drawTrails = true,
    -- view transform
    zoom = 8.0,
    rotX = 0.0,
    rotY = 0.0,
    dragging = false,
    lastMouseX = 0,
    lastMouseY = 0,
    -- color
    hue = 200,
    -- performance
    stepsPerFrame = 4
}

local function lorenz(x, y, z, sigma, rho, beta)
    local dx = sigma * (y - x)
    local dy = x * (rho - z) - y
    local dz = x * y - beta * z
    return dx, dy, dz
end

-- RK4 integrator for system
local function rk4_step(x, y, z, dt, sigma, rho, beta)
    local k1x, k1y, k1z = lorenz(x, y, z, sigma, rho, beta)
    local x2 = x + 0.5 * dt * k1x
    local y2 = y + 0.5 * dt * k1y
    local z2 = z + 0.5 * dt * k1z

    local k2x, k2y, k2z = lorenz(x2, y2, z2, sigma, rho, beta)
    local x3 = x + 0.5 * dt * k2x
    local y3 = y + 0.5 * dt * k2y
    local z3 = z + 0.5 * dt * k2z

    local k3x, k3y, k3z = lorenz(x3, y3, z3, sigma, rho, beta)
    local x4 = x + dt * k3x
    local y4 = y + dt * k3y
    local z4 = z + dt * k3z

    local k4x, k4y, k4z = lorenz(x4, y4, z4, sigma, rho, beta)

    local nx = x + (dt/6) * (k1x + 2*k2x + 2*k3x + k4x)
    local ny = y + (dt/6) * (k1y + 2*k2y + 2*k3y + k4y)
    local nz = z + (dt/6) * (k1z + 2*k2z + 2*k3z + k4z)

    return nx, ny, nz
end

local function pushTrail(s)
    table.insert(state.trail, 1, {x = s.x, y = s.y, z = s.z})
    if #state.trail > state.maxTrail then
        for i = state.maxTrail + 1, #state.trail do state.trail[i] = nil end
    end
end

local function resetState()
    state.x = 0.1 + (math.random() - 0.5) * 0.2
    state.y = 0.0 + (math.random() - 0.5) * 0.2
    state.z = 0.0 + (math.random() - 0.5) * 0.2
    state.trail = {}
end

function love.load()
    love.window.setTitle("Lorenz Attractor — Interactive (LÖVE)")
    love.graphics.setBackgroundColor(0.02, 0.02, 0.02)
    math.randomseed(os.time())
    resetState()
    love.graphics.setLineWidth(1)
end

function love.update(dt)
    -- allow multiple small steps per frame for stability
    if not state.paused then
        local steps = state.stepsPerFrame
        local stepDt = state.dt
        for i = 1, steps do
            state.x, state.y, state.z = rk4_step(state.x, state.y, state.z, stepDt, state.sigma, state.rho, state.beta)
            pushTrail(state)
        end
    end
end

-- simple 3D -> 2D projection with rotation
local function project(px, py, pz)
    -- rotate around X then Y
    local cx = math.cos(state.rotX)
    local sx = math.sin(state.rotX)
    local cy = math.cos(state.rotY)
    local sy = math.sin(state.rotY)

    -- rotate X
    local ry = py * cx - pz * sx
    local rz = py * sx + pz * cx
    -- rotate Y
    local rx = px * cy + rz * sy
    local rz2 = -px * sy + rz * cy

    -- perspective-ish scale (optional)
    local scale = state.zoom
    local sx2 = rx * scale
    local sy2 = ry * scale

    return sx2, sy2, rz2
end

function love.draw()
    local w, h = love.graphics.getDimensions()
    love.graphics.push()
    love.graphics.translate(w/2, h/2)

    -- draw trail
    if state.drawTrails and #state.trail > 1 then
        for i = 1, #state.trail - 1 do
            local a = 1.0 - (i / #state.trail) -- fade
            local p1 = state.trail[i]
            local p2 = state.trail[i+1]
            local x1, y1 = project(p1.x, p1.y, p1.z)
            local x2, y2 = project(p2.x, p2.y, p2.z)
            love.graphics.setColor(0.2 + 0.8 * (i/#state.trail), 0.6, 1.0 - 0.6*(i/#state.trail), a)
            love.graphics.line(x1, y1, x2, y2)
        end
    end

    -- draw current point
    local cx, cy = project(state.x, state.y, state.z)
    love.graphics.setColor(1, 0.9, 0.2)
    love.graphics.circle("fill", cx, cy, 3)

    love.graphics.pop()

    -- HUD
    love.graphics.setColor(1,1,1,0.9)
    love.graphics.print(string.format("sigma: %.3f  rho: %.3f  beta: %.4f", state.sigma, state.rho, state.beta), 10, 10)
    love.graphics.print(string.format("dt: %.4f  steps/frame: %d  trail: %d  paused: %s", state.dt, state.stepsPerFrame, state.maxTrail, tostring(state.paused)), 10, 30)
    love.graphics.print("Controls: Space pause | R reset | T toggle trails | Arrows dt/trail | W/S/A/D tweak rho/sigma/beta", 10, 50)
    love.graphics.print("Mouse drag to rotate, scroll to zoom", 10, 70)
    love.graphics.setColor(1,1,1,0.6)
    love.graphics.print("FPS: "..tostring(love.timer.getFPS()), 10, love.graphics.getHeight() - 20)
end

function love.keypressed(key)
    if key == "space" then
        state.paused = not state.paused
    elseif key == "r" then
        resetState()
    elseif key == "t" then
        state.drawTrails = not state.drawTrails
    elseif key == "up" then
        state.dt = math.min(0.1, state.dt * 1.2)
    elseif key == "down" then
        state.dt = math.max(1e-5, state.dt / 1.2)
    elseif key == "right" then
        state.maxTrail = math.min(20000, state.maxTrail + 200)
    elseif key == "left" then
        state.maxTrail = math.max(10, state.maxTrail - 200)
    elseif key == "w" then
        state.rho = state.rho + 0.5
    elseif key == "s" then
        state.rho = math.max(0, state.rho - 0.5)
    elseif key == "a" then
        state.sigma = math.max(0, state.sigma - 0.5)
    elseif key == "d" then
        state.sigma = state.sigma + 0.5
    elseif key == "q" then
        state.beta = math.max(0, state.beta - 0.05)
    elseif key == "e" then
        state.beta = state.beta + 0.05
    end
end

function love.mousepressed(x, y, button)
    if button == 1 then
        state.dragging = true
        state.lastMouseX = x
        state.lastMouseY = y
    end
end

function love.mousereleased(x, y, button)
    if button == 1 then
        state.dragging = false
    end
end

function love.mousemoved(x, y, dx, dy)
    if state.dragging then
        state.rotY = state.rotY + dx * 0.005
        state.rotX = state.rotX + dy * 0.005
    end
end

function love.wheelmoved(x, y)
    if y > 0 then
        state.zoom = state.zoom * 1.1
    elseif y < 0 then
        state.zoom = state.zoom / 1.1
    end
end

More Lua tutorials