Colour-blind mode: presets + shape cues (1.2)
Colour-blind mode: presets + shape cues
Section titled “Colour-blind mode: presets + shape cues”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.
What ships
Section titled “What ships”- 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. - 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.
- 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_ALLturns them on with preset Off, for an A/B by eye. - Two full colour systems: every preset colour has an SDR value (
CvdPaletteSdr) and a separate HDR/P3 value (CvdPaletteHdr).HdrLook.activepicks the table, the same split asColorProfileSdr/ColorProfileHdr. All 120 values are live Remote Console colour tunables ("local": true, so they are per device and not part of the patch hash). - 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.
Method
Section titled “Method”-
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.
- protan:
-
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), plusElementPalette.GEMandPLAYER_HALO.soundhas 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.
Audit: hue-only gameplay signals
Section titled “Audit: hue-only gameplay signals”“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.phasedhas 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.
Already redundant (not counted)
Section titled “Already redundant (not counted)”- 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.
Where the colours enter (no new shaders)
Section titled “Where the colours enter (no new shaders)”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 agenerationcounter.set_presetand a profile flip while a preset is active callColorGamut.bump_generation()(new). Every existing colour-cache owner already pollsColorGamut.generation(main LUTs, GamutGlobals, EnemyAuraLook, ZoneRenderer, elite fx), so they all rebuild without new plumbing. threat_out(path, alpha): THREAT widened once throughColorGamut.outfor 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.
- Preset state:
- Source swaps:
ElementPalette.color_for_srgbreturns the preset value directly, bypassingvivid(), 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_vividtakes the same bypass.ColorGamut.out(…, path)is still applied once, last, on the sRGB value, so P3 widening and thehdr_alphapath 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
SwarmRendererwith a procedural ring texture (theGlowTexture.buildpattern) on the coveredfx/additive_mat.tres. - Teeth and dashes are GDScript
draw_*calls in nodes that already useadditive_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_ALLdev 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.
Verification a reviewer can SEE
Section titled “Verification a reviewer can SEE”-
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_captureopt-in scenecvd: 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 forcesHdrLook.set_override_label("hdr"). Run it withUI_CAP_SIZES=iphone,ipad,tv, under both--rendering-method mobileandgl_compatibility. Then runsheet.pyover each PNG. -
Review agents:
bh-visual-qareviews the sheets for legibility at each size. Skillbh-vfx-iterationis the workflow if Chris flags a cue as “not quite right” by eye. -
On device: the console command
cvd_presetflips presets live on ATV/iPhone, so Chris can review the SDR and HDR tables on real hardware.
Decisions for Chris’s veto
Section titled “Decisions for Chris’s veto”- One THREAT hue for all hostile shots and hazards under a preset. Enemy bodies keep their element colours.
- Boss and Warden v2 telegraphs are not remapped (already shape-coded).
- 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.
- HDR tables start equal to SDR and are tuned separately on HDR hardware.
- Cues are on only with a preset;
CUES_FOR_ALLis a dev A/B tunable. - The stats-sheet ▸ marker and legend copy appear only under a preset (not for everyone).
- HP bar notches appear only under a preset.
- No fifth “shape cues only” option.
- No telemetry of the chosen preset (it would need privacy page + ASC label updates).
- 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.
- Target release 1.2 (not a 1.1 carry-in).