EricDubé.com

Describing Canvas Animations in Lua

July 21, 2026

luafengaricanvasreactvite

The banner across the top of this site is a canvas animation, but none of the animations are written in JavaScript. Each one is a Lua script, loaded as raw text at build time and executed in the browser by Fengari, a Lua 5.3 virtual machine implemented in JavaScript. The JavaScript side owns exactly one thing: a small set of drawing primitives registered as Lua globals. Everything else — particle state, physics, colour, timing — lives in the .lua file.

There are five: a warp starfield, a flow field, a harmonograph, a rotating point-cloud torus, and fireworks. Clicking between the site's tabs advances to the next one.

The appeal is not that Lua is faster or better suited to the arithmetic. It plainly is not; every drawing call crosses a VM boundary. The appeal is that an animation becomes a self-contained artifact with no build step, no imports, and no access to the DOM beyond the handful of functions it is handed. Adding one means dropping a file into a directory.

The contract

Each script defines up to two global functions. t is seconds since start; w and h are the canvas backing-store dimensions, passed every frame so a script never caches a stale size. State persists between frames in Lua locals at file scope.

the script contract
function setup(w, h) end -- optional, called once before the first frame function draw(t, w, h) end -- called once per animation frame

The drawing API is deliberately small — enough to express the animations, little enough to keep the bridge honest:

globals visible to every script
background(r, g, b) -- opaque fill of the whole canvas fade(r, g, b, a) -- translucent full-canvas fill, for motion trails fill(r, g, b, a) -- set fill colour fill_hsl(h, s, l, a) -- set fill colour, HSL stroke(r, g, b, a) -- set stroke colour stroke_hsl(h, s, l, a) line_width(px) circle(x, y, radius) -- filled ring(x, y, radius) -- stroked line(x1, y1, x2, y2) rect(x, y, w, h) -- filled

Registering one of these means pushing a JavaScript function onto the Lua stack and binding it to a global name. Arguments are read positionally off the stack rather than passed as parameters:

RandomCanvasAnimation.jsx
const num = (i, fallback) => lua.lua_gettop(L) >= i && !lua.lua_isnoneornil(L, i) ? lua.lua_tonumber(L, i) : fallback; const reg = (name, fn) => { lua.lua_pushcfunction(L, fn); lua.lua_setglobal(L, to_luastring(name)); }; reg('circle', () => { ctx.beginPath(); ctx.arc(num(1, 0), num(2, 0), Math.max(0, num(3, 0)), 0, 2 * Math.PI); ctx.fill(); return 0; // count of Lua return values, not the values themselves });

The return value is the number of results the function leaves on the stack. Of the primitives, fade is the one that most earns its place: filling the canvas with a low-alpha colour each frame is what produces motion trails, and three of the five animations are built almost entirely out of that effect.

Calling back into Lua runs through a protected call:

RandomCanvasAnimation.jsx
const callGlobal = (name, args) => { lua.lua_getglobal(L, to_luastring(name)); if (!lua.lua_isfunction(L, -1)) { lua.lua_pop(L, 1); return true; // an absent optional hook is fine } for (const a of args) lua.lua_pushnumber(L, a); if (lua.lua_pcall(L, args.length, 0, 0) !== lua.LUA_OK) { console.error(`Lua ${name}() error:`, lua.lua_tojsstring(L, -1)); lua.lua_pop(L, 1); return false; } return true; };

lua_pcall turns a Lua runtime error into a return code and an error string on the stack rather than an exception. The frame loop stops scheduling when it sees false, so a broken script degrades to a still image instead of logging sixty errors a second.

An animation, end to end

flow-field.lua
local N = 420 local P = {} function setup(w, h) for i = 1, N do P[i] = { x = math.random() * w, y = math.random() * h } end end function draw(t, w, h) fade(8, 8, 14, 0.06) line_width(1.4) local scale = 0.006 for i = 1, N do local p = P[i] local a = math.sin(p.x * scale + t * 0.3) + math.cos(p.y * scale - t * 0.2) local ang = a * math.pi local nx = p.x + math.cos(ang) * 1.7 local ny = p.y + math.sin(ang) * 1.7 stroke_hsl((a * 60 + t * 24) % 360, 90, 62, 0.75) line(p.x, p.y, nx, ny) p.x, p.y = nx, ny if p.x < 0 or p.x > w or p.y < 0 or p.y > h then p.x, p.y = math.random() * w, math.random() * h end end end

Two summed trigonometric terms stand in for a noise field — cheap, and smooth enough that neighbouring particles agree on direction. The t terms rotate the field slowly so the system never settles. Hue is derived from the same value that steers the particle, which is why colour tracks direction instead of being an independent decoration. Particles that leave the canvas respawn at random rather than wrapping, which keeps density even as the field pushes them into corners.

Fengari is packaged for Node, not the browser

This is where most of the time went. The obvious dependency is fengari, and it installs and bundles without complaint. It then fails at runtime in the browser three times, each failure hidden behind the last.

The first is process is not defined. Fengari's liolib computes a module-scope constant from process.versions.node. The natural fix — define a minimal process global before importing — produces the second failure, and it is worse than the first, because of this branch:

fengari/src/luaconf.js
if (typeof process === "undefined") { const LUA_DIRSEP = "/"; // ... browser-safe defaults, no node builtins touched } else if (require('os').platform() === 'win32') {

Fengari uses the presence of process to decide whether it is running under Node. Shimming it does not make the library more browser-compatible; it convinces the library that it is not in a browser, and sends it down a path that calls os.platform(). Bundlers resolve node builtins to empty stubs for browser targets, so platform is undefined and the call throws. The two failure modes are mutually exclusive: one library needs the global present, another needs it absent.

Aliasing os to a real shim clears that and reveals the third: Cannot read properties of undefined (reading 'O_CREAT'). To back Lua's io.tmpfile, liolib pulls in tmp, a Node-only package that reads flags off fs.constants at module scope. And an fs alias broad enough to satisfy it also intercepts unrelated modules elsewhere in the client bundle.

The correct answer is fengari-web, the project's own browser distribution: a prebuilt bundle with the Node-only paths compiled out. It exports the same lua, lauxlib, lualib and to_luastring primitives, so the bridge code is unchanged, and it contains no reference to process or to any node builtin. Switching removed every shim and let the bundler config revert to what it had been.

The lesson generalises past Fengari. When a library's browser incompatibility is structural rather than incidental, shimming node builtins one at a time is a treadmill — each shim widens the surface the next one has to cover. The question worth asking after the first failure, not the third, is whether the maintainers already ship a browser build.

The canvas is rarely the size it claims

A canvas has two independent sizes: the CSS box it occupies and the backing store it draws into. The backing store defaults to 300×150 regardless of layout, and setting one does not update the other.

Sizing from a ResizeObserver alone leaves a gap. The observer's first callback is asynchronous, so any frame that runs before it draws into the 300×150 default, which the browser then stretches to fill the element. On a 919×140 banner that is a visibly smeared, wrong-aspect image for the first frames. Sizing eagerly as well as on resize closes it:

RandomCanvasAnimation.jsx
const resize = (w, h) => { const nw = Math.max(1, Math.round(w)); const nh = Math.max(1, Math.round(h)); if (canvas.width !== nw) canvas.width = nw; if (canvas.height !== nh) canvas.height = nh; }; resize(canvas.clientWidth, canvas.clientHeight); // before the first frame

The equality guards matter more than they look. Assigning to canvas.width clears the canvas and resets 2D context state even when the value is unchanged. An unguarded write inside a ResizeObserver callback would wipe the accumulated trail buffer on every observation — and the trails are the entire visual effect for most of these animations.

Scale to the box you actually have

The banner is roughly 919×140 — a letterbox. Any animation that scales its motion by width will throw its content off a canvas that short, and the failure is easy to miss because the script itself never errors.

Fireworks was the clear case. Spark velocities derived from w * 0.004 gave about 3.7 pixels per frame on a 919-pixel-wide canvas; against a spark lifetime of roughly 83 frames (life decaying by 0.012 each frame), every burst cleared a 140-pixel-tall canvas almost immediately. Rescaling the same constants against h kept the bursts in frame.

The harmonograph had the same problem inverted: a single amplitude of min(w, h) * 0.36 produced a 50-pixel figure adrift in a 919-pixel-wide space. Independent axis amplitudes — w * 0.45 and h * 0.42 — let it fill the banner.

The rule for a background that has to survive arbitrary aspect ratios: scale each axis by its own dimension, and reserve min(w, h) for things that must stay circular — the torus uses it, because a donut scaled per-axis is an ellipse.

Cycling without breaking hydration

Advancing the animation on navigation is a layout-level concern, since the layout survives route changes while the page content below it swaps out:

_layout.tsx
const { pathname } = useLocation(); const [step, setStep] = useState(() => Math.floor(Math.random() * LUA_ANIMATIONS.length)); const lastPath = useRef(pathname); useEffect(() => { if (lastPath.current === pathname) return; lastPath.current = pathname; setStep((s) => s + 1); }, [pathname]); const animation = LUA_ANIMATIONS[step % LUA_ANIMATIONS.length];

The selected script is handed down as a prop, and the animation's effect lists it as a dependency — so a change tears down the Lua state, cancels the frame loop, and builds a fresh VM.

The tempting alternative — key={step} to force a remount — is a trap in a server-rendered app. The starting index is randomised per page load, so server and client pick different values. Keys are not serialised into HTML, so React would find a different key during hydration and throw away the server-rendered node. Passing the script as a prop keeps the rendered <canvas> identical between server and client, and confines the nondeterminism to an effect that only ever runs client-side.

What it costs

fengari-web is 209 kB raw, 67 kB gzipped — not a trivial amount to ship for a decorative banner. It is imported dynamically from inside the effect, which keeps it out of the server bundle entirely and out of the initial client chunk; the bundler emits it as a separate file fetched after the page is interactive. Nothing about first paint waits on it, and a visitor who never reaches an animated route never downloads it.

Per frame, the heaviest script — the torus, at 648 projected points — issues roughly 1,950 canvas calls, each crossing from Lua into JavaScript; the flow field issues about 1,680. Those counts, rather than the arithmetic, are the number to watch. The Lua-side maths is trivial; cost is dominated by boundary crossings and canvas state changes. Keeping the primitives coarse — one call draws an entire circle, rather than exposing beginPath and arc separately — is what keeps the count in the low thousands instead of four times that.

A note on testing animations

Chrome does not fire requestAnimationFrame in a backgrounded tab. An animation that appears completely broken — exactly one frame rendered, no errors, no further callbacks, a canvas frozen on a plausible-looking still — may simply be running in a tab that is not visible. document.hidden is worth checking before debugging anything else, because every downstream measurement taken in that state is a single-frame snapshot masquerading as a steady state.

The way around it is to stop depending on the frame clock: drive draw from an ordinary loop with t advanced by hand, hash the canvas pixels at intervals, and assert that the hashes differ. That sidesteps the throttle, runs deterministically, and answers the question that actually matters — whether the animation evolves — rather than whether it happened to paint something once.