hanabi: engine binding and UI, replacing the spike #4

Merged
joeyr merged 1 commit from hanabi/engine-binding into main 2026-08-16 17:33:17 -05:00
Owner

The spike answered its question and is gone. What replaces it is the binding layer — spawning card objects from the committed spritesheet, laying hands out in front of each seat, hiding every hand from the person holding it, turning button clicks into rules.ttslua calls, and surviving a reload. No rule is reimplemented here.

Design calls

Cards sit on the table, not in hand zones. A hand zone takes ownership of layout and reorders cards, and slot order is exactly what a clue refers to ("your second and fourth are red"), so it has to stay under the script control. Cards spawn locked: a player who can pick up their own card can look at it, and then there is no game.

Rendering is total and idempotent, not incremental — every position recomputed from state on every action. The incremental version would be faster and would drift, and a table that disagrees with the rules state is worse than a slow one. Sixty cards maximum; nothing to optimise for.

Illegal moves are reported privately, since they are nearly always misclicks. Legal moves are broadcast, because in Hanabi every action is public information.

Bug fixed before it could ship

The card-object map was keyed by integer card id. JSON.encode turns a sparse integer-keyed table into a JSON object, and JSON keys are strings — so every key went from 7 to "7" on reload, every lookup missed, and the next render would have spawned a second full set of cards on top of the first. Only ever visible after a reload. Keys are stringified on both sides now.

Verification

A headless harness stubs enough of the TTS API to load the bundle and drive it through the same entry points a player uses, then plays complete games through the buttons. 300 seeded deals, ~25 assertions each, zero failures. Covers the save/load round trip, that every hand card ends up hidden from exactly one colour, that off-turn and empty-clue attempts are refused privately without spending a token, and that a new game clears the old one off the table. Rules verified separately at 945 assertions.

What this does not cover

Geometry. The numbers in LAYOUT are reasoned, not observed, and nothing here has been run in TTS. That needs a real table and is the next step.

The spike answered its question and is gone. What replaces it is the binding layer — spawning card objects from the committed spritesheet, laying hands out in front of each seat, hiding every hand from the person holding it, turning button clicks into `rules.ttslua` calls, and surviving a reload. No rule is reimplemented here. ### Design calls **Cards sit on the table, not in hand zones.** A hand zone takes ownership of layout and reorders cards, and slot order is exactly what a clue refers to ("your second and fourth are red"), so it has to stay under the script control. Cards spawn **locked**: a player who can pick up their own card can look at it, and then there is no game. **Rendering is total and idempotent**, not incremental — every position recomputed from state on every action. The incremental version would be faster and would drift, and a table that disagrees with the rules state is worse than a slow one. Sixty cards maximum; nothing to optimise for. **Illegal moves are reported privately**, since they are nearly always misclicks. Legal moves are broadcast, because in Hanabi every action is public information. ### Bug fixed before it could ship The card-object map was keyed by integer card id. `JSON.encode` turns a sparse integer-keyed table into a JSON object, and JSON keys are strings — so every key went from `7` to `"7"` on reload, every lookup missed, and the next render would have spawned a second full set of cards on top of the first. Only ever visible after a reload. Keys are stringified on both sides now. ### Verification A headless harness stubs enough of the TTS API to load the bundle and drive it through the same entry points a player uses, then plays complete games through the buttons. **300 seeded deals, ~25 assertions each, zero failures.** Covers the save/load round trip, that every hand card ends up hidden from exactly one colour, that off-turn and empty-clue attempts are refused privately without spending a token, and that a new game clears the old one off the table. Rules verified separately at 945 assertions. ### What this does not cover Geometry. The numbers in `LAYOUT` are reasoned, not observed, and **nothing here has been run in TTS**. That needs a real table and is the next step.
hanabi: engine binding and UI, replacing the spike
All checks were successful
lint / lua (pull_request) Successful in 19s
f42b9d22b2
The spike answered its question and is gone. What replaces it is the binding
layer: spawning card objects from the committed spritesheet, laying hands out in
front of each seat, hiding every hand from the person holding it, turning button
clicks into rules.ttslua calls, and surviving a reload. No rule is reimplemented
here -- rules.ttslua remains the only thing that decides whether a move is legal.

Cards sit on the table in front of each seat rather than inside the hand zones.
A hand zone takes ownership of layout and will reorder cards, and slot order is
exactly what a clue refers to ("your second and fourth are red"), so it has to
stay under the script's control. Cards spawn locked, because a player who can
pick up their own card can simply look at it, and at that point there is no
game left to play.

Rendering is total and idempotent rather than incremental: every position is
recomputed from state on every action. The incremental version would be faster
and would drift, and a table that disagrees with the rules state is worse than a
slow one. Hanabi has at most sixty cards, so there is nothing to optimise for.

Illegal moves are reported privately to whoever attempted them, because they are
nearly always misclicks and broadcasting them is noise. Legal moves are
broadcast, because in Hanabi every action is public information and the table is
entitled to see it.

Fixes a bug that would only ever have shown up after a reload: the card-object
map was keyed by integer card id, and JSON.encode turns a sparse integer-keyed
table into a JSON object, whose keys are strings. Every key went from 7 to "7"
on load, every lookup missed, and the next render would have spawned a second
full set of cards on top of the first. Keys are stringified on both sides now.

Verified headlessly. A harness stubs enough of the TTS API to load the bundle
and drive it through the same entry points a player uses, then plays complete
games through the buttons: 300 seeded deals, ~25 assertions each, zero failures.
It covers the save/load round trip, that every hand card ends up hidden from
exactly one colour, that off-turn and empty-clue attempts are refused privately
without spending a token, and that starting a new game clears the old one off
the table. Rules verified separately at 945 assertions.

What it cannot cover is geometry. The numbers in LAYOUT are reasoned, not
observed, and nothing in this commit has been run in TTS. That is the next step
and it needs a real table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
joeyr merged commit 3e2110d611 into main 2026-08-16 17:33:17 -05:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
joeyr/tts-games!4
No description provided.