Appearance
Turn-based mutator recipe
Use this recipe for a sequential turn-based tile. Adapt the table and field names to the tile, but keep the SDK import, call placement, and test shape.
Pass or clear the turn from the mutator
Import both helpers from the client entry point. Call them after writing the new authoritative match state, in the same mutator that accepts the move. Do not wrap them in if (ctx.isServer): the client pass clears or marks the caller optimistically, while the server pass performs the authoritative fan-out.
typescript
import { clearTurn, setTurn } from "poe-tiles-sdk/v1/client.js";
import type { TileMutator } from "./types";
export const makeMove: TileMutator<"makeMove"> = async (ctx, input) => {
const match = await ctx.table("match").get("current");
if (!match) throw new Error("The match has not started");
// Validate the caller and move, then derive the complete next state from
// authoritative stored state. These names are tile-specific.
const { nextMatch, nextPlayerId, pushBody } = applyMove({
match,
userId: ctx.userId,
move: input,
});
await ctx.table("match").set({
itemKey: "current",
value: nextMatch,
});
if (nextMatch.status === "finished") {
await clearTurn(ctx, { all: true });
return;
}
await setTurn(ctx, {
userIds: [nextPlayerId],
push: { body: pushBody },
});
};setTurn uses replace semantics by default, so passing the next player also clears the previous holder. Use the same pattern when a start/reset mutator makes the opening player actionable. Use clearTurn(ctx, { all: true }) for every terminal path. A context-rich push body is preferred; omit push to use the generic default.
Test the confirmed platform projection
Test the tile's game state and the platform turn projection. Await the mutation's confirmed promise, then observe each user's private projection from that user's own client. A projection sent to Bob is intentionally not readable from Alice's client.
typescript
import {
createPoeTileTestHarness,
waitFor,
} from "poe-tiles-sdk/v1/test-utils.js";
import { tileBackendConfig } from "./backend-config";
import type { TileSchema } from "./schema";
type TurnRow = { active: boolean };
test("a confirmed move passes the platform turn to the next player", async () => {
const harness = createPoeTileTestHarness<TileSchema>({
store: { backendConfig: tileBackendConfig },
});
try {
const { store: alice } = await harness.createClient({ userId: "alice" });
const { store: bob } = await harness.createClient({ userId: "bob" });
const mutation = await alice.mutate.makeMove({ move: "example" });
await mutation.confirmed;
await waitFor(bob, {
queryFn: async (tx) =>
(
(await tx
.privateOfUser("bob")
.table("_poe_turn")
.get("current")) as TurnRow | undefined
)?.active === true,
description: "Bob's turn marker to become active",
});
const bobTurn = await bob.query((tx) =>
tx.privateOfUser("bob").table("_poe_turn").get("current"),
);
expect((bobTurn as TurnRow | undefined)?.active).toBe(true);
const aliceTurn = await alice.query((tx) =>
tx.privateOfUser("alice").table("_poe_turn").get("current"),
);
expect((aliceTurn as TurnRow | undefined)?.active).not.toBe(true);
} finally {
harness.dispose();
}
});Add a terminal-path assertion too: complete the round, await confirmation, wait for each previously active user's projection to become inactive, and assert the tile's own terminal state. Do not stop at an optimistic render or inspect another user's private projection from the wrong client.