LeCodesdocs

Bouncing sprites

A Scene2D, a setLoop that moves each ball by speed * dt, and taps that spawn into world space. Motion is dt-based, so it's identical at any frame rate; world Y points up, so a bounce flips the sign of whichever velocity component crossed an edge. The viewport size arrives via the resize event, so the demo seeds itself once it has one.

Fork in LeCodes
// A Scene2D, a per-frame loop, and taps that spawn into world space. Each ball carries its own
// velocity and moves by `speed * dt`, so motion is identical at any frame rate. World Y is up, so
// a bounce flips the sign of whichever velocity component crossed an edge.
const scene = new Scene2D({ background: "#0b0d12", filter: "linear" })

const COLORS = ["#FF4032", "#FFB020", "#22c55e", "#38bdf8", "#a855f7"]
const R = 26

// One white Canvas disc, reused as the texture for every ball — a per-sprite tint recolors it.
const disc = new Canvas(R * 2, R * 2, { pixelRatio: 2 })
disc.fillStyle = "#ffffff"
disc.beginPath().arc(R, R, R, 0, Mathf.TAU).fill()

type Ball = { node: Sprite, vx: number, vy: number }
const balls: Ball[] = []
let W = 0, H = 0

function spawn(x: number, y: number) {
    const node = new Sprite({ texture: disc, size: [R * 2, R * 2], position: [x, y] })
    node.color = COLORS[Mathf.randomInt(0, COLORS.length - 1)]!
    scene.add(node)
    balls.push({ node, vx: Mathf.random(-260, 260), vy: Mathf.random(-260, 260) })
}

// device.width/height are 0 until the host reports a size, and change on rotate — so keep the
// camera centred on every resize and seed the first few balls once we actually have a viewport.
function fit(width: number, height: number) {
    W = width; H = height
    scene.camera.position = [W / 2, H / 2]
    if (balls.length === 0 && W > 0 && H > 0) {
        for (let i = 0; i < 5; i++) spawn(Mathf.random(R, W - R), Mathf.random(R, H - R))
    }
}
fit(device.width, device.height)
device.addEventListener("resize", fit)

scene.addEventListener("click", ev => { if (W > 0) spawn(ev.worldX, ev.worldY) })

setLoop(dt => {
    for (const ball of balls) {
        const p = ball.node.position
        p.x += ball.vx * dt
        p.y += ball.vy * dt
        if (p.x < R) { p.x = R; ball.vx = Math.abs(ball.vx) }
        else if (p.x > W - R) { p.x = W - R; ball.vx = -Math.abs(ball.vx) }
        if (p.y < R) { p.y = R; ball.vy = Math.abs(ball.vy) }
        else if (p.y > H - R) { p.y = H - R; ball.vy = -Math.abs(ball.vy) }
        ball.node.position = p
    }
})

// A HUD over a scene is a UIWidget, never a UIScreen — a screen and a scene can't be open at the
// same time. attachTo(scene) binds the widget to the scene's layer so it shows and hides with it.
const hud = UIWidget([
    UIText("Tap to spawn").style({ fontSize: 14, color: "#c7ccd6", fontWeight: 600 }),
]).style({ top: "max(safe-top, 16px)", left: 16 })
hud.attachTo(scene)

scene.open()
hud.show()

Press Run to launch the floating simulator. It stays open as you browse — opening another example just swaps the app inside it.

Related docs