Glow for Free

How movy fakes neon in a terminal with one persistent buffer.

Intro

Since 0.3.0, movy has a glow feature: anything drawn through Frame can bloom, and anything that moves leaves a neon trail. I expected that look to be the expensive part of the engine. It turned out to be the cheapest — no bloom pass, no trail system, no per-object bookkeeping. One extra buffer and three operations per frame. This post is about why that's enough.

First, the canvas. A terminal is a grid of text cells, not a framebuffer. Truecolor gives you 24 bits per cell, and with the half-block you get two independently colored pixels per cell. That's enough resolution to be interesting — but the neon look isn't about resolution. It's about what movy does between frames.

movy doesn't really draw glow. It draws light, and then it's bad at forgetting.

The Expensive Way

The obvious way to make something glow is to glow it: for every bright object, draw a few blurred copies behind it, fading outward, every frame. It works. It's also slow and fiddly — each glowing thing needs its own halo, and if you want trails (that lovely motion smear) you now have to remember where everything was N frames ago and redraw its ghosts.

That's a lot of bookkeeping for an effect that's supposed to feel effortless. So movy doesn't track objects at all. It tracks light.

One Buffer, Persistent

A movy Frame keeps two floating-point RGB layers:

solid: []V3   // opaque stuff — backgrounds, bodies, tiles. Rewritten every frame.
glow:  []V3   // additive light. You add into it — and it PERSISTS across frames.

You draw opaque pixels into solid (with px / rect), and you emit light into glow (with gpx / grect). The solid layer is normal: clear it, redraw it, done. The glow layer is the trick — nobody clears it. It carries over.

Decay, Blur, Add

Every frame starts with one call, beginFrame(), which does exactly two things to that persistent glow buffer:

  1. Decays it — multiply every pixel by glow_decay (0.72 by default). Last frame's light drops to about 72% of its brightness.
  2. Blurs it — a cheap separable 1-2-1 blur, so light bleeds a little into its neighbors.

Then you add this frame's fresh emissions on top. At the end, composite() mixes it all down for the terminal:

// once per frame, before you draw anything:
frame.beginFrame();   // glow *= 0.72, then a 1-2-1 blur

// ... draw opaque pixels into `solid`, emit light into `glow` ...

frame.composite();    // clamp(solid + glow) -> vignette -> scanline
                      //   -> warmth -> flash -> tint -> surface

That's the entire engine of the look. Three operations — decay, blur, add — on one buffer. There is no glow "feature" anywhere in here. There's a buffer that fades and spreads, and then you keep pouring light into it.

A neon scene rendered by movy with the glow buffer zeroed: a magenta outline, an amber horizon, a cyan floor grid and three small moving lights — flat, no halos, no trailsglow buffer off
The identical frame with the glow buffer composited in: the outline and horizon bloom, and the moving lights leave fading neon trailsglow buffer on
The only difference is whether the glow buffer is composited in. Same geometry, same colors, same frame — the halos on the still shapes and the trails behind the moving ones are what the buffer remembered. (Rendered headless with Frame.savePng(), 160×90 px.)

Why It Has to Be Float

Those layers are V3 — floating-point RGB — not u8. That's not premium pixels for the fun of it; it's load-bearing.

Light is additive: two beams that overlap are brighter than either one alone. If you store color in bytes and clamp at 255 the moment things get bright, every overlap flattens to white-ish mush and the bloom dies on contact. Keep it in float, let values run past 1.0 while they accumulate, and only clamp at the very end, inside composite(). That over-bright headroom — the range you're not allowed to display yet — is exactly where the glow lives.

Bloom and Trails Are the Same Thing

Here's the part I still find a little too satisfying. I never wrote a "bloom effect." I never wrote a "trail effect." Both just fall out of those same three operations, and which one you get depends on a single thing: whether the light moved.

Same color at a still position each frame → stable bloom. If it moves → a trail.

One buffer, one decay constant, and the difference between two beloved effects is just motion. The glow isn't remembering objects — it's remembering light, and it does the remembering for you.

The Knobs

Because it's all just a buffer with a decay constant, art-directing the entire aesthetic comes down to a couple of public floats you can change any frame, no recompile:

Honestly, half of what passes for "art direction" in movy is me dragging glow_decay up and down until a scene feels right.

Where It Ended Up

Because it costs nothing to switch on, it's switched on almost everywhere now. The two games I'm building on movy — GR1D, a Tron-style light-cycle game, and PHAZE, a run'n'gun — lean on it hard. Both are still under wraps; when they surface, every frame you'll see is this one buffer at work. And it has escaped the games: DUET, my tracker, pushes its music visualizers and the mixer's VU meters through the same Frame, so the mix glows too.

Underneath it's still what it was on day one: a buffer of light that decays and spreads a little every frame — more or less what the phosphor on an old CRT does. Glow isn't a feature you add. It's a feature you don't fully remove.