Appearance
Compact multiplayer API recipe
Use this shared path for React, Preact, SolidJS, Vanilla JS, and Phaser tiles. The schema, mutators, hooks, host APIs, and tests are framework-independent; only the UI subscription adapter changes.
1. Create one client
In the tile entry point, create Poe once, call Poe.setupStore(clientConfig) once, show a loading state while store.waitForBootstrap() resolves, and pass store into the UI. Pass the real Poe object too when the UI opens host surfaces such as Poe.room.pickMembers() or Poe.users.openProfile().
Keep app state in synced-store. Use component or scene state only for disposable presentation details such as hover, animation progress, and an open menu.
2. Model people and seats
- Read active membership from
$users, filtering forremovedAt === undefinedbecause removed members retain tombstone rows. App-owned seats may outlive membership; do not count retained seats as active members. Never write system tables. - Read names and avatars from
$userInfo; never display a raw user ID. - Store seats, teams, roles, turn order, phase, and board state in app-owned tables.
- Auto-seat newly admitted members with deterministic
onAddUserslogic, wired into bothclient-config.tsandbackend-config.ts. - Decide public, per-user-private, and server-only visibility before writing the schema. Do not hide public data with UI filtering.
3. Add or assign a player
Open the host picker from a visible in-tile button or empty-seat button:
ts
const picked = await Poe.room.pickMembers({
title: "Choose player",
selection: { max: 1 },
excludeUserIds: [currentUserId],
playingUserIds: seatedUserIds,
});
const user = picked?.users[0];
if (user) {
await store.mutate.assignSeat({
seatId,
userId: user.userId,
now: Date.now(),
});
}Treat the picker result as UI input. In assignSeat, call assertRoomMember(ctx, { userId: input.userId }), re-check that the seat is available, write the assignment, and call notifyUsersAddedToTile when the user was assigned or added. Expected conflicts such as a seat already being taken should early-return or write visible status; do not throw a background error.
4. Commit a turn
Put validation and the authoritative state transition in one mutator. Generate IDs and timestamps at the UI call site, pass explicit next values rather than toggle instructions, and read before merging a row.
After an accepted move:
- Call
setTurn(ctx, { userIds: [nextUserId], push: { body } })for the next player. It already updates their turn marker, unread count, Recents order, and optional push. - Call
clearTurn(ctx, { all: true })when the match ends. - Call
notifyActivityonly when the sidebar preview, actor Recents bump, spectators, or non-turn recipients need a separate update. Do not duplicate the next player's unread/push merely for discoverability. - Emit activity at semantic boundaries, never for every pointer or animation frame.
Subscriptions may fire for the optimistic write and again after confirmation. Render from the latest snapshot and deduplicate sounds, animation, and toasts by event ID or previous state.
5. Bind the shared reader to the scaffold
Keep reusable readers at module scope and type them with AppReadContext.
| Scaffold | Adapter |
|---|---|
| React | useLiveQuery(store, readState); keep the reader reference stable. |
| Preact | useEffect(() => store.subscribe(readState, setState), [store]); returning the unsubscribe function handles cleanup. |
| SolidJS | createLiveQueryResource(() => props.store, () => readState) for query-shaped state. Use subscribeToTable plus onCleanup when table diffs drive keyed animation. |
| Vanilla JS | Subscribe once in mountApp, update existing DOM nodes from each snapshot, and retain the unsubscribe function if the mount can be destroyed. |
| Phaser | Let the DOM shell own subscriptions and the latest snapshot. Dynamically import the Phaser scene, pass getSnapshot() and semantic callbacks into it, and render snapshot changes into scene objects. Never mutate synced-store from update() or another per-frame loop. |
React, Preact, and SolidJS scaffolds include Tailwind. Vanilla JS and Phaser use plain CSS unless Tailwind is explicitly configured.
6. Prove two-client behavior
Use createPoeTileTestHarness for the same schema and mutators in every scaffold. Create at least two clients, verify $users / $userInfo and onAddUsers, make a move as one client, await .confirmed or a waitFor* condition, and assert the authoritative state from the other client. Add a manager-harness assertion for turn, unread, Recents, and notification behavior.
For UI tests, use the scaffold's testing library. Vanilla JS and Phaser can mount their DOM shell directly in Happy DOM; keep Phaser behind the dynamic import seam so ordinary UI tests do not require canvas.
Test host RPCs with the real Poe object returned by createClient(), not a loose handwritten fake.