devlog.

AsciiWorldEngine · Study

Three bytes a cell, doing nothing

A reference clip landed — GrowNowGames' ASCII City Update 2 — and it did one thing this engine didn't: filled the cell behind every glyph, so a building read as a solid coloured block instead of a scatter of bright characters on black. Going looking for what that would take, the frame buffer already had the plane to do it with. It had had it for a while. Nothing had ever written to it.

0
New fields added to the world, a building, a car or a cell
3.3×
How much brighter the ink stays than its own fill, always
+19%
Cost of --view blocks over classic, on the street — verified here
−4.7%
Whole-frame render, even with the new view built in — verified here

The gap was one sentence

Every building here already carries a named hue — one of six facades, from a neon grid green to a red terminal orange — and every vehicle and street surface picks its own colour the same way. Walk the street and the red tower, the cyan tower and the yellow tower are plainly three different buildings. But the hue was only ever the glyph's colour. The cell behind each character stayed black, so at any distance a building dissolved back into a scatter of coloured dots on black — the exact thing an ASCII renderer is always accused of looking like.

The reference engine does the opposite: the wall is a filled block of one hue with detail drawn on it, the road is a colour, a taxi is a filled yellow shape with wheels. Nothing in its frame is a bright glyph floating on black. The whole difference between the two comes down to one sentence: it colours the cell; this one coloured the glyph.

Classic

A street in AsciiWorldEngine's classic view: towers rendered as coloured characters on a black background, the road between them unlit.
Coloured glyphs on black — the look every capture on this site has shown until now.

Blocks

The same street in --view blocks: each tower filled with a dark shade of its own hue behind the glyphs, and the road now a lit grey surface with visible lane markings and kerbs.
Same seed, same position, same everything else. The road stops being the largest black shape in frame.

“It colours the cell; this one coloured the glyph.”

The whole gap, in one sentence

The plumbing was already there

The surprise is how little of this turned out to be new. The frame buffer has carried a per-cell background plane — three bytes a cell — since registration plates needed one: a plate is black characters on yellow, which a foreground colour alone can't say. Every way the engine turns a frame into a picture already reads it — the terminal writer emits the colour run, the evidence SVG draws a filled rectangle under the text, the wasm bridge hands the whole plane to the browser's own painter. None of that is new code. It's been wired end to end since the plate work landed, and on every frame that wasn't a registration plate it painted nothing, because nothing ever set it.

That made the fill a rendering change, not a data one. Every place the renderer already picks a glyph's colour already has the hue in hand — Grid::put just also writes a flat 0.30 downscale of that same colour into the background plane and raises a flag saying the cell is filled. One factor on all three channels keeps the hue exact and keeps the per-cell lightness variation that gives a wall its depth: a brighter wall cell gets a brighter, still-dark fill. The world model needed nothing new — no field on a building, a car or a cell — because the fill is derived at the moment it's drawn, not stored anywhere.

Why a view, not a flag

Because the background plane costs nothing while it's black, turning the fill on could be gated behind a single per-frame check — the same check that already made the plate work free on every frame without one. That's what let this ship as --view classic|blocks|middle, read the same way --weather clear|rain|downpour already is, rather than as a setting that quietly changed the default look. Classic isn't a second code path kept alive for compatibility; it's the fill code simply not running. Every capture and every film already on this site stays exactly the frame it was shot as.

That distinction was checked, not assumed: three scripted --capture walks in --view classic came back byte-identical to the frames the engine wrote before any of this landed. And it's worth being straight about the reference clip here too — its whole four minutes are the new look, with no old-view section and no side-by-side. That it can fall back to a bare-glyph mode is a credible claim about how engines like this are usually built, but it isn't demonstrated in the source. Proving reversibility here, rather than assuming it, is the actual difference between a view and a repaint.

The three things that weren't free

Deriving the fill from a hue the engine already had was the easy 90%. Three things needed real work:

  • The ground had gaps. A road cell whose glyph is a space was simply skipped, so the street was already speckled — colour on the characters, black between, and the road is the single largest black area in any frame. In a filled view the ground now writes its fill even where no glyph lands. It's the single change that opens the street up the most.
  • Contrast had to be designed, not just derived. A glyph on a fill of its own hue can vanish if the two lightnesses sit too close. A flat 0.30 downscale — tried by eye against 0.24 and 0.38 side by side — keeps the ink about 3.3× brighter than its own field at every lightness, so it can't disappear even in the tightest case: amber on amber, a hue with no chroma to fall back on, only luminance.
  • Plates had to stay plates. The legibility scorer that grades a registration reads runs of coloured background, so once the whole city has one, a filled frame could read as one enormous plate. It didn't need fixing — the scorer already disambiguates a plate from its surroundings by a separate plate mask, not by the background colour itself — but that had to be checked, not assumed, and it's now a named test rather than a hope.

Indoors, the same rule holds

The reference engine's buildings read as one flat hue apiece; this engine's walls vary lightness cell to cell for depth, and that variation was the one thing not up for negotiation. Fill and keep the gradient, or the change is a downgrade wearing an upgrade's clothes.

Classic

Inside a room in AsciiWorldEngine's classic view: walls and floor drawn as coloured characters on black.
A room, same as every interior shot on this site until now.

Blocks

The same room in --view blocks: walls and floor filled with a shaded colour field behind the glyphs, the per-cell lightness that shows depth still visible on the walls.
Same room, same position. The wall still reads as a wall with depth on it, not a flat panel.

What it costs, measured here

Filling the background is new work on the hottest path in the renderer, so it was measured rather than assumed — six interleaved pairs of 400 frames at 180×60, tools/bench-view.sh, the same binary run in --view classic and --view blocks alternately so machine load lands on both equally:

Viewmean ms/framedelta
classic0.614
blocks0.732+19.2%

The machine wasn't fully settled while this ran — load average sat around 7–8 rather than the under-0.5 the project's own benchmarking notes ask for — but the method is interleaved pairs precisely so steady background load lands on both sides equally, and it reproduces the project's own +19% figure for the street almost exactly. Most of the cost is in paint, not the renderer itself: a filled frame emits a terminal colour-change sequence wherever the fill colour changes, where a classic frame emits none. Classic stays the default, and stays free — nothing pays this cost unasked.

The render got faster along the way

Profiling render broken down by pass, to see where the fill's cost was landing, found two passes doing work once a column that only depends on the row — sky was recomputing an elevation angle per cell instead of once a row, and ground was re-deriving the same world cell down a column where one screen cell spans several rows of it. Both were hoisted out.

Measured the same way, interleaved against the build immediately before this change, six pairs of 400 frames at 180×60:

mean ms/framedelta
before0.644
after, --view classic0.614−4.7%

The "after" binary includes the whole view-mode change — the extra branch on every pass that checks whether to fill — and it's still faster than the build before it existed, because the two hoists pay for the new branching several times over. Under the same machine conditions as the table above, so read the absolute numbers with the same caution; the direction and the rough size of the win are the finding. walls, the single most expensive pass, was left alone — it has no redundant work to hoist out, and chasing a few percent there risks the look for not much.

What shipped

Three named views, run the same way weather is and toggled live with B while you walk:

ViewWhat it does
--view classiccoloured glyphs on black — the look every capture on this site showed before this change, and still the default
--view blocksa filled colour field behind every surface — buildings, road and traffic alike
--view middlethe buildings filled, the road and traffic left bare — a lit skyline over a dark street

All three work everywhere the engine runs — --vista, --capture, --film, --doorway, --lift, the browser build — because the fill isn't a fourth picture the renderer draws. It's the frame buffer's own background plane, finally filled.


The engine and view-cost deltas above were measured directly on this machine with the project's own tools/bench-view.sh, not quoted from its commit message or docs. Everything else — the plumbing description, the contrast tuning, the plate guard — is read from the source and its own study document, docs/base-colour.md.

← Back to devlog