Per-entity render color is tier-driven, single source of truth in tier.color, sent to the shader as a vec4 uniform
Context
Phase 3 of the approved merge-puzzler plan: every entity was rendering as a hardcoded solid red circle (render_ffi.mjs's fragment shader returned a literal vec4<f32>(1.0, 0.0, 0.0, 1.0)), with no way to tell tiers apart visually -- essential once entities of six different tiers coexist on screen simultaneously.
Options considered
Decision
render.gleam gained a private tier_colors(entities) -> List(#(Float, Float, Float)) helper (list.map over tier.color(e.tier)), computed once per render call and passed as a new positional parameter into both render_entities_to_bytes_raw and render_entities_to_canvas_raw (renderEntitiesToBytes/renderEntitiesToCanvasRaw in render_ffi.mjs). render_ffi.mjs gained a 5th uniform binding (binding 4, FRAGMENT-stage visibility) of type vec4<f32> (not vec3 -- WGSL's uniform address-space alignment requires vec3<f32> to be sized/aligned as 16 bytes anyway, so vec4 sidesteps a real padding footgun for no extra cost, alpha always 1.0). drawEntities zips entities with their same-length-by-construction colors array by index, building one color uniform buffer per entity alongside the existing center/radius buffers. fs_main now returns the color uniform directly instead of the hardcoded red literal.
Verification
140 JavaScript tests passing (up from 139 pre-Phase-3), fully additive: one new dedicated test (render_entities_to_bytes_uses_each_entitys_own_tier_color_test) renders two entities of two different tiers (0 and 3, both exact 0/255 RGB byte values so there's no float-to-byte rounding ambiguity) in a single draw call, positioned far enough apart not to touch, and asserts the correct, distinct color lands at each entity's own known texture pixel -- real GPU execution via Deno's native WebGPU (OffscreenCanvas), not mocked. Every pre-existing pixel-red assertion across render_test.gleam and game_test.gleam still passes unchanged, since tier 0's color is pinned to exactly (1.0, 0.0, 0.0) (established in decision 0017/0019's tier_test.gleam). Manual eyeball verification via the dev server was not performed: this project's headless-browser harness (tools/browser_check.ts) already documents, from its own prior investigation, that WebGPU canvas presentation pixel readback does not work under headless Chromium on Linux (upstream limitation, https://github.com/gpuweb/gpuweb/issues/1781) -- the new real-GPU-readback test above is a strictly stronger verification of exact byte-level color correctness than an eyeball check would have been.
Consequences
Any future entity property that needs to reach the shader per-entity (not just color) now has a proven pattern to follow: compute it in Gleam from typed entity/tier data, thread it through the *_raw FFI layer as a new positional list parameter, zip it with entities in drawEntities. tier.color remains the only place to edit to retune any tier's visual appearance.