Renderer contract
What Motif requires of a chess board renderer. This is the minimal surface a renderer must expose for Motif’s board-touching components to work; satisfy it and Motif components compose with your renderer.
Rabbit is Motif’s canonical paired renderer and the reference implementation. Any other renderer (chessground, a custom WebGL board, a server-rendered HTML board, future Rabbit variants) is welcome to satisfy this contract.
Philosophy
Section titled “Philosophy”The renderer contract is geometry-only. No chess semantics — legality, turn order, move history, castling rights, draw conditions — flow through this contract. All of that lives in the data layer (see GAME-CONTRACT.md).
A Motif component that wants chess state asks the Game. A Motif component that wants pixel- or square-level geometry asks the renderer. The two contracts are about categorically different things; they never overlap.
Why this matters: an honest geometry-only contract keeps the renderer swap real. The moment we let “compute legal moves for this position” into the renderer contract, every renderer implementor has to embed a chess engine. The moment we let “render this game’s history” in, every renderer has to know Tabia’s tree shape. Holding the line keeps renderers small and replaceable.
The contract
Section titled “The contract”A renderer is an object — Motif components don’t care how it was
constructed — exposing the methods and events below. All methods are
synchronous unless noted. All coordinate arguments are clientX /
clientY (CSS pixels relative to the viewport), the natural shape of
DOM pointer events.
Geometry queries
Section titled “Geometry queries”squareAt(clientX: number, clientY: number): string | nullHit-test viewport coordinates against the board. Returns the square
name (algebraic, e.g. "e4", or extended for non-8×8 boards like
"aa10") the point lands on, or null if outside the playable
surface. The square name shape must match what the renderer accepts
in setPosition keys.
squareSize(): numberSide length of one board square in CSS pixels. Used by consumers rendering external piece visuals (drag ghosts, preview overlays) that should match the board’s piece size. Computed from the live board geometry — must reflect any resize, not just the initial mount.
Used by: PositionSetup (palette drag).
Position read / write
Section titled “Position read / write”getPosition(): { object: PieceMap, fen: string }Snapshot of the board’s current piece placement. object is a
{ square: pieceCode } map of occupied squares. fen is the
board-portion-only FEN string (no side-to-move, castling, etc. —
those aren’t the renderer’s business). Implementations that natively
produce only one of the two forms must compute the other.
setPosition(pos: PieceMap): void | Promise<void>Replace the board’s piece placement. The piece map shape is the
inverse of getPosition().object. Renderers may animate the diff,
return a promise, or set synchronously — consumers don’t depend on
the return value.
Used by: PositionSetup (palette drop → place, drag-off → remove, Clear / Reset).
Orientation
Section titled “Orientation”flip(): voidToggle the board’s display orientation. Implementations may animate. Orientation is not part of the FEN — it’s a per-view preference, not a per-game property — so consumers treat this as a pure visual operation.
getOrientation(): 'white' | 'black'Current orientation. Currently only used internally by the canonical renderer; included here so consumers querying viewport-relative state can do so without coupling to a specific renderer’s property names.
Used by: Toolbar (flip action), PositionSetup (Flip button).
Annotations
Section titled “Annotations”clearUserShapes(): voidRemove any user-drawn shapes (right-click circles, drag arrows) the board is currently displaying. Renderers without a user-shape feature can implement this as a no-op.
Used by: Toolbar (clear-annotations action).
Events
Section titled “Events”on(event: string, handler: (detail: any) => void): () => voidSubscribe to renderer events. Returns an unsubscribe function. The events Motif components subscribe to:
Detail: { from: string, to: string | null, piece: PieceCode, valid: boolean }
Fired when the user releases a piece-drag gesture. to is null if
the release point was outside the playable surface. valid reflects
the renderer’s own legality check (renderers using consumer-supplied
“legal moves” hints should report valid: false for moves not in
that hint set).
In edit modes where the consumer treats off-board drops as “remove
the source piece,” consumers listen for { to: null, valid: false }
and respond with a setPosition mutation. Renderers do not bake in
this behavior; they emit the event neutrally.
Used by: PositionSetup (refresh FEN readout on any board change; off-board removal in setup mode).
What is intentionally NOT in this contract
Section titled “What is intentionally NOT in this contract”These keep showing up as “wouldn’t it be nice if the renderer exposed…” — they don’t belong here. Listed with rationale so future contributors don’t drift them in.
-
Chess legality queries (
getLegalMovesFor,isInCheck,canCastle). These are chess semantics; they live in the Game contract / legality engine. Renderers may accept a legal-moves hint to gate gestures, but they don’t answer questions about legality. -
PGN / FEN parsing or serialization beyond the board portion. The renderer is geometry; the data layer is chess. A full FEN composer reads metadata from the Game.
-
Move history, navigation, variation trees. All Game contract. Renderers display whatever position they’re told to display; they do not own a notion of “previous position” or “next move.”
-
Piece visual primitives (e.g.
createGhostPiece(code)). Adding these to the contract bakes in a specific visual treatment (Rabbit’s SVG filters, chessground’s CSS sprites, etc.) that doesn’t generalize. Consumers that need an external piece visual render it themselves from piece SVGs they already hold, sized viasquareSize(). If a future consumer makes this truly universal across renderers, it could be promoted to the contract — but not speculatively. -
Render-mode toggles (e.g.
setEditMode(true)). “Edit mode” is an app-level concept (see PositionSetup) that composes several renderer behaviors together. The renderer exposes the individual knobs; the app combines them. This keeps the renderer from accruing app-level state. -
Animation duration or visual styling props. Renderer-specific visual configuration is the renderer’s own surface, not the Motif contract. Apps wiring a renderer configure it directly; Motif components don’t poke at visual settings.
Implementing the contract for a new renderer
Section titled “Implementing the contract for a new renderer”The pattern: wrap your renderer’s native API in a thin adapter that exposes the methods above. Rabbit satisfies this contract natively (no adapter needed). For chessground:
// chessground-motif-adapter.js (sketch)export function motifAdapter(cg) { // cg: chessground instance return { squareAt: (x, y) => { const el = document.elementFromPoint(x, y); return el?.closest('square')?.getAttribute('class')?.match(/\b[a-h][1-8]\b/)?.[0] ?? null; }, squareSize: () => cg.state.dom.bounds().width / 8, getPosition: () => { const obj = Object.fromEntries([...cg.state.pieces].map( ([sq, p]) => [sq, p.color[0] + p.role[0].toUpperCase()] )); return { object: obj, fen: cg.getFen() }; }, setPosition: (pos) => cg.setPieces(new Map(Object.entries(pos).map(/* ... */))), flip: () => cg.toggleOrientation(), getOrientation: () => cg.state.orientation, clearUserShapes: () => cg.setShapes([]), on: (event, handler) => { // chessground's events map to motif events with minor renaming; // 'drop' here would wrap cg's 'move' or 'change' callbacks. // ... return unsub }, };}The adapter stays in the app (or a dedicated @promote/chessground-adapter
package); Motif itself never imports chessground.
How this contract evolves
Section titled “How this contract evolves”Adding a method: someone needs the method, AND it’s reasonable to ask of an arbitrary renderer. If it’s renderer-specific (a Rabbit visual flourish), it belongs in Rabbit’s public API but not here. If it asks about chess semantics, it belongs in the Game contract instead.
Removing a method: only if no Motif component still uses it. Mark deprecated for one release minimum before removal.
When in doubt: smaller contract wins.
Audit (current use, 2026-05-15)
Section titled “Audit (current use, 2026-05-15)”The reverse-engineered footprint as of writing:
| Method / event | Used by |
|---|---|
squareAt | PositionSetup |
squareSize | (planned, PositionSetup) |
getPosition | PositionSetup |
setPosition | PositionSetup, SanPreview’s consumer-supplied factory |
flip | Toolbar, PositionSetup |
getOrientation | (none currently — kept for forward-compat) |
clearUserShapes | Toolbar |
on('drop') | PositionSetup |
That’s the entire renderer surface Motif touches. The smallness is the point: this is what we’re committing to keep modest.