Documentation

The client

v2 · Stable

Animate game changes

The platform bundles Motion : the motion package, version 12.43.0, formerly Framer Motion : and exposes its React surface as boardweaver/motion. Like every bundled library it resolves to a single shared copy the frame provides, so nothing is added to your bundle and your game is version-locked to that copy.

import { motion, AnimatePresence } from "boardweaver/motion";

Board games are animation-heavy and hand-rolled CSS transitions are where generated code is weakest. Two primitives do most of the work, and they map directly onto what a board game needs.

layoutId : a piece moving from A to B

Give the same layoutId to an element in two places and Motion animates between them automatically, however the tree is restructured. This is "a card moves from hand to table" expressed in one prop : no measuring, no coordinates.

{hand.map((card) => (
  <motion.div key={card.id} layoutId={card.id} onClick={() => play(card.id)}>
    {card.name}
  </motion.div>
))}

Render the same layoutId inside the table area next render and the card flies there. Wrap groups that should animate together in <LayoutGroup>.

AnimatePresence : pieces entering and leaving

React removes an element the moment it leaves the tree, which is exactly wrong for a captured piece or a played card. AnimatePresence keeps it mounted until its exit animation finishes. This matters more than it looks: a broadcast can splice a piece out of the middle of the array, so pieces disappear without warning.

<AnimatePresence>
  {pieces.map((p) => (
    <motion.div
      key={p.pieceId}
      initial={{ scale: 0 }}
      animate={{ scale: 1 }}
      exit={{ scale: 0, opacity: 0 }}
    />
  ))}
</AnimatePresence>

key must be stable and unique or exit animations will not run.

Animating what an opponent did

useLastChange() (see the match API section) reports which piece and space ids changed in the last snapshot, whether it was your own move, and whether several actions were coalesced into one update. Combine it with layoutId and you get opponent moves animating for free.

The platform cannot tell you what an action meant : "a capture happened" is not derivable from a state diff. If you need that, have applyActions write a breadcrumb onto metaData and read it in the client.

Respect reduced motion

Some players get motion sick. This is not optional polish:

import { MotionConfig, useReducedMotion } from "boardweaver/motion";

// Whole-subtree: honours the player's OS setting.
<MotionConfig reducedMotion="user">…</MotionConfig>

// Or branch on it yourself.
const reduced = useReducedMotion();
<motion.div transition={{ duration: reduced ? 0 : 0.3 }} />

What is available

Import Use it for
motion The animated elements: motion.div, motion.button, motion.svg, …
AnimatePresence Exit animations for elements leaving the tree
LayoutGroup Coordinating layout animations across siblings
MotionConfig Subtree-wide defaults, including reducedMotion
useReducedMotion Branching on the player's motion preference
useAnimate Imperative sequences (animate(scope.current, …))
useMotionValue Animated state that does not re-render on every frame
useTransform Deriving one motion value from another
useSpring Spring-smoothing a motion value
useMotionTemplate Composing motion values into a string (e.g. a CSS filter)
useMotionValueEvent Subscribing to a motion value's changes
useInView Whether an element is in the viewport
usePresence / useIsPresent Custom exit choreography inside AnimatePresence
useAnimationFrame A per-frame callback

Types: MotionProps, MotionStyle, HTMLMotionProps, SVGMotionProps, Variants, Transition, TargetAndTransition, VariantLabels, MotionValue, AnimationPlaybackControls, AnimatePresenceProps, MotionConfigProps, UseInViewOptions.

Gesture props (whileHover, whileTap, whileDrag, drag) are props on motion.* elements, not separate imports.

Anything not in the tables above is not part of the platform surface, even if motion.dev documents it : Reorder, LazyMotion, m, useScroll and Motion's internals are deliberately excluded. Use boardweaver/dnd for dragging things around; it is the blessed drag surface and handles keyboard dragging, which Motion's drag does not.

Notes on the upstream documentation

  • Where motion.dev writes import { motion } from "motion/react", use import { motion } from "boardweaver/motion". Importing motion, motion/react or framer-motion directly is a compile error : those specifiers do not exist inside a game.
  • The site documents Motion's current release, which may be newer than the 12.43.0 bundled here. If a prop it describes is a type error in your game, that is the likely reason.

Animating an opponent's move : useLastChange()

Your own clicks are fully observable: click() returns an id, onResult gives the verdict, match.pending is the queue. An opponent's move is not : you receive a whole new snapshot with no note saying what happened. useLastChange() is that note.

const { actorPlayerId, pieceIds, self, coalesced } = useLastChange();

useEffect(() => {
  if (coalesced) return; // not one move — don't animate through it
  for (const id of pieceIds) flashPiece(id);
}, [pieceIds, coalesced]);
Field Meaning
actorPlayerId Who moved, or null when the change isn't attributable to one action.
appliedActionId Which action, or null : same cases.
self The change came from a click this client sent.
pieceIds / spaceIds / playerIds Entities whose object identity changed. Reconcile keeps untouched entities reference-equal, so this is the change set : not a diff you have to compute.
coalesced This snapshot isn't one action. Animate to it, not through it.

The returned object is a stable reference per snapshot, so it is safe as a useEffect dependency: the effect fires once per change, not once per render.

Four things to get right, because each is a way a naive diff-and-animate breaks:

  • Handle null attribution. It is a normal value, not an edge case. A reconnect resync, a lifecycle-only broadcast, and the very first snapshot all have no single actor : and the platform reports null rather than naming the most recent player, because naming one for a delta that may hold several moves reads as a certainty it doesn't have.
  • One broadcast is not one action. A resync or a mid-game join folds several actions into one delta; that is what coalesced marks. Make your animation layer interruptible and idempotent rather than a replay queue : a queue that assumes 1:1 desynchronizes permanently the first time that assumption breaks.
  • self with a non-empty id set is meaningful. Normally your own move arrives with empty lists: the optimistic apply already painted it, so nothing changed. If your applyActions is nondeterministic (a shuffle, a draw), the authoritative result differs from your prediction, and those entities are the server correcting you : usually worth rendering differently from an opponent's move.
  • Removed entities are not listed. They aren't in this snapshot, so their ids wouldn't resolve. Animate exits from the entity unmounting.

Semantics come from metaData, not from the diff

useLastChange() tells you what changed, never what it meant. "Piece moved A→B" is derivable; "that was a capture, play the capture animation" is not, and the platform cannot invent it : only your game knows. There is no event stream. The pattern is a breadcrumb your own applyActions leaves:

// /src/game.ts
export const applyActions: ApplyActionsFn<MetaData> = (state, action) => {
  // ... your rules ...
  state.metaData.lastEvent = { kind: "capture", pieceId: captured.pieceId };
};
// /src/frontend.tsx
const game = useGameState<MetaData>();
const { appliedActionId } = useLastChange();

useEffect(() => {
  // Null on any coalesced snapshot. The breadcrumb still holds the PREVIOUS
  // action's event, so reading it here would replay that animation.
  if (appliedActionId === null) return;
  const event = game.metaData.lastEvent;
  if (event?.kind === "capture") playCaptureAnimation(event.pieceId);
}, [appliedActionId]);

Key the effect on appliedActionId rather than on the breadcrumb's contents: two identical events in a row (the same capture twice) are the same value, and an effect keyed on the value would miss the second one.

The null guard is not optional. appliedActionId goes "2-7" → null → "2-8", because coalesced snapshots come in between attributed ones and a lifecycle-only re-broadcast (a player renames, a seat is abandoned) is exactly that. Without the guard the dependency changes twice per action and the same capture animation plays twice.

Need a hand with this?

Join our Discord