The colour system: palette structure, rationale, progressions
The colour system — one rationale for every palette
Section titled “The colour system — one rationale for every palette”This is the source of truth for how colour is structured in Dark Cosmos: which palette families
exist, where each family’s values live, the rationale behind each decision, and the progressions the
player reads as colour. It consolidates what was previously distributed across
docs/architecture/ui-tokens.md, rendering-and-performance.md, content-and-elemental.md,
DESIGN.md and the colour specs.
Every hex or value quoted here is illustrative. The files named in each section are the source of truth — prose copies go stale (the same rule as the determinism baselines). If code and this doc disagree, fix the doc in the same chunk as the code.
The two pillars (design law, from DESIGN.md)
Section titled “The two pillars (design law, from DESIGN.md)”- One cyan accent carries primary UI weight — near-black canvas, a single strong accent
(
accent.main, seed#33e6ff), not a rainbow of competing colours. Brand identity, not a style choice up for revision. - An identity colour is fixed per kind and never re-tinted for decoration. Colour differentiation is gameplay-legible signal (element / weapon / enemy identity) first, aesthetics second.
Everything else — other hex values, the glass/glow card treatment, bar styling — is current convention: genuinely good, revisable.
The five layers
Section titled “The five layers”Every colour on screen is decided by exactly one of five layers. When changing a colour, know which layer owns it; when adding a colour, wire it through the right layer.
| # | Layer | Values live in | Answers |
|---|---|---|---|
| 1 | UI brand tokens | ui/tokens/defaults.json (+ ui/tokens.gd) |
what menus, text, HUD chrome look like |
| 2 | Gameplay identity | data/bible.json, render/element_palette.gd, ui/weapon_icon.gd, main.gd colour LUTs |
which element / weapon / enemy is which |
| 3 | Semantic progressions | ui/hud.gd _hp_color, render/player_renderer.gd, render/cvd_palette.gd |
how the player reads urgency, threat, progression |
| 4 | Colour-blind remap | render/cvd_palette{,_sdr,_hdr}.gd |
how ~8% of players read the same signals |
| 5 | Output conditioning | render/color_gamut.gd, render/color_profile_{sdr,hdr}.gd, HdrLook |
what actually reaches SDR vs HDR / P3 screens |
1 — UI brand tokens
Section titled “1 — UI brand tokens”Every UI colour is a live token (see ui-tokens.md for the full TokenBind data flow). The
structure is two-level, which is what makes the family coherent:
- 12 seed hues (
seed.accentcyan,seed.neutralslate, blue, violet, magenta, red, orange, gold, green, lime, teal) — the only place raw hex is written. - Every role is an OKLCH ramp reference over a seed (
"@accent.150 c1.1"= hueaccent, ramp step 150, chroma 1.1; ramps computed byLtColorinaddons/live_tokens/). Families:text.bright→text.faint(a pure lightness ladder on the neutral ramp),surface.*(near-black translucent panels — never flat pure black),accent.*, and the per-familysuccess/gold/warn/danger/violet/magenta/blue/tealramps. - Rationale: changing one seed re-derives its whole family with consistent lightness/chroma
relationships, and a new role inherits the family’s perceptual spacing instead of a hand-picked hex.
Where two roles must read as different things (results-panel dealt-vs-taken damage, level-up card
roles), the choice is measured in OKLab ΔE (the dE notes in
ui/results_panel.gd,ui/level_up_panel_legacy.gd) — the same distance metric as the CVD palettes.
UI CanvasLayers draw above canvas_max_layer: no tonemap, no grade, UI 1.0 = UI white. UI
colours widen to P3 only through ColorGamut.ui_out (encoded PATH_A); Tokens.c() does this for
you, Tokens.c_src() returns the raw sRGB source for places that mix colours before widening.
2 — Gameplay identity colours
Section titled “2 — Gameplay identity colours”Elements — data/bible.json data.elements[].color (14 today) is the single source; it flows
through ElementPalette.color_for_srgb() → vivid() → per-draw-path widening. vivid() (sat ×1.7,
sat floor 0.92, value floor 0.9) pushes every hued colour hard so nothing reads washed-out on the
near-black field; greyscale colours pass through untouched so loot stays distinct. Rationale for the
floor at the source: one saturation policy everywhere, precomputed into LUTs at run start
(main.gd _element_color_lut/_element_color_vivid), never re-run per entity per frame.
Weapons — ui/weapon_icon.gd _COLOR is a fixed per-kind identity colour, and each icon is a
procedural mini-recreation of that weapon’s real fx so icon and field look cannot drift. Never
element-tinted (pillar 2): a weapon keeps its colour regardless of the element it carries. The one
intentional exception on the FIELD side: weapon FX of the light element render pale gold instead of
vivid yellow (ElementPalette.WEAPON_LIGHT_SAT, kill switch VisualLook.weapon_tints), because
light and lightning both hit the vivid saturation floor and converged into one hue under
additive blending.
Enemies — resting colour resolves by precedence (main.gd _base_color_for_type →
_enemy_colors):
- Bosses: their identity colour (
ArchetypeRenderer.identity_color; the Warden’s body takes its own tint, crimson when enraged viaBOSS_COLOR). - Swarmers inside a Dimension: that Dimension’s signature hue (
_swarmer_tinge; Light’s is a deterministic per-spawn random tinge). - Any enemy with an innate element: that element’s colour (so swarmer reads fire-red, tank cold-blue…).
- Otherwise: the bible enemy
colorfield, via_build_enemy_type_colors()’s type LUT (PATH_B; sized to the MAXTYPE_*id, ids are non-contiguous).
An active foreign-element aura overrides the body colour on screen so reactions stay legible. Enemy projectiles render in the firing enemy’s colour so a bullet reads as “the same enemy that fired it”.
Collision-avoidance rationale (why specific hues are what they are):
- Loot is white-cyan (
ElementPalette.GEM, greyscale sovivid()leaves it alone) — XP gems read as loot, not as an element, and no enemy/element hue can ever collide with them. This superseded the old “splitter teal to dodge the green gems” workaround: the gem went white instead, so poison enemies may read green safely today. - Auraless/elementless fallback is
ElementPalette.NEUTRALthreat magenta — no element uses it, so magenta always means “no signal”. - Elite overlays throb with the elite identity colour (
#c06afffamily), kept off the bosses and the skirmisher so mini-boss identity stays pure (_NO_LIVE_TINT_TYPES).
3 — Semantic progressions (colour the player reads as state)
Section titled “3 — Semantic progressions (colour the player reads as state)”- HP urgency ramp —
Hud._hp_color(frac):>60%cyan (accent.main),30–60%a lerp toward amber (gold.main),<30%red (danger.hot). Hue escalation = urgency escalation; reuse this exact curve as the trigger for any new HP-driven prominence so colour and visual weight escalate together. The three stops are also the CVDHP_HI/MID/LOentries; with a preset Off they read the tokens. (bar.hp— green — is only the stats-sheet preview bar, not the live HUD fill.) - Player ascension —
PlayerRenderer.TIER_ACCENT: cyan → teal → azure → violet → gold across the 5 level-tier bands, alongside halo scale 0.80→1.58, core heat 0→0.48 and thruster 0.40→1.28. The craft visibly ascends in hue, not just in size; crossing a tier pops the ascension flash. - THREAT hue — while a colour-vision preset is active, every hostile shot, zone, telegraph and
bomb collapses to one THREAT colour (
CvdPalette.threat, per-preset) plus redundant shape cues (rings on hostile bolts). With preset Off, hostile shots keep the firing enemy’s identity colour — danger is legible both ways. - Boss enrage — the established crimson threat family (
BOSS_COLOR), shared by the Warden’s enraged tint and danger tones.
4 — The colour-blind remap (signal, not art)
Section titled “4 — The colour-blind remap (signal, not art)”Spec: docs/superpowers/specs/2026-10-20-colourblind-mode-design.md. Settings row “Colour vision”
(Off / Deuteranopia / Protanopia / Tritanopia), saved per device (MetaState.cvd_preset) — a player
preference, not a remote tunable.
- Remaps only gameplay-critical colour: every element, the THREAT hue, the HP bands,
UI_GOOD/UI_EVOLVE, and the stats-sheet preview roles. The art is never remapped: backgrounds, nebulae, area tints, hulls, glass UI, boss identity colours, menus all stay. - Two full tables —
CvdPaletteSdrandCvdPaletteHdr(60 entries each: 14 elements + THREAT + HP_HI/MID/LO + UI_GOOD/UI_EVOLVE per preset), picked by the active colour profile. Table values bypassvivid(): the palette’s lightness spacing would be undone by the saturation floor. - Method, so the numbers are reproducible: Machado/Oliveira/Fernandes (2009) simulation matrices for verification; pairwise OKLab ΔE_ok maximized (readable ≈5, target ≥10) under the target deficiency and normal vision; readability floor OKLab L ≥ 0.62 on the near-black field; seeds from the Okabe-Ito and IBM colour-blind-safe families, refined by simulated annealing.
- All 120 values are live local colour tunables (
"local": true, per device — never a store flip, Apple 2.3.1).CUES_FOR_ALLis the dev A/B for shape cues at preset Off. - Verification you can look at:
tools/ui_captureCVD tableau + simulation sheets (UI_CVD=…), floor/spacing tests intests/test_cvd_palette.gd, behaviour teststest_cvd_{hostile,hp_fill,ui}.gd. - Cache invalidation:
CvdPalette.generationis polled by every colour-cache owner (main LUTs,GamutGlobals,EnemyAuraLook,ZoneRenderer, elite fx) — one bump rebuilds them all, and bumpsColorGamut.generationtoo.
5 — Output conditioning (what actually reaches the screen)
Section titled “5 — Output conditioning (what actually reaches the screen)”- Two full colour systems, SDR and HDR (spec
docs/superpowers/specs/2026-10-08-hdr-sdr-colour-systems-design.md): every colour source has an independent SDR and HDR value set (ColorProfileSdr/ColorProfileHdr,HdrLook.activepicks,main._apply_color_profileapplies). HDR values were seeded from a headroom mapping once, then tuned independently. Nothing derives one mode’s values from the other at runtime, and SDR gets its own A/B verdict — it is not a leftover. - P3 widening (
render/color_gamut.gd, plan2026-10-11-hdr-p3-palette.md): the sRGB source is widened once in OKLCH — hue and lightness stay, chroma moves to the same relative position on the P3 boundary — cached per draw path, dialed 0→1 byGAMUT_WIDEN(UI:GAMUT_UI_WIDEN). - Draw paths (
HdrTier): PATH_A (modulate /draw_*/source_color/ Line2D — engine linearises), PATH_B (MultiMesh instance COLOR /cs_inuniforms — NOT linearised), PATH_R (particles / Light2D / raw vec uniforms). Widen once at the source; never re-feed a widened colour; run HSV/lerp maths on the sRGB value first, then widen. Never call.h/.s/.von a widened result (negative channels make HSV meaningless). - Soft translucent draws on the linear canvas need the
hdr_alpharemap or they read 2–10× brighter and hazy (see CLAUDE.md; the unremapped-alpha batch). Dark veils/dims need the inverse, not this remap. - Invalidation:
ColorGamut.generation(world colours) andui_generation(UI), refreshed frommain._sync_output_gamutevery frame.
Working rules (the law, condensed)
Section titled “Working rules (the law, condensed)”- Colour is signal. Never re-tint an identity colour for decoration; when in doubt the answer is no.
- One cyan accent carries UI weight; everything else is a family or an identity.
- No collisions: loot is white, the auraless fallback is a hue no element uses, danger is one hue — and where two colours must read differently, measure ΔE rather than eyeball it.
vivid()at the source, into precomputed LUTs — never per-entity per-frame HSV maths.- Widen once per draw path; sRGB maths first. A widened colour is final.
- CVD remaps signal, not art, and its values bypass
vivid(). - Two output systems, independently tuned; SDR changes get their own A/B verdict.
- Any new colour value that could be tuned ships as a live Remote Console tunable, not a const.
Adding or changing a colour — checklist by family
Section titled “Adding or changing a colour — checklist by family”- UI colour: add/adjust a role in
ui/tokens/defaults.jsonas a ramp reference (raw hex only on aseed.*).tests/test_token_keys_exist.gdcatches typos; TokenBind makes it live. Check ΔE to the roles it sits beside. - New element: bible
colorfield — it flows everywhere automatically, but add its SDR + HDR entries to all threeCvdPalettepresets and re-check the floor/spacing tests (tests/test_cvd_palette.gd). - New enemy: bible
color+EnemyPool.TYPE_*+_build_enemy_type_colorsLUT sizing (max id, non-contiguous) — full wiring in skillbh-add-content. Remember an innate element outranks the type colour in the resting look (precedence above). - New weapon kind:
WeaponIcon._COLORentry + bible element; the icon is never element-tinted. - Any new gameplay-visible state needs a render consumer in the same chunk and a
tools/vfx_libraryentry + score — undrawn state ships invisible. - Anything translucent + soft: give it the
hdr_alpharemap path and a saved material.
Known gaps (honest inventory, 2026-10-26)
Section titled “Known gaps (honest inventory, 2026-10-26)”- Cross-family ΔE is only verified inside the CVD palettes. Enemy-vs-enemy and element-vs-enemy hue collisions on the field are eyeballed, not measured — an audit tool would close this.
bar.hp(green) vs the live HP fill (cyan→amber→red): the stats-sheet preview bar reads a different family than the HUD bar it previews. Probably one of them is wrong; needs a Chris call.- Enemy identity vs element precedence means an element-carrying type’s bible
coloris dormant — deliberate (reaction legibility first), but it surprises anyone editing bible colours expecting them to show. - Weapon identity hues are hand-tuned consts (
WeaponIcon._COLOR), not tokens or bible data; the procedural mini-fx design is the drift mitigation, not a guarantee.