This documents framework v1, which is deprecated. Games already on it keep running and their authors can keep editing them, but new games are created on the current framework — start with React frontends.
Game themes
A theme.json is the game's structured theme document. It lives at /theme.json (game root, not under /src) and is stored as JSON — it is not imported as a code module. Themes are validated against a strict schema when you save them; an invalid theme is rejected with the offending field reported.
theme.json and /src/theme.html are two distinct artifacts. theme.json is the structured, allow-listed token document described here. /src/theme.html is a separate free-form HTML styling reference comp (see design-theme-instructions). They are not the same file and not interchangeable.
Top-level shape
The top-level object is strict — unknown keys are rejected.
| Field | Type | Notes |
|---|---|---|
version |
1 |
Required literal. |
mood |
string? |
Optional, ≤64 chars. |
voice |
string? |
Optional, ≤64 chars. |
fonts |
{ heading, body, mono } |
Strict object, exactly these three keys. Each value is a font key — an identifier-shaped string (letters, digits, underscore). NOT a CSS font-family list, NOT a raw CSS string. |
colors |
Record<palette, Record<shade, color>> |
Map of palette name → (map of shade key → literal color). E.g. colors.accent["500"] = "#ef9f27". Shade keys are numeric ("50".."900") or named. Values are literal colors only — no references. |
semanticTokens |
Record<group, Record<token, { light, dark }>> |
Two-level map: group → token → { light, dark }. The pair object is strict (exactly light + dark). Each side is either a reference (see below) or a literal color. |
radii |
Record<shade, length> |
Length values (e.g. "4px"). |
shadows |
Record<name, "none" | ShadowLayer[]> |
Either the literal "none" or an array of structured shadow layers { offsetX, offsetY, blur, color, spread?, inset? }. Not a raw CSS shadow string. |
palettes |
Record<name, Record<key, color>> |
User-defined palettes (may be {}). Literal colors only — referenced from semantic tokens via the palettes.<name>.<key> form. |
spacing |
Record<shade, length> |
Length values. |
background |
{ light, dark }? |
Optional. Global board background. See its own subsection. |
defaultColorMode |
"light" | "dark"? |
Optional. Which side of semanticTokens / background paints on first load. When set, the theme is deterministic — every visitor sees this mode until they toggle. When unset, the page picks up the visitor's prefers-color-scheme (light OS → light, dark OS → dark); a ThemeToggle button still flips it explicitly. The live board follows the app's global theme regardless of this field. |
Reference grammar
A value in semanticTokens (or a ref-or-color slot inside background) is either a literal color or one of exactly two reference forms:
"<palette>.<shade>"— into top-levelcolors(e.g."accent.500"→colors.accent["500"])."palettes.<name>.<key>"— into thepalettesblock (e.g."palettes.warmth.bg"→palettes.warmth.bg).
Any other string shape is rejected. References resolve through chains up to depth 8; cycles and dangling references are rejected. colors and palettes are the leaves of the reference graph — a reference-shaped string inside them is rejected.
background (optional)
When present, background is { light, dark }, each a background variant:
baseColor— a ref-or-color. The base fill painted behind all layers.layers— an ordered array of up to 8 layer objects, painted bottom-to-top. Each layer is a closed shape selected by itskind:"solid"—{ kind, color }.coloris a ref-or-color."linear-gradient"—{ kind, angle, stops }.angleis a number 0–360 (degrees).stopsis an array of 2–8 stops, each{ color, at? }wherecoloris a ref-or-color andatis an optional percentage position (number or"NN%"string)."radial-gradient"—{ kind, shape?, position?, stops }.shapeis optional,"circle"or"ellipse".positionis optional{ x, y }where each is a percentage.stopsis the same 2–8 stop array as linear."pattern"—{ kind, pattern, color, size?, opacity? }.patternis one of"grid" | "scanlines" | "dots" | "diagonal-lines".coloris a ref-or-color.sizeis an optional length.opacityis an optional number 0–1."blur-orb"—{ kind, color, x, y, size, blur?, opacity? }.coloris a ref-or-color.x/yare percentages (orb center).sizeis a length.bluris an optional length.opacityis an optional number 0–1.
border— optional{ color, width? }.coloris a ref-or-color;widthis an optional length.
The layer list is capped at 8 and each layer is a fixed, closed shape — this is not free-form CSS.
Validation
A theme is checked in stages when you save it:
- Structure — shape and required fields. The top-level object is strict, so unknown top-level keys are rejected here.
- References — every
semanticTokensreference is followed to a real color. Cycles, dangling references, and chains deeper than 8 are rejected. - Values — every resolved color, length, and shadow value is re-checked against the allowed value set. A semantic-token chain that bottoms out at a non-color string (e.g. a color token that resolved to a spacing value) fails here.
Every color, length, and shadow value must come from a closed allowed set: no raw CSS strings, no CSS shorthand, no font-family lists. Theme values become CSS shown to every player, so the value grammar is restricted for safety. An invalid theme is rejected on save, with the offending field identified.
Minimal example
{
"version": 1,
"fonts": { "heading": "Heading", "body": "Body", "mono": "Mono" },
"colors": {
"accent": { "50": "#fef6e3", "500": "#ef9f27", "900": "#412402" },
"neutral": { "800": "#2c2c2a" },
"surface": { "50": "#fffdf8", "100": "#fdf6e8", "200": "#faeeda" },
"surfaceDark": { "300": "#1f2046", "500": "#14152e" }
},
"semanticTokens": {
"bg": {
"canvas": { "light": "surface.50", "dark": "surfaceDark.500" },
"surface": { "light": "#ffffff", "dark": "surfaceDark.300" }
},
"fg": {
"default": { "light": "neutral.800", "dark": "surface.200" },
"muted": { "light": "neutral.800", "dark": "rgba(250,238,218,0.78)" }
}
},
"radii": { "xs": "2px", "md": "4px", "2xl": "16px" },
"shadows": {
"none": "none",
"sm": [{ "offsetX": 0, "offsetY": 1, "blur": 2, "color": "rgba(0,0,0,0.05)" }]
},
"palettes": {},
"spacing": { "1": "4px", "2": "8px", "0.5": "2px" }
}
Example with a background
{
"version": 1,
"fonts": { "heading": "Heading", "body": "Body", "mono": "Mono" },
"colors": {
"accent": { "500": "#ef9f27" },
"neutral": { "800": "#2c2c2a" },
"surface": { "50": "#fffdf8" },
"surfaceDark": { "500": "#14152e", "900": "#080919" }
},
"semanticTokens": {
"bg": {
"canvas": { "light": "surface.50", "dark": "surfaceDark.500" }
},
"fg": {
"default": { "light": "neutral.800", "dark": "surface.50" }
}
},
"radii": { "md": "4px" },
"shadows": { "none": "none" },
"palettes": {},
"spacing": { "1": "4px", "2": "8px" },
"background": {
"light": {
"baseColor": "surface.50",
"layers": [
{
"kind": "linear-gradient",
"angle": 180,
"stops": [
{ "color": "surface.50", "at": "0%" },
{ "color": "accent.500", "at": "100%" }
]
}
]
},
"dark": {
"baseColor": "surfaceDark.900",
"layers": [
{
"kind": "linear-gradient",
"angle": 180,
"stops": [
{ "color": "surfaceDark.900", "at": "0%" },
{ "color": "surfaceDark.500", "at": "100%" }
]
},
{
"kind": "blur-orb",
"color": "accent.500",
"x": "30%",
"y": "20%",
"size": "320px",
"blur": "80px",
"opacity": 0.35
}
]
}
}
}