Skip to content

Make the dance a conversation with call and answer - #46

Merged
JacobStephens2 merged 20 commits into
masterfrom
more-fun
Oct 26, 2026
Merged

JacobStephens2 merged 20 commits into
masterfrom
more-fun

Conversation

@JacobStephens2

@JacobStephens2 JacobStephens2 commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Clave becomes a conversation. When your 8-count ends you either take the song card, or call a figure your partner must answer on theirs. Answered calls chain into afinque. A dropped call costs Connection unless a Recovery card covers it. Every 8-count lands on a rung, the montuno gets louder, every painting was redone with GPT Image 2.5, and an original house band can play under the dance.

v1 could not be lost. Simulated pairs never stumbled, never lost (0.0%), and three quarters of games got the same rating: a turn's best play was worth 1.90 points over its worst. The call gives a pair something to risk, something to read in each other's hands, and a crescendo to build toward.

END_DANCE(callId | null)
  take the song card      only if you make no call and neither took nor adjusted the hold
  settle the call over you
    answered  → its Style + afinque link bonus + ★ in the pocket
    unmet     → dropped: −1 Connection, afinque breaks   (a Recovery card can cover it first)
  Connection 0?           → the dance ends here
  make your call          not on 8-counts 8 or 16, not over an empty floor
  close the rung          Break ▸ Recovered ▸ Perfect ▸ Awkward ▸ Clean
Call Your partner's next 8-count dances a Move and… Style
Stay with me nothing more 0
Open it up from Open or Half-Open 1
Echo me one of the caller's Moves from this phrase 2
Change the room changes the position 3
Stay close from Closed or Half-Closed 4
Style it includes a Flourish 4
Show me something a Move worth 3+ Style 8

Also in the rules: the hold (a Position played before any other card takes or adjusts the floor, giving up the song bonus), Ponche (flo_005, a Beginner Pose), Hand Twist in the starter instead of Lead Left Turn, private lessons on top of the draw pile, a retuned song deck, and a re-fit rating ladder. The rulebook, 15 dated rulings, the card list and CONTEXT.md carry all of it.

+<MusicProvider view={snapshot}>  house band: cuerpo ▸ montuno at the phrase change, off by default
 <GameScreen>
   <Hud>
+    <AfinqueMeter>             links / cap, next bonus
   <PhraseHistory>              + a rung badge on every danced 8-count
   <DanceTable>
+  <CallBanner>                 the standing call, live "✓ Answered" / "○ Not answered yet"
   <TurnActions>
-    Finish the 8-count
+    Take the song · +N   <CallPicker> sheet   Cover with <Recovery card>
   <Hand>                       a Position before any card = the hold
   <PassScreen>                 + the call is the hand-off's headline
+  <SnapshotCues>               cues + toasts on both devices, from eventsBetween(prev, next)
   <Terminal>
+    <SongRecap>                illustration, rung counts, phrases, calls, biggest 8-count

Commits, in order: the card repaint · the rules, engine and content (ENGINE_VERSION 5 → 6) · the illustrations · server media types · the client · the browser tests · the soundtrack record · four commits of review fixes · the task log · an online-test fix found on staging · the Mureka and Suno soundtrack records · the house band's loops, player and record.

The house band

Two original instrumental loops, made with Suno v6 in Suno's own app on Jacob's Pro account. That is the one route on which Suno's terms assign the output to him; a reseller's API cannot. The Cuerpo loop plays through phrase 1 and crossfades into the Montuno when the song card turns to Montuno. Both are cut from the takes to whole bars and loop without a seam.

Music drawer → [House band off|on]          off by default, remembered per device
  on  → AudioContext built inside the click; fetch + decode cuerpo, then montuno
  snapshot.currentSong.section
    Cuerpo  → cuerpo.mp3   40 bars · 51.56 s · loops between its own loop points
    Montuno → montuno.mp3  56 bars · 73.04 s · 3 s equal-power crossfade in
  dance over → 3 s fade out      tab hidden → suspend      off → 0.8 s fade

Each file is a 0.75 s pre-roll, the loop body and a 0.75 s post-roll, so a browser that trims MP3 padding differently still lands on matching audio. Both are 44.1 kHz, 192 kbps, −16.0 LUFS, 1.3 MB and 1.8 MB, fetched only after the band is switched on. The drawer still links to the real Llorarás, which is never hosted. docs/audio/soundtrack.md has the provenance, settings and loop engineering.

Staging: https://staging.clave.dance runs this branch at eaf6fc1, before the house band. At Jacob's request, production gets the merge without a staging pass. Staging is a separate service, database and Apache site beside production.

Evidence

Balance, measured on the real engine (400 simulated games per policy; v1 baseline from the same harness):

v1 now
Losses: cautious / sensible / bold / reckless 0.0% for every competent policy 0.0 / 0.5 / 28.8 / 99.8%
Score spread (sd/mean, completed games) 0.08 0.16–0.22
Biggest rating band 76% of games 29.1%
Best deck vs buying a $1 Move every turn +2.2% +11.1%
Smart calling vs never calling — +20.5%
Pose finish (share of final phrases) 1.2% 24–35%
Phrase 2 Style vs phrase 1 (no calls) flat ×1.62, and 8-count 16 is the biggest
Song cards that never fire 5 of 16 none (c15 pays only for an answered call)

Misses, kept honest: a sensible pair loses 0.5% of games, against a 5–20% target. A Recovery card covers a 1-Connection drop exactly, and raising the drop cost to 2 sends bold play to 76% losses. Song cards 4 and 9 still fire in only 2% and 13–14% of games whatever they pay; the fix there is the condition or the starter, not the number. A pair that obeys the ★ blindly scores below a pair that never calls (60.4 vs 64.3). The call rewards reading your partner, not obedience.

Tests:

  • Before: 169 unit tests. 48 browser runs (24 tests × 2 projects), with [mobile-chrome] plays a complete sixteen-count hot-seat game timing out at 30 s on master. npm run typecheck saw only src/.
  • After: 395 unit tests. 80 browser runs (40 tests × 2 projects): 78 pass and 2 skip by design, each a test that applies only to the other project. npm run typecheck now covers tests, browser tests and scripts too, which turned up 53 type errors, all fixed. Lint, format, build and the art check pass; the bundle is 125 KB gzipped (limit 500).
  • Review: six independent reviewers covered rules in the UI, layout and accessibility, events and state, a playtest (including an online game), a fresh pass over the engine, and standards and docs. Three verifiers reproduced 38 of their 40 findings. 37 are fixed, and two independent re-checks confirmed the fixes. The 38th, static-file caching, predates this branch.
  • House band, verified separately: the seams were re-measured with the decoder shifted ±30 ms, and every join sits inside the file's own sample-to-sample range with no level dip. The live handover was played in Chromium, and the full browser suite ran twice. That check found two problems, both fixed. Stopping the band mid-fade threw, because a browser refuses a gain write inside a fade; the test fake now enforces the same rule. One tempo figure in the record was mis-stated. I added one more fix: with music saved as on, a phone's first touch-start may not be allowed to start audio, so every gesture now retries. After the fixes, all 80 browser runs passed again, and the real-browser shutdown and handover checks passed.

Production serves the card art untyped today, fixed here:

$ curl -sI https://clave.dance/card-art/pos_001.webp | grep -i content-type
content-type: application/octet-stream        → image/webp after this PR

Merge Danger

Door: Two-way for the code. One-way for games saved while it is live.

Engine-5 rooms and hot-seat saves resume under engine 6 on defaults (server test included). Pre-ladder history entries take their rung from their own outcome. Games saved under engine 6 carry state that engine 5 was never tested against, so a rollback may strand rooms that are mid-dance.

Blast Radius: Everything.

Every turn's ending, scoring, balance, ratings, all card art, and the phone, tablet and desktop layouts change. The app grows by about 3 MB of audio, which nobody downloads unless they switch the band on. Merging to master runs the production deploy. Its last runs were held by the server's active-room guard, because rooms had moved within 48 hours; it was not an SSH fault. The guard passes now. Issue #45 (in progress) requires ENGINE_VERSION to stay at 5 and the goldens to stay put; whichever lands second rebases and runs npm run golden:write. No overlap with #39.

Left for later:

  • Predates this branch, found along the way. /api/rooms and the hot-seat completion beacon (rate-limited in Rate-limit the hot-seat beacon and cap its stored rows #35) draw on one 10-per-minute, per-IP budget in packages/server/src/app.ts, so ten finished hot-seat songs from one IP within a minute block room creation. Real play won't get there, but the e2e suite did, and it now stubs the beacon. A separate limiter for the beacon would close it. Static files are served without Cache-Control or ETag. At 1280×800 the song band clips a two-line song-card description.

  • Balance misses (measured; the rulings record the levers): the sensible-pair loss rate, song cards 4 and 9, shared flair as a near-automatic bonus for pairs that never call, and the ★ obedience trap.

  • For Jacob's ear: the Montuno loop arrives at its seam about 1.5 dB hotter than it leaves, so the band steps back a notch each time round. A 24-bar alternative measures much tighter. Switching to it means changing the loop points in house-band-loops.json and swapping in the re-cut file.

Not in this PR: shines as a card type, telegraphing from the watcher's hand, an AI partner for solo play, a dealt song deck. Before the house band, four soundtrack attempts failed: ElevenLabs Music, Google Lyria 3.5, Mureka mureka-9.5, and Suno V5_5 through the reseller APIFRAME. None sounded like a band playing, and audio bought through a Suno reseller carries no rights to ship. A public-domain search found only 1909–1925 Cuban danzón and son, which predates salsa's sound.

API access injected by vaulted-agent

Names only. No value was printed, logged or committed. Claude Code's permission classifier blocked reading vaulted-agent's own configuration as credential exploration, so this is what the session's environment exposed, not a confirmed manifest. The push and this PR used the account's own gh keyring login, not an injected token.

Used

  • OPENAI_API_KEY: GPT Image 2.5 (gpt-image-2.5-sunburst, high quality, 1024×1536). About 100 renders: 84 attempts across the 22 repainted cards, 4 model-comparison probes, and 12 for Ponche and the three illustrations. Prompts are in docs/art/card-art-prompts-v2.jsonl and docs/art/card-art-direction.md.
  • ELEVENLABS_API_KEY: ElevenLabs Music (music_v2_5), 4 soundtrack generations, 5,469 credits. Failed verification.
  • GEMINI_API_KEY: Lyria 3.5 and Lyria 3 Clip, 7 soundtrack generations, which failed verification. Gemini 3.1 Pro and 2.5 Pro as listening judges, about 24 audio calls across the three attempts. Model listing.
  • MUREKA_API_KEY: Mureka mureka-9.5 instrumentals, 2 requests and 3 takes, 45 of the account's 51 credits. Failed verification.

Added to the vault mid-session and used through va bash: APIFRAME_API_KEY: Suno V5_5 through APIFRAME, 5 requests, 10 takes, 55 of 100 free-plan credits. Not shippable; see the soundtrack record.

No key at all: the house band. Its takes were made in Suno's web app on Jacob's own Pro account, through his Chrome session.

Present, not used: ANTHROPIC_API_KEY, ASSEMBLY_AI_API_KEY, BASETEN_API_KEY, BLACK_FOREST_LABS_API_KEY, CARTESIA_API_KEY, DEEPINFRA_API_KEY, FAL_AI_API_KEY, FIREWORKS_API_KEY, GRANOLA_API_KEY, GROK_API_KEY, GROQ_API_KEY, HUME_API_KEY, HUME_SECRET_KEY, IDEOGRAM_API_KEY, KEEPLORE_API_KEY, META_AI_API_KEY, RECRAFT_AI_API_KEY, SAMBA_NOVA_API_KEY, TOGETHER_AI_API_KEY

Infrastructure credentials, also present and not used: AWS_SES_ACCESS_KEY, AWS_SES_SECRET_ACCESS_KEY, CLOUDFLARE_API_TOKEN, CLOUDFLARE_EDIT_ZONES_TOKEN, DIGITALOCEAN_TOKEN, DOCKER_READ_ONLY_PAT, PORKBUN_API_KEY, PORKBUN_SECRET_KEY, TRACEWAKE_CAREER_GITHUB_TOKEN, TRACEWAKE_DROME_PAT, TRACEWAKE_PASSWORD, TRACEWAKE_SMTP_PASSWORD, TRACEWAKE_VAULTED_AGENT_GITHUB_TOKEN, TRACEWAKE_VOICE_AGENT_PAT

This task needed five of the 38: OPENAI_API_KEY, ELEVENLABS_API_KEY, GEMINI_API_KEY, MUREKA_API_KEY and APIFRAME_API_KEY. The DNS, cloud, email and GitHub tokens let a coding agent reach well beyond this repo if it misbehaved or were prompt-injected. A per-task manifest would narrow that.

All 23 card illustrations are new renders from gpt-image-2.5-sunburst. The Open Position anchor was painted first; every other card was painted with that anchor attached as a reference image, so the same couple, clothes, palette, and painted floor carry across the set. The figures move more, both hand connections read at thumbnail size, faces stay apart in a social frame, and the Follow wears low practical shoes instead of heels.

Cross Body Lead — Closed (mov_013) gets its own Closed-frame composition instead of a copy of mov_005. The prompts, model, endpoint, and reference for every card are in docs/art/card-art-prompts-v2.jsonl.

Checks: npm run check:art.
When a dance step ends, the dancer either takes the song card's bonus or
calls a figure for the partner to answer next 8-count. Answering pays the
call's Style, plus afinque (a chain bonus for consecutive answers), plus 1 if
it was the song's favoured call. Dropping a call costs 1 Connection unless a
Recovery card covers it. A Position played before any other card takes or
adjusts the hold, and every 8-count now ends on one rung of a five-step
ladder (Perfect, Clean, Awkward, Recovered, Break).

Content: seven calls in rules.call, favoured calls on the song deck, the
answeredCall bonus op, Hand Twist in the starter instead of Lead Left Turn,
private lessons to the top of the draw pile, Ponche (flo_005, a Beginner
Pose worth no Style, painted with the same GPT Image 2.5 anchor as the
repaint), a louder montuno, and the rating ladder re-fit against measured
games.

Engine version 5 -> 6. Every new state, checkpoint and content field has a
default, so a stored engine-5 room resumes and plays on, and history entries
from before the ladder take their rung from their own outcome. Goldens are
regenerated with a sixth, call-aware strategy that calls, answers, covers
and adjusts (`npm run golden:write`). Also: a clamped addStyle, one shared
Connection-loss ending, an exhaustive bonus-condition switch, and a
song-bonus preview that sees the answer to a standing call.

The rulebook, 14 dated rulings, the card list, CONTEXT.md, the plan's
post-v1 amendment and the README carry the new rules, with the measured
numbers they were tuned against.
Three GPT Image 2.5 illustrations in the card art's style, made from the same
anchor render: the couple dancing in front of a six-piece band for the home
screen, a joyful finish for a completed song, and the couple laughing and
reaching for each other again for a lost one. The art direction doc records
the prompts, the candidates, and why each was chosen.
The static file server answered .webp with application/octet-stream, so
every card painting reached the browser untyped. It now names image/webp,
the audio types a soundtrack would need, and the other files Vite emits.
Ending a dance step now offers "Take the song · +N" beside a "Make a call"
sheet: the seven calls with their asks and Style, the song's favoured call
with its in-the-pocket bonus, and what the afinque chain would add. A banner
keeps the standing call and its live answered or not-answered state in front
of both players, the hot-seat hand-off leads with the call, a Recovery card
covers it from the dance row, and a Position played before any other card
takes or adjusts the hold. The HUD gains an afinque meter, and every danced
8-count shows its rung in the phrase strip.

A pure snapshot-events differ replaces the HUD's score differ and drives
synthesized cues and short toasts on both devices for calls, answers, drops,
covers, afinque, the montuno and the cierre. It stays silent across a reset
turn, a reload and a hot-seat hand-off.

The song ends on a recap: the end-of-song illustration, the rung counts, both
phrases, the calls, and the biggest 8-count. The rules dialog generates its
call, afinque, hold and ladder sections from the game's own rules, and the
home screen leads with the house-band illustration.

Everything is read from the snapshot. No balance number is written into the
client.
New specs make a call through the UI from a fresh hot-seat game, hand over,
answer it, and see it paid out; cover a call with a Recovery card; adjust
the hold; carry a call across two online devices; end a song and a lost
dance on their recaps; and work the call sheet with the keyboard alone.
Seeded states come from real engine games, so a content retune cannot leave
a test checking a state the rules no longer reach.

Existing specs follow the new starter deck and the new end-dance button.
The sixteen-count game is marked slow, since it needs more than the default
30 seconds on the phone profile. The e2e server now uses an in-memory room
database, so repeated local runs no longer fill the 50-room floor.
A second attempt at an original house-band soundtrack, this time with
Google's Lyria 3.5. The takes had the right tempo and length but failed the
same quality gate as the ElevenLabs attempt: two independent listening
models scored all seven between 1 and 4 out of 10 for sounding like a band
in a room, and one take leaked eight seconds of speech. Nothing ships; the
record keeps the prompts, measurements and verdicts for the next try.
The rulings already said a stumble turns neither face of the song card, but
the engine still paid one whose condition a stumble happened to meet: on
8-count 5 a stumble collected +1 Connection, and with Reconnect in hand it
came out ahead. The song bonus guard now covers the stumble, so the preview
and the payout cannot disagree.

The order a dropped call resolves in is now a ruling rather than an
accident: the 8-count's own song card pays first, then the call over it is
settled.

Each danced 8-count now records the call it made, how the call over it
resolved (a stumble's drop counts as a drop), the song bonus it landed, and
whether it took or adjusted the hold. The snapshot projects what ending the
8-count now would pay (song card and standing call together) and what an
answer to a call made now would earn from the afinque chain. It also
publishes the stumble cost and where each lesson tier lands, so the client
reads these numbers instead of repeating them, and a finished game carries
its whole log.
The gold end-of-dance control now reads what ending the 8-count really pays,
so it can no longer promise a Connection that a dropped call takes straight
back. The banner and the hand-off print what a drop costs. The call sheet's
afinque figure comes from the engine's projection, the cover control stops
offering to throw away a call that is already answered, and keyboard focus
lands on the next step's control instead of the page body.

On desktop the table scrolls instead of clipping, so the hand stays
reachable with a call standing at 1280x720 and 1366x768. All seven calls fit
the sheet, and the shut-call reason travels with the control. The cue layer
outlives the swap to the end screen, so the cierre and a final dropped call
are seen and heard. A cue no longer reports a multi-step jump after a
reconnect as a single step.

The recap counts calls, drops, covers and song bonuses from each 8-count's
own record instead of the log, which missed stumble drops and fell out of
its window on long games. Also: the rules dialog shows the live game's
numbers, a private lesson flies to the draw pile it lands on, the phrase
strip names the hold, the music drawer no longer reports a failure that
never happened, phones fetch the smaller hero, and the Break rung passes
contrast.

New browser tests cover a dropped call, the cierre over the end screen, a
drop that ends the dance, a 1366x768 laptop answering from the hand, and
focus after choosing a call.
npm run typecheck covered only src/, so 53 type errors had piled up in the
test suites, most of them END_DANCE actions written without their callId.
Each workspace now has a tsconfig.tests.json, and a root tsconfig.tools.json
covers the e2e specs, scripts and test configs. The errors are fixed.
…reen

At 1280px wide the opening step's dance row wrapped onto a third line and
pushed "Dance the basic" out of its band, on the first 8-count of every
game. The shut-call label is shorter, the opening guide leaves the row on
short desktops (screen readers still hear it), and the row's grid track now
grows to the height it wraps to. That also clears a tablet overflow and some
older 2px bleeds.

The drop price stops promising a clean bonus that an earlier dropped call
already took: the snapshot now publishes the phrase's dropped call. The call
sheet shows the same price as the banner.

End-screen toasts sit clear of the score and the headline, and on short
screens such as landscape phones they stay inside the viewport. Also: the
rules dialog's stumble sentence reads the live game's cost, the hot-seat
hand-off attributes the call's words to the caller, and a new game opens
scrolled to the top.
The two-device call test read the active screen's hand the moment a turn
changed hands. Locally the next snapshot is already there; against the
deployed staging site it arrives a network round trip later, so the test
danced the basic four times and gave up. It now waits for the screen to
announce its own 8-count first.
A third attempt at an original house-band soundtrack, with Mureka's
mureka-9.5 instrumental model. Its three takes were the best behaved of any
attempt: on tempo, free of voices, and lossless. They still failed the same
gate, with every listening pass scoring 1 to 4 out of 10 for sounding like
a band in a room. One listener reported a glitch every ten seconds that
measurement shows is not in the file, so this record also points at the
places where the two listeners disagree, for a human ear to settle.
Nothing ships; the README and the plan amendment now count three attempts.
A fourth attempt, with Suno's newest model (V5_5; there is no v6) through
the reseller APIFRAME. Its ten takes were the first with a clave and a
bass tumbao both listeners agreed on, but they still sounded programmed:
one take reached 6/10 and the identical request then gave 4 and 4. They
could not ship regardless. Suno has no public API, assigns output rights
only to its own Pro and Premier subscribers, and its terms bar obtaining
output outside its own channels, so audio bought through a reseller
carries no rights to pass on. The README and the plan amendment now
count four attempts.
The Suno attempt's record, and the commit that added it, said there is no
Suno v6. Suno released v6 on 2026-09-09 and retired its earlier models
the same day. What is true is narrower: APIFRAME's model list still stops
at V5_5, so it does not offer v6, and which model actually answered these
V5_5 requests cannot be confirmed from outside.
The repository is published under all rights reserved, for reference and
portfolio purposes, so two sentences in the licensing notes that called
Clave open source were wrong.
Jacob made four takes with Suno v6 in Suno's own app on his own Pro
account on 2026-10-24, the one route on which the output is his. Cuerpo B
and Montuno A, the steadier groove of each pair, are cut into bar-exact
loops: 40 bars (51.56 s) for the cuerpo and 56 bars (73.04 s) for the
montuno, each placed where the take's tempo drift leaves its two ends
within about 1% of each other.

Each file is a 0.75 s pre-roll, the loop body and a 0.75 s post-roll,
joined with an 80 ms equal-power crossfade, so a decoder that trims or
keeps a few samples of its own still lands on matching audio. The loop
points are in house-band-loops.json. Both files are 44.1 kHz stereo
192 kbps MP3 at -16.0 LUFS, and the seams were measured clean in ffmpeg,
in Chromium's decodeAudioData and in a looping AudioBufferSourceNode.
The music drawer gains a house band switch, off by default and remembered
per device. Switched on, the band plays the cuerpo through the first
phrase and crossfades to the montuno when the song card's section turns
to Montuno, read from the snapshot like everything else, then fades out
when the dance ends. Each device plays its own copy; nothing is synced.

The band builds its own AudioContext inside the gesture that turns it on,
fetches the cuerpo first and the montuno behind it, loops each between
its loop points so no seam is ever played, and pauses with a hidden tab.
A preference saved as on starts on the next gesture, and every gesture
retries, because a phone lets the end of a touch start audio but not its
start. Teardown stops the voice without writing its gain, which a browser
refuses in the middle of a fade. The test fake now enforces that rule.

The drawer still links to the real Llorarás on Spotify, and its button
now reads "Music".
The soundtrack record gains a fifth attempt, the one that shipped: why
Suno's own app on the owner's Pro account is the route on which the
output is his, the model and the exact settings as Suno recorded them,
the condensed style text v6 generated from, all four takes measured, and
the loop engineering with the candidates it rejected. The montuno's
remaining 1.5 dB step at its seam is noted, with a tighter 24-bar
alternative.

The plan's §9 amends the music non-goal in its first two clauses only
(synchronization is still a non-goal) and D7, and takes "a bundled or
original soundtrack" off the Phase 6 menu as delivered. The README,
CONTEXT.md and the task log say what ships.
@JacobStephens2
JacobStephens2 merged commit ba0d474 into master Oct 26, 2026
4 checks passed
@JacobStephens2
JacobStephens2 deleted the more-fun branch October 26, 2026 05:05
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