The design language of a fast multiplayer word game. A symmetric light/dark system where roles swap, two hues carry every meaning, and each screen is quiet around one thing — the letter tile.
01 Principles
Principles
Five beliefs, in priority order — when two conflict, the higher one wins. The mechanical choices under them aren't separate rules; they're these, made concrete. Written to be used in a critique, not admired on a wall.
The experience is the whole point
Judge every decision by how the round plays — never how it looks or how clever it is. Speed and clarity are the biggest levers inside the experience, but the small joys — a word landing, the trace snapping shut — are part of it too, and don't get traded away to shave milliseconds.
so "does this make the round better to play?" is the only question that settles an argument. Pretty, novel, and distinctive are all downstream of it.
Fast and clear, before anything on screen
The player is racing a clock: every read instant, every control obvious, nothing between them and the next word. When speed or legibility fights beauty, novelty, or personality, speed wins — every time.
so this governs the look, never the feel in 01 — a treatment that costs even a beat of legibility loses, however good it looks.
Calm does double duty — speed and feel
A quiet interface keeps fewer things between the player and their word, and gives the game a composed, premium calm. Low stimulation isn't restraint for its own sake; it's how Outword stays fast and feels good at once.
so a muted palette, one thing moving at a time, and nothing on screen competing with the board for the eye.
Craft is restraint; over-design is the failure
Craft here is precision, not richness — the truest colour, the tightest spacing, the cleanest state. The quickest way to be wrong is a flourish that's trying. If it looks "designed," cut it back.
so the strongest move is usually to remove something — new emphasis competes with existing emphasis, and more signal is rarely more clarity.
Unmistakably Outword — earned, never decorated
It reads as itself the instant you see it: the tile, the swipe-trace, the two grounds. Identity is the last thing to protect in a conflict — clarity always outranks it — but it's always present, and it comes from the mechanic, not from ornament.
so distinctiveness is a byproduct of the game's own shapes, never a treatment added to look unique.
How they show up
The recurring mechanical choices in this document — each one is a driver above, made concrete.
- The board is the hero; everything else stays quiet. One primary action per screen, controls in outline, and no decoration that pulls the eye off the letters.
- One hue, one meaning · one pill, one live value. Green is earned, rose is danger, plus one system hue for focus; a pill holds a single changing number, never a static label.
- Motion belongs to menus, not gameplay. Ambient movement on idle screens; the instant a round goes live, everything freezes.
- Two grounds, symmetric. Light and dark are one system with the roles swapped; every colour is a token, so a hardcoded value can't survive a mode flip.
02 Foundations
Color
Two grounds. The color that is the page in one mode is the ink in the other. The ground/ink pair is a true inversion; the softer tiers — soft, faint, lit — are hand-tuned per mode rather than blindly swapped, so each reads correctly.
Tokens are built in three tiers: a base reference (--dk-* / --lt-*) holding the only literal values; an active layer (--ground, --fg…) that components reference; and a mode swap on :root[data-mode="light"] that repoints the active layer. Flipping that one attribute is the whole mechanism — a hardcoded hex can't follow it, which is why it's forbidden.
fig.01 — the same two roles, grounds swapped
The two semantic hues
Green (--earned) and rose (--danger) are the only colors that carry meaning — and the only ones that don't invert. Each means exactly one thing. Shown in context, on the word pill they live on:
fig.02 — earned · danger · dup · neutral
Type on any semantic fill is always --ground — never white, never black. In dark mode --danger is a light rose, so white on it lands at ≈1.7:1 — unreadable — while --ground ink clears 8:1. Green is positive only; rose is negative only. If green ever also meant "selected," the system would be broken.
03 Foundations
Typography
Three families as tokens — display, body, and word — the same split this document is set in. Arial Black carries the loud moments; a system face carries the reading; the word face is the tiles.
Weights: 500, 600, 700, 800 — nothing else. Live numbers use tabular-nums. The eyebrow (uppercase, +2px, --fg-soft) is the recurring structural label — reuse it, don't invent section headers.
04 Foundations
Radius
Three radii carry the system. Reach past them only with a reason.
Also present: 8 (brand die), 10 (segmented), 18 (sheet top), 2 (rails). Prefer 12 / 999 / 14.
05 Foundations
Spacing
A 4px base grid. Common gaps: 8 / 12 / 16 / 20 / 24. The modal head is the reference rhythm — 20 above the title, 4 to the sub, 16 to the rule, 12 under.
06 Foundations
Motion
- Mode cross-fade — the whole app eases grounds over 0.5s.
- Tile state 0.08s · button press 0.07s · chevron 0.15s.
- Brand-mark trace — the ambient 5700ms cycle (see Brand mark).
Respected everywhere. Every animated rule has a reduced-motion branch that removes movement, keeping only a dim or fade where feedback is needed. The mark and tiles go static.
07 Brand
Brand mark
A filled disc with a 3×3 lattice, and a line that traces a legal board path across it — the disc is the leading O of "utword." SVG on a 0 0 100 100 viewBox; geometry locked to the approved reference.
fig.06 — the live mark, tracing legal board paths · disc --fg · dots --mark-dot · trace --ground
| Part | Spec |
|---|---|
| disc | cx/cy 50, r 34.2, fill --fg |
| lattice | 9 dots, r 3.15, --mark-dot, spaced by R_CELL 0.234 × diameter 68.4 |
| trace | polyline, --ground, width 3.9, round caps, pathLength=1 |
| motion | draw 1400 → hold 2800 → retract 1000 → pause 500 (5700ms), eased |
Legal traces
The trace only ever draws a real board path — cells that touch, edges or corners, and never the same one twice. It's the exact rule you swipe by in a round: a word is just a legal trace with letters on it.
The rule allows 10,256 legal paths of 3 to 9 cells. Drawing them at random throws the occasional awkward shape at logo size, so the mark instead rotates through a curated set — the twelve below, chosen to read cleanly — starting from the canonical hook (1·4·7·6·3), which is also the app icon and the still frame under reduced-motion.
the rotation set · the live mark above cycles these in order · hook leads and is canonical
Motion — the lattice holds, the trace moves
Each trace runs the same beat: draw 1400ms → hold 2800ms → retract 1000ms → pause 500ms — a 5700ms cycle, eased cubic in-out — then the next in the rotation. The nine dots never move; only the line does. It's the game's own swipe gesture, slowed to an ambient pace.
The instant a round starts, live=false and the mark freezes on its resting hook. Nothing moves while someone is racing the clock — ambient motion belongs to menus, not to the board, where it would only pull the eye off the tiles. It also pauses when scrolled off-screen (IntersectionObserver) and holds completely still under prefers-reduced-motion.
The NameGrid is the same primitive — as of v3.14.1 its letters recede, so its trace line stays visible while the game board's is hidden.
08 Components
Tiles
The heart of the UI; everything else is quiet around it. Square by construction, radius 14, 2.5px border, box-sizing border-box so a state change never shifts the grid.
lit = --select + --select-stroke + --select-ink
resting is a solid fill — an outline read as "unfinished"
On the game board the trace line is hidden — filled tiles carry the word, feedback lives in the pill. The name board is the sole exception: its letters recede, so the line earns its place. Tiles are the one control with no press-dip; it would fight the trace.
Name board
On the home screen your name is spelled across a mini board — the reward for typing a real name. It's the same tile, the same selected treatment, and the same polyline as gameplay, so it reads as a board rather than decoration. The name sits on a random legal path (adjacent cells, no reuse); the rest is filler. The name letters wear the selected/recede treatment (--select fill + --select-stroke) while filler stays the bright resting tile — the inverse emphasis of gameplay, which is exactly why the trace line shows here.
5 columns · tile radius 9 · trace --trace, width 2.4, 50% · draws at 190ms/letter
The board is 5 wide, 2 rows up to 10 letters and 3 beyond. A self-avoiding walk places the name; leftover cells take filler from a fixed set. Each open types the name on — 260ms in, then a tile every 190ms with the trace growing behind it — and every tap re-lays a fresh random arrangement.
10 Components
Toggle
Two toggles. The segmented control switches between named options — the on segment fills with --fg, so the choice reads as loud as a primary. The mode toggle is a single icon button that flips the ground.
on = --fg fill · --ground ink · 44px · radius 10
off segments are --fg-soft on transparent
home-icon-btn · 44×44 · a half-filled disc reads as "appearance"
one glyph, not a sun/moon — only the ground flips (0.5s cross-fade)
The segmented control has no empty state — one option is always on. It's a choice between peers, not an on/off switch; there is no sliding-thumb switch in the system, and adding one would introduce a second selection metaphor competing with the tile's lit state.
11 Components
Pills
Radius 999, reserved for a single live value — the clock, the score, the word being traced. Not for recaps or badges; those use weight and ink.
fig.10 — timer · urgent (rose, ground ink) · score
The word pill's resting state is boxless, so touching a tile has nothing to collapse; min-height holds the slot. Arriving fades grey→green; leaving is instant (.empty is transition:none) so no green blob lingers after the word is gone.
12 Components
Cards & sheets
Exactly three elevation levels — depth comes from the tile's inset lip, not stacked shadows: --ground (page), --raise (on it), --lit (a tile in the trace).
rank · name · longest pill · points · tap to expand → per-word points
green takes fill AND border — solid pills match; tinted surfaces keep a solid --earned stroke
An earned (green) or danger (rose) element takes the hue on both fill and border — never a coloured fill left with a neutral --rule stroke. Solid states match border to fill (--earned on --earned); tinted surfaces pair an --earned-wash/--earned-card-bg fill with a solid --earned-card-border stroke, so the edge still reads green.
Bottom sheet
The app's modal rises from the bottom — that's where thumbs already are. The scrim dismisses; the ✕ is for people who don't know that. Every gap in the head is a multiple of 4: 20 above the title, 4 to the sub, 16 to the rule, 12 under.
Create game
Set it up, then read the code out to everyone.
| Part | Spec |
|---|---|
| scrim | fixed inset 0, rgba(6,16,32,.72), fade-in 160ms |
| sheet | max-width 520, bg --ground, 1px --rule, radius 18 18 0 0, shadow 0 -8px 40px --shadow, max-height 88vh |
| rise | sheet-up 220ms cubic-bezier(.22,1,.36,1) — none under reduced-motion |
| head | title 800/17, sub 500/12.5, close 44×44 outline |
| foot | border-top --rule, padding respects env(safe-area-inset-bottom) |
fig.11 — scrim dismisses · sheet rises from the thumb line · 18px top radius only
13 Components
Fields
One field style. The name field is the default; the join field is the same field with a .code modifier — centered, uppercase, +6px — for typing a host's 4-letter code.
name + join input · height 52 · radius 12 · fill --raise · code +6px, centered
same tokens, swapped ground
That .code field is only for entering a code to join a game. The room's own code — the one the host reads out — is never in a field. It's open display text: --font-display, 54px, +14px.
States
Validation runs on submit, never while typing. The field itself doesn't change on error — the message does the work: names show an inline .field-err, the join code shows an .err banner. Fields never disable.
Names need at least 2 letters.
Pick a different name.
default · focus (--gold ring) · too short (<2) · blocked (profanity). Also: "Use at least two different letters."
.code fieldGet the 4-letter code from your host.
uppercase · +6px · centered · exactly 4 letters. 4 but unknown → "No game found with that code."
The field checks above are for names and codes. A traced word is validated in the word pill (see Pills): under the minimum length (3, climbing to 4/5 in Survival) reads short; not-a-word or a blocklisted word reads bad; already-found reads dup. Same profanity BAD_SET keeps bad words off the board and the leaderboard.
14 Patterns
System states
Outword is realtime and multiplayer, so waiting and failure are core surfaces, not corners. Every state below ships in the app — the copy is verbatim, and it's where the Voice principles actually earn their keep.
the primary swaps its label and disables while Firebase is pending (create / join)
No connection. Playing solo works anyway — the board and the dictionary are on your phone.
multiplayer actions disable; solo stays live — board and dictionary are on-device
Room codes are 4 letters.
No game found with that code. Double-check the letters with your host, or pick one from Public games below.
That room is full. 12 players max.
That game already started. Ask the host for a new round.
specific, actionable, never apologetic — always naming the next move
Nobody's scored yet this month
Nothing open right now. Ask your host for a code, or start your own game.
Looking for games…
leaderboard · public list (empty) · public list (loading) — each points to an action
Every waiting state disables and relabels rather than spinning a generic loader; every error is specific and names the fix; every empty state invites the next step instead of setting a mood. Nothing in this product is allowed to fail silently or block the one thing that always works — solo play.
15 Patterns
Help & recovery
Two systems from 3.18: a swipeable help deck that teaches the game in the game's own language, and a recovery code so a cleared browser never loses your name and rank.
Help sheet
Opened from the header ? — a swipeable deck across two sections: Playing and Competing. The chooser lists them with a thumbnail board that spells each name on a trace; open one and the cards demonstrate on a real mini board — the same tile and trace as gameplay — so the tutorial is the game, not a diagram of it. It auto-opens once on a true first visit, and never during a live round, where the clock would keep running behind it.
the chooser · each thumbnail spells its section on a trace board · cards teach on real mini boards
Recovery
Identity lives under a hidden ID. The recovery code is a human-readable encoding of it — two words from a fixed list plus the raw id (otter-cobalt-a1b2c3d4) — so it round-trips exactly. It's shown as open code text, never a boxed input (the same rule as the room code). A key icon in the header is the only way in (there is no help card for it): tap it to view your code — “This code is your account — save it to get your name and rank back if you reset, or to play as you on another phone.” — or, if the ID looks wiped, it opens straight to restore.
.rec-codebox on --raise, 1.5px --rule-strong · code 17/500 · copy · share · save image → then "Saved it"
home-icon-btn (44×44), home screen only · "lost" flag routes straight to restore · restore pastes the code into a field
16 Standards
The rules, short
- Never hardcode a color — always a token, so it survives a mode flip.
- Type on --earned / --danger fills is --ground. Never white or black.
- Green means positive; rose means negative. One meaning each.
- An earned/danger element carries the hue on fill and border — never a coloured fill with a neutral --rule stroke.
- Pills are for one live value. Recaps use weight and ink — not pills, not green.
- One primary per screen; everything else is an outline. No third style.
- Icon buttons are 44×44 / radius 12 / --fg-soft — modal close included, no exceptions.
- Three elevation levels: ground, raise, lit. Depth from the tile lip, not shadows.
- Radius: 12 (most), 999 (pills), 14 (tiles). Past these only with a reason.
- Tiles never press-dip. Trace hidden on the game board; name board excepted.
- Respect prefers-reduced-motion on anything that moves.
17 Standards
Do & don't
The rules above, shown. Each pair is a real component rendered right and wrong — the reason matters more than the rule, because it's what lets you judge the cases this doc doesn't cover.
--rule stroke reads as a mistake up close and mutes the signal.18 Standards
Accessibility
Target: WCAG 2.1 AA — 4.5:1 for body text, 3:1 for large text (≥24px, or ≥19px bold) and non-text UI. Measured from the tokens, not asserted; the failures are disclosed below, not hidden.
| Pair | Dark | Light | Target |
|---|---|---|---|
| --fg on --ground | 12.70 ✓ | 12.70 ✓ | 4.5 body |
| --fg-soft on --ground | 6.36 ✓ | 4.78 ✓ | 4.5 body |
| --fg-faint on --ground | 3.19 ✕ | 2.29 ✕ | 4.5 body |
| --fg-faint on --raise | 2.55 ✕ | 2.48 ✕ | 4.5 body |
| --fg-soft on --raise | 5.07 ✓ | 5.17 ✓ | 4.5 body |
| --ground on --earned | 8.98 ✓ | 5.71 ✓ | 4.5 body |
| --ground on --danger | 8.52 ✓ | 7.54 ✓ | 4.5 body |
| --focus vs --ground | 5.27 ✓ | 5.38 ✓ | 3.0 non-text |
| --rule vs --ground | 1.65 — | 1.30 — | 3.0 non-text |
--fg-faint is the faintest tertiary ink (fig captions, hints like "tap word to show on board"). It does not meet 4.5:1 in either mode, and in light mode misses even the 3:1 large-text floor. It's a real gap, flagged not buried — the fix is to darken --dk-fg-faint / --lt-fg-faint until both clear 4.5:1, then re-verify. --rule (hairlines) sits below 3:1 by design — decorative, never load-bearing, and marked "—" not "✕".
- Hit targets — 44px on every interactive element, the modal close included. No exceptions.
- Focus — always-visible
:focus-visiblering, 2px --gold (=--fg), offset 2px; the home/mode icon button rings in --focus. Passes 3:1 non-text (5.3:1). - Keyboard & AT — known gap. Spelling a word is a pointer-driven swipe across tiles; there is no keyboard or assistive-tech path to trace a word today. It's a real limitation for a swipe-native game, disclosed here rather than omitted; a keyboard model (arrow-to-move, space-to-select) is the open item.
- Reduced motion — honored throughout; the mark and tiles hold still under
prefers-reduced-motion. - Live numbers — tabular-nums so they don't shift width as they tick.
19 Standards
Voice & content
Plain verbs, sentence case, active voice. A control says what happens and keeps the same name through the flow. Errors don't apologise and name the fix; empty states invite the next step rather than set a mood. The shipped examples live in System states.
| Instead of | Outword says |
|---|---|
| "Error: room capacity exceeded" | That room is full. 12 players max. |
| "Submit" | Create game |
| "No data" | Nobody's scored yet this month |
| "Network error" | No connection. Playing solo works anyway… |
| "Invalid input" | Room codes are 4 letters. |
The product currently mixes game and room: the button reads "Create game," its confirm reads "Create room," and errors use both ("That game already started" · "That room is full"). That breaks the rule above. Pick one mapping — room for the shared space, game for a single round — and use it end to end. This is a product fix, flagged here so the doc doesn't paper over it.
20 System
Governance
The system has one owner and one source of truth. That's not a limitation of being small — it's the thing that keeps two years of changes from turning into a pile of one-off exceptions.
- Source of truth
app.jsxat its currentAPP_VERSION. This document is generated from it; where they disagree, the code wins and the doc is the bug. The builtapp.jsis output — never edited by hand except for a verified one-value string change.- Add vs. reuse
- Reuse first. A pattern earns a component only once it appears in roughly three places; before that it stays local. A new token or component needs a name, a one-line reason, and an entry here — no silent additions.
- Naming
- Tokens are semantic, not literal —
--earned, not--green;--ground, not--navy. The name says the job, so the value can change without the name lying. - Versioning
- Minor for features, patch for fixes. Every release bumps four things together —
APP_VERSION,app.js?v=,sw.jsVERSION, andCHANGELOG.md— or the service worker serves a stale shell and the change never ships. - Deprecation
- Nothing is deleted out from under a caller. Old names resolve to canonical through the alias layer and are marked "don't add new ones"; superseded artifacts (the old
design-system.html) are labelled stale rather than removed until nothing points at them. - Review
- One change per release, and a visual mock is approved before any UI code is written. Unrelated changes never ride along unflagged.
Recent changes
- 3.18
- Recovery codes (readable word-word-id, view/restore sheet reached from the header key icon). The help sheet stays two sections — Playing, Competing.
- 3.14.6
- Brand-mark motion → curated rotation of twelve legal traces (was four).
- 3.14.1
- Name-board letters recede, so its trace line stays visible while the game board's is hidden.
- 3.12
- Game-board trace line hidden — filled tiles carry the word; feedback moved to the pill.
21 System
Deliberately omitted
What the system refuses to build is as load-bearing as what it does. Each omission traces to a principle; adding any of these would cost more in clarity than it returns.
- Nothemes beyond the two groundsLight and dark already cover it. A third theme is surface area with no job — and every extra theme is another set of contrast pairs to keep honest. (Principle 3, 4)
- Noshadow / elevation stackDepth comes from the tile's inset lip and three flat surface levels — ground, raise, lit. Stacked shadows would add rendering cost and visual noise for a hierarchy that's already legible. (Principle 3, 4)
- Nofourth hueTwo semantic hues (earned, danger) plus one system hue (focus) is the whole palette. A genuinely new hue would start diluting meaning and break "one hue, one meaning." (Principle 2, 4)
- Noicon libraryA handful of 20px stroked glyphs, drawn to one spec. More icons means more to misread and more to keep consistent, for a game that needs almost none. (Principle 4)
- Nocomponent-count targetA small, opinionated set beats a large generic one. Growth is demand-driven (the rule of three), never a roadmap goal. (Principle 4)
- Noanimated mode switchChanging mode is a quiet 0.5s cross-fade, not a moment. Celebrating it would contradict low stimulation. (Principle 3)
Like the systems it's modelled on, this one isn't a reason to make every screen identical, and it isn't a substitute for product thinking. It removes the small decisions so the real ones get the attention.
22 Tokens
Reference
Each active role resolves, per mode, to a value in the base tier (--dk-* / --lt-*) — the only hardcoded hexes in the system. The table shows those resolved values: 28 roles × 2 modes = 56 base literals. Components reference the active role, never a literal. Only colour is tokenised; durations, spacing and radii are raw constants (see those sections). Legacy aliases resolve to canonical in one place, so nothing breaks — prefer canonical names in new work.
| Token | Dark | Light | Role |
|---|---|---|---|
| --ground | #202845 | #F2F0EA | the page |
| --raise | fg .08 | #FAF9F6 | cards, fields, rows |
| --fg | #F2F0EA | #202845 | primary text / fill |
| --fg-soft | #A5ABC4 | #5F6987 | secondary text |
| --fg-faint | #6C7597 | #98A0B8 | tertiary text |
| --rule | fg .17 | ink .14 | hairlines, outlines |
| --rule-strong | fg .32 | ink .30 | emphasised borders |
| --tile | #F2F0EA | #202845 | resting tile face |
| --tile-ink | #202845 | #F2F0EA | letter on resting tile |
| --die-edge | #7E828F | #9496A0 | tile edge detail |
| --select | #202845 | #F2F0EA | selected tile fill |
| --select-ink | #F2F0EA | #202845 | letter on selected tile |
| --select-stroke | fg .5 | ink .5 | selected tile border |
| --lit | #3D445C | #D5D4D3 | lit surface (non-tile) |
| --lit-ink | #F2F0EA | #202845 | ink on lit surface |
| --lit-edge | #2D354F | #E5E3E0 | lit edge detail |
| --trace | #F2F0EA | #202845 | connecting trace line |
| --earned | #7BE0A3 | #276A43 | positive — valid / win |
| --danger | #F2B8BC | #8E2430 | negative — invalid / error |
| --focus | #7E9BE0 | #2F58D0 | focus ring |
| --earned-wash | earned .12 | earned .10 | green tint bg |
| --danger-wash | danger .14 | danger .10 | rose tint bg |
| --earned-card-bg | earned .06 | #E6EEE3 | earned card fill |
| --earned-card-border | earned .45 | #9FC2A9 | earned card border |
| --mark-dot | #9698A0 | #686D7D | brand-mark lattice dots |
| --shadow | #0A0E22 .38 | ink .16 | drop shadow |
| --shadow-lift | #0A0E22 .28 | ink .20 | button lift shadow |
| --scrim | #0A0E22 .72 | ink .55 | modal scrim |
--tray→--ground · --tray-raise→--raise · --tray-line→--rule · --die→--tile · --ink→--tile-ink · --paper→--fg · --paper-dim→--fg-soft · --gold→--fg · --ok-green→--earned · --bad-fill→--danger · --cta→--fg · --urgent→--danger · --error-surface→--danger-wash · --error-border→--danger · --error-text→--danger · --valid→--fg