Colour and how an entity looks
ContrastPolicy and the APCA measurement behind it, EntityDisplay and EntityPalette, and the one hex parser everything shares.
All documentation — Reference
Start here
Concepts
Authoring for a game
Playing a story
The generated code
The plugin and sync
On this page
An authored colour is user data. Nothing here changes one unless you ask by name — and when you do ask, you are told exactly what happened to it.
ContrastPolicy
Whether an authored colour may be lightened or darkened for readability, and against what background.
Pure: hex in, hex out, no engine types. A result is worth holding rather than recomputing per frame.
| Member | |
|---|---|
static ContrastPolicy Off | The default. The author’s colour, always untouched and never measured. |
static ContrastPolicy ReadableOn(string backgroundHex, double minLc = DefaultMinLc) | Enforce readability on a background |
const double DefaultMinLc | 60 |
bool IsOff · string? BackgroundHex · double MinLc | |
ContrastResult Apply(string? foregroundHex) | |
static double? Lc(string? textHex, string? backgroundHex) | The signed Lc of one colour on another |
static bool IsSafeHex(string? hex) | |
static bool TryParseHex(string? hex, out byte r, out byte g, out byte b, out byte a) |
Readability is measured with APCA, not WCAG 2 relative luminance — which under-weights how dark a saturated colour reads.
backgroundHex takes #RGB, #RGBA, #RRGGBB or #RRGGBBAA. Alpha is ignored in the
measurement — APCA models opaque colours, so a host drawing a translucent panel must pass the
colour the player actually sees.
minLc is a magnitude, never a signed value.
Apply never throws for any foreground value — null, empty, arbitrary text. Every refusal comes
back as an outcome.
ContrastResult
| Member | |
|---|---|
Outcome | |
Hex | The colour to draw, or null |
Lc | The signed APCA Lc against the policy’s background |
HasColor | There is a colour to draw. Says nothing about whether it is readable. |
IsReadable | It clears the threshold |
For Off and Unchanged, Hex is the same string instance you passed in — casing and short
form included. Nothing is rewritten in place.
Lc is zero when nothing was measured, which is every outcome that has no background. Read
Outcome first.
ContrastOutcome
| Member | |
|---|---|
NoColor | Nothing was authored. Absent, which is not the same as black. |
InvalidHex | Not a colour this can read, so it is refused rather than coerced into a default that would look authored |
Off | The policy is Off. Untouched and unmeasured. |
Unchanged | It already cleared the threshold, and comes back exactly as authored |
Adjusted | It was lightened or darkened until it cleared |
Unreachable | It cannot clear the threshold on this background, not even at pure white or pure black. Hex is the author’s colour, unchanged; nothing is substituted. |
Unreachable is the one worth surfacing to whoever authored the colour — it is a choice the project
has to make, not one the runtime can make for it.
The hex allow-list
IsSafeHex is the one allow-list, and TmpMarkup uses it too, because an authored colour can be
any string that reached the project’s data.
TryParseHex expands #RGB and #RGBA by doubling each digit and reads #RRGGBB and #RRGGBBAA
as written. Alpha defaults to 255.
EntityDisplay
How one entity is drawn: a name, a colour, a portrait — read off the entity the runtime has already resolved.
| Member | |
|---|---|
static EntityDisplay Of(ResolvedActor actor, ContrastPolicy? contrast = null) | From what you are already holding, with no lookup |
static EntityDisplay Of(ResolvedEntity entity, ContrastPolicy? contrast = null) | |
Entity · Id · Code | The code is stable across a rename, and is what codegen names |
Name | Overlaid, language-resolved, current |
Color · HasColor · ColorHex · ColorOutcome | |
Color ColorOr(Color fallback) | |
MediaRef? Portrait | |
bool IsAction | The entity is an input action: its definition draws a mention of it as a keycap, and Code is what your game maps to its own action |
MentionLook ToMentionLook() |
Name never falls back to the code, so an entity with no name draws as empty. Both readings —
the $name intrinsic first, then the property — are field-shaped, carrying the translated value and
the Localized flag.
Color is meaningless when HasColor is false, where it is white: the identity tint, not a value
to draw as though it were authored. Prefer ColorOr, which names your default at the call site.
ColorHex is what TmpMarkup wants — a formatted line takes a string and never a Color.
Portrait is the payload’s own media reference, not a Sprite. Pass it to MediaAddress.From
and load it through MediaLibrary. It reads the $portrait intrinsic first, else the
entity’s picture id as a reference with no URL, which the media layer can still resolve.
ToMentionLook() is how an @mention of an entity draws with the same name and colour as its name
plate. For an input action it also marks the look — IsAction, with the entity’s code as
ActionCode — and never carries a glyph: the player’s current binding is your game’s, and your
mention hook adds it. See an input action’s glyph.
EntityPalette
Where an EntityDisplay comes from when the host does not already hold the entity: one store,
one policy, one answer.
| Member | |
|---|---|
EntityPalette(INodeViewContext view, ContrastPolicy? contrast = null) | |
ContrastPolicy Contrast | The one policy every display from this palette was built with |
EntityDisplay Of(ResolvedActor actor) · Of(ResolvedEntity entity) | No lookup |
EntityDisplay? ByCode(string? code) | By the author’s code — what your own script and the generated code know |
EntityDisplay? ById(string? id) | By id — what a payload reference and an @-mention carry |
MentionLook? ResolveMention(string entityId) | Hand it straight to TmpMarkup.Format. A mention of an input action comes back marked as one, with no glyph — wrap it to add one |
Everything here reads the runtime’s resolved entities, so a renamed or translated character can never be current in one place and stale in another.
Two entities sharing a code resolve first-wins, with one duplicate-entity-code diagnostic at load.
The null rule
It has two halves, and it is consistent across this whole layer:
- a verb that takes something you are already holding throws on null;
- a verb that looks one up —
ById,ByCode— answers null.
Nothing returns a blank display, which would be indistinguishable from an entity the author left
unfilled. ResolveMention answering null for an unknown id is the same rule, and it lets TmpMarkup
fall through its own chain rather than drawing nothing.
UnityColors
The hex-to-Color edge, and nothing else.
| Member | |
|---|---|
bool TryParse(string? hex, out Color color) | Color.white on a refusal — the identity tint, not a value to draw |
Color Or(string? hex, Color fallback) | |
string ToHex(Color color, bool withAlpha = false) | Uppercase |
ToHex uses the same formatter the contrast policy does, so a colour adjusted by the policy and one
written here are the same string.
The channels are sRGB values written straight into Color, as Unity’s own utility does. In a
linear-colour-space project Unity converts at upload — do not gamma-correct here.