Skip to content

Colour-blind mode: presets + shape cues (1.2)

Status: design for Chris’s veto, 2026-10-20. Direction decided by Chris on 2026-10-20: Settings presets plus redundant shape cues. Target: store 1.2. Tracks GitHub issue #78 and AAA audit gap 5 (“danger/element read by hue”, docs/research/2026-10-15-aaa-tooling-gap-audit.md). Plan: docs/superpowers/plans/2026-10-20-colourblind-mode.md.

Dark Cosmos puts danger and element identity into hue on a near-black field. About 8% of men have a red-green deficiency. Under protanopia or deuteranopia today, a hostile shot and the player’s own shot of the same element look the same. The blood slow zone and the poison hazard look the same as a friendly fire pool. Fire, blood and decay collapse into one brown. The game is still playable, but the player reads the screen wrong, and in a bullet heaven that means taking hits.

  1. Settings row “Colour vision”: Off / Deuteranopia / Protanopia / Tritanopia. The left/right arrows and A cycle it, like the volume rows. It is saved per device in the prefs save (MetaState.cvd_preset). It is a player preference, not a remote tunable, which is the ComfortFx precedent.
  2. Palette remap of gameplay-critical colours only: element colours (bodies, auras, stacks, bullets, zones), one THREAT hue for hostile shots and hazards, HP bar bands, and the stats-sheet preview roles. The neon art style is NOT remapped: backgrounds, nebulae, area tints, ship hulls, glass UI, boss identity colours and menus all stay as they are.
  3. Redundant shape cues wherever danger or element is only told apart by hue (see Cues). They are on whenever a preset is active. A dev-only tunable CvdPalette.CUES_FOR_ALL turns them on with preset Off, for an A/B by eye.
  4. Two full colour systems: every preset colour has an SDR value (CvdPaletteSdr) and a separate HDR/P3 value (CvdPaletteHdr). HdrLook.active picks the table, the same split as ColorProfileSdr/ColorProfileHdr. All 120 values are live Remote Console colour tunables ("local": true, so they are per device and not part of the patch hash).
  5. Verification tooling so a reviewer can SEE each preset at iPhone, iPad and TV sizes, both as rendered and as a CVD-simulated contact sheet.
  • Simulation: Machado, Oliveira and Fernandes (2009), severity 1.0 (full dichromacy, the worst case). The matrices are applied to LINEAR sRGB and the result is clamped to 0..1.

    • protan: [[0.152286,1.052583,-0.204868],[0.114503,0.786281,0.099216],[-0.003882,-0.048116,1.051998]]
    • deutan: [[0.367322,0.860646,-0.227968],[0.280085,0.672501,0.047413],[-0.011820,0.042940,0.968881]]
    • tritan: [[1.255528,-0.076749,-0.178779],[-0.078411,0.930809,0.147602],[0.004733,0.691367,0.303900]]

    Brettel/Viénot was considered. Machado was chosen because it has one matrix per type, which makes it trivial in both GDScript tests and Python. It also matches what DaltonLens and the Chrome DevTools emulation use.

  • Distance: OKLab Euclidean distance ×100 (ΔE_ok). 1 is just noticeable side by side. About 5 is readable at a glance for small moving sprites. 10 or more is “different thing”.

  • Readability floor: every remapped gameplay colour has OKLab L ≥ 0.62, so it stays bright on the near-black field. The background is not remapped.

  • Palette source: the starting points were the Okabe-Ito (2008) and IBM Design colour-blind-safe families: orange/sky-blue/bluish-green/yellow/vermillion/reddish-purple, and the ultramarine/magenta/orange/gold of IBM. Those are 7 and 5 colours, and the game has 13 live elements plus a threat hue. So the seeds were produced by simulated annealing over an HSV grid. The objective was to maximise the minimum pairwise ΔE_ok under the target deficiency AND under normal vision (a colour-blind player’s friends still watch), with a penalty on hue drift from today’s colour so each element keeps its identity where possible. A hard constraint keeps every element ≥ 10.5 from the THREAT hue. The THREAT hues landed on IBM/Okabe-Ito members: deutan magenta, protan orange, tritan vermillion-red.

  • Scope of the floor: elements actually referenced by shipped content (weapons, enemies, dimensions in data/bible.json), plus ElementPalette.GEM and PLAYER_HALO. sound has no weapon or enemy, so it is in the tables but excluded from the floor test.

  • HDR: each HDR value is checked AFTER ColorGamut.widen(c, 1.0, LINEAR) (the P3 push), then simulated. HDR seeds start equal to SDR and are tuned separately by eye on HDR hardware (Chris’s two-colour-systems rule).

  • Today’s baseline, measured the same way after ElementPalette.vivid(): normal-vision minimum 1.8 (cold/time). Under CVD the minimum is 0.7 to 1.2, with 6 or 7 element pairs below 5 in each deficiency.

“Hue-only” means that the colour is the only thing telling two gameplay-different things apart in store play (DevFlags off). Signals with a redundant size, shape, glyph, label or motion cue are listed separately and not counted.

# Signal What is confused with what Source today Remap Cue
H1 Enemy projectiles vs player projectiles Both use the element colour table. The shapes differ only 15x5 vs 18x2.5 px main._enemy_proj_colors (_base_color_for_type, ENEMY_PROJ_COLOR) and main._proj_colors Hostile shots → THREAT Hollow hostile ring around each enemy shot
H2 Blood slow zone (hostile) vs friendly fire zones (Nova, Wildfire reactions) Red disc vs red-orange disc DimensionHazardRenderer.SLOW_FILL/SLOW_RING; ZoneRenderer reaction zones color_for(z.element) Slow zone → THREAT Hazard teeth outline
H3 Poison hazard zone (hostile) vs friendly fire zones Green vs orange collapses under deutan/protan DimensionHazardRenderer.POISON_FILL/POISON_RING Poison → preset poison, ring → THREAT Hazard teeth outline
H4 FunZo hostile zones vs friendly zones Fuchsia disc+ring vs element disc+ring ZoneRenderer.FZ_COL (shader uniform col, set from GDScript) col → THREAT Teeth on a child Node2D (the quad is a shader material)
H5 Bomb / electric strike warn discs vs the friendly Chalice gold pool; warn vs detonate Orange→red imminence is partly hue. Fill growth is redundant BombRenderer.WARN_*/FULL_*, bm["color_inner"/"color_ring"] Ring → THREAT, fill keeps the lerp Hazard teeth on the ring
H6 Lancer charge/fire telegraph and Light-area laser warn vs the player’s Light beam / Railgun Pale gold line vs pale gold line LancerTelegraphRenderer._draw literals; DimensionHazardRenderer laser light_col Charge line → THREAT Dashed line while charging; solid on fire
H7 Applied aura identity, mainly shock (damage vulnerability) vs burn (DoT) Rings differ only by element colour EnemyAuraLook._runs / _color_cache Element table Dashed segments for the shock ring
H8 Element identity pairs cold/time, void/psychic, fire/blood/decay, lightning/light, kinetic/aether merge ElementPalette.color_for_srgb → vivid() Preset tables bypass vivid() None (identity is also in names and icons)
H9 Stats sheet preview highlights GOOD green vs EVOLVE gold vs plain; legend copy “Green = …” ui/stats_sheet.gd GOOD, EVOLVE, legend UI_GOOD/UI_EVOLVE roles ▸ marker on changed rows; legend copy names the marker

Count: 9 store-reachable hue-only signals. In addition:

  • 1 DevFlags-only signal: Stasis Web (friendly) vs hostile webs (same hue). This is not handled and is noted for when it ships.
  • 1 undrawn state: Glass Wisp enemies.phased has no render consumer (the MISSING card in the VFX library). This is not a CVD issue and is out of scope; it needs its own issue.
  • HP bar bands: colour is redundant with bar length and the low-HP pulse. Remapped anyway because it is cheap, and notches are added under a preset.
  • Elite: size, throb, bar and name.
  • Powerups: glyphs.
  • Crit numbers: size.
  • Threat radar: no type colouring.
  • Dimension danger tiers: pips and label.
  • Boss edge indicator: chevron and name.
  • Weapon icons, drones: shape.
  • Boss and Warden v2 telegraphs: shape-coded shader looks; unchanged.
  • Charger dash telegraph: strong shape; it follows the element table.
  • Ghost and elite warp: shapes.

Palettes (seed values; SDR and HDR start identical)

Section titled “Palettes (seed values; SDR and HDR start identical)”

Element tables. Bold rows are the pairs that collapsed today.

element today (bible) deutan protan tritan
fire #ff6a4d #ff9080 #e08770 #ffbdb2
cold #6cc8ff #38e0ca #40bfff #b2faff
lightning #ffe34d #e0c200 #e0d79d #e0a500
poison #8cff6a #bdffb2 #3ce000 #abe09d
void #b06aff #c19de0 #a570e0 #bb80ff
aether #e0e0ff #8080ff #9da2e0 #b2b2ff
kinetic #c9d1d9 #b2e5ff #9de0d7 #3892e0
light #fff3a0 #ffe540 #ffee80 #ffe540
decay #8a7a5c #e69500 #ff73ab #cc00ff
time #a0e0ff #38a8e0 #4099ff #38b4e0
sound (unused) #ff9ee0 #ffbd99 #e667c0 #ff00f2
blood #ff5a5a #ff0000 #ffb2b2 #e07870
psychic #c08aff #b299ff #b2ffc6 #a067e6
nano #6affd0 #00ffcc #ffff26 #26ffbe
THREAT per enemy #ff1f9a #ffa000 #ff2a3c

Roles (HP bands re-picked 2026-10-21 so each rendered fill is at least as bright as Off’s: luminance >= 0.68 / 0.68 / 0.27 for HI / MID / LO; hue bands blue-cyan / yellow / red-pink; the HDR picks are hue-locked to SDR and at least 3.5 dE apart):

role deutan protan tritan
HP_HI (SDR) #31faff #7ce6fe #7cffff
HP_MID (SDR) #fefe04 #ffff3e #ffd328
HP_LO (SDR) #fe4e4e #fe5fac #f2626b
HP_HI (HDR) #45edff #43ebfe #26feff
HP_MID (HDR) #edf22a #f0f54c #ecda0c
HP_LO (HDR) #fb546d #fe6189 #fe6093
UI_GOOD #4db8ff #4db8ff #ff5c8a
UI_EVOLVE #ffd21f #ffd21f #ffb000

Measured floors (ΔE_ok ×100), of the SHIPPED tables, from python3 -I tools/cvd_sim/cvd.py [--hdr] (2026-10-21). The shipped render/cvd_palette_{sdr,hdr}.gd are authoritative; the colour tables above are the first draft. The HDR tables are hue-locked to the SDR picks (hue within 20 degrees, chroma >= 0.9x, L >= 0.63).

preset target CVD, used elements + gem + halo, SDR / HDR vs THREAT, SDR / HDR normal vision, SDR / HDR HP roles, SDR / HDR UI roles, SDR / HDR
deutan 5.6 / 7.9 10.7 / 11.1 5.4 / 8.0 25.6 / 22.7 34.0 / 38.8
protan 6.7 / 9.6 11.0 / 11.0 7.5 / 9.7 24.8 / 23.4 28.8 / 32.3
tritan 7.1 / 7.0 10.8 / 11.5 6.1 / 7.1 23.1 / 22.1 15.8 / 22.3

The protan SDR target is 6.7, not the 6.9 first drafted.

The floors enforced by test: target-CVD minimum over used elements ≥ 5.5 for SDR and HDR, every element vs THREAT ≥ 10, normal-vision minimum ≥ 5.0, OKLab L ≥ 0.62 for every element, THREAT and role colour, and (HDR) hue/chroma identity against the SDR pick. A console edit that breaks a floor is allowed live; the test fails only when the shipped defaults break it.

  • render/cvd_palette.gd (class_name CvdPalette, extends RefCounted):
    • Preset state: preset (OFF/DEUTAN/PROTAN/TRITAN), id()/from_id() (“off”/“deutan”/“protan”/“tritan”), set_preset(), active(), cues_on().
    • Colour lookups: element_srgb(element_id, fallback), threat_srgb(), threat_out(path, alpha), ui_srgb(role, fallback).
    • Generation: sync_profile(hdr), and a generation counter. set_preset and a profile flip while a preset is active call ColorGamut.bump_generation() (new). Every existing colour-cache owner already polls ColorGamut.generation (main LUTs, GamutGlobals, EnemyAuraLook, ZoneRenderer, elite fx), so they all rebuild without new plumbing.
    • threat_out(path, alpha): THREAT widened once through ColorGamut.out for the given draw path, with the caller’s alpha. Hot white cores (the Lancer’s inner flash, the laser’s white core) are left white, so only the coloured layer changes.
  • Source swaps:
    • ElementPalette.color_for_srgb returns the preset value directly, bypassing vivid(), when a preset is active. vivid() pushes saturation ≥ 0.92 and value ≥ 0.9, which would undo the lightness separation the seeds rely on. main._element_color_vivid takes the same bypass.
    • ColorGamut.out(…, path) is still applied once, last, on the sRGB value, so P3 widening and the hdr_alpha path rules are unchanged.
  • Hostile colours: enemy projectile colours, the hazard renderers and the lancer telegraph go through CvdPalette.threat_out() / threat_srgb() at the point where they pick a colour today.
  • Unchanged: boss identity colours and the Warden v2 drone tint (is_homing == 2). The shape-coded boss looks are not touched.

Shader-touch flag: none of the tasks edits a .gdshader.

  • The hostile ring is a new MultiMesh layer inside SwarmRenderer with a procedural ring texture (the GlowTexture.build pattern) on the covered fx/additive_mat.tres.
  • Teeth and dashes are GDScript draw_* calls in nodes that already use additive_mat.
  • The FunZo uniform is set from GDScript.

Because the ring layer is a new MultiMesh + texture pipeline variant, the device task still runs the baked-iPhone A17 check (godot#124088), windowed captures under mobile and gl_compatibility, and bh-fresh-install-stall-check. If review asks for a shader-drawn ring instead, that is a new opus task.

Cue Applies to Drawing Cost
Hostile ring Every enemy projectile except Warden v2 drones Hollow ring texture, radius 1.6x the bullet width, THREAT colour, alpha 0.7, one extra MultiMesh layer in the same sync loop One draw call
Hazard teeth Blood slow, poison, FunZo, bomb/strike ring Sawtooth polyline 16 to 32 teeth just outside the ring (draw_polyline), tooth depth 6 px, THREAT colour CPU polyline per zone; ≤ 12 zones live
Dashed telegraph Lancer charge, Light-area laser warn draw_dashed_line dash 10 / gap 8 while charging; solid on fire. The dash pattern is the “not yet” signal None
Dashed shock ring EnemyAuraLook shock aura Alternate segment runs skipped (8 of 16 SEGS) None
HP notches HUD HP bar Two 2 px ticks at 30% and 60% (the colour thresholds) None
Stats marker Stats sheet changed rows and rung-met “▸ “ prefix; legend “▸ = what X changes” (font has_char check, ASCII “>” fallback) None

On for everyone? Recommendation: no, only with a preset. Reasons:

  • The hostile ring and teeth change the look of every fight.
  • Chris has tuned the neon look by eye on both colour systems.
  • The CUES_FOR_ALL dev tunable lets Chris A/B it.

Promoting a cue to everyone later is a one-line default change. It would ship in a reviewed version (Apple 2.3.1), never as a remote flip.

  • tools/cvd_sim/:

    • cvd.py (Machado + OKLab, the same maths as the GDScript test helper).
    • sheet.py: given a capture PNG, writes a 4-column contact sheet (normal / protan / deutan / tritan).
    • palette_opt.py: the seed optimizer, for re-tuning.
  • tools/ui_capture opt-in scene cvd: a fixed combat tableau with:

    • enemy and player bullets of the same element;
    • blood + poison hazards next to a Nova fire zone;
    • a bomb warn;
    • a charging lancer;
    • burn + shock auras;
    • the HUD at 25% HP;
    • the stats sheet preview.

    It is captured once per preset, plus cvd_hdr, which forces HdrLook.set_override_label("hdr"). Run it with UI_CAP_SIZES=iphone,ipad,tv, under both --rendering-method mobile and gl_compatibility. Then run sheet.py over each PNG.

  • Review agents: bh-visual-qa reviews the sheets for legibility at each size. Skill bh-vfx-iteration is the workflow if Chris flags a cue as “not quite right” by eye.

  • On device: the console command cvd_preset flips presets live on ATV/iPhone, so Chris can review the SDR and HDR tables on real hardware.

  1. One THREAT hue for all hostile shots and hazards under a preset. Enemy bodies keep their element colours.
  2. Boss and Warden v2 telegraphs are not remapped (already shape-coded).
  3. The element tables are optimizer seeds. Some hues move a long way (protan psychic is mint, protan nano is yellow, tritan decay is violet) and are for Chris to tune by eye.
  4. HDR tables start equal to SDR and are tuned separately on HDR hardware.
  5. Cues are on only with a preset; CUES_FOR_ALL is a dev A/B tunable.
  6. The stats-sheet ▸ marker and legend copy appear only under a preset (not for everyone).
  7. HP bar notches appear only under a preset.
  8. No fifth “shape cues only” option.
  9. No telemetry of the chosen preset (it would need privacy page + ASC label updates).
  10. Out of scope: Glass Wisp phase (undrawn state), collisions in the Off palette’s own elements (normal-vision cold/time at 1.8), and the DevFlags-only stasis web.
  11. Target release 1.2 (not a 1.1 carry-in).