Agentz #canvas #animation #ui

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.