Appearance
Table V3 — Geometry-Driven Brat Felt
Date: 2026-07-27 · Status: shipped · Plan: table-v3
The decision (Y-statement, distilled)
In the context of the shipped BratPokerTable — the platform's highest-visibility surface, a FELT-REGISTRY-gated component with a rich seat state machine (acting / folded / all-in / winner / sitting-out / disconnected / rebuy / empty / reserved, SB·BB·D markers, peek-seer, bet-sweep + win-drain ceremonies) — needing to run well on phones,
facing a hand-tuned percentage layout that could only step-scale a fixed 2.4:1 box (tableScale 1/.85/.7/.5), a duplicated compact fork (CompactBratTable + ?ui=compact) on a separate fixed-slot geometry, and seat spacing that read uneven because seats are boxes, not points (measured 130px side gaps vs 155px top/bottom),
we decided for extending packages/game-ui/src/geometry/ with a validated engine — superellipse frame, arc-length placement where angle is an output, footprint-even gap solving (gap = (perimeter − Σ box-tangent-extents)/N), dealer parity (odd N: own top slot; even N: none, all gaps equal), two-ray bet anchors (constant clear distance from the card edge along the seat→pot ray), a crowding policy (shrink to hold minGap, floor at minCardScale, then overlap with tilt + z-order, hero + actor on top), and container-driven sizing (ResizeObserver fills the actual box) — consumed by a new BratTableV3 orchestrator that reuses the real seat/pot/board/dealer components, shipped behind ?ui=v3, flipped to default only after a full state-parity gate, with the compact fork and the classic percentage <PokerTable> retired at the end,
and against (a) re-deriving the percentages (keeps the step-scale ceiling), (b) a parallel geometry-v3/ module (permanent duplication), (c) re-implementing the felt visuals (needless regression risk — only positions change),
accepting a placement-source swap on the most-watched surface (mitigated by the flag + RTL state-parity matrix + component reuse) and ε-approximate gap equality near sharp superellipse corners.
Amendments (Phase 4 smoke, user-directed)
- Constrained frame, not fill-any-aspect. The felt holds ONE preferred wide oval at uniform scale for the whole normal container range — cross-player appearance consistency beats per-pixel fill. It deviates only at extremes: below a height threshold it flattens smoothly; below a narrowness threshold it morphs to a centered circle. No stepped breakpoints; thresholds are named constants in
V3_DEFAULTS. - Center elements scale with the felt. Pot number, bet pills, and ceremony chips take the frame scale (they were frozen at 1×).
- Equal perceived gaps. The spacing extent uses the seat's visual card body (not the hole-card cluster rise, which inflated side-pair gaps) with a
stackedTightnessfactor; collision/disjointness checks keep the full cluster box. - Foot-band action card. V3 drops the bottom
ActionBarstrip: on your turn (or a blind request) the actions render as a floating brat card in the felt's reserved foot band, no backdrop — the table stays visible while you decide. The machinery (bet sizing, chip range, turn timer, dispatcher telemetry) is the reusedActionBarinternals. - Invisible columns. The felt spans the full content area; the geometry reserves side/head/foot bands (
columnLeft/columnRight/rowTop/rowBottom) where chat/history/observer panels float OVER the felt — the ring solves inside the center band, so seats are structurally clear of the panels.
The lab loop
The constants were not designed at a desk. An SVG lab + article prototyped the math interactively; the engine landed as pure functions (geometry/{shape,ring,bets,view}.ts, unit-tested T1–T7); then an in-app tuning lab (/dev/table-lab, unlisted) drove the REAL BratTableV3 through geometryOverrides — every knob live, resizable stage presets, overlay guides computed by the SAME solveV3Layout the felt renders from, and a Copy config button. The user tuned on the real components and the pasted output was committed verbatim as the live defaults (7cda4a3aa). The public explainer (article + standalone lab) ships on the frontpage at /notebook/table-geometry.
The tuned defaults (locked 2026-07-27)
| Constant | Value | Meaning |
|---|---|---|
n | 2.35 | superellipse exponent (oval ↔ rounded-rect) |
preferredAspect | 2.35 | the ONE oval held across the normal band |
flattenStart → flattenEnd | 2.65 → 5 | smooth flatten ramp, capped at aspect 2.75 |
circleStart → circleEnd | 1 → 0.8 | narrow-container morph to a centered circle |
minGap / minCardScale | 10 / 0.33 | crowding: protected gap, legibility floor |
stackedTightness | 1.31 | side-pair optical spacing correction |
| seat footprint | 138 × 240 (spacing 62) | collision box vs visual spacing extent |
dealerExtent / betDist | 66 / 76 | dealer slot arc extent; bet-to-card-edge distance |
potFrac / boardFrac | 0.36 / 0.02 | pot high on the top spoke; board center-low |
componentScale | 0.76 | global component shrink on top of the crowd solve |
| view projection | identity | the FP / arcade-iso projections stay lab-only knobs |
One tunable surface: V3GeometryConfig (19 knobs), defaults in V3_DEFAULTS (geometry/shape.ts) + V3_GEOMETRY_DEFAULTS (useV3Positions.ts).
Rollout & retirement
?ui=v3 flag → RTL state-parity matrix (every seat state × V3, the felt registry's D5 gate — FELT-REGISTRY rows 66–70) + manual full-hand smoke → default flip for every table → retirement: CompactBratTable + its fixed-slot geometry + the .ct-* styles deleted; the classic percentage <PokerTable> + its position hooks deleted; the frontpage SharedLiveTable and the training sandbox repointed to BratTableV3. ?ui=v2 remains a supported escape to the previous BratPokerTable (its position tables stay). Grep gates pin the retirement: zero compact references, zero classic PokerTable imports across apps/ + packages/.
References
- Plan:
.indusk/planning/table-v3/(research → ADR → 19-row test trajectory) - Public explainer: frontpage
/notebook/table-geometry(+/lab) - Registry:
packages/game-ui/FELT-REGISTRY.md - Changelog: 2026-07-27 — Table V3
Post-flip evolution (2026-07-28)
The plan kept living after the default flip; current state additions:
- Petal placement is the live default (
seatAnchorOffset: 1): each seat PLAQUE sits outside the rim on its center ray — the rim, the center line, and the plaque's inward-facing side meet at the petal's base (shift = the ray-exit distancemin(hw/|ux|, hh/|uy|), so bottom seats move about half what side seats do). The frame insets by the bloom, making plaque containment exact at k=1 — side seats can no longer touch the chat column. - Petal-era defaults locked (user lab tune, reviewed + amended): pure ellipse (n 2), preferredAspect 2.05, flatten ramp 2.05→3.5 to cap 4, circle 0.95/0.75, minGap 4, componentScale 0.98, padY 0.
containerFrameclamps band ordering so no lab tune can create a stepped morph. - Solve-derived introspection:
V3Frame.regime(aspect band + morph progress) is computed inside the fit and rendered verbatim by both labs — readouts never recompute engine logic from raw config.solveV3Layoutauto-resolves partial configs (no silent-NaN layouts); the dealer drop ridesV3Layout.dealerDropPx(resolved config, lab↔live parity); the replay watch window (HandReplayPlayer) rendersBratTableV3. - Structure (cleanup): pure engine in
geometry/{defaults,solve,config}.ts(theuseV3Positionshook only measures); ONE shared lab knob registrygame-ui/src/lab/knobs.tsconsumed by both labs, completeness pinned by test (everyV3GeometryConfigfield must have a knob). - Known handoff: phone-chrome overlap (side panels vs felt) is owned by the
mobile-table-chromeplan — the felt reshapes correctly on phones; the page chrome does not collapse yet.