Tutorial: build Tap Race
Tap Race is the game boardr create scaffolds: everyone mashes a button on their phone, first to the target wins. It's deliberately tiny — but it exercises the whole pipeline: shared state, an alwaysAllowed move, seeded randomness, a board UI, a phone UI, and tests. This page builds it from an empty folder so you see what every piece is for.
If you'd rather start from the finished thing: npx boardr create tap-race and read along.
1. The folder
tap-race/
boardr.game.json ← manifest: identity + entry points
package.json
src/
logic.ts ← the game (runs on the table, sandboxed)
board.tsx ← shared screen (optional)
phone.tsx ← per-player controller (optional)
logic.test.ts
icon.svg ← library tile
rules.md ← the rulebook players open mid-game2. The manifest
boardr.game.json tells the table what this is and where the built entry points live:
{
"manifestVersion": 1,
"id": "com.example.tap-race",
"name": "Tap Race",
"version": "0.1.0",
"minPlayers": 1,
"maxPlayers": 4,
"phoneMode": "optional",
"sdkVersion": "^0.1.0",
"entries": {
"logic": "dist/logic.js",
"boardUi": "dist/board.js",
"phoneUi": "dist/phone.js"
},
"description": "Race your friends — first player to reach the tap target wins.",
"tags": ["quick", "party"],
"icon": "icon.svg",
"rules": "rules.md"
}phoneMode: "optional" means the game is playable hotseat on the table alone, and from phones. See the manifest reference for every field.
3. The logic
// src/logic.ts
import { defineGame, type GameState, type PlayerID } from '@boardr/sdk'
export interface TapRacePublic {
taps: Record<PlayerID, number>
target: number
}
export type TapRaceState = GameState<TapRacePublic>
export default defineGame<TapRaceState>({
name: 'tap-race',
minPlayers: 1,
maxPlayers: 4,
setup: (ctx) => ({
public: {
taps: Object.fromEntries(ctx.players.map((p) => [p.id, 0])),
// seeded: same seed + same players = same target, always
target: 10 + ctx.random.die(10),
},
secret: {},
}),
moves: {
tap: {
// a race has no turns — everyone may act at any time
alwaysAllowed: true,
move: (state, ctx) => {
state.public.taps[ctx.playerID] = (state.public.taps[ctx.playerID] ?? 0) + 1
},
},
},
endIf: (state) => {
const { taps, target } = state.public
const winner = Object.keys(taps).find((pid) => (taps[pid] ?? 0) >= target)
if (!winner) return undefined
return { winner, scores: { ...taps } }
},
})Three things to notice:
- Everything is
public— there's nothing to hide in a race. A card game would put hands insecretand the deck ininternal, and the engine would do the filtering. alwaysAllowed: trueopts this move out of turn order. Without it, the engine rejects moves from anyone butcurrentPlayer.- The target comes from
ctx.random— neverMath.random(), which throws in the sandbox. Determinism is what makes replay and hot-reload possible.
4. The tests
Write these before the UIs — the testkit runs the real engine, so passing tests mean the game actually works:
// src/logic.test.ts
import { createTestMatch } from '@boardr/testkit'
import { describe, expect, it } from 'vitest'
import game, { type TapRaceState } from './logic'
describe('tap race', () => {
it('lets any player tap at any time', () => {
const h = createTestMatch(game, { numPlayers: 2, seed: 'race' })
h.moves['tap']!(undefined, { as: 'p0' })
h.moves['tap']!(undefined, { as: 'p1' })
h.moves['tap']!(undefined, { as: 'p1' })
const pub = (h.getState() as { public: { taps: Record<string, number> } }).public
expect(pub.taps).toEqual({ p0: 1, p1: 2 })
})
it('ends when someone reaches the target', () => {
const h = createTestMatch(game, { numPlayers: 2, seed: 'finish' })
const target = (h.getState() as { public: { target: number } }).public.target
for (let i = 0; i < target; i++) h.moves['tap']!(undefined, { as: 'p1' })
expect(h.match.gameover).toMatchObject({ winner: 'p1' })
})
})npm test — green. Now the pixels.
5. The board UI
The shared screen shows everyone's progress. It receives the board view ({ public }), match meta, and dispatch:
// src/board.tsx
import type { BoardUiProps } from '@boardr/sdk'
import type { TapRacePublic } from './logic'
type BoardView = { public: TapRacePublic }
export default function Board({ view, meta }: BoardUiProps<BoardView>) {
const { taps, target } = view.public
const { players, gameover } = meta
if (gameover) {
const names = players.filter((p) => p.id === gameover.winner).map((p) => p.name)
return <h1 style={{ textAlign: 'center', padding: '4rem' }}>🏆 {names.join(' & ')} wins!</h1>
}
return (
<div style={{ padding: '2rem' }}>
<h1>First to {target} taps</h1>
{players.map((p) => (
<div key={p.id} style={{ display: 'flex', gap: '1rem', margin: '0.75rem 0' }}>
<span style={{ width: '10rem' }}>{p.name}</span>
<progress max={target} value={taps[p.id] ?? 0} style={{ flex: 1 }} />
<span>{taps[p.id] ?? 0}/{target}</span>
</div>
))}
</div>
)
}6. The phone UI
One big button. dispatch resolves with the engine's verdict — show rejections, they're your ctx.invalid messages:
// src/phone.tsx
import { useState } from 'react'
import type { PhoneUiProps } from '@boardr/sdk'
import type { TapRacePublic } from './logic'
export default function Phone({ playerID, view, meta, dispatch }: PhoneUiProps<{ public: TapRacePublic }>) {
const { taps, target } = view.public
const [error, setError] = useState<string | null>(null)
if (meta.gameover) {
return <h2>{meta.gameover.winner === playerID ? 'You win! 🏆' : 'Game over'}</h2>
}
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<p>{taps[playerID] ?? 0} / {target}</p>
<button
style={{ width: '70vmin', height: '70vmin', borderRadius: '50%', fontSize: '3rem' }}
onClick={async () => {
const result = await dispatch('tap')
setError(result.ok ? null : result.reason)
}}
>
TAP
</button>
{error && <p>{error}</p>}
</div>
)
}7. Play it
npm run build # bundles logic + UIs to dist/
npx boardr dev # watch + hot-reload into a running tableOpen the game from the table's library: Open lobby for phones, or pick a player count and hotseat it. Save a change to logic.ts mid-game — the match survives the reload with its state intact.
8. Make it yours
Ideas that each teach one more SDK concept:
- A sabotage move —
stealsets a rival's taps back; gate it withcanMoveso it's only legal once per game (per-player flags inpublic). - Hidden goals — deal each player a secret bonus condition into
secret[p]and check it inendIf. - Rounds — best of three: keep
roundandwinsinpublic, reset taps between rounds, end the game inendIfonly at two wins.