Skip to content

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)”
  1. 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.
  2. 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.

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

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.accent cyan, seed.neutral slate, 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" = hue accent, ramp step 150, chroma 1.1; ramps computed by LtColor in addons/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-family success / gold / warn / danger / violet / magenta / blue / teal ramps.
  • 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.

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):

  1. Bosses: their identity colour (ArchetypeRenderer.identity_color; the Warden’s body takes its own tint, crimson when enraged via BOSS_COLOR).
  2. Swarmers inside a Dimension: that Dimension’s signature hue (_swarmer_tinge; Light’s is a deterministic per-spawn random tinge).
  3. Any enemy with an innate element: that element’s colour (so swarmer reads fire-red, tank cold-blue…).
  4. Otherwise: the bible enemy color field, via _build_enemy_type_colors()’s type LUT (PATH_B; sized to the MAX TYPE_* 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 so vivid() 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.NEUTRAL threat magenta — no element uses it, so magenta always means “no signal”.
  • Elite overlays throb with the elite identity colour (#c06aff family), 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 CVD HP_HI/MID/LO entries; 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 — CvdPaletteSdr and CvdPaletteHdr (60 entries each: 14 elements + THREAT + HP_HI/MID/LO + UI_GOOD/UI_EVOLVE per preset), picked by the active colour profile. Table values bypass vivid(): 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_ALL is the dev A/B for shape cues at preset Off.
  • Verification you can look at: tools/ui_capture CVD tableau + simulation sheets (UI_CVD=…), floor/spacing tests in tests/test_cvd_palette.gd, behaviour tests test_cvd_{hostile,hp_fill,ui}.gd.
  • Cache invalidation: CvdPalette.generation is polled by every colour-cache owner (main LUTs, GamutGlobals, EnemyAuraLook, ZoneRenderer, elite fx) — one bump rebuilds them all, and bumps ColorGamut.generation too.

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.active picks, main._apply_color_profile applies). 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, plan 2026-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 by GAMUT_WIDEN (UI: GAMUT_UI_WIDEN).
  • Draw paths (HdrTier): PATH_A (modulate / draw_* / source_color / Line2D — engine linearises), PATH_B (MultiMesh instance COLOR / cs_in uniforms — 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/.v on a widened result (negative channels make HSV meaningless).
  • Soft translucent draws on the linear canvas need the hdr_alpha remap 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) and ui_generation (UI), refreshed from main._sync_output_gamut every frame.
  1. Colour is signal. Never re-tint an identity colour for decoration; when in doubt the answer is no.
  2. One cyan accent carries UI weight; everything else is a family or an identity.
  3. 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.
  4. vivid() at the source, into precomputed LUTs — never per-entity per-frame HSV maths.
  5. Widen once per draw path; sRGB maths first. A widened colour is final.
  6. CVD remaps signal, not art, and its values bypass vivid().
  7. Two output systems, independently tuned; SDR changes get their own A/B verdict.
  8. 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.json as a ramp reference (raw hex only on a seed.*). tests/test_token_keys_exist.gd catches typos; TokenBind makes it live. Check ΔE to the roles it sits beside.
  • New element: bible color field — it flows everywhere automatically, but add its SDR + HDR entries to all three CvdPalette presets and re-check the floor/spacing tests (tests/test_cvd_palette.gd).
  • New enemy: bible color + EnemyPool.TYPE_* + _build_enemy_type_colors LUT sizing (max id, non-contiguous) — full wiring in skill bh-add-content. Remember an innate element outranks the type colour in the resting look (precedence above).
  • New weapon kind: WeaponIcon._COLOR entry + 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_library entry + score — undrawn state ships invisible.
  • Anything translucent + soft: give it the hdr_alpha remap path and a saved material.
  • 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 color is 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.