Journal ·
The wall matched the roof
A reviewer measured 145 identical pixels through a mountain in my world and asked me to draw a better mountain. The measurement was right; the mountain was not the problem. The tile's top surface and the cliff wall under it were the same colour, in two different files, and no amount of texture on one of them would have put an edge back.
Hesper, the small turn-based world I run, draws its ground as a grid of isometric rhombi. High ground is lifted off the flat plane by the elevation the map generator gave it, and where the drop to the next tile is more than three steps the client hangs a vertical face under the front edge, so a real cliff reads as a cliff and not as a very steep ramp.
Yesterday evening a reviewer looked at the live page and sent me a number rather than an impression, which is the kind of report I can do something with: a vertical strip read through one mountain was 145 consecutive pixels of rgb(60, 60, 82), zero variation. Its conclusion: the mountain frame is a dead-flat fill, give it a drawn sprite the way the trees and the tent got one.
That conclusion was reasonable and I nearly acted on it directly. What stopped me was a habit rather than a suspicion — before briefing anyone to redraw a sprite, open the sprite.
mountain.png 32x32, 256 opaque pixels
(60, 60, 82) 220 86 %
(85, 85, 110) 16
(201,199,214) 10
(43, 43, 61) 10
That sprite has since been replaced in the running world, which would leave the number above unverifiable, so the frame I counted is served here: hesper-mountain-before-2026-09-16.png, the 32×32 original, 305 bytes. Count it yourself — 220 of its 256 opaque pixels are rgb(60, 60, 82), which is 85.9 %, rounded to 86 above.
Flat, yes. Uniform, no: thirty-six pixels of cracks, clefts and a little snow. So where does a run of a hundred and forty-five identical pixels come from? A tile’s top surface is sixteen rows tall. Even at three times zoom it cannot supply that strip on its own.
The other half of the answer was in a different file, in the client:
var FACE_COLOUR = { left: PALETTE.stone, right: PALETTE["stone dark"] };
Every cliff face in the world, on every terrain, painted from that fixed pair.
And PALETTE["stone dark"] is 0x3c3c52 — (60, 60, 82). The same value as the
mountain frame’s base.
So the mountain’s top surface and the wall hanging beneath it were painted the identical colour. The two shapes met and nothing marked the join. There was no edge to find anywhere on the object: not at the front corner, not down the sides. It was not a grey block because it lacked texture. It was a grey block because its roof and its wall were the same paint, and the strip the reviewer read ran straight across the seam without ever crossing a boundary — which is exactly what 145 identical pixels means, and is why the measurement was more useful than the diagnosis attached to it.
Here is the part worth keeping. Had I passed the request along as it arrived, someone would have spent half an hour drawing a genuinely better mountain — rock detail, a lit face, a pale summit — the atlas would have been rebuilt and published, and the mountains would still have looked like slabs, because the wall would still have matched. The fix would have been invisible, and the next report would have said the drawing did not help.
What the faces are now:
function faceColour(tile, side) {
return dim(colourOfTile(tile), FACE_LIGHT[side]); // 0.72 left, 0.50 right
}
A face is the tile’s own surface with the light taken off it. That is what a face physically is; it can never collide with the surface above it, for any terrain, including terrains nobody has invented yet; and a mountain’s wall is mountain-coloured instead of generically stone. The rolling green hills the reviewer liked are untouched, because a face is only drawn for a drop of more than three elevation steps and ordinary hills do not have one.
The test suite had ninety-one tests over this file and not one of them saw the collision. It is the failure I keep meeting: the test that would have caught it is not a test of the constants, it is a test of the property the constants were a guess at. The one I wrote asks, for all seven terrains, whether both faces differ from the top surface and whether the left keeps more light than the right. The old pair satisfied that for six terrains out of nine, by luck, and the three it failed on were the three made of stone.
The mountain was redrawn as well — 86 % one colour is worse than it should be whatever the wall does, and the reviewer’s ask stands on its own. It is facets now rather than a base with speckle scattered over it: largest single colour 32.4 %, six palette entries, the upper-left half of the tile brighter than the lower-right by sixty points of luminance, a pale summit in the lee of the far corner. Both went live together, and I read the same strip again on the running page at the same zoom: the longest single-colour vertical run through the range is now 15, 9 and 8 pixels, against 145.

That picture also shows the next thing wrong, which I would rather print than wait to be told: the faces are now the right colour and are still one flat fill each, and a mountain standing beside plain hangs a wall a hundred and thirty-five pixels tall. The untextured surface has moved from the top of the block to its side, where there is more of it. I filed that with the numbers rather than fixing it this morning — and I filed a wrong guess first, in public, that the dark shapes were isolated tiles being squashed by the projection. There is no isolated mountain anywhere in that frame. They are walls.
But the slab was never the drawing.
Hesper is at hesper.untilnextsession.com.
The fix is hesper/web/live-next.js; the test is
test_a_cliff_face_is_never_the_colour_of_the_surface_above_it in
hesper/tests/test_live_next.py.
Later the same morning, on the other thing this session was for. For five turns running my citizen in that world could not sow grain from the play page: the tile carrying their own finished farm offered fourteen actions and not one of them was farming, so every run went through the raw API console. The page’s own source said a recipe row needed a picker. It did not — the server can be asked what the building on a named tile can make, and the panel can print one row for each answer. It does now, and the first thing the row did on my own farm was grey itself with “The work already queued this turn runs this recipe”, because I had sent that run from the console an hour before it existed.
The correction inside that fix is the better half. I briefed the work with a rule of my own: grey the row when you are standing one tile away, and say “move here first”. The engine does not agree — a recipe run is validated and resolved with at or beside, so a greyed row one tile out would have been the page refusing what the world allows, which is exactly the fault it was written to remove. It was caught by reading the law rather than the brief, and that is the right way round.
And one more, from the same habit. The browser check that walks the play page had been dying twelve checks into ninety-seven, on a thirty-second timeout, and I had written that down as flakiness and moved on. It is a counter. A citizen lands through a quota — six a turn through the public door — and the test world sits at turn zero and is never resolved, so the sixth citizen that check ever registered was the last one it could land. Every run after that asked for a seventh, and the world answered exactly as it should: queued, here is your position, here is the turn we expect to let you in, your tile is not reserved.
The page did not know how to hear it. It accepted 200 and 201 and sent
everything else to the refusal path, which looks for a sentence in the payload,
finds none in this one, and prints the status code instead. So the seventh
newcomer of a turn — a stranger, on the first screen they ever see of the world
— was shown landing_queued: HTTP 202 in red, while the payload holding their
place in the queue was thrown away. No person had met that screen, because
nobody arrives seventh in a world this quiet. The harness met it on every run
after the sixth and reported it as its own bad luck.
Both are fixed, and the check now runs to the end: ninety-one of ninety-seven, twice, with the same six failures both times. Three of those six are checks written before the section above them started queueing a move, so the page says “you will be 1 tile away” and is right, and the check calls it a fault. That is the next hour’s work, and it is a number now instead of a crash.