openfrontio / openfrontio/OpenFrontIO
Players can be given identical or indistinguishable colours, especially under colour vision deficiency
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 1.4k
- Avg merge
- 17h 43m
- Merged PRs (30d)
- 310
Description
### What happens
Players can be given colours that are impossible to tell apart, and in large
lobbies two players can be given the **same colour**.
In a full 125-player public lobby the allocator currently issues **118 distinct
colours to 125 players** — seven pairs share a byte-identical colour.
It is much worse under colour vision deficiency. In the default theme, at
**8 players**, two players are already indistinguishable to a deuteranope
(ΔE2000 1.9, where ~2.3 is the just-noticeable threshold).
I raised this after watching a stream where the colour proximity made the map
hard to follow, and wanted to check whether it was a real effect or just me. It
is measurable, so I measured it.
### How to reproduce
Any large public lobby. `MAX_PLAYER_COUNT` is 125
(`src/server/MapPlaylist.ts:31`) and `humanColors` holds 63 entries, so the
palette is exhausted with 62 players still to colour. The World map carries 72
nations against a 49-entry `nationColors`; giantworldmap carries 107.
### Measurements
Minimum ΔE2000 between *any two players in the same game*, simulating the
current allocator exactly (same `simpleHash`, same `PseudoRandom`, same greedy
max-min selection) against the shipped palettes. CVD simulated with the Machado
et al. (2009) matrices. Values below ~2.3 are below the just-noticeable
threshold.
| players | normal | deuteranopia | protanopia | tritanopia |
| ------: | -----: | -----------: | ---------: | ---------: |
| 8 | 26.1 | **1.9** | 5.6 | 8.7 |
| 16 | 16.9 | **1.9** | 4.7 | 5.7 |
| 24 | 11.8 | **1.6** | **0.8** | **2.4** |
| 32 | 8.5 | **0.3** | **0.8** | **2.4** |
| 48 | 5.9 | **0.3** | **0.6** | **2.1** |
| 64+ |**0.00**| **0.00** | **0.00** | **0.00** |
Separation between player *classes*, in a World-map game (8 humans, 72 nations,
400 bots):
| pair | worst ΔE2000 |
| --- | ---: |
| humans vs nations | **0.61** |
| nations vs bots | **0.32** |
| humans vs bots | **1.98** |
All three are below the just-noticeable threshold.
### Why
`ColorAllocator` already tries to solve this — it picks each new colour by
maximising the minimum CIEDE2000 distance to colours already handed out
(`src/client/theme/ColorAllocator.ts`). The idea is right. There are five gaps:
1. **Assigned colours are put back into circulation.** When the pool empties it
is refilled from `fallbackColors`, which the constructor builds as
`[...colors, ...fallback]` — including the colours already handed out. Two
players then get the same colour.
2. **Distinctness is abandoned above 50 players.** Selection switches to random
once 50 colours are assigned, for performance. The guarantee disappears
exactly when lobbies are most crowded — players 51–125, up to 60% of a full
lobby.
3. **The metric only models normal vision.** Scoring is ΔE2000 on the raw
colour. The colourblind theme swaps the *palette* but not the *metric*, so it
inherits the same blind spot.
4. **The colourblind theme's fallback is not colourblind-safe.**
`colorblind-theme.json` ships 32 CVD-safe entries but a byte-identical
`fallbackColors` to `default-theme.json`. Past 32 players, colourblind users
get the non-CVD-safe list. That list also contains `#ffcdff` twice.
5. **Player classes never compare against each other.** Humans, nations and bots
are allocated from separate palettes by separate allocators. A player looking
at the map cannot tell those classes apart — they just see territories — so
the separation that matters most is the one nothing measures. Hence the 0.61
and 0.32 above.
Smaller: `botColors` and `nationColors` are each passed as their own fallback
(`ThemeProvider.ts`), so combined with `[...colors, ...fallback]` every entry
appears twice in the fallback list.
### Proposed change
Keep the existing approach and close the gaps:
- Score candidates against **every vision model the theme lists**, taking the
worst case, instead of normal vision only.
- Judge distinctness across **all players on the map at once** rather than
per-class, so a nation can never be handed a colour a human is using.
- Never reissue a colour that is already in play.
- Replace the 50-player cutoff by caching each candidate's distance to the
nearest colour in play and updating it incrementally. That is O(candidates)
per allocation instead of O(candidates × assigned) — cheaper than the code it
replaces, so the cutoff can go rather than move.
- When no palette colour is far enough from what is in play, synthesise one from
a low-discrepancy sweep of LCH space.
- Make both knobs theme JSON data (`observers`, `distinctnessFloor`) so the
behaviour can be tuned per theme without code changes.
Bots keep sharing a small palette by design — hundreds of them cannot be
mutually distinct, and trying would crowd out the players it matters most to
tell apart. Their colours are reserved so everyone else stays clear of them.
### Result
Worst separation between any two players, across every vision model the theme
checks:
| players | before | after (default) | after (colourblind) |
| ------: | -----: | --------------: | ------------------: |
| 8 | 1.90 | **15.08** | **9.96** |
| 32 | 0.30 | **5.09** | **6.28** |
| 64 | **0.00** | **5.07** | **4.20** |
| 125 | **0.00** | **3.18** | **2.67** |
Between classes, same World-map game:
| pair | before | after |
| --- | ---: | ---: |
| humans vs nations | 0.61 | **2.48** |
| humans vs bots | 1.98 | **10.34** |
| nations vs bots | 0.32 | **2.52** |
Every pair now clears the ~2.3 just-noticeable threshold; before, three did not.
The margins on the nation pairs are deliberately modest — see the palette
character section below for why, and what the alternative buys.
Verified in a running client as well as on the bench: on the World map with 60
bots, all 72 nations received a distinct colour, read back out of the live game
rather than from a harness.
### Keeping each palette's character
The three palettes have distinctly different looks, and I read that as
deliberate rather than incidental. Mean LCH chroma:
| palette | mean L | mean C | vivid (C≥60) | muted (C<35) |
| --- | ---: | ---: | ---: | ---: |
| `humanColors` | 73 | 53 | 44% | 25% |
| `nationColors` | 66 | 33 | 2% | 57% |
| `botColors` | 61 | 17 | 0% | 96% |
Players punchy, nations restrained, bots nearly grey. A player reads which kind
of thing a territory is from that, without being told.
My first attempt broke it. Generated colours came from one fixed region of LCH
regardless of which palette they were extending, and since most nation colours
in a large game are generated, nations drifted 11 points darker and 15 points
more saturated — 22% of them coming out "vivid" where 2% were before. The
human-to-nation chroma gap collapsed from 20 to 8, and nations started reading
like players.
Each allocator now derives its generation region from the palette it extends, so
a synthesised nation colour stays nation-like. Nations land at chroma 36 against
33 originally, 1% vivid, and the gap holds at 19. Humans and bots are unaffected
either way — bots never generate, and the human palette already spans a wide
range.
**This costs separation and I want to be upfront about it.** The nation palette
is a narrow band by design, so fitting 72 mutually-distinct nations inside it is
tight:
| | ignoring palette character | keeping it (what I did) |
| --- | ---: | ---: |
| nations within | 3.41 | 2.44 |
| humans vs nations | 3.63 | 2.48 |
| nation chroma drift | +15 | +3 |
| nations reading "vivid" | 22% | 1% |
Both clear the ~2.3 just-noticeable threshold, but the character-preserving
option has much less headroom. I chose it because the muted-nations /
vivid-players distinction looks load-bearing to players, and because it is the
reversible choice — the region and the floor are both data, so widening it later
is a theme JSON edit rather than a code change. If you would rather have the
headroom, that is a one-line change and I will make it.
### Things I would want reviewed rather than taken on trust
- **Nation colours change a lot.** In that World game only 6 of 72 nation
colours came from the shipped palette; the rest were synthesised. Once bot
colours are reserved too, little of the 49-entry `nationColors` clears a ΔE 5
floor under CVD. `distinctnessFloor` is the dial to trade separation back for
palette fidelity, and I would rather you pick that number than me.
- **125 players is near the limit of what colour can do.** 3.18 (default) and
2.67 (colourblind) are above the just-noticeable threshold but not
comfortable. 125 colours mutually distinct under three vision models is close
to what sRGB can express.
- **The nation pairs are the tightest** — humans vs nations 2.48, nations vs
bots 2.52, against 10.34 for humans vs bots. Both clear the threshold, but
they are where the headroom went, for the palette-character reason above. Part
of it is palette data rather than allocation: two hexes (`#968296`,
`#aa96aa`) appear in both the bot and nation palettes, and even removing them
the nearest cross-palette pair is 0.26 — the two overlap perceptually under
CVD as shipped. Curating those felt like your call rather than something to
slip into this change.
- `Colord.delta()` disagrees with the CIEDE2000 reference formula by up to 2.5
on some near-neutral pairs (`#e6fffa` vs `#ffcce5`: colord 31.60, reference
29.13). The current allocator uses it. I replaced it with an implementation
checked against the Sharma, Wu & Dalal (2005) reference data, which also
avoids re-converting sRGB→LAB on every comparison.
### Not included
Pattern-based disambiguation. It is the obvious next axis once colour runs out,
and a `territoryPatterns` pass already exists — but patterns are purchasable
cosmetics (`src/core/CosmeticSchemas.ts` has `productId`, `priceId`,
`priceSoft`, `priceHard`), so auto-assigning one would either grant paid content
or override a skin someone bought. That is a product decision, not an
accessibility one. Noting it as possible future work rather than proposing it.
### Scope
Client-side only: `src/client/theme/` and the two theme JSONs. Nothing in
`src/core` — the simulation never reads colours. No new dependency. Tests extend
`tests/Colors.test.ts`; the file's diff is purely additive, with no existing test
modified or removed.
I have a branch implementing this with tests passing, and will open a PR if this
gets approved and assigned.
Discord: dr-youvi-avant
Contributor guide
Assessment
This issue has not been assessed yet.