Structured render labels
Game authors use structured render labels for area titles and other board chrome that name a seat (for example, a pieces stash or captured-pieces panel). The API separates who the label is about from what it says, so display names are resolved in front at draw time instead of being baked into render JSON.
Types live in src/common/render-label.ts. The pattern mirrors Structured move log.
Overview
- In
render(), setareas[].label(or other label fields) to aRenderLabelobject — usually viaseatAreaLabel(). - Do not embed
"Player 1"in strings or calli18next.t()for seat-specific labels insiderender(). - Front calls
resolveRenderLabels(rep, players, users, t)before handing the rep to@abstractplay/renderer. - Each structured label becomes a plain
stringwith the correct username and locale.
// Streetcar — taken housing limits (canonical pilot)
label: this.seatAreaLabel(player, "apgames:validation.streetcar.TAKEN_LABEL"),
// locales/en/apgames.json — validation.streetcar
"TAKEN_LABEL": "'s housing limits"
Data model
RenderLabel
string | StructuredRenderLabel
| Field | Purpose |
|---|---|
textKey |
i18n key (usually apgames:validation.<game>.…) |
textParams |
Optional interpolation (side, count, …) — not usernames |
actor |
Who the label refers to; usually { kind: "seat", seat } |
Plain strings remain valid for labels with no player reference (deck, market, discard pile).
ChatActorRef
Same actor kinds as the move log:
| Kind | Shape | Resolution |
|---|---|---|
seat |
{ kind: "seat", seat } |
`` uses chatPlayerToken(seat); display name substituted after t() |
label |
{ kind: "label", key, params? } |
When textParams.player is present, resolved via t(actor.key, actor.params) |
none |
{ kind: "none" } |
Neutral label; no name substitution |
Game author API
seatAreaLabel(seat, textKey, textParams?)
Protected helper on GameBase — preferred for player-owned areas:
areas.push({
type: "pieces",
label: this.seatAreaLabel(p, "apgames:validation.mygame.LABEL_STASH"),
ownerMark: p,
pieces: [...],
});
Reference: Streetcar (TAKEN_LABEL on taken housing limits).
Neutral labels
No seat — use a plain string or structured label without a seat actor:
label: "Cards in deck",
// or
label: { textKey: "apgames:validation.mygame.LABEL_REMAINING", actor: { kind: "none" } },
Board-level labels
Entropy-style dual boards:
board.boardOne!.label = this.seatAreaLabel(seat, "apgames:validation.entropy.BOARD_ORDER");
localStash (captured pieces)
areas.push({
type: "localStash",
label: this.seatAreaLabel(player + 1, "apgames:validation.volcano.CAPTURED_LABEL"),
stash: [...],
});
i18n
- Add keys under
validation.<game>inlocales/en/apgames.json. - Use
for the seat name placeholder — **not**, not hard-coded"Player 1". - Do not call
i18next.t()for seat-specific area labels inrender(); front resolvestextKeyin the user's locale. - See i18n for namespace conventions. English only in repo; CI propagates other languages.
Where labels appear
| Location | Renderer area / field | Player-specific? |
|---|---|---|
| Pieces stash bar | areas[] with type: "pieces" |
Usually |
| Captured pyramids | type: "localStash" |
Usually |
| Polyomino picker | type: "polyomino" |
Sometimes |
| Board marker | board.markers[] with type: "label" |
Sometimes |
| Entropy boards | board.boardOne.label / boardTwo.label |
Yes |
| Button bar | buttonBar → buttons[].label |
Rarely |
Schema: renderer schema reference (renderLabel).
Consumer integration
Playground and custom front-ends should resolve labels before drawing:
import { resolveRenderLabel } from "@abstractplay/gameslib";
const text = resolveRenderLabel(area.label, playerNames, (key, params) => i18n.t(key, params));
Abstract Play front walks the full rep via resolveRenderLabels() — game authors normally only set labels in render().
Comparison with structured move log
| Move log | Render labels | |
|---|---|---|
| Emit in | collectChatLogLine / pushSeatChatLine |
render() on label fields |
| Shape | { actor, textKey, textParams } |
Same (RenderLabel object) |
| Resolve in | formatChatLogEntryNodes |
resolveRenderLabels (front) |
| i18n namespace | apresults: |
apgames:validation.<game>: |
| `` token | chatPlayerToken(seat) |
same |
Anti-patterns
| Don't | Do instead |
|---|---|
label: `Player ${p}'s stash` |
label: this.seatAreaLabel(p, "apgames:validation.…") |
label: i18next.t(…, { playerNum: p }) in render() |
structured label; front calls t() |
"Player 's hand" in locale JSON |
"'s hand" |
Rely on front replaceNames() regex |
structured label with actor.seat |
Bake players[0].name into the render rep |
seat + key only |
Canonical examples
| Pattern | Reference game | Source |
|---|---|---|
Seat-owned pieces area |
Streetcar | streetcar.ts — TAKEN_LABEL |
localStash captured pieces |
Volcano | volcano.ts — CAPTURED_LABEL |
| Dual board titles | Entropy | entropy.ts — BOARD_ORDER / BOARD_CHAOS |
Testing
- Assert
render()emits a structured object with expectedtextKeyandactor.seat(not a resolved string). - Optionally call
resolveRenderLabel(label, names, mockT)in unit tests to verify wording. - Play through Streetcar in Lab / GameMove to confirm area titles show usernames.
Status
Phase 2 (Aug 2026): Streetcar — first migrated game (TAKEN_LABEL).
Phase 3 (Aug 2026): Hardcoded Player N labels removed from volcano, mvolcano, penguin, moonsquad, gyges, gorogo, cifra, acity, and entropy board titles. Remaining games still use i18next.t(…, { playerNum }) in render() (Phase 4). Front replaceNames() remains as a shim until migration completes.