Return to Home

Ball physics simulation

This is a simple physics simulation of a number of balls that are attracted to the mouse, and experience gravity.

It was made using an WIP branch from lukewilliamboswell/roc-ray which is a graphics platform for the roc programming language.

I wanted to find the simplest thing I could make to test the platform end to end, and also a new package lukewilliamboswell/roc-pga2d which I put together to learn more about Geometric Algebra and to help with developing the roc-ray platform and more interesting examples.

(Note: this uses the mouse... sorry mobile users I hadn't thought about you guys when making this 😅)

app [Model, init!, render!] {
    rr: platform "../platform/main.roc",
    rand: "https://github.com/lukewilliamboswell/roc-random/releases/download/0.3.0/hPlOciYUhWMU7BefqNzL89g84-30fTE6l2_6Y3cxIcE.tar.br",
    pga: "https://github.com/lukewilliamboswell/roc-pga2d/releases/download/0.3.0/pdeyRVVsip_FFlHVK_ybcSzKxZLspU_KyMBicijEL-c.tar.br",
}

import rr.RocRay
import rr.Draw
import rr.Keys
import rand.Random
import pga.PGA2D

Ball : {
    position : PGA2D.Multivector,
    velocity: PGA2D.Multivector,
    mass: F32,
    color: RocRay.Color,
}

# CONSTANTS
numberOfBalls = 10
minBallMass = 1
maxBallMass = 20
minSpringK = 0.001  # Too small: barely any force
maxSpringK = 1.0    # Too large: unstable/explosive behavior
minDamping = 0.0    # No damping (perpetual motion)
maxDamping = 1.0    # Full damping (immediate stop)

Model : { balls : List Ball, springK: F32,  damping: F32, gravity: F32 }

init! : {} => Result Model []
init! = \{} ->

    RocRay.initWindow! { title: "Spring Physics" }
    RocRay.setTargetFPS! 60
    RocRay.displayFPS! { fps: Visible, pos: { x: 10, y: 10 }}

    balls = generateBalls

    Ok {
        balls,
        springK: 0.02,
        damping: 0.9,
        gravity: 0.5,
    }

render! : Model, RocRay.PlatformState => Result Model []
render! = \model, { mouse, keys } ->

    Draw.draw! White \{} ->

        # Display spring stiffness and damping
        Draw.text! {
            text: "Stiffnes: $(formatF32 model.springK) - press A/D to change",
            pos: { x: 10, y: 30 },
            size: 20,
            color: Black,
        }

        Draw.text! {
            text: "Damping: $(formatF32 model.damping) - press W/S to change",
            pos: { x: 10, y: 50 },
            size: 20,
            color: Black,
        }

        Draw.text! {
            text: "Gravity: $(formatF32 model.gravity) - press UP/DOWN to change",
            pos: { x: 10, y: 70 },
            size: 20,
            color: Black,
        }

        Draw.text! {
            text: "Press R to reset the balls",
            pos: { x: 10, y: 90 },
            size: 20,
            color: Black,
        }

        forEach! model.balls \ball ->

            # Get ball position
            w = if (Num.abs ball.position.e12) < 0.000001 then 0.000001 else ball.position.e12
            ballX = ball.position.e20 / w
            ballY = ball.position.e01 / w

            # Draw spring to mouse position
            Draw.line! {
                start: { x: ballX, y: ballY },
                end: mouse.position,
                color: Gray,
            }

            # Draw ball (vary radius based on mass)
            radius = 1 + (ball.mass - minBallMass)
            Draw.circle! {
                center: { x: ballX, y: ballY },
                radius,
                color: ball.color,
            }

            {}

    balls =
        updateBalls model mouse.position
        |> \b ->
            # reset balls if space is pressed
            if Keys.pressed keys KeyR then
                generateBalls
            else
                b

    springK =
        if Keys.pressed keys KeyA then
            model.springK - 0.01
            |> limit { min: minSpringK, max: maxSpringK }
        else if Keys.pressed keys KeyD then
            model.springK + 0.01
            |> limit { min: minSpringK, max: maxSpringK }
        else
            model.springK

    damping =
        if Keys.pressed keys KeyW then
            model.damping + 0.01
            |> limit { min: minDamping, max: maxDamping }
        else if Keys.pressed keys KeyS then
            model.damping - 0.01
            |> limit { min: minDamping, max: maxDamping }
        else
            model.damping

    gravity =
        if Keys.pressed keys KeyUp then
            model.gravity + 0.1
            |> limit { min: 0, max: 10 }
        else if Keys.pressed keys KeyDown then
            model.gravity - 0.1
            |> limit { min: 0, max: 10 }
        else
            model.gravity

    Ok {model & balls, springK, damping, gravity }

updateBalls : Model, RocRay.Vector2 -> List Ball
updateBalls = \model, mousePos ->

    List.map model.balls \ball ->

        # Get ball position
        w = if (Num.abs ball.position.e12) < 0.000001 then 0.000001 else ball.position.e12
        ballX = ball.position.e20 / w
        ballY = ball.position.e01 / w

        # Calculate spring force direction as an ideal point at infinity
        springForce = PGA2D.idealPoint {
            x: (mousePos.x - ballX) * model.springK / ball.mass,
            y: -1 * (mousePos.y - ballY) * model.springK / ball.mass,
        }

        # Calculate gravity force
        gravityForce = PGA2D.idealPoint {
            x: 0,
            y: -model.gravity,
        }

        combindedForce = PGA2D.add springForce gravityForce

        # Update velocity
        velocity = PGA2D.muls ((PGA2D.add ball.velocity combindedForce)) model.damping

        # Create translator from velocity
        translator = PGA2D.translator {
            dx: -velocity.e01,
            dy: -velocity.e20,
        }

        # Update ball position using translator
        position = PGA2D.mul translator ball.position

        { position, velocity, mass: ball.mass, color: ball.color }

generateBalls : List Ball
generateBalls =
    List.range { start: At 0, end: Before numberOfBalls }
    |> List.walk { seed: Random.seed 1234, balls: [] } \state, _ ->

        gen = Random.step state.seed ballGenerator

        { seed: gen.state, balls: List.append state.balls gen.value }

    |> .balls

ballGenerator : Random.Generator Ball
ballGenerator =
    { Random.chain <-
        position: { Random.chain <-
            x: Random.boundedU32 0 800 |> Random.map Num.toF32,
            y: Random.boundedU32 0 600 |> Random.map Num.toF32,
        }
        |> Random.map PGA2D.point,
        velocity: { Random.chain <-
            x: Random.boundedU32 0 10 |> Random.map Num.toF32,
            y: Random.boundedU32 0 10 |> Random.map Num.toF32,
        }
        |> Random.map PGA2D.idealPoint,
        mass: Random.boundedU32 minBallMass maxBallMass |> Random.map Num.toF32,
        color: { Random.chain <-
            r: Random.boundedU8 0 255,
            g: Random.boundedU8 0 255,
            b: Random.boundedU8 0 255,
        } |> Random.map \{ r, g, b } -> RGBA r g b 255,
    }

forEach! : List a, (a => {}) => {}
forEach! = \l, f! ->
    when l is
        [] -> {}
        [x, .. as xs] ->
            f! x
            forEach! xs f!

limit : F32, { min: F32, max: F32} -> F32
limit = \x, { min, max } ->
    if x < min then min else if x > max then max else x

# format a float to a string with 2 decimal places
formatF32 : F32 -> Str
formatF32 = \f32 ->
    rounded = Num.round (f32 * 100)
    whole = rounded // 100
    decimal = Num.abs (rounded % 100)
    decimalStr =
        if decimal < 10 then
            "0$(Num.toStr decimal)"
        else
            Num.toStr decimal

    "$(Num.toStr whole).$(decimalStr)"