Attribute clash
A night road in ordered dither on a ZX Spectrum screen. The car’s tail lights recolour every 8x8 attribute cell they touch.
Made with Claude Opus 5.5
- Technique
- pixel art, dithering
- Inspired by
- vintage computers
- Shape
- Any screen
- Added
- 27 September 2026
Colours
- #1C1B1Abackground
- #DAD8CEforeground
- #CF6A4Caccent
Export
FormatThis browser can’t make WebP files.
Shapecropped from 16:9
Crop
SizeThis browser can’t draw a file that large.
Notes
The ZX Spectrum stored colour once per 8x8 block of pixels, one ink and one paper for the whole block. Anything lit inside a block recolours every ink pixel in it, and that spill is attribute clash.
Sources
- Sinclair Research, ZX Spectrum graphic modes, 1982.
- Attribute clash.
Source code
wallpapers/attribute-clash/design.py, 101 lines
"""A ZX Spectrum night road in ordered dither, where two tail lights recolour the ink of every 8x8 attribute cell they touch."""
import numpy as np
from walldye import ACCENT, BG, BG_ALT, BG_DEEP, UI_HI, Canvas, P, Vec, by_regime, design, mix
from walldye.pixel import Pixels
SW, SH, ATTR = 256, 192, 8 # Spectrum screen in pixels; attribute cell side
BORDER = 8 # screen border, in pixels
PAPER, INK, LIT = 0, 1, 2 # screen palette indices
VX, HZ = 127, 91 # vanishing point column, horizon row
SLOPE = 0.42 # road half-width per row below the horizon
CAR = """
.......############.......
......##.#.#.#.#.#.#......
.....##.#.#.#.#.#.#.#.....
....##.#.#.#.#.#.#.#.#....
.########################.
#........................#
#.LL.......####.......LL.#
#........................#
##########################
.###..................###.
"""
CAR_X, CAR_Y = 114, 150
STREAKS = (2, 3, 5, 8, 12, 17, 23) # rows of wet-road reflection below the bumper
# BG_DEEP recedes on a dark ground but vanishes on paper, so half a BG_ALT step frames it there.
BORDER_TONE = by_regime(BG_DEEP, mix(BG, BG_ALT, 0.5))
@design(aspects="any")
def draw(s: Canvas) -> None:
# A portrait screen is too narrow for 4-unit pixels plus the border.
px = 4 if s.landscape else 3
size = Vec(SW, SH) * px
c = s.pick(landscape=(0.7, 0.493), portrait=(0.5, 0.4), snap=px)
# keep the border clear of the right edge on narrow landscapes (16:10)
c = Vec(min(c.x, s.w - size.x / 2 - 2 * BORDER * px), c.y)
o = c - size / 2
ys, xs = np.mgrid[0:SH, 0:SW]
cols = np.arange(SW)
sky = 0.22 * np.clip((ys - HZ + 40) / 40, 0, 1) ** 1.8
# the sparsest Bayer level reaches the bottom edge
ground = 0.035 + 0.2 * np.clip(1 - (ys - HZ) / (SH - HZ), 0, 1) ** 1.6
tone = np.where(ys < HZ, sky, ground)
ridge = np.round(HZ - 7 - 5 * np.sin(cols / 23 + 1) - 3 * np.sin(cols / 9 + 2)).astype(int)
tone[(ys > ridge) & (ys < HZ)] = 0.5
# the road plus a clear verge for the posts
tone[(ys > HZ) & (np.abs(xs - VX) < (ys - HZ) * SLOPE * 1.22 + 2)] = 0
scr = Pixels(SW, SH, [None, UI_HI, ACCENT])
scr.dither(tone, [PAPER, INK])
scr.grid[HZ, :] = INK
scr.grid[ridge, cols] = INK
r = s.rng(9)
for _ in range(14): # stars
scr.grid[r.randrange(4, HZ - 34), r.randrange(4, SW - 4)] = INK
for side in (-1, 1):
# aimed past the bottom edge so the kerb keeps its slope to the last row
end = round(VX + side * SLOPE * (SH + 20 - HZ))
scr.line(VX, HZ, end, SH + 20, INK)
for k in range(1, 9): # verge posts, evenly spaced in depth
z = 1.15 * k
y = round(HZ + 100 / z)
x = round(VX + side * (SLOPE * 100 / z * 1.11 + 2))
scr.grid[y - max(1, round(10 / z)) + 1 : y + 1, x] = INK
dash = np.zeros(SH, bool)
for z in np.arange(0.9, 12, 0.7).tolist(): # centre dashes
dash[round(HZ + 100 / (z + 0.25)) : round(HZ + 100 / z)] = True
dash[CAR_Y - 4 : CAR_Y + 10] = False # a gap, so the dash doesn't read as an aerial
scr.grid[dash, VX] = INK
scr.stamp(CAR, CAR_X, CAR_Y, {".": PAPER, "#": INK, "L": INK})
rows = CAR.strip().split("\n")
lamps = [(j, i) for j, row in enumerate(rows) for i, ch in enumerate(row) if ch == "L"]
lit = np.zeros((SH, SW), bool)
for j, i in lamps:
lit[CAR_Y + j, CAR_X + i] = True
# Tail-light reflections streak down the wet road, one under each lamp.
for i in sorted({i for _, i in lamps})[::2]:
for k in STREAKS:
lit[CAR_Y + len(rows) + k, CAR_X + i : CAR_X + i + 2] = True
scr.grid[lit] = INK
# One ink per attribute cell: every ink pixel in a cell that holds a light turns LIT.
cells = lit.reshape(SH // ATTR, ATTR, SW // ATTR, ATTR).any(axis=(1, 3))
clash = cells.repeat(ATTR, 0).repeat(ATTR, 1)
scr.grid[clash & (scr.grid == INK)] = LIT
b = BORDER * px
frame = P().rect(o.x - b, o.y - b, size.x + 2 * b, size.y + 2 * b)
s.fill(frame.rect(o.x, o.y, size.x, size.y), BORDER_TONE, rule="evenodd")
rules = P()
for i in range(1, SW // ATTR):
rules.M(o.x + i * ATTR * px, o.y).V(o.y + size.y)
for j in range(1, SH // ATTR):
rules.M(o.x, o.y + j * ATTR * px).H(o.x + size.x)
s.stroke(rules, BG_ALT, 1)
scr.draw(s, px, o)"""A ZX Spectrum night road in ordered dither, where two tail lights recolour the ink of every 8x8 attribute cell they touch."""
import numpy as np
from walldye import ACCENT, BG, BG_ALT, BG_DEEP, UI_HI, Canvas, P, Vec, by_regime, design, mix
from walldye.pixel import Pixels
SW, SH, ATTR = 256, 192, 8 # Spectrum screen in pixels; attribute cell side
BORDER = 8 # screen border, in pixels
PAPER, INK, LIT = 0, 1, 2 # screen palette indices
VX, HZ = 127, 91 # vanishing point column, horizon row
SLOPE = 0.42 # road half-width per row below the horizon
CAR = """
.......############.......
......##.#.#.#.#.#.#......
.....##.#.#.#.#.#.#.#.....
....##.#.#.#.#.#.#.#.#....
.########################.
#........................#
#.LL.......####.......LL.#
#........................#
##########################
.###..................###.
"""
CAR_X, CAR_Y = 114, 150
STREAKS = (2, 3, 5, 8, 12, 17, 23) # rows of wet-road reflection below the bumper
# BG_DEEP recedes on a dark ground but vanishes on paper, so half a BG_ALT step frames it there.
BORDER_TONE = by_regime(BG_DEEP, mix(BG, BG_ALT, 0.5))
@design(aspects="any")
def draw(s: Canvas) -> None:
# A portrait screen is too narrow for 4-unit pixels plus the border.
px = 4 if s.landscape else 3
size = Vec(SW, SH) * px
c = s.pick(landscape=(0.7, 0.493), portrait=(0.5, 0.4), snap=px)
# keep the border clear of the right edge on narrow landscapes (16:10)
c = Vec(min(c.x, s.w - size.x / 2 - 2 * BORDER * px), c.y)
o = c - size / 2
ys, xs = np.mgrid[0:SH, 0:SW]
cols = np.arange(SW)
sky = 0.22 * np.clip((ys - HZ + 40) / 40, 0, 1) ** 1.8
# the sparsest Bayer level reaches the bottom edge
ground = 0.035 + 0.2 * np.clip(1 - (ys - HZ) / (SH - HZ), 0, 1) ** 1.6
tone = np.where(ys < HZ, sky, ground)
ridge = np.round(HZ - 7 - 5 * np.sin(cols / 23 + 1) - 3 * np.sin(cols / 9 + 2)).astype(int)
tone[(ys > ridge) & (ys < HZ)] = 0.5
# the road plus a clear verge for the posts
tone[(ys > HZ) & (np.abs(xs - VX) < (ys - HZ) * SLOPE * 1.22 + 2)] = 0
scr = Pixels(SW, SH, [None, UI_HI, ACCENT])
scr.dither(tone, [PAPER, INK])
scr.grid[HZ, :] = INK
scr.grid[ridge, cols] = INK
r = s.rng(9)
for _ in range(14): # stars
scr.grid[r.randrange(4, HZ - 34), r.randrange(4, SW - 4)] = INK
for side in (-1, 1):
# aimed past the bottom edge so the kerb keeps its slope to the last row
end = round(VX + side * SLOPE * (SH + 20 - HZ))
scr.line(VX, HZ, end, SH + 20, INK)
for k in range(1, 9): # verge posts, evenly spaced in depth
z = 1.15 * k
y = round(HZ + 100 / z)
x = round(VX + side * (SLOPE * 100 / z * 1.11 + 2))
scr.grid[y - max(1, round(10 / z)) + 1 : y + 1, x] = INK
dash = np.zeros(SH, bool)
for z in np.arange(0.9, 12, 0.7).tolist(): # centre dashes
dash[round(HZ + 100 / (z + 0.25)) : round(HZ + 100 / z)] = True
dash[CAR_Y - 4 : CAR_Y + 10] = False # a gap, so the dash doesn't read as an aerial
scr.grid[dash, VX] = INK
scr.stamp(CAR, CAR_X, CAR_Y, {".": PAPER, "#": INK, "L": INK})
rows = CAR.strip().split("\n")
lamps = [(j, i) for j, row in enumerate(rows) for i, ch in enumerate(row) if ch == "L"]
lit = np.zeros((SH, SW), bool)
for j, i in lamps:
lit[CAR_Y + j, CAR_X + i] = True
# Tail-light reflections streak down the wet road, one under each lamp.
for i in sorted({i for _, i in lamps})[::2]:
for k in STREAKS:
lit[CAR_Y + len(rows) + k, CAR_X + i : CAR_X + i + 2] = True
scr.grid[lit] = INK
# One ink per attribute cell: every ink pixel in a cell that holds a light turns LIT.
cells = lit.reshape(SH // ATTR, ATTR, SW // ATTR, ATTR).any(axis=(1, 3))
clash = cells.repeat(ATTR, 0).repeat(ATTR, 1)
scr.grid[clash & (scr.grid == INK)] = LIT
b = BORDER * px
frame = P().rect(o.x - b, o.y - b, size.x + 2 * b, size.y + 2 * b)
s.fill(frame.rect(o.x, o.y, size.x, size.y), BORDER_TONE, rule="evenodd")
rules = P()
for i in range(1, SW // ATTR):
rules.M(o.x + i * ATTR * px, o.y).V(o.y + size.y)
for j in range(1, SH // ATTR):
rules.M(o.x, o.y + j * ATTR * px).H(o.x + size.x)
s.stroke(rules, BG_ALT, 1)
scr.draw(s, px, o)
Run it yourself
$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render attribute-clash --theme fireproof -o attribute-clash-fireproof-16x9.svg