Skip to content

Game feel: typography, motion, impact FX, a measured audio mix, and a real table - #74

Open
8tp wants to merge 3 commits into
mainfrom
feat/game-feel
Open

Game feel: typography, motion, impact FX, a measured audio mix, and a real table#74
8tp wants to merge 3 commits into
mainfrom
feat/game-feel

Conversation

@8tp

@8tp 8tp commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Brings Coup's typography, motion, impact feedback, audio mix and haptics up to the standard of the juice work in chudopoly, and — more importantly — brings across the method: every number is measured, and the design rules are enforced by tests and the type system rather than by discipline.

The plan is docs/GAME-FEEL-PLAN.md. The binding design law is the new ART-DIRECTION.md.


Why this is large

Coup's generated art was already good. Everything downstream of it was not: no typeface at all, ~40 system emoji doing functional iconography, no impact layer of any kind, and an audio mix in which losing an influence — the only irreversible event in the game — was quieter than shuffling the deck.

Those are systems, not tweaks, so most of the diff is four new self-contained modules plus their tests.

Area
src/app/anim/ Motion engine — one rAF clock, transform contract, hitstop, measured FLIP · 1,576 lines
src/app/fx/ Impact layer — particles, trauma shake, flash plate, floaters · 3,077 lines
src/app/components/icons/glyphs/ Inline-SVG glyph set replacing the emoji · 30 files
tests/app/ New test suites
ART-DIRECTION.md + 2 docs 1,444 lines

Tests: 599 → 988. Test files: 22 → 44. Typecheck (4 projects), npm test, npm run build and npm audit --omit=dev --audit-level=high all pass.

Three commits: docs, engines, then the table and wiring.


The headline: the audio mix was wrong, and only rendering it showed that

The mix was first "fixed" by summing oscillator gains on paper. It looked right and passed review. Rendered through the real graph, three of the four tier boundaries were still inverted — worst case −8.84 dB — and yourTurn, a HUD chrome blip, measured 8.6 dB louder than the deck shuffle. Five cues were having their level set by the compressor rather than by the mix table at all.

Getting a trustworthy measurement was itself the hard part. The first three passes were invalid, caught because two identical cues 60 ms apart rendered louder than the arithmetic sum of their individual peaks — physically impossible. The cause:

Chrome's DynamicsCompressorNode applies a +6.5 dB internal makeup gain that is absent at the first sample and ramps in over ~300 ms, with or without input.

Every render now pre-rolls 1.0 s of silence (stable across 0.5/1/2 s to 0.02 dB). Without it, the render is not a measurement of this mix.

All four boundaries are now positive (+1.82 to +1.98 dB), recorded in docs/AUDIO-MIX.md, and gated by tests/app/audio/mix.test.ts — which runs in plain vitest with no Web Audio, against committed measurements, and has been proven to fail both ways (stale trims, and a genuinely re-rendered sabotage).

The render path uses the same buildGraph / voiceGain / startVoice as playback. There is deliberately no offline-only copy, so a measurement cannot describe a mix the player never hears.


Design rules enforced, not documented

ART-DIRECTION.md is written as binding law with a reason attached to every rule, and two of them are executable:

  • Restraint is a type error. The FX tuning table types a bystander row as flash: FxFlash<QuietTone> | null where QuietTone = Exclude<FxTone, 'crimson'>. Reddening an event that happened to somebody else fails to compile (Type '"crimson"' is not assignable to type 'QuietTone') and fails two tests.
  • Reduced motion is a test. ART-DIRECTION.md §7 says motion collapses to fades while sound and haptics stay. The cue log is asserted byte-identical between the two paths, so a future change that deletes information instead of collapsing motion fails the build.

§1.1 also records the collisions found by auditing the existing build, including the one that mattered most: Contessa's card border and the "you are being targeted" ring were the identical hex #ef4444 — "she holds a Contessa" and "you are about to be couped" rendered as the same pixel. Character hues moved to lower saturation; danger became a material (45° hazard stripes) rather than a hue.


What changed, by area

Identity. Oswald self-hosted via next/font/local (52 KB, no CDN — the app is offline-capable). Functional system emoji 40 → 0. Five duplicated per-character palettes collapsed to one module. Coins, deck count, treasury and timers are monospace with tabular-nums, which they previously were not — they were the only numbers in the app that changed during play and the only ones proportional.

The table. Desktop was a phone column in a 1456 px viewport with ~65 % of the screen unused and no table at all — the deck was 8 px of header text. It now has seats around a felt, a deck pile whose height tracks deckCount, a discard well, and a treasury. One DOM tree, not two: a hidden second copy would fight the live one for the FX seat registry. Mobile is byte-for-byte the grid it was; the 390×844 ship-gate check passes with the hand fully on-screen.

Motion. One clock, one transform contract, easeOutBack on both position and scale (free squash-and-stretch), 45 ms hitstop fired at contact, measured FLIP with the three zero-rect guards, and a press mechanic for cards.

Impact. Particles (pooled, zero per-frame allocation), trauma-model shake with a perceptual floor, a mix-blend-mode: screen flash plate, and floating text — none of which existed. The shake targets the felt only; the phase banner, prompts and hand are siblings and were verified never to transform.

Input feel. Controls no longer go disabled with a title. A refused action now answers out loud: cue, haptic, a 320 ms shake, and a sentence that reads its numbers from constants.ts ("You need 7 coins to Coup — you have 6"). Ineligible targets are rendered and marked rather than silently omitted — previously the forced-Coup panel could render zero buttons on a faction-split table.

Haptics. Seven patterns behind a priority-aware floor, firing on incoming events for the first time; previously all ~24 call sites were the player's own taps, so being assassinated buzzed nothing.

Contrast. text-gray-500 measured 2.85:1 on --raised — a WCAG failure, not a polish item. Swept onto tokens at 5.90:1 (70 uses → 3). gray-400 and gray-300 were audited and deliberately left; they pass at 5.42 and 9.34 worst-case.


Two bugs that only exist at runtime

Neither is visible in source review, and both were found by watching a real game.

React 19's StrictMode aborted every transient action card. Its dev-mode mount→cleanup→mount fired the flight's abort, which tore down the card — so Coup, Steal and Assassinate flashed for a single commit and vanished. The two verbs that move a persistent element animated correctly throughout, which is exactly why it hid.

Chrome does not run requestAnimationFrame in a hidden tab. A flight in progress when the player switches away simply stops: land never fires, and anything waiting on it waits as long as they are gone. Measured at 21 seconds of a stuck full-screen reveal plate, card parked at --fx: -184px. Flights now settle on visibilitychange — landing rather than aborting, because they did arrive, just instantly and unobserved.


Notable numbers that did not survive checking

Two constants taken from the reference project were wrong and are corrected in the source with the derivation:

  • easeOutBack at BACK = 0.9 overshoots 2.99 %, not 4.5 %. The classic 1.70158 overshoots exactly 10.00 % — which is where the folklore "19 px past a slot" comes from: 10 % of a 190 px travel.
  • easeOutQuint is 0.23 % off the true bezier at p=0.15, not 3.1 %.

Similarly, chudopoly's confetti shading floor (SHADE_MIN = 0.34) was tuned against navy felt; against Coup's darker ground it rendered about a third of the win burst invisible, and was re-solved to 0.55.


Known gaps

Stated rather than hidden:

  • Haptics have two owners. FX fires them from its tuning rows and useHapticFeedback does its own selection; it resolves to one buzz per beat only because of hook call order. Fragile, worth consolidating.
  • PlayerSeat's faction rails are still coloured borders (border-l-blue-400 / border-l-red-400) where §1.2 wants a debossed rail. Logged in §8.
  • 162 grey text utilities remain un-tokenised. They all pass contrast, so this is the second half of that gate line, not a defect.
  • The music bed has never been measured against the cues. The tier ladder is 17.6 dB tall with chrome at −34.6 dBFS, so tier-4 cues may sit under the bed.
  • cardShuffle has an 18.6 dB crest — the highest in the bank — which single-handedly costs ~4 dB of the ladder's height. Reported rather than rewritten, because this PR changes levels, not sound design.
  • Spectator mode is not verified in a browser (needs a second client). ringSplit(6) is unit-tested.
  • Grayscale hierarchy and a per-state screenshot corpus remain unmeasured — both are open items on ART-DIRECTION.md §8's ship gate, which is checked in and honest about what still fails. No box on that gate is ticked that was not measured, including the two this PR improves but does not finish.

Reviewing this

Start with ART-DIRECTION.md §0–§2 for the rules, then docs/AUDIO-MIX.md for the measurement method. The four new modules under src/app/{anim,fx}, icons/glyphs/ and tests/app/audio/ are self-contained and readable on their own. Everything else is wiring.

8tp and others added 3 commits August 9, 2026 01:30
ART-DIRECTION.md is binding law with a reason attached to every rule, and
§1.1 records the collisions measured in the build it was written against —
including Contessa's card border and the target ring being the identical hex
#ef4444, so "she holds a Contessa" and "you are about to be couped" rendered
as the same pixel.

§8's ship gate is checked in deliberately unticked. It is honest about what
still fails.

docs/AUDIO-MIX.md carries the measured mix: the method, the per-cue table,
the regeneration procedure, and an explicit list of what remains unmeasured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LgH9q6E7j2KZp3PCDSMfV
Four self-contained systems, none of which existed.

anim/  — one rAF clock, a transform contract, easeOutBack on position AND
scale, 45ms hitstop fired at contact, measured FLIP with the zero-rect
guards. Two constants taken from the reference project did not survive
arithmetic and are corrected in place: BACK=0.9 overshoots 2.99% (not 4.5%),
and the classic 1.70158 overshoots exactly 10.00% — which is where the
folklore "19px past a slot" comes from.

fx/    — particles (pooled, zero per-frame allocation), a trauma-model shake
with a perceptual floor, a screen-blend flash plate, floating text. The
restraint rule is a type error, not a convention: a bystander row is typed
`FxFlash<QuietTone>` where QuietTone excludes crimson.

audio/ — the mix was inverted and the first fix was arithmetic on paper.
Rendered through the real graph, three of four tier boundaries were still
inverted, worst case -8.84 dB, with `yourTurn` 8.6 dB above the deck shuffle.
Chrome's DynamicsCompressorNode applies a +6.5 dB makeup gain that ramps in
over ~300ms, so every render pre-rolls 1.0s of silence. All four boundaries
now measure positive and are gated against committed measurements.

haptics — seven patterns behind a priority-aware floor. A flat floor collapses
the vocabulary to whichever pattern fires first.

glyphs — 27 UI marks plus six character silhouettes, two stroke widths, no
gradients. Four of the six were redrawn after viewing them at 16px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LgH9q6E7j2KZp3PCDSMfV
Desktop was a phone column in a 1456px viewport with ~65% unused and no table
at all — the deck was 8px of header text. It now has seats around a felt, a
deck pile tracking deckCount, a discard well and a treasury. One DOM tree at a
1024px breakpoint, not two: a hidden second copy would fight the live one for
the FX seat registry. Mobile is byte-for-byte the grid it was.

Typography: Oswald self-hosted via next/font/local, no CDN — the app is
offline-capable. Functional system emoji 40 -> 0. Five duplicated per-character
palettes collapsed to one. Coins, deck, treasury and timers are monospace with
tabular-nums; they were the only figures that change during play and the only
ones set proportional.

Input feel: controls no longer go `disabled` with a title attribute. A refused
action answers with a cue, a haptic, a 320ms shake and a sentence that reads
its numbers from constants.ts. Ineligible targets are rendered and marked
rather than omitted — the forced-Coup panel could previously render zero
buttons on a faction-split table.

Contrast: text-gray-500 was 2.85:1 on --raised. Swept onto tokens at 5.90:1.

Two bugs that only exist at runtime, both found by watching a real game:
React 19's StrictMode double-invoke aborted every transient action card, so
Coup/Steal/Assassinate flashed for one commit and vanished; and Chrome does
not run rAF in a hidden tab, so a flight in progress froze mid-transform and
its land callback never fired — measured as 21 seconds of a stuck full-screen
reveal plate. Flights now settle on visibilitychange.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LgH9q6E7j2KZp3PCDSMfV
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant