← Back to Yichus

Particle System

Five particles with gravity, wall bouncing, and damping -- rendered on a canvas at 60fps. All physics and rendering are written in pure Bosatsu, a total functional language. No virtual DOM, no JavaScript physics libraries.

Canvas Rendering 60fps Animation Pure Functional Physics

Live Demo

1

Data Model: Structs for State

Each particle is a simple struct holding position and velocity. Five particles are grouped into a System5 struct -- no mutable arrays, no classes, just plain data.

struct Particle(x: Double, y: Double, vx: Double, vy: Double)
struct System5(p1: Particle, p2: Particle, p3: Particle,
               p4: Particle, p5: Particle)

# All five particles live in a single State container
particles: IO[State[System5]] = state(initial_system)

Because Bosatsu is total, the compiler can prove this code always terminates. No infinite loops, no null pointers, no runtime exceptions.

Compare to React
In React, you'd use useState with a mutable array of objects. Nothing prevents you from accidentally mutating particles in place, skipping renders, or creating infinite update loops. Bosatsu's totality guarantee eliminates these classes of bugs.
2

Physics: Pure Functions on Structs

Physics is a pure function: old state in, new state out. step_particle applies gravity, updates position, and bounces off walls. step_system applies step_particle to all five particles.

def step_particle(p: Particle, dt: Double) -> Particle:
  match p:
    case Particle(px, py, pvx, pvy):
      vy1 = `+.`(pvy, `*.`(gravity, dt))      # gravity
      nx  = `+.`(px, `*.`(pvx, dt))            # move x
      ny  = `+.`(py, `*.`(vy1, dt))            # move y
      bx  = bounce_axis(nx, pvx, min, max_x)  # wall bounce
      by  = bounce_axis(ny, vy1, min, max_y)
      Particle(bx.pos, by.pos, bx.vel, by.vel)

This function is testable in isolation -- pass in a particle, get a particle back. No mocking, no DOM, no animation timers needed for unit tests.

Compare to React
React particle systems typically mutate arrays inside useRef or requestAnimationFrame callbacks, making the physics logic entangled with the rendering lifecycle. Here, physics and rendering are completely separate pure functions.
3

Canvas Rendering: Draw Commands as Data

render_particles reads the current state and produces a list of CanvasCommands -- clear, fill, circle. These are data values, not immediate draw calls.

def render_particles(ps: State[System5]) -> IO[CanvasCommands]: (
  sys <- ps.read().flatMap()
  match sys:
    case System5(a, b, c, d, e):
      c1 = draw_particle([], "#ff6b8a", a)  # pink
      c2 = draw_particle(c1, "#4ecdc4", b)  # teal
      c3 = draw_particle(c2, "#ffe66d", c)  # yellow
      c4 = draw_particle(c3, "#a8e6cf", d)  # mint
      c5 = draw_particle(c4, "#7c83fd", e)  # purple
      pure(sequence([clear("#1a1a2e"), *c5]))
)

The runtime interprets these commands and draws to the canvas. Because canvas_render binds state to a canvas element, the runtime knows exactly when to redraw -- no polling, no dirty checks.

Compare to React
In React, you manually call ctx.clearRect(), ctx.beginPath(), ctx.arc(), etc. inside a useEffect or requestAnimationFrame. You're responsible for coordinating state reads with draw timing. Yichus handles this automatically through canvas_render's state binding.
4

Animation Loop: on_frame

on_frame registers a callback that fires on every requestAnimationFrame. The callback reads the current state, runs the physics step, and writes the new state. That write triggers the canvas to redraw.

def frame_update(_: Unit) -> IO[Unit]: (
  dt = `/.`(d_16, d_1000)       # ~16ms per frame
  ps <- particles.flatMap()
  current <- ps.read().flatMap()
  ps.write(step_system(current, dt))
)

frame_registration = on_frame(frame_update)

The entire animation loop is three lines: read state, compute next state, write state. The runtime handles frame scheduling and canvas redraw automatically.

Compare to React
React canvas animations typically require manual requestAnimationFrame management with cleanup in useEffect return values, mutable refs for animation IDs, and careful dependency arrays to avoid stale closures. Yichus's on_frame eliminates all of this boilerplate.
5

The Binding Map: O(1) Canvas Updates

At compile time, Yichus's UIAnalyzer detects that canvas_render binds the particles state to the canvas element. It generates a binding map entry:

// Generated at compile time
_bindings["state_1"] = [{
  elementId: "simulation",
  property: "canvas",
  renderer: render_particles
}];

When frame_update writes to particles, the runtime looks up "state_1" in the binding map, finds the canvas binding, and calls the renderer. No virtual DOM diff. No tree walk. Direct, O(1) dispatch.

6

Summary

LayerWhatWhere
Data ModelStructs for particles and systemParticle, System5
PhysicsPure functions: gravity, bounce, dampingstep_particle, bounce_axis
RenderingCanvas draw commands as datarender_particles
Animationon_frame drives the loopframe_update
RuntimeBinding map dispatches state writes to canvasCompile-time generated

The entire particle system -- physics, rendering, animation -- is under 130 lines of pure Bosatsu. No JavaScript, no mutation, no escape hatches.