@anweather/plugin-board-kit
Board-UI primitives for any game board on web-boardgame-platform: a remote
plugin's frontend bundle (boardgame-plugins, tactics-game-plugin, the
platform's own plugins/tic-tac-toe-remote) and the native boards still in
this repo's frontend/. One implementation, one package, on the public npm
registry.
Published with the SDK, at the SDK's version. publish-plugin-sdk.yml
releases this package together with @anweather/game-plugin-ssdk and the
Kotlin runtime from one sdk-v* tag, so a plugin's board kit, SSDK and
runtime pin all name the same release. See plugin-protocol/CLAUDE.md,
"Publishing for cross-repo plugins".
Why a package and not a copy. A plugin's frontend bundles its own React
(nothing marked external). react and react-dom are peer
dependencies here, so the consumer's own node_modules/react is the one
this code binds to: exactly one React instance per bundle. The shell-script
vendoring this replaced existed to dodge the two-React failure a plain
cross-directory import produced ("Cannot read properties of null (reading
'useState')" at runtime), and left eight of ten copies stale.
Contents
mountReactBoard(Component, { onError?, fallback? })returns the{ mount, update, unmount }handle a remote bundle exports across the ShadowMount boundary. It wraps the board in an error boundary: a render error shows a message instead of a blank rectangle, is reported throughonError, and clears on the nextupdate().BoardShell(centered-column layout) andTurnIdentity(the "You are X" row, with a spectator variant). Inline styles throughout;TurnIdentitytakes a CSScolor, never a class name, so it renders the same inside a shadow root with no Tailwind in reach.computeIsMyTurn/computeIsAwaiting/computeIsSpectatorover a minimal structuralTurnState(anything withlifecycle,currentTurnParticipantId,participantsand optionallyawaitingParticipantIds,GameViewincluded).computeIsAwaitingis what "waiting on me" means during a simultaneous phase.teamOf/teamMembersfor a 4-seat, 2-team, alternating-seat game (mirrors platform-domain'sTeamAssignment).- The display half of platform-domain's
TurnClock:useClockNow,useClockTimeout,formatClock,remainingMs,clockReading, and theClockBadge/StartTurnGatecomponents. What running out does stays the engine's call. - The board camera,
useCamera(see "The board camera" below): pinch, drag, wheel and keyboard zoom/pan over a<canvas>, an<svg>viewBox or a positioned<div>, plusFitButton/FitIcon,mergeHandlers, the pure math under it (paddedWorld,fitWorld,canvasTransform, ...) andcreateCamera, the machine with no React.useCanvasCamerais its deprecated predecessor, now a wrapper over it. - The board frame,
useBoardFrame(see "The board frame" below): whether the board is in the platform's full-screen box, its size, the menu corner to keep clear and a wide/tall layout, plus the platform's full-screen geometry as constants (MENU_RESERVE, ...). boardTelemetry(props): the host'sBoardProps.telemetryreporter (event(name, attrs),error(err, context), into the platform's Faro, rate-limited and stamped with game type/id/participant by the platform), orNOOP_BOARD_TELEMETRYon a host without one. Never throws.
Not here on purpose: nameplateAccent.ts (this repo's
frontend/src/plugins/) hardcodes per-gameType colours across the native
catalogue, which is platform logic, not a board primitive.
The board camera
One camera for every board. It converged in boardgame-plugins'
@boardgame-plugins/game-ui from four boards' glue over
useCanvasCamera (spirit-island, catan, viticulture and monopoly, svg
and div boards), then moved here so the canvas boards (risk, zephyrs,
pocket-tactics) run the same one. Three layers, each usable alone:
camera.ts (pure math: a world, a zoom, an offset clamped inside the
world), cameraController.ts (createCamera: the pointer machine, no
React, no DOM beyond a window it is handed) and useCamera.tsx (the
hook, FitButton).
// An svg: the drawing is lw x lh; the world is it padded to the
// element's shape, so the svg's `meet` and the pointer mapping agree.
const cam = useCamera({ world: paddedWorld(lw, lh, elW, elH), label: 'Map' })
<svg ref={cam.ref} {...cam.props} viewBox={cam.viewBoxAttr} style={{ touchAction: 'none' }}>...</svg>
{cam.zoomed && <FitButton onFit={cam.fit} />}
// A canvas: map the world at zoom 1 onto the canvas, then the camera,
// then draw in drawing coordinates.
const cam = useCamera<HTMLCanvasElement>({ world: paddedWorld(w, h), onTap: (p) => select(cam.toLayout(p.x, p.y)) })
ctx.setTransform(canvas.width / cam.world.w, 0, 0, canvas.height / cam.world.h, 0, 0)
cam.applyTransform(ctx)
<canvas ref={cam.ref} {...cam.props} style={{ touchAction: 'none' }} />
Options (CameraOptions), all but world optional:
world(paddedWorld, orfitWorldto keep the drawing clear of what floats over the board),origin(where the drawing's box starts),minZoom/maxZoom(1 / 4).opening(): a zoom and a world point to centre on, framed at mount and wheneverframeKeychanges; wheneversettleKeychanges too, until the player first touches the camera. While zoomed in a newworldis not taken up (it would move the board under the fingers) unless the element changed shape.- Taps: by default an unmoved press's click reaches the element under
it.
onTap(clientPoint)(a board that hit-tests itself: a canvas) gets it instead, withtapSlop(6px),tapMaxMsandonDoubleTap(320ms, 30px). A press that moved past the slop, grew a second finger or was claimed swallows its click. singlePointerPan(default true): false for a board whose own one-finger gesture is a drag; then two fingers move the camera andonDrag(start/move/end/cancel, drawing coordinates) gets the drag, cancelled by a second finger. Read at every press.keyboard(default on: the surface is a focusablerole="group"named bylabel; arrows pan an eighth of the view, + / - zoom x1.25, 0 fits).
What it returns (Camera): view (zoom, offsetX, offsetY),
world, viewBox / viewBoxAttr, transform / applyTransform(ctx),
zoomed, pressing, fit, zoomTo, zoomBy, centerOn, toWorld,
toLayout, toClient, claim and the controller. useCameraOn(ref, opts) is the same hook on a ref the caller already has.
No pointer is captured: moves are heard as they bubble to the window
(something that stops one owns that gesture) and lifts on the way down,
so a pan that leaves the board keeps going and a lift anywhere ends the
pointer; PointerBook repairs lifts that never arrive. A secondary
mouse button never pans; a sideways scroll never zooms. A long-press
that explains dispatches CAMERA_CLAIM_EVENT on its subject, and the
camera under it gives up the rest of that press (no pan, no tap).
@anweather/plugin-board-kit/testing's rigCamera(config) drives the
machine on a fake window in node:test (this package's
test/cameraController.test.ts).
useCanvasCamera (deprecated) keeps its signature and its contract
for the game's own onPointerDown/Move/Up/Cancel (world coordinates,
React events, a pinch claiming the rest of the sequence) as a wrapper
over useCamera. What changed is the camera's own behaviour: the fixes
above, and a wheel step out is exactly the inverse of one in (1/1.1,
not 0.9).
The board frame
Full screen is the canonical game layout. A registry entry with
fullScreenBoard: true gets FullScreenBoard instead of GamePage's
column: a 100dvh felt root padded once by the safe-area insets, a box
marked data-fullscreen-board that is the board's whole area and its
scroll container, the ☰ BoardCornerMenu floating over that box's
top-left, notices along the top between 56px side insets and the result
card at the bottom. useBoardFrame is how a board reads that contract
(it replaces spirit-island's useFrame and its copies in catan and
viticulture, and tactics' fullScreen.ts):
const [el, ref] = useState<HTMLDivElement | null>(null)
const frame = useBoardFrame(el, { wideAspect: 1.15, minHeight: 360 })
<div ref={ref} style={{ height: frame.height ?? 560, flexDirection: frame.wide ? 'row' : 'column' }}>
<Header style={{ paddingLeft: frame.menuReserve }} />
</div>
Pass the element itself (a callback ref into state), not a ref object,
so the hook runs once it exists. It returns (BoardFrame):
full: inside the platform's box. Thenheightis exactly the box's (a ResizeObserver keeps it current: a phone turning, a toolbar collapsing), the box scrolls, and the insets are already applied: never pad for them again.menuReserve:MENU_RESERVE(48) while the platform's ☰ floats over the box's top-left, else 0. Keep that 48px square (MENU_CORNER_RECT,overlapsMenuCorner(rect)) free of anything tappable, long-pressable or that must be read. By default the ☰ sits in the platform's thin top bar above the box (the box says so:data-fullscreen-menu="bar",menuInBar(box)), and the reserve is 0; only a game that opts out of the bar (registryfullScreenTopBar: false) gets the floating corner. Readframe.menuReserverather thanMENU_RESERVEitself, so a board follows whichever it is in.width,height: the element's width; on the ordinary page the height from its top edge to the viewport's bottom (the platform's chrome above it counted up totopCap, 40% of the viewport), at leastminHeight(a number, or{ tall, wide }).layout/wide: wide when width / height >wideAspect(default 1.2) and width >=minWideWidth(default 0). spirit-island 1.2, catan 1.15, viticulture 1.05 with 900px.insets: only withviewportFallback: true, a host fixed over the bare viewport with no platform box -- full screen, and the board padsSAFE_AREAitself. No platform layout produces one.box: the box element, once found.
Before it measures (a static render, the first paint) it is
UNMEASURED_FRAME: not full, tall, no size. Inside the box it turns a
ShadowMount host's overflow-x: hidden into clip while mounted
(clipHost, default on), so a sticky header sticks to the box and not to
a host that never scrolls; a registry entry passing hostOverflowX="clip"
already has that.
The pure half is boardFrame.ts (computeFrame, frameLayout,
findFullScreenBox, the constants), all testable in node. The constants
are the platform's own numbers: MENU_BUTTON_INSET 8, MENU_BUTTON_SIZE
40 (BoardCornerMenu), NOTICE_SIDE_INSET 56 and RESULT_CARD_INSET 12
(FullScreenBoard). test/boardFrame.test.ts reads those two platform
files and fails if either stops matching, so moving the menu means
changing both in one PR.
Not here (yet): one "is this a phone" breakpoint. The other boards' checks
are different questions (risk's map container under 520px and
(pointer: coarse), triple-triad's window under 760px, monopoly's
container under 900px, queens-blood's continuous scale); folding them into
one helper would change what each does, so they stay with their boards
until each moves to full screen. frameLayout(w, h, { wideAspect: 0, minWideWidth: 900 }) already says monopoly's.
Developing
npm ci && npm run build && npm test && npm run typecheck
npm test covers the pure helpers (turn, team and clock arithmetic), the
camera's math and pointer machine, the board frame's arithmetic (and its
constants against the platform's own FullScreenBoard and BoardCornerMenu),
and the hooks rendered to static
markup, with node:test; the components are typechecked and exercised by their
consumers' own Playwright suites. To try an unpublished change in a
consumer, npm pack here and install the tarball there; the consumer's
package.json should never point at this directory by path, since a
file: link resolves react through this directory's own node_modules
and reintroduces the two-React bundle.