Game feel: typography, motion, impact FX, a measured audio mix, and a real table - #74
Open
8tp wants to merge 3 commits into
Open
Game feel: typography, motion, impact FX, a measured audio mix, and a real table#748tp wants to merge 3 commits into
8tp wants to merge 3 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 newART-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.
src/app/anim/src/app/fx/src/app/components/icons/glyphs/tests/app/ART-DIRECTION.md+ 2 docsTests: 599 → 988. Test files: 22 → 44. Typecheck (4 projects),
npm test,npm run buildandnpm audit --omit=dev --audit-level=highall 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:
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 bytests/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/startVoiceas 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.mdis written as binding law with a reason attached to every rule, and two of them are executable:flash: FxFlash<QuietTone> | nullwhereQuietTone = 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.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 withtabular-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,
easeOutBackon 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: screenflash 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
disabledwith atitle. A refused action now answers out loud: cue, haptic, a 320 ms shake, and a sentence that reads its numbers fromconstants.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-500measured 2.85:1 on--raised— a WCAG failure, not a polish item. Swept onto tokens at 5.90:1 (70 uses → 3).gray-400andgray-300were 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
requestAnimationFramein a hidden tab. A flight in progress when the player switches away simply stops:landnever 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 onvisibilitychange— 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:
easeOutBackatBACK = 0.9overshoots 2.99 %, not 4.5 %. The classic1.70158overshoots exactly 10.00 % — which is where the folklore "19 px past a slot" comes from: 10 % of a 190 px travel.easeOutQuintis 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:
useHapticFeedbackdoes 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.cardShufflehas 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.ringSplit(6)is unit-tested.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, thendocs/AUDIO-MIX.mdfor the measurement method. The four new modules undersrc/app/{anim,fx},icons/glyphs/andtests/app/audio/are self-contained and readable on their own. Everything else is wiring.