A first-person painter.
The end-of-game time-lapse — your painting replayed in the order you made it.
The world was drawn but never colored. You spawn in a Mediterranean village square rendered as blank paper with ink outlines, holding a spray can and nine pigments. Every surface is a numbered region. Hit it with the right color, keep your overspray off the neighbors, and bring the square back to life.
It's a shooter's control scheme pointed at something that isn't shooting.
This is a response to the one-shot game-building challenge @mattshumer_ kicked off — build a AAA-quality first-person shooter in Three.js from a single prompt. That challenge produced a lot of very good shooters.
CHROMA asks what else the format is good for. Strip the shooting out of a first-person shooter and the machinery underneath is still excellent: a crosshair is a precision pointing device in 3D space with instant feedback. Doom is fun because pointing-and-committing feels good, not because of the gore. Every FPS verb has an honest counterpart in painting:
| FPS | CHROMA |
|---|---|
| Weapon slots 1–9 | Pigment slots 1–9 |
| Ammo / reload | Can pressure / shake to repressurize |
| Headshot precision | Coverage without hitting the neighbors |
| Enemies die | Regions come alive |
| Level clears | The world goes from dead gray to vibrant |
A Call of Duty map looks identical at minute ten. Here you end up standing inside your own painting.
npm install
npm run devThen open http://localhost:5180.
W A S D |
Move |
| Mouse | Look |
| Click (hold) | Spray |
1–9 / Wheel |
Select pigment |
R (hold) |
Shake can — repressurize |
Shift / Space |
Sprint / Jump |
H |
Toggle region numbers |
Esc |
Pause |
P |
Photo mode (after finishing) |
This is the part that isn't a toy. Regions do not "turn on" when you've sprayed enough at them; paint genuinely accumulates in texture space.
Each region owns a private WebGLRenderTarget. To lay down a puff, the region's
own geometry is drawn with a shader whose vertex stage writes
gl_Position = uv * 2 - 1 — the mesh is rasterized into its own UV layout,
flattened out like a pelt. The fragment stage still receives the interpolated
world position, so it measures true 3D distance from that texel's surface
point to the nozzle.
That single trick buys everything that makes it feel like paint:
- spherical falloff in world units, not stretched UV units
- strokes cross UV seams correctly — both islands see the same 3D sphere
- back faces reject themselves via the spray-direction dot product
- layering is just alpha compositing, so later coats cover earlier mistakes
Coverage is tracked on the CPU against a cloud of surface probes that replays the same falloff and compositing math as the shader, so the progress bar can't drift away from what's actually on the wall. No GPU readback, no pipeline stall.
Supporting pieces:
- UV atlasing (
src/world/geoutil.js) — three's primitives map every box face onto the same 0..1 square, so painting one face would bleed onto all six. Each draw group gets packed into its own atlas cell with a texel gutter. - Reachability — a paintable surface must be reachable from where a player
can stand, or its region can never complete. Walls are planes, roofs are
open-ended pyramids, and anything with a buried face declares
hideso those probes get pruned. - Ink (
src/fx/post.js) — a normal+depth prepass feeds a Sobel pass that draws real outlines, including interior creases. The linework thins as the canvas fills and dissolves entirely at the end. - Audio (
src/core/audio.js) — every sound is synthesized at runtime, no files. The spray is bandpassed noise whose filter tracks can pressure, so a dying can audibly thins and starts to spit. Completion chimes are pentatonic and indexed by pigment, so finishing regions makes music.
Everything is procedural. There is not a single texture, model, or audio file in this repository.
A region completes at 94% correct coverage and reverts below 80% — so burying finished work under the wrong pigment genuinely undoes it, while clipping it with a stray pass doesn't. Neatness is the share of the world currently holding the wrong color. Streak counts consecutive regions finished cleanly; it measures craft, not speed.
- Session length: 115 regions ≈ 12–18 minutes for a completionist. Drop a
building or a window row in
SquareBuilder.build()to shorten it. - Spray feel: the
SPRAYblock at the top ofsrc/game.js. The distance→radius curve is the core skill expression — step close for shutters and trim, back off to lay in a whole wall. - Difficulty:
COMPLETE_AT/UNCOMPLETE_AT/PAINT_ATinsrc/paint/paintsystem.js.
src/
game.js state machine, spray resolution, scoring, finale
core/ input, synthesized audio
paint/ paintsystem (splat + coverage), paintmaterial, markers
world/ scene (the square), geoutil (UV atlas + probes), sky, palette
player/ controller (FPS movement), viewmodel (the can)
fx/ post (ink/bloom/grade), particles
ui/ hud
Three.js + Vite. Built with Claude Code.




