Have more questions? Join our Discord

The React client

The client draws the board and sends the seat's moves. It decides no rule: what the seat may do comes only from its offers. Exact hook signatures are in boardweaver/react reference.

The file

The entry is /src/frontend.tsx. A match cannot load without it.

  • Default export: the board component, which takes no props. The build fails without it.
  • GameOver (optional named export): the end screen. See Game over.

The client may import only:

  • react (React 18): useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useReducer, useContext, useId, useImperativeHandle, useSyncExternalStore, useTransition, useDeferredValue, useDebugValue, startTransition, createContext, forwardRef, memo, Fragment, Component, StrictMode, createElement, cloneElement, isValidElement and Children. Nothing else: no Suspense or lazy. For a portal, use Portal from boardweaver/ui.
  • react/jsx-runtime, which JSX uses.
  • boardweaver/react: the hooks and types in boardweaver/react reference.
  • boardweaver/motion, boardweaver/ui and boardweaver/dnd: animation, accessible controls and drag and drop, in Client libraries.
  • boardweaver/game: the zod schema and type of each client type the game logic defines, in Client types.
  • zod: z and ZodError, in Client libraries.
  • The game's own .ts and .tsx files, by a relative path (./board) or a /src/ path (/src/board).
  • The game's own .json files, by their full path, as the JSON value they hold. A file in /src/data/ is one the game logic can read too: see Data files.

Any other import fails the commit with Cannot resolve import. That includes npm packages, react-dom, boardweaver/immer and CSS files. Style with the style prop or a <style> element.

The client has no network, no eval, no workers and no browser storage. It may load images from the game's art and fonts from Google Fonts, and plays the game's sounds with useSounds() (Sounds).

Drawing the board

Draw the board from useBoard(), the seat's view with its draft applied, and make only the offers of useOffers() clickable:

import { useMemo } from "react";
import { useAct, useOffers, useBoard, type Action } from "boardweaver/react";

export default function App() {
  const board = useBoard();
  const offers = useOffers();
  const act = useAct();

  const offerBySpace = useMemo(() => {
    const bySpace = new Map<string, Action>();
    for (const { action } of offers) {
      if (action.type === "SpaceClick") bySpace.set(action.spaceId, action);
    }
    return bySpace;
  }, [offers]);

  if (!board) return null;
  return (
    <div role="grid">
      {board.spaces.map((space) => {
        const offer = offerBySpace.get(space.spaceId);
        return (
          <button key={space.spaceId} disabled={!offer} onClick={() => offer && act(offer)}>
            {space.pieces?.[0]?.publicKind ?? ""}
          </button>
        );
      })}
    </div>
  );
}
  • The board is JSON in camelCase: spaces[].spaceId, pieces[].publicKind, publicState, metaData. Parse a map with the schema of its type from boardweaver/game, as Client types shows.
  • Pieces have no ids. Use the space id and the index as React keys.
  • A piece click names the piece by its index in this board: { type: "PieceClick", spaceId, index }.
  • useOffers() is empty for a spectator, on another seat's turn, and while a decision is on its way.

Taking a step

act(action) takes one step and answers at once:

type Means
applied A choice or cancel step joined the draft. The board already shows it.
sent The step ended the decision, which is on its way to Boardweaver.
failed The step was not taken. reason.code says why.

act belongs to the board of the render that created it. Call it once per event. A second act call in the same handler, or one from a timer or after an await, fails as STALE.

To take several steps from one event, such as a form that collects several values, pass them as a list: act([first, second, third]). Each step applies to the board the step before it left, each joins the decision as its own step, and only the last may end it. When a step fails, the steps before it stay in the decision, the rest are not taken, and the failed outcome's applied says how many joined.

Commands, confirm and cancel

The client draws a control for each offer that is not a board click:

  • A Command offer: a button with the offer's label.
  • A confirm offer: the button that ends the decision, such as "Take".
  • A cancel offer: a switch or take-back, such as clicking another staged card.
  • An offer with an input: a field. For Text, offer the suggestions and accept up to maxLen characters. For Integer, accept min to max on step. Send the value in the action: act({ ...offer.action, value }).

useDraft() lists the draft's steps, so the client can show what the seat chose so far.

What the client must draw

Boardweaver draws a bar under the board: the menu, the controls for other players' steps, rules, sound, full screen and leave. It shrinks the area your client draws in to fit above the bar. On the board it draws only a card when the player returns and each step's log lines while steps play (Other players' steps). The client draws:

  • whose turn it is: board.activePlayerIds and useSelf()?.isActive;
  • names, avatars and which seats are bots: usePlayers();
  • scores during play: useScores();
  • the Undo button: useUndo();
  • what changed: log lines and movements from useLastChange();
  • decisions on their way: usePending();
  • failures: useFailures();
  • light and dark colors: useColorMode() and useGameTheme() (Design the theme).

Undo

const undo = useUndo();
<button onClick={() => undo.undo()} disabled={undo.available === "none"}>Undo</button>

available is draft (takes back the last draft step at once), server (asks Boardweaver to take back the last committed step) or none. The game logic decides what may be taken back (Changing the state).

Decisions on their way

usePending().pending is the one decision or undo on its way. When its predicted is true, the board already shows the result. When it is false, the step draws randomness or reveals: show that the move is on its way, and the board updates when Boardweaver answers.

Failures

useFailures().failures keeps every failed step and refused decision until the client calls dismiss(id). Show each one and let the player dismiss it. When Boardweaver refuses a decision, its steps that are still legal come back as the draft, and the player confirms again.

What changed

useLastChange() gives the last change: steps, each with actorPlayerId, lines and movements, or an undo. Each line is a LogLine (Changing the state): its player is "actor", "none" or { seat }, its optional kind is the game's label, and each of its segments is a string or a link { key, text }. Show a line's text, which lineText joins from its segments, after its player's name:

const players = usePlayers();
const step = useLastChange().steps.at(-1);
const actor = players.find((player) => player.id === step?.actorPlayerId)?.name;
const text = (step?.lines ?? []).map(lineText).join(" ");

Each movement is Moved, Added, Removed, Flipped or Shuffled, with positions as { type: "At", spaceId, index }, or { type: "Hidden", spaceId } for a hidden piece. Animate them in your own components, with boardweaver/motion (Client libraries). When coalesced is true, the board jumped (first load, reconnect, undo, skip to now, step back, or a jump to a move on a replay's slider or move log): show the new board without animating. direction is back when the player stepped or jumped back: show the earlier board, and never play its movements in reverse.

Other players' steps

Boardweaver shows the steps of other players (people and bots alike) one at a time, in order, even when several arrive together. Each step stays on the board for 600 ms at 1x, and useLastChange() reports each one as it is shown. To match your own animation, set the time per step:

useStepDuration((change) => 400 * Math.max(1, change.steps.length));

usePlayback() says where the board is. status is now when the board is the match as it stands. It is playing or paused while the board shows an earlier board. That is a step the player has not seen, a step they went back to, or, on return, the board they last saw. Then useBoard() is that earlier board, useOffers() is empty, useDraft() has no steps, and an act or Undo fails as PLAYING. The player's draft comes back at now. useScores() and useMatch() still describe the match as it stands, so do not show scores beside an earlier board as if they belonged to it.

When a player opens a match after other players moved, useMatch().ready is false until the first page of the steps they missed arrives (boardweaver/react reference). Then the board shows the last board they saw, with status paused. A spectator opens at now.

Boardweaver then covers the board with a card: how many steps the player missed, Watch, Skip to now, and Always skip to now in this match. Watch plays the missed steps, and Skip to now jumps to now without playing them. While the player watches, the bar under the board has Back, Play or Pause, Forward and Skip to now, and plays at 1x. Each step's log lines show with their players' names in a bubble over the bottom center of the board. Steps that arrive while the match is open play on their own, with the same bubble. At now, the bar offers Back and the game log: every step's log lines, oldest first, with the steps the player missed marked. A tap on a step moves the board there. On a phone the log rises from the bar with a swipe up.

Keep the bottom center of the board free of anything the player reads while steps play, or draw your own controls: pass ownControls: true, and Boardweaver hides its card, its bubble and its step controls while that component is mounted. Then give the player pause, play, stepForward, stepBack, skipToNow and setSpeed, and always show skipToNow while status is not now, so the player can reach the board they act on:

const playback = usePlayback({ ownControls: true });
{playback.status !== "now" && <button onClick={playback.skipToNow}>Skip to now ({playback.behind} behind)</button>}

Links in the log

A log line's link (.link(key, text), Changing the state) is a control in Boardweaver's game log, on the match page and on the replay page. useLogLinks hears it: enter when the player's pointer or focus first reaches the link, leave once both have gone, and click on a click, each with the link's key. Each enter has one leave with the same key. A click on a link does not move the board. Show what the key names however the game likes, such as the card in a side panel, and clear it only on the leave of the key it shows:

const [shownCard, setShownCard] = useState<string | null>(null);
useLogLinks((event) => {
  if (event.type === "enter") setShownCard(event.key);
  if (event.type === "leave") setShownCard((shown) => (shown === event.key ? null : shown));
});

Every viewer reads every key, so a key names only what every viewer may see.

The log in the game

Boardweaver draws the match's log in its own panel. To keep the log in the game, for example in a side panel, draw it there and pass ownPanel: true from that component: Boardweaver then hides its panel while the component is mounted, and its history button, which keeps its count of the moves the player has not seen, opens and closes the game's log instead.

function LogSidebar() {
  const { open, setOpen } = useHistory({ ownPanel: true });
  if (!open) return null;
  return (
    <aside>
      <button onClick={() => setOpen(false)}>Close</button>
      <History kindStyles={{ combat: { color: "crimson" } }} />
    </aside>
  );
}

<History /> draws the log as Boardweaver's panel does, in the text color of where it is placed and the theme's colors.accent.500: each decision under its player, the rows the player missed marked, and the row on the board marked. A tap on a row moves the board there, and its links reach useLogLinks. To draw the log yourself, read useHistory().entries: each line has its seat resolved as playerId, its kind and its segments.

Scores

useScores() is keyed by the player id as a string: scores[String(playerId)].publicPoints. Each seat sees every seat's publicPoints and only its own privatePoints. The client cannot read the game logic's score labels: write the column names in the client too.

Layout

useViewport() gives the size of the area your client draws in. It changes when the player resizes the window, rotates a phone or enters full screen. Size the board from it, or choose a layout from it. The tic-tac-toe client sizes its cells from the width, less its padding and gaps:

import { useViewport } from "boardweaver/react";

const PADDING = 16;
const GAP = 6;

function useCellSize() {
  const { width } = useViewport();
  return Math.max(32, Math.min(88, Math.floor((width - 2 * PADDING - 2 * GAP) / 3)));
}

Sounds

Upload each sound with upload_sound or Upload sounds in the studio's Sounds view: MP3, WAV, OGG or M4A, at most 10 MB, under a key of UpperCamelCase ASCII letters and digits, such as DiceRoll. Uploading needs the Designer or Professional plan, and every player hears the game's sounds whatever their plan. An upload is a pending change, which the studio preview plays at once and commit makes permanent. A sound lives at /sounds/<Key>: upload the same key again to replace it, move it with rename_file and remove it with delete_file, then change every key the client plays to match. A sound is fetched the first time it plays.

Play a sound by its key with useSounds(). Say at each play whether it is "music" (a soundtrack or an ambient loop) or an "effect" (a sound that answers a step):

import { useEffect } from "react";
import { useLastChange, useSounds } from "boardweaver/react";

export function GameSounds() {
  const sounds = useSounds();
  const change = useLastChange();

  useEffect(() => {
    const theme = sounds.play("ThemeLoop", { type: "music", loop: true });
    return () => theme.stop();
  }, [sounds]);

  useEffect(() => {
    if (change.coalesced || change.direction === "back") return;
    if (change.steps.some((step) => step.movements.length > 0)) {
      sounds.play("PiecePlaced", { type: "effect" });
    }
  }, [change, sounds]);

  return null;
}
  • play(key, { type, loop?, volume? }) returns { stop() }. It throws for a key the game has no sound for. volume is 0 to 1, and iOS Safari ignores it.
  • stopAll(type?) stops every playing sound, or every one of a type.
  • Play each step's sound from an effect on useLastChange(), so it plays as Boardweaver shows the step, for the player's own steps and other players' alike. Skip a coalesced change and one whose direction is back, as for animation.
  • Start music in an effect and stop it in the cleanup, so it ends with the component.

Each player turns music and effects on and off, for every game or for one match, from the sound control on the bar. Boardweaver applies it: an effect played while effects are off does nothing, and music plays muted while music is off, so it is in the right place when the player turns it back on. useAudioSettings() gives { music, effects }, only to show the player's choice in your own controls.

Browsers play no sound until the player presses or types in the game. Music started before that starts at the first press. An effect played before it is dropped.

Sound made with Web Audio ignores the player's settings, so a player who turned sound off still hears it. Upload a file and play it with useSounds() instead.

Game over

When the match is finished and the board has shown every step (usePlayback().status is now), Boardweaver renders GameOver after the board, if the game exports it. The replay of a finished match never renders GameOver. It covers the board itself (for example position: "fixed", inset: 0) and offers the end controls:

export function GameOver() {
  const end = useMatchEnd();
  return (
    <div role="dialog" style={{ position: "fixed", inset: 0 }}>
      {end.startsNextMatch && (
        <button onClick={end.nextMatch} disabled={end.nextMatchPending}>Next match</button>
      )}
      <button onClick={end.returnToGames}>Return to games</button>
    </div>
  );
}

Without a GameOver export, Boardweaver shows its own end screen with the results and the next-match and return buttons. With one, Boardweaver shows nothing at the end, so GameOver must show the result. A match can also end without a winner when it is locked by an error, or when a player leaves it: usePlayers() then marks who left with abandoned, and isGameOver may still be false. Write the end screen for those cases too.

Spectators

A spectator's client renders the same component. useSelf() is null, useMatch().viewer is null, and there are no offers. A spectator starts at now, and sees each player's steps one at a time, as a seated player does.