Skip to content

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 stackedTightness factor; collision/disjointness checks keep the full cluster box.
  • Foot-band action card. V3 drops the bottom ActionBar strip: 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 reused ActionBar internals.
  • 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)

ConstantValueMeaning
n2.35superellipse exponent (oval ↔ rounded-rect)
preferredAspect2.35the ONE oval held across the normal band
flattenStart → flattenEnd2.65 → 5smooth flatten ramp, capped at aspect 2.75
circleStart → circleEnd1 → 0.8narrow-container morph to a centered circle
minGap / minCardScale10 / 0.33crowding: protected gap, legibility floor
stackedTightness1.31side-pair optical spacing correction
seat footprint138 × 240 (spacing 62)collision box vs visual spacing extent
dealerExtent / betDist66 / 76dealer slot arc extent; bet-to-card-edge distance
potFrac / boardFrac0.36 / 0.02pot high on the top spoke; board center-low
componentScale0.76global component shrink on top of the crowd solve
view projectionidentitythe 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 distance min(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. containerFrame clamps 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. solveV3Layout auto-resolves partial configs (no silent-NaN layouts); the dealer drop rides V3Layout.dealerDropPx (resolved config, lab↔live parity); the replay watch window (HandReplayPlayer) renders BratTableV3.
  • Structure (cleanup): pure engine in geometry/{defaults,solve,config}.ts (the useV3Positions hook only measures); ONE shared lab knob registry game-ui/src/lab/knobs.ts consumed by both labs, completeness pinned by test (every V3GeometryConfig field must have a knob).
  • Known handoff: phone-chrome overlap (side panels vs felt) is owned by the mobile-table-chrome plan — the felt reshapes correctly on phones; the page chrome does not collapse yet.