Koala — a small interactive graphics framework
I built Koala to scratch a specific itch: I wanted interactive math and graphics demos embedded directly in blog posts, without reaching for a heavy library or fighting a general-purpose game engine. The result is a small TypeScript framework that sits on top of the browser Canvas API and gives you a pannable/zoomable 2D stage, a few drawing utilities, a GUI system, and some linear algebra helpers. It compiles to a single koala.js bundle.
This post walks through how it works and what you can do with it.
The core idea
Every Koala demo is a script — a function that receives a PlayStage and returns a loop function. The stage sets up the canvas, the camera, and input handling. The loop function gets called every animation frame.
import { koalaScript } from './koalaStart'
import { PlayStage } from 'koala'
export default function main() {
return koalaScript('my-demo', 'My Demo', (stage: PlayStage) => {
// one-time setup goes here
const { renderer: ko, context: ctx } = stage.getCanvasContext()
// return the per-frame loop
return () => {
ko.drawGrid(0.5, 0.2)
// draw something each frame
}
})
}
The koalaScript helper wraps this in a { name, label, start, stop } object that the Playground layout can manage. start creates the stage, wires up the animation loop, and stop cancels it — so switching between demos is clean.
The koalaStart.ts factory looks like this:
export function koalaScript(name, label, script) {
let animId = null
let shouldStop = false
function start(conn) {
const stage = new PlayStage(conn.canvas, conn.overlay)
stage.install()
const loopFn = script(stage)
function loop() {
stage.updatePlayground()
loopFn()
if (!shouldStop) animId = requestAnimationFrame(loop)
}
animId = requestAnimationFrame(loop)
}
return {
name, label,
start: (conn) => start(conn),
stop: () => { shouldStop = true; if (animId) cancelAnimationFrame(animId) },
}
}
Each frame, stage.updatePlayground() clears the canvas, resets the transform, and applies the current camera zoom before your draw calls run.
PlayStage
PlayStage is the root object of every demo. It holds:
camera— a 2D camera with pan, zoom, and coordinate transform utilitieskeyboard— tracks which keys are currently held or just pressedeventManager— a composable event handler stack (mouse, wheel, pointer)cameraInput— a built-in handler that lets the user pan with middle-click and zoom with the scroll wheel
Getting the canvas context always goes through getCanvasContext(), which lazily creates the renderer:
const { context: ctx, renderer: ko } = stage.getCanvasContext()
ctx is the raw CanvasRenderingContext2D. ko is Koala's CanvasRenderer, which wraps most draw calls with a camera transform so your world coordinates are independent of canvas pixel size.
Drawing
The CanvasRenderer (ko) provides a handful of methods built on top of ctx. All positions go through camera.getTransformed(p) internally, so you work in world space and the camera handles the rest.
Grid:
ko.drawGrid(0.5, 0.2, '#d0d0d0')
// cell size 0.5, line width 0.2, color
The grid computes its visible range from the current camera boundary, so it always fills the viewport regardless of where you are panned to.
Lines and paths:
ctx.beginPath()
ctx.strokeStyle = '#ff2020'
ctx.lineWidth = 3
ko.moveTo(vec2(-1, 0))
ko.lineTo(vec2(1, 0))
ctx.stroke()
moveTo and lineTo on the renderer accept vec2 values in world space. For drawing a connected sequence of points you can use drawLines:
ko.drawLines([vec2(0,0), vec2(0.5, 0.3), vec2(1, -0.1)], '#80d0ff')
Plotting a function:
ko.drawFunction(x => Math.sin(x), -Math.PI, Math.PI, 0.01, '#55aaff')
Or the shorthand plot(f, color) which covers [-10, 10] with a step of 0.02.
Text:
ctx.fillStyle = '#d0d0d0'
ko.drawText('hello', vec2(0, 0.5))
Arcs:
ctx.beginPath()
ctx.fillStyle = '#20d020'
ko.arc(vec2(0, 0.3), 20, 0, Math.PI * 2)
ctx.fill()
The radius here is in screen pixels (not world units), which is intentional — dots and markers should stay a fixed visual size regardless of zoom.
Points:
ko.drawPoints([vec2(0,0), vec2(0.5, 0.5), vec2(-0.3, 0.2)])
The camera
The camera keeps a world-space coordinate system and maps it to screen pixels. Panning and zooming are built in: middle-click to drag, scroll wheel to zoom. Your draw code doesn't need to know about any of this — you just pass world coordinates and getTransformed does the conversion.
A few utilities you can call directly:
// Get the visible world-space bounding box
const [min, max] = stage.camera.getBoundary()
// Get current zoom level
const z = stage.camera.getZoom()
// Convert from world to screen manually
const screenPt = stage.camera.getTransformed(vec2(x, y))
RingBuffer
For anything that needs a trailing trace — a particle path, a time-series plot, a robot arm trajectory — RingBuffer is handy. It stores the last N values in a circular buffer and exposes them through forEach in insertion order:
import { RingBuffer } from 'koala'
const trail = new RingBuffer(60)
// in the loop:
trail.push(vec2(Math.cos(t), Math.sin(t)))
ko.drawLines(trail, '#ff5555')
drawLines accepts anything with a forEach that yields vec2 values, so the ring buffer plugs in directly.
Here is the full "circular trace" example that ships as the first playground script:
import { koalaScript } from './koalaStart'
import { PlayStage } from 'koala'
import { vec2 } from 'koala/linear'
import { RingBuffer } from 'koala'
export default function main() {
return koalaScript('koala1', 'Circular trace', (stage: PlayStage) => {
let f = 0.0
let trail = new RingBuffer(40)
const { renderer: ko, context: ctx } = stage.getCanvasContext()
return () => {
f += 0.05
ctx.fillStyle = '#ffffff'
ko.drawGrid(0.1, 0.2, '#d0d0d0')
trail.push(vec2(Math.cos(f) * 0.2, Math.sin(f) * 0.2))
ko.drawLines(trail, '#ff2020')
}
})
}
The trail automatically ages out as new points are pushed in, which gives the fading-comet look without any manual cleanup.
GUI overlays
Koala has an HTML overlay system layered on top of the canvas. You create an overlay positioned in world space, then append DOM elements to it. The overlay repositions itself each frame as the camera moves.
const gui = stage.createGui()
const panel = gui.overlay()
panel.position = vec2(-1, 0.5)
panel.scale = 8
panel.appendChild(gui.h1('Controls'))
panel.appendChild(gui.text('Drag the points'))
const slider = gui.slider(0.5, 0, 1, 0.01)
slider.addEventListener('input', () => {
myParam = parseFloat(slider.value)
})
panel.appendChild(slider)
The scale property controls how many world units the overlay occupies horizontally. The GUI factory provides h1, h2, h3, text, slider, img, code, and a canvas element for sub-canvases, plus raw div creation if you need something custom.
This is how the Lagrange/Hermite demo labels each section — three overlays at different world-space x positions, each with a heading and some controls, all moving together with the camera when you pan.
Linear algebra
koala/linear exports lightweight vector and math utilities. Everything is a plain object ({ x, y }, etc.) — no classes, no prototype overhead.
import { vec2, vec3, add, sub, scl, mul, neg } from 'koala/linear'
const a = vec2(1, 0)
const b = vec2(0, 1)
const c = add(a, b) // { x: 1, y: 1 }
const d = scl(c, 0.5) // { x: 0.5, y: 0.5 }
The std namespace has more:
import { std } from 'koala/linear'
// Lagrange polynomial through a set of points
const y = std.lagrange(x, [vec2(0, 0), vec2(1, 1), vec2(2, 0)])
// Cubic Hermite spline
const p = std.cubicHermite(p0, p1, m0, m1, t)
// Linspace
const xs = std.linspace(0, 1, 0.01)
// Numerical derivative
const df = std.functionDiff(Math.sin) // returns a function
// Dot product and matrix-vector multiply
const dot = std.dot([1, 2, 3], [4, 5, 6])
const y = std.prod([[1,0],[0,1]], [3, 4])
These were mostly built as I needed them for the math demos. The parametric Lagrange and cubic Hermite functions are used in the interpolation playground to let you drag control points and see the curve update in real time.
A more involved example: the parabola demo
The "Parabola & robot arm" script shows how most of these pieces fit together. It draws a parabola, its normals, incoming vertical rays, and their reflections, all animated as the parabola's shape changes:
export default function main() {
return koalaScript('lab2', 'Parabola & robot arm', (stage: PlayStage) => {
const ctx = stage.getCanvasContext().context
const ko = stage.getCanvasContext().renderer
let a = 1 / 20
const scl = 0.1
const range = 50
const parabola = x => x * x * a
const parabolaDif = x => 2 * x * a
const focus = () => 1 / (4 * a)
function plotCurve(f) {
let first = true
for (let i = -range; i < range; i += 0.5) {
const p = vec2(i * scl, f(i) * scl)
if (first) ko.moveTo(p); else ko.lineTo(p)
first = false
}
}
function reflect(d: vec2, n: vec2) {
const dot = d.x * n.x + d.y * n.y
return vec2(d.x - 2 * dot * n.x, d.y - 2 * dot * n.y)
}
return () => {
// grid
ctx.beginPath(); ctx.strokeStyle = '#d0d0d0'
ko.drawGrid(0.5, 0.2, '#d0d0d0')
ctx.stroke()
// parabola
ctx.beginPath(); ctx.strokeStyle = '#d02020'; ctx.lineWidth = 10
plotCurve(parabola)
ctx.stroke()
// focus
ctx.beginPath(); ctx.fillStyle = '#20d020'
ko.arc(vec2(0, scl * focus()), 40, 0, Math.PI * 2)
ctx.fill()
// shrink parabola over time
a /= 1.001
}
})
}
The robot arm in the same script uses RingBuffer to store the tip position history of a two-joint arm and plots it as a trail — the kind of thing that would be tedious to animate manually but works cleanly inside a frame loop.
What it is not
Koala is not a game engine. There is no physics, no scene graph, no retained-mode rendering, no entity system. It is a thin layer that gives you a live canvas loop with a coordinate system that doesn't break when the user zooms in.
It also does not ship as an npm package — it lives in the same repo as the blog and gets compiled as a Vite library bundle. If you want something with the same flavor but more complete, p5.js covers similar territory with a much larger API surface.
The playground
All of the scripts described here are live in the Playground. You can browse and run each one, and hit F1 to open the source in a Monaco editor panel. From there you can send the code to the live editor (>_) and edit it in the browser.
The live editor runs in a sandboxed iframe and reloads the script on every save, so it is a reasonably tight iteration loop for experimenting with new ideas.
