Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CHROMA

A first-person painter.

The square repainting itself

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.

Origin

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.

What it looks like

Unpainted Partway
0 / 115. Every region tagged with the pigment it wants. Partway. The far side of the square is done; overspray costs you Neatness.
Finished Title
Finished. The linework dissolves — it was a drawing, now it's a painting. The square drifts, colorless, behind the title.

Running it locally

npm install
npm run dev

Then open http://localhost:5180.

Controls

W A S D Move
Mouse Look
Click (hold) Spray
19 / Wheel Select pigment
R (hold) Shake can — repressurize
Shift / Space Sprint / Jump
H Toggle region numbers
Esc Pause
P Photo mode (after finishing)

The paint system

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 hide so 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.

Scoring

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.

Tuning

  • 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 SPRAY block at the top of src/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_AT in src/paint/paintsystem.js.

Layout

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.

About

A first-person painter. Paint-by-numbers with the trigger discipline of a shooter — built in Three.js with zero assets.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages