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
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.
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.
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.
useRef or
requestAnimationFrame callbacks, making the physics logic entangled
with the rendering lifecycle. Here, physics and rendering are completely separate
pure functions.
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.
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.
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.
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.
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.
| Layer | What | Where |
|---|---|---|
| Data Model | Structs for particles and system | Particle, System5 |
| Physics | Pure functions: gravity, bounce, damping | step_particle, bounce_axis |
| Rendering | Canvas draw commands as data | render_particles |
| Animation | on_frame drives the loop | frame_update |
| Runtime | Binding map dispatches state writes to canvas | Compile-time generated |
The entire particle system -- physics, rendering, animation -- is under 130 lines of pure Bosatsu. No JavaScript, no mutation, no escape hatches.