The orb: animating an agent's emotional state on a canvas
Every agent in Agentz is represented by an orb: a glowing, breathing, particle-wreathed sphere that idles gently when the agent is resting and visibly works when it's thinking. It's the most frivolous subsystem in the app and the one people comment on first, which is its own lesson about desktop software.
This week the orb got three upgrades — a glass containment shell, a proper idle/working distinction, and a fix for a devious zoom bug — so it's a good moment to write down how the thing works.
Not a shader, not a library
The first version of agent visualisation used a 3D force-graph library.
It got replaced with a custom canvas renderer, and the orb has since grown
into a single ~1,100-line file that does pseudo-3D by hand: Vec3 points, a
Y-axis rotation, and a perspective projection with a focal length of
min(width, height) * 1.5. There's no WebGL and no noise field — all motion
is composed sine waves and advancing orbital angles, which turns out to be
plenty when you layer enough of them.
Each orb instance builds a small zoo of element pools, created once with a seeded RNG so a given agent's orb is deterministic:
- 8 ethereal wisps — soft trailing veils that orbit the core
- 14 sparkles — brief bright points that pop and fade
- 6 aurora ribbons — wide curved bands sweeping through the sphere
- 7 energy tendrils — thin arcs that whip around when energy is high
- 25 floating motes — dust drifting with velocity plus a gentle pull back toward the centre
- 4 outer glow layers at 1.7×, 2.1×, 2.6×, and 3.2× the base radius
The core itself breathes — a slow scale oscillation with a pulsing bright spot inside. Base radius is 22% of the canvas's smaller dimension, so the same code renders a tiny sidebar orb and a big agent-card orb.
One number to rule them: energy
The whole animation is parameterised by an OrbState with two scalars,
energy and intensity, both 0–1. Idle sits at energy 0.15; when the agent
is processing a request it jumps to 0.95. Everything expressive derives from
that one number:
breathRate = 0.003 + energy * speed * 0.035
rotationRate = 0.001 + energy * speed * 0.018
workingMult = 0.1 + energy * speed * 1.25 // drives all element pools
pulseSpeed = 0.5 + energy * speed * 5.0
Two effects only switch on past a threshold, which is what makes "working" read as a different mood rather than just faster: above energy 0.5 the hue starts cycling — up to ±25° of drift on a slow sine — and above 0.3 the saturation climbs by up to 20 points. An idle orb is calm and monochrome; a working orb shimmers through neighbouring colours like it's running hot.
The transition between states matters as much as the states. The rendered
state isn't the target state — it's an animatedState that exponentially
chases the target with a lerp factor of 0.08 per frame, roughly half a
second to settle. Without it, an agent finishing a response made the orb
snap from frantic to comatose in one frame, which read as a glitch rather
than as relaxation.
The glass shell
The newest visual layer is a containment effect that makes the orb read as
a sphere inside glass rather than a fuzzy ball of light. It's about 80 lines
of pure createRadialGradient and arc — no images, no filters:
- an inner shadow gradient around the boundary, so the contents look enclosed
- a blue-white rim stroke plus a soft outer glow just beyond it
- a primary specular highlight in the upper-left (white fading from 0.45 alpha) and a smaller secondary one, faking a light source
- a caustic glow at the bottom, tinted with the agent's primary colour, as if light refracted through the sphere onto a surface
- a curved bright edge in the upper-right, drawn with
globalCompositeOperation = 'screen'
None of these is physically justified. Together they trigger enough "glass" associations that your brain does the rest — the cheapest kind of rendering there is.
The zoom bug
The bug of the week: the render loop was doing its math in canvas.width
and canvas.height — device pixels — while the context had already been
scaled by devicePixelRatio for crisp HiDPI output. At DPR 2 with default
zoom the two coordinate systems happen to agree closely enough to hide the
error. Hit Cmd+/− in Electron, though, and the effective DPR changes: the
orb drifted off-centre and clipped against the canvas edge.
The fix is the boring, correct one: store the logical (CSS pixel) size on
the orb instance, use it for every render calculation and clearRect, and
let the DPR transform be the only thing that knows about device pixels. The
same commit clamped the glass radius to 85% of the distance to the nearest
edge, so the shell can never overflow its canvas no matter how the layout
squeezes it.
Keeping 60fps honest
Each orb runs its own requestAnimationFrame loop, and a dashboard full of
agent cards means a dozen orbs animating at once. Two things keep that from
melting laptops: an IntersectionObserver pauses any orb that scrolls out
of view, and a visibilitychange handler pauses everything when the window
is hidden. A paused orb early-returns before touching the canvas at all.
There's also a static mode that renders exactly one frame, used where an
orb is decoration rather than status.
Time currently advances a fixed 16ms per frame, which assumes 60Hz — on a 120Hz display everything runs double speed, an assumption that will need revisiting. On the list.
Is any of this necessary? No. Agents would work identically behind a static icon. But the orb is doing real interface work: you can tell at a glance, from across the room whether an agent is idle or thinking, and the state transitions make the app feel alive in a way a spinner never does. The frivolous subsystem is load-bearing.