Skip to content

Activity Creator

The canonical (always up-to-date) version of this skill and the rest of the Joiner docs live at https://poe-tiles-docs.pages.dev/guide.html. Check there for the latest CLI install snippet (used to upgrade poe-tiles) and to refresh this skill in an existing project — the docs page links to the snippet, and poe-tiles skills install --dir .claude/skills overwrites the on-disk skill files with the version bundled in the currently-installed CLI.

Build a Joiner activity from a natural-language prompt. Scaffold → schema → UI → tests → deploy.

User Argument:

If empty, ask "What app should I build?" and stop. Otherwise derive a kebab-case app name (e.g. "voting app" → voting-app). Append a short random suffix to the name (e.g. voting-app-k4x7): the activity handle is unique per creator, and a bare descriptive handle collides with — or silently overwrites — an earlier activity built from a similar prompt. The handle is plumbing; .poe-tile.json → displayName replaces it on every user-facing surface.

Important things you should do

  • Batch independent tool calls. Issue unrelated reads, searches, and diagnostics in parallel instead of spending one model round trip per file or command; keep dependent actions sequential when one needs the previous result.
  • Make activity state resilient to navigation and reloads by default, including solo play. When a player navigates away, closes the activity, or reloads and then returns to the same instance, restore their ongoing match/run and progress instead of sending them to a fresh start. Use synced-store for durable state; derive the resumed screen from that state after bootstrap, and never reset it just because the UI mounted again. Keep only disposable presentation state in memory. See references/game-ux-best-practices.md for checkpoints, timers, and fair recovery of committed attempts.
  • For AI-powered activities (a chatbot, an AI opponent or game-master, on-the-fly content generation), call the model from a synced-store action with createModelStream (streamed text deltas) or createImageGenerator (an image, re-hosted as a URL you can store) — build on the Poe model catalog rather than wiring in your own provider, and discover model ids with Poe.listModels(). There is no client-side model API: an action keeps your prompts out of the shipped bundle, shares one result with every player, and means your activity can never hold a credential. Preflight the feature with Poe.requestPoeBotAccess() before dispatching: a user whose Poe account isn't usable cannot pay for the call, and this one line has the platform prompt them to fix it (a host-owned modal with inline account linking) — { canUse: true } means proceed in the same gesture, { canUse: false } means they declined (keep the feature visible, disabled). Don't fire the action and let it fail server-side or silently hide the feature (the surface-errors-don't-degrade principle, applied to AI); use Poe.getPoeBotAccess() only when you need a silent check with no prompt. See @references/backend-api.md and @references/client-api.md.
  • For feature changes in an existing activity, follow the feature-specific testing guidance (usually tests first). For brand-new activity scaffolds, build the minimal UI/logic first, then add the required tests once the core flow exists.
  • Use store.waitForBootstrap() for the board handoff, with a visible loading state. Do not leave the iframe blank while bootstrapping: render a small in-activity loading indicator immediately, then mount/show the playable board once waitForBootstrap() resolves. It resolves as soon as authoritative data is ready from either source — local cache or first server pull — so offline-capable cases (already-loaded instance, or a fresh instance declared via Poe.tiles.prepareNewInstances) unblock immediately without a server round-trip. Avoid waitForServerData() in the render path: it always waits for a server pull, which breaks offline launches and stalls fresh prepared instances that have no server state to fetch. Reserve waitForServerData() for tests / Node-side scripts where a server round-trip is actually required.
  • Render synced-store data through store.subscribe() or the framework live-query helpers (createLiveQuery, createLiveQueryResource, etc.), not store.query(): store.query() is a one-shot read and will not repaint when another user, device, or mutator changes the data.
  • Put every system hook you declare on the backend in defineClientConfig({ hooks }) too, using the same browser-safe handler whenever possible. Poe.setupStore(config) runs declared hooks optimistically for fresh creator launches (including stores declared via Poe.tiles.prepareNewInstances) before server data arrives, then server data replaces the overlay. If a hook has server-only side effects, split it into a shared deterministic helper plus a tiny backend-only tail, or guard that tail with if (ctx.isServer).
  • Seat initial players in onAddUsers, not from UI mount code. For room members, prepared instances, and offline setup, the system hook is the source of truth: onAddUsers should create/update app-local player rows, seats, teams, turn order, and any per-user projections needed for the starting state. The UI should consume those rows after bootstrap; do not call a join/seatSelf mutator on mount just to create the current player's row, because that misses offline/pre-added members and forces setup to wait for the viewer to open the activity.
  • Publishing is your HOST's, not this skill's. Who uploads a build, when, what pins the live version, and what the creator's buttons do are all host-specific, so they are stated where the host states them. Follow your system prompt's publishing rules; they win over anything you read elsewhere. Only when your host says nothing about publishing, read references/publishing.md and follow it — it describes driving the CLI yourself.
  • A multiplayer activity is not verified until you have seen it with more than one player. A solo pass proves only the single-player path; turn order, seating, whose-turn indicators, live opponent updates, and the room-side notification surfaces (recents, unread badges, "Your Turn", in-app banners) are all invisible with one player, and they are where multiplayer activities actually break. Multi-user coverage in the app's own tests is the baseline, and the simulation link is how the USER confirms it end to end — so surface it, and prefer it over asking them to find a second person.
  • Before the FIRST upload, fill in the listing-page fields yourself — do NOT ask the user. Generate them from what the app actually does (derive from synced-store/schema.ts, ui/App.*, and the original prompt): set .poe-tile.json → displayName to a placeholder summary name — "New" plus the kind of activity the prompt asked for ("New Zombie Activity", "New Strategy Activity", or plain "New Activity" when the prompt names no theme) — not an invented final title; the user chooses the real name when the activity is listed publicly, and a name they supplied at any point always wins over the placeholder. Rewrite README.md (long description), replace .poe-tile.json → shortDescription with a ≤140-char sentence specific to the app, and for every game or participant-count-sensitive activity populate .poe-tile.json → players with the supported total seats (humans + AI). Derive min, optional max, and an optional genuinely better recommended sub-range from the implemented rules — never from the current room, a test roster, or guesswork. max is a real creator-declared gameplay or capacity bound; omit it when the implementation has no upper bound. Never use the schema or platform ceiling as max: an uncapped one-player-or-more activity uses { "players": { "min": 1 } }, which the listing renders as 1+. players is load-bearing beyond the listing page: it decides whether an upload offers the user a multiplayer-simulation link and how many phones that link opens (recommended.min when declared, else min), so an under-declared range costs them the easiest way to test the activity. Treat any remaining literal TODO in the text metadata as a bug — fix it. See references/scaffolding-a-new-app.md Step 5.5.
  • Do not submit discovery categories. Categories are managed automatically by the platform. Use the current .poe-tile.json schema (v10); if an older config contains a top-level categories field, delete that field before publishing. Keep declaring supported player counts in players.
  • Before the FIRST upload, choose instancing from the activity's state semantics and set .poe-tile.json → instancing explicitly. Use "instancing": "per-context" when every launch in one context should join the same durable competition or puzzle: leaderboard / score-chase activities such as Poe Jump, Hoops, and Duck Duck Duck, and daily activities such as Daily Word Grid. Use "instancing": "per-launch" when each launch is a separate match and one context may legitimately host multiple matches, such as Chess and Checkers. Do not decide from solo versus multiplayer or turn-based versus real-time; ask whether two launches in the same context should see the same state. The manifest value is per-launch, not per-instance, and instancing requires $schema v7. For a per-context score chase, keep individual runs separate inside the shared store while sharing the leaderboard.
  • Generate the profile picture before the first upload. Do not ask the user for permission or wait for authorization to make the activity public. Commit a full-bleed square image that reflects the activity's actual visual identity, inspect it, and set .poe-tile.json → profilePicture. Use the scaffold's own generator — bun run regenerate-screenshot writes a square 720×720 assets/screenshot.png; do not write your own screenshot script. During edit drafts, keep the existing icon; when finalizing an update to an already-public activity, regenerate it automatically if it is missing or materially stale. See references/scaffolding-a-new-app.md Step 5.5.
  • Defer gallery screenshots until the activity is being presented as finished. Visibility does not mark this boundary — the gate is the user's readiness. Do not create or regenerate screenshots while iterating or for edit drafts. When the user says the activity is ready, or when finalizing an update to an established activity, capture or update only missing or materially stale screenshots. Use 1–3 representative gallery images, including at least one genuinely in-action or meaningfully populated state, and inspect each for loading UI, debug chrome, clipping, and stale content.
  • Make preview video opt-in when the activity is being presented as finished. Visibility does not mark this boundary either — the gate is the user's readiness. When the user says the activity is ready, or when finalizing an update to an established activity, ask whether they want a preview video and poster before creating them. Video work is more expensive than screenshots, so never create or regenerate it without an explicit yes; declining it must not block the upload. For an established activity, ask only when its video is missing or materially stale.
  • Apps scaffolded via poe-tiles tiles init upload source code by default (visible to anyone who fetches it). The sourceBundle block in the app's .poe-tile.json controls this — set "visibility": "owner_only" to keep source private to the creator, or delete the block entirely to skip source upload (the app will then be non-editable / non-remixable on the platform). The built-in default ignore list keeps .env*, node_modules/, dist/, etc. out of the bundle automatically.
  • Locate or create the project before running doctor. For existing work, find the directory containing the activity's package.json and .poe-tile.json; do not assume the shell starts there. For a new activity, scaffold and install dependencies first. Then run poe-tiles doctor --cwd <project-dir> with the absolute project path. An empty workspace is an expected starting state, not a failed setup: do not run project-readiness checks there. If doctor was already run in an empty or parent directory, locate or scaffold the project and rerun it there rather than treating the resulting failures as broken dependencies or auth.
    • Fix genuine failures reported against the actual project before continuing. ./scripts/doctor.sh (or bun run doctor) is the shortcut when the command runs inside that project. For missing upload auth, use poe-tiles login only in an environment that supports interactive browser login; in a managed creator workspace with injected credentials, report the auth failure instead. Never ask for an API key in chat.
  • Mobile-first. Apps must work on both desktop and mobile, but prioritize mobile — most players are on phones. Design touch targets, viewport sizing, and on-screen keyboard behavior for mobile first; desktop layouts come second.
  • On-screen keyboard: in the native apps the WebView never resizes for the soft keyboard — the platform relays a keyboard inset instead. See @references/client-api.md.
    • Every Android-app activity iframe automatically gets a default install that pads the activity's root so a focused input clears the keyboard. The default applies only in the Android app; mobile browsers resize the viewport for the keyboard themselves, so no keyboard handling is needed there.
    • For every input, textarea, or [contenteditable], still make an explicit iOS keyboard-layout decision: a full-viewport, fixed-height, docked-input, or bottom-sheet activity must call installKeyboardLayoutInset() from poe-tiles-sdk/v1/client.js once at startup — an explicit call replaces the Android platform default. Options cover the target selector and mode ("shrink-root" for docked-widget layouts, "padding-bottom" for plain scroll containers). Do not assume browser viewport resizing or the host compatibility fallback is sufficient.
    • If that same opted-in layout also includes editable controls in ordinary scroll flow rather than docked above the keyboard, additionally pair those controls with createKeyboardFocusScroller().
  • Apps must remain polished and legible in both light and dark system modes. Use a game-specific, theme-aware palette (prefers-color-scheme, CSS variables, Tailwind dark: variants) instead of a light-only or dark-only palette, and verify the app in both modes before declaring it done. The host app may also force light or dark app-wide (the user can override their OS setting in settings); the override is delivered through prefers-color-scheme, so a theme-aware app obeys it automatically with no extra work. Don't hardcode a single scheme, and don't try to read the device appearance through any channel other than prefers-color-scheme (e.g. a native/OS probe) — that bypasses the user's app-wide choice.
  • Apps must render nicely on the For You feed. Apps do not own the full viewport — they render inside a host iframe whose size is set by the parent. On mobile (e.g. iPhone SE, 375×667 viewport) the For You feed iframe is roughly 350px × 509px; on desktop the manager's For You feed iframe is roughly 780px × 414px (phone-shaped, host takes the rest of the screen for chrome). Apps must render well at both sizes. Layout must not have unintentionally overlapping or clipped elements. If the app is not intended to scroll, it must not scroll at either size — size content to fit, don't rely on the host clipping overflow. Test with the iframe at both sizes before declaring the app done.
  • Don't render your own persistent title bar at the top. An activity plays inside an iframe, and the platform draws the activity's title in chrome above the iframe and a message/chat box below it. A fixed in-activity title just duplicates the platform's and wastes vertical space. Show a title only transiently where it earns its place (e.g. a start/lobby screen), not as fixed chrome across the session.
  • Start on the playable experience. The first real screen after bootstrap should be the board/run/round/lobby users can act in, not a marketing page, rules wall, or separate setup explainer. Teach in context on that surface; if enough players are already seated by onAddUsers, let the activity be ready to play immediately.
  • Surface failures in the UI. For every user-triggered async action, show a toast or inline alert with a useful message; console.error / console.warn is only diagnostic and must not be the only handling. Background sync errors are already handled for you — the platform surfaces every store background error as a toast and reports it, for any activity that calls Poe.setupStore(). Do not subscribe to store.onBackgroundError just to toast the message; that duplicates the platform toast. Reach for a hook only when you can do something the platform can't:
    • store.onFailedMutation((info) => { ... }) — a server rejection rolled back your optimistic pass and you need to repair local state: restore a draft the user typed, clear a spinner, reset a pending id. This is the common case. (Letter League restores the player's drafted word this way.)
    • store.onBackgroundError((error) => { ... }) — only when you must also handle the non-mutation kinds (store_not_bootstrapped, stuck_mutation, no_access_to_this_store) with app-specific copy or a recovery affordance. If you do, pass Poe.setupStore(config, { backgroundErrorToast: false }) so your UI replaces the platform toast rather than doubling it.
  • Poe is not a global. It is created once in the app's entry file (tile/src/entry.ts or entry.tsx) via createPoe({ environment }) — the only module that imports poe-tiles-sdk. Every Poe.* snippet in the references assumes that instance is in scope, so pass what you need from the entry file into the rest of your app: the whole object, or just one capability (e.g. Poe.haptics). Type the receiver with PoeAPI or PoeAPI["haptics"] from poe-tiles-sdk/v1/client.js — an import type costs no runtime import. See @references/client-api.md.
  • Any continuous-render activity (a rAF loop redrawing a canvas every frame) must adopt the platform render budget. If your code owns the loop (canvas 2D, three.js, WebGL), wire createRenderBudget() from poe-tiles-sdk/v1/client.js into renderer construction (rendererOptions() + configureRenderer()) and gate rendering — never simulation or input — with shouldRenderFrame()/renderFrame(), keeping ONE budget per document across game-round remounts (use acquireSharedRenderBudget() / adoptSharedRenderBudgetRenderer() when mounts rebuild the renderer). If an engine owns the loop (Phaser), map resolveRenderBudgetDefaults() into the game config (fps.limit, render.powerPreference) and do not construct a budget. This caps mobile rendering at 60 fps and budget DPR (a large, measured battery win on high-refresh phones) and reports the render stats the platform uses to find battery-hungry activities; an unbudgeted activity renders at panel rate with uncapped-DPR buffers and ranks worse in the fleet power sweep. The resolved values are defaults an activity may explicitly override (maxPixelRatio / antialias / targetFps) — overrides are honored verbatim and stamped into the activity's power telemetry. See @references/client-api.md.
  • Size every drawing buffer by the budget's resolved pixelRatio, and never by CSS size alone. A canvas whose backing store matches its CSS box is stretched by the browser on every retina display; text smears first, and a small fixed buffer blown up to fill a phone or a desktop panel can be painting one pixel for every four or five it shows. You cannot catch this by looking: headless screenshots and most desktop previews render at 1×, where an unscaled buffer looks perfectly sharp, so this is a decision to make in code rather than a check to make by eye. For a canvas you own, set canvas.width = Math.round(cssWidth * pixelRatio) (and the same for height), then ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0) once per resize so all drawing code keeps working in CSS units. When an engine owns the loop, its buffer resolution is part of what resolveRenderBudgetDefaults() must drive, alongside fps.limit and render.powerPreference — a Phaser game configured at a fixed world width/height with Scale.FIT renders one buffer pixel per world unit and lets CSS do the rest, which ships blurred (Phaser's zoom does not fix it; that scales the displayed size, not the buffer). Take the ratio from the budget's resolved pixelRatio rather than raw window.devicePixelRatio, so the mobile battery cap still applies. Re-apply it whenever the canvas is resized, and recheck at a device pixel ratio above 1 before calling the activity done.
  • Use Poe.haptics for haptic feedback on user actions — Poe.haptics.impact("light"|"soft"|"medium"|"rigid"|"heavy"), Poe.haptics.notification("success"|"warning"|"error"), Poe.haptics.selection(). Fire-and-forget and safe from any context (iframe or top frame). The SDK owns platform routing and fallbacks; do not call native bridges or browser/device haptics APIs directly. See @references/client-api.md.
  • Use var(--poe-safe-area-inset-{top|bottom|left|right}, env(safe-area-inset-*)) for any padding that protects content from device chrome or host-app overlays. The platform stylesheet maps each var to the matching env(...) value by default, but a parent app (e.g. the manager when it draws a top bar above the iframe) overrides them with explicit px values so the child does not double-pad. Never use raw env(safe-area-inset-*) directly — it ignores parent-app overlays and the child renders behind the parent's chrome. See references/safe-area-insets.md.
  • Call applyNativeAppGestureOverrides() once at startup in your entry file for any player-facing activity. Without it, the native app's WebView keeps its default touch gestures, so dragging or long-pressing an interactive element (a game piece, a card, a token) triggers the OS text-selection callout / context menu / share sheet instead of the in-app action the player intended — it makes a touch-driven game feel broken. The call suppresses text selection, the iOS long-press callout, and native drag (re-enabling them inside input/textarea/[contenteditable] so typed text stays selectable), and is a no-op outside the native app (desktop browsers / Safari / Chrome keep normal selection). Import it from poe-tiles-sdk/v1/client.js. Skip it only for a document-like or text-heavy activity where free text selection is the point. See @references/client-api.md.
  • Prefer direct manipulation for movable pieces. When a player moves a piece, card, token, or tile from one location to another, make drag-and-drop the primary touch / pointer interaction instead of requiring click/tap the piece and then click/tap its destination. Keep tap-to-select then tap-destination and keyboard controls as accessible fallbacks; use Pointer Events, highlight legal drop targets during the drag, snap the piece back with feedback after an invalid drop, and test both the drag path and fallback.
  • Treat iOS magnifier suppression as a release gate, not only as a named hold-gesture feature. applyNativeAppGestureOverrides() cannot stop the magnifier loupe WebKit starts when a finger rests long enough to enter text-selection mode. Audit every non-scrolling gameplay surface that can receive a held finger — boards, canvases, racks, card/piece fields, joysticks, and control pads — even when the activity has no explicit hold gesture; an ordinary slow tap or drag can still trigger it. Call suppressLongPressMagnifier(element) on each such surface. Use the plain call for pointer-driven surfaces and { preserveTaps: true } when quick taps on descendants rely on click handlers. Never apply it to native scrolling, form fields, or navigation links: cancelling touchstart also cancels scrolling, double-tap zoom, and native click synthesis, while preserved synthetic clicks cannot focus inputs. Remove listeners during unmount, and verify slow taps, holds, and drags in the actual iOS app before declaring the activity done. See @references/client-api.md.
  • Animate physical actions instead of jumping straight to their outcomes. When the UI depicts dice, coins, cards, spinners, pieces, projectiles, or similar objects being rolled, flipped, shuffled/dealt, spun, moved, or thrown, show a brief animation that enacts the action before settling on its result. Compute and persist the authoritative result independently of the animation—never let presentation RNG decide game state—then animate toward that known result. Keep it quick, block duplicate input while it plays, pair it with fitting haptic/audio feedback, and under prefers-reduced-motion use a shortened transition that still communicates cause and effect. Test the ordering with controlled timers or animation-completion hooks, never sleeps: action starts, result settles, then follow-on controls unlock.
  • Make the other players visible — this is a core part of what makes a social game rewarding, not a nice-to-have. In multi-player apps, render the display name and avatar of everyone the user is playing with and against, next to their in-app representation (their snake in a multi-player snake game, their cursor, their seat at the table, their score row, their move) and at the moments that matter (whose turn it is, who just moved, the end-of-game results). Seeing real faces is what makes a session feel like playing with people rather than against software. Read names and avatars from $userInfo; render them in the game's own visual style, and design for a missing photo (initials fallback), not a broken image. Make those avatars tappable to open the player's profile with Poe.users.openProfile({ userId }) — the interactive payoff of surfacing who you're playing with. See @../synced-store/references/getting-user-info-of-members.md.
  • When assigning room users to app-local roles, seats, teams, or turns, use Poe.room.pickMembers() for the host-provided picker. Hide ineligible users with excludeUserIds; show occupied seats as Playing with playingUserIds. Child rooms show active parent-room users in a separate People in parent room section as existing room members unless the child has a live local $users row for that user (someone who left the child room stays pickable — the platform re-admits them on access). Contacts are selectable by default and selected contacts are added to the room before the result is returned; pass addFromContacts: false only when the product explicitly calls for choosing existing room members. In a dm-* room, the host moves the app to a 1:1 DM or new group room as needed instead of growing the frozen DM membership. Treat the result as UI input: validate server-side writes with assertRoomMember(ctx, { userId }), and use notifyUsersAddedToTile(ctx, ...) for the standard added/assigned notification.
  • Surface invite / add-player controls inside the activity, and set seats up for async play — don't rely on the host's chrome. When the app needs more participants — to fill a seat, assign a role, or start a match — render a clearly visible in-activity control (e.g. an "Invite" / "Add player" button on the start/lobby screen, or right on an empty seat) that opens the Poe.room.pickMembers() picker described above. If an empty seat or slot is drawn as an invite affordance (such as a card with a + icon and "Invite player"), make the entire slot one accessible button / tap target: tapping the icon, label, or surrounding empty card must open the picker. Do not require the player to find a smaller nested button, and cover the slot surface—not only its label—in an interaction test. Do not depend on the surrounding host chrome (the manager / top bar) to provide this: the app renders in contexts where that chrome is absent, hidden, or non-obvious (the For You feed, a freshly-prepared instance, embeds), so a player should never have to leave the app and hunt through platform UI to get a friend in. Seating does not require players to be online — pickMembers() can return offline room members and seat assignments persist — so prefer letting a player assign opponents to seats up front (and auto-seat newly-added users in the onAddUsers hook) so a turn-based game can be fully set up and waiting before everyone is present, instead of requiring all players online at once to begin. Pair an offline assignment with a setTurn / push notification so the absent player is pulled back in.
  • Cover creator-authored behavior with mutator/unit tests and Happy DOM UI tests. Jest DOM matchers are configured by the scaffold. Do not add or unskip Playwright E2E tests for the normal creator workflow; use browser tests only when the user explicitly requests them or the behavior genuinely depends on a browser-only API. See references/unit-tests.md.
  • Each test must fail when the behavior it names is broken — verify that rather than assuming it. Coverage and per-state test counts say a line ran, not that anything asserted what it did: a rule module can sit at 90% line coverage, with a test named for every rule, and still pass after each rule is deleted in turn. So for every rule that decides whether an action is legal, whether a round is over, or who may act: assert which rule rejected an action, not merely that something did — a validator typed boolean, or one throwing the same message for every violation, cannot support that assertion, so return a distinct reason code and pick an input that violates only the rule under test. Test generated content (board, level, deal, daily puzzle) as a pure function over many seeds, asserting both the invariants that make it playable and that different seeds produce different content — a generator that stops reading its seed still returns a valid board, so single-seed tests keep passing while every player gets identical content. Before calling a rule tested, delete it in the source, confirm the test fails, and restore it. See references/test-quality.md.
  • Treat the actor's activity bump as a release gate: every meaningful accepted user action that changes activity state or advances play bumps the activity for the actor. Route the bump through the same synced-store mutator that commits the action; when gameplay state is otherwise local, call a small activity mutator once at the semantic boundary (for example start/restart, a completed stroke or drag, or a submitted choice), never on every pointer frame. Ensure the caller receives notifyActivity with recency: "bump". If one fan-out already includes the actor, make that call bump; if recipient attention differs or targetUserIds excludes the actor, add an actor-only call with targetUserIds: [ctx.userId], recency: "bump", and no unread or push. setTurn for the next player does not satisfy this invariant because it bumps only newly marked turn-holders. Exclude rejected/no-op actions and transient input such as pointer movement, hover, or unsaved typing. Also exclude per-user preferences visible only to the acting user — mute, volume, haptics, theme, and reduced motion — even though they are accepted actions that write state. These change how the activity presents itself to one person rather than what happened inside it, so bumping Recents for them makes a private settings toggle read as a move. Persist the preference and return without notifying. Shared settings that affect other players or play — house rules, difficulty, round length, board size, and similar room-wide configuration — still bump. Test through createPoeTileInManagerTestHarness that the actor's Recents position/timestamp advances after server confirmation while the actor's unread count does not.
  • In multi-player apps, call notifyActivity whenever durable user-visible activity needs a custom sidebar preview or should reach people beyond the next turn-holder. setTurn already moves each newly marked recipient's room to the top of Recents, increments unread, and optionally pushes, so do not add notifyActivity only for discoverability. Use unread: "increment" without push for passive updates other people should count as unread ("spymaster gave a clue", "Aaron reacted 🎉"). Every explicit, user-invoked nudge action targeting another player must pass unread: "increment" with the same targetUserIds; push alone does not increment unread. Verify a nudge with createPoeTileInManagerTestHarness: the recipient's manager unread and confirmed private _poe_unread projection each increment, while the sender's unread does not. Omit targetUserIds, unread, and push for a preview refresh that should reach every member, including the actor, without adding a badge — such a call updates each row in place and does not reorder Recents (recency defaults by attention; pass recency: "bump" explicitly only when the badge-less event is genuinely "the latest thing that happened here"). Leave unread omitted in both the schema and client config to use the SDK default. Do not add expectNoUnread() merely because the activity currently sends no notifications; the default keeps later setTurn and notifyActivity({ unread: "increment" }) additions working. Use customUnread() in both places only for app-owned chat/thread read state. Use expectNoUnread() in both places only when the activity will NEVER badge anyone — it is an assertion, not a switch: the increment paths are per-call and not policy-aware, so an activity that declares it and then calls unread: "increment", setTurn, or the automatic leaderboard high-score notify still badges its recipients while claiming otherwise. What it buys is that the platform withholds the increment mutator and treats any count that does appear as damage to repair. (noUnread() is deprecated — it is the former name for expectNoUnread(), and it read as a guarantee the platform cannot make.) Pick push for required actions not represented by turn state and high-signal opt-in events ("friend beat your high score"). Use postToChat when the event should also appear as one app-owned announcement in the containing chat; do not add app-local room guards just for this because notifyActivity skips the chat append when no containing chat room exists. For games like checkers, chess, darts, and Poe Jump, do not post to chat on every move; reserve postToChat for terminal/high-signal milestones such as a player winning, a match ending, or a new high score being reported. See @references/client-api.md "When to notify, and at what level".
  • Before finishing any activity that can send a push, inventory every delivery path and report its notification-aggressiveness score. Count setTurn, notifyActivity({ push }), notifyUsersAddedToTile, and automatic leaderboard notifications; estimate the expected and plausible worst-case pushes to one recipient in 24 hours across all active rooms and instances. One semantic event must have one push owner — opt out of an automatic path before adding a custom replacement. A low score with no hard-blocking dimension can ship; a moderate score needs an explicit rationale; an aggressive score must be redesigned. Read references/push-notification-guidelines.md for the rubric, worked examples, and review checklist.
  • When a notification should land the user somewhere specific, attach a tiny entry context — and validate it against your store on arrival. An activity can't otherwise tell "opened from a notification" from a normal open, so it can't show a challenge overlay, a game-over recap, first-turn framing, or scroll to a mentioned entity. Pass push.context (small JSON, an id plus a kind discriminator — capped at 4 KB, no secrets) on the notifyActivity push, then read it at launch with Poe.consumeEntryContext() ({ source: "push" | "banner" | "badge" | "direct", notification? }, consumed once per mount, route-mounted activity only). Treat context as a stale-able hint, never authoritative state: re-derive display state from your store and act only if it still applies — show the overlay only if the challenge is still unbeaten, scroll only if the message is in loaded history, else fall through to a normal open. Show nothing for routine, low-signal events (an ordinary turn, a daily nudge) — a mistimed overlay is worse than none. For a DOM-nested tiles.openChild child (which always reads direct), forward what it needs via openProps. See @references/client-api.md Poe.consumeEntryContext().
  • Don't build a chat UI inside your activity — the platform already provides one. Chat is itself a platform app, and a multiplayer activity runs inside a room; when that room has a chat, the host surfaces it alongside your activity, so players already have a shared place to talk without you reimplementing messaging. Instead of an in-activity chat, drop only high-signal milestones into that containing chat with notifyActivity({ postToChat }) (a player won, a match ended, a new record) — the destination is resolved server-side and the append skips gracefully when the activity isn't inside a chat room (e.g. a standalone For You feed instance), so no app-local guard is needed. See @references/client-api.md notifyActivity.
  • In turn-based games, declare whose turn it is with setTurn / clearTurn. Import them with import { clearTurn, setTurn } from "poe-tiles-sdk/v1/client.js";, write the new match state first, then call the helper in the same mutator without an isServer guard. A new setTurn mark automatically moves the recipient's room to the top of Recents, increments unread, renders "Your Turn," and sends the configured push; do not pair it with notifyActivity merely to make the activity findable. Use notifyActivity separately only when the move should change the sidebar preview or update additional members such as the actor or spectators. Copy the mutator and confirmed-projection test shape from references/turn-based-mutator.md; use @references/client-api.md only for advanced options.
    • Pass the turn forward with await setTurn(ctx, { userIds: [nextPlayerId] }). replace defaults to true, so the previous holder is cleared automatically and the next player gets the recents bump and push — no separate clear call, and no "forgot to clear the previous player" bug. Re-marking someone already up is idempotent and does not bump again. On game over, clear everyone with await clearTurn(ctx, { all: true }); clears do not reorder Recents.
    • Make the push body context-rich and engaging, not a generic "It's your turn." Pass push: { body } describing what just happened ("Jacob just took your bishop", "It's your turn to guess the word", "Aaron checked you — your move"), computed in the same mutator that detects the event — a specific, human push pulls a player back far better. Fall back to the generic default body only when there's no meaningful context (e.g. an undo).
    • Let the manager compose the title — pass push as just { body }. Both setTurn and notifyActivity fill the title at delivery as "<tile>: <room/opponent>" (matching the recipient's recents-row title). Pass an explicit title only to override that.
  • Before implementing a turn-based game, ask how stalled turns should resolve. Confirm whether everyone should wait indefinitely or whether another player may skip an inactive turn after an activity-specific deadline. If turns are skippable, confirm both the inactivity window and the consequence (skip, default action, forfeit, removal, etc.); a rapid party game may use seconds while an asynchronous word game may use many hours. Do not copy one universal timeout across games. See references/game-ux-best-practices.md.
  • Before implementing a turn-based game, ask what happens when a seated player leaves the room. Treat only explicit membership removal (onRemoveUser) as leaving — closing the activity or losing the connection must preserve the seat and resumable state. Prefer removing the player and repairing the active turn, teams, and win conditions when that keeps the rules fair and the flow intact. If it would invalidate the match or no safe continuation is obvious, enter a durable resolution state and prompt the remaining players to choose a new match or an activity-specific alternative such as replacement, forfeit, or abandonment; never silently choose for them. See references/game-ux-best-practices.md.
  • When a turn can stall, ask whether waiting players should nudge the blocking player before escalating. Prefer a targeted, context-rich push that brings the current player back before exposing skip, default-action, forfeit, or removal controls. Choose activity-specific nudge timing, resend cooldown, and post-nudge grace from the expected cadence — synchronous and asynchronous games should not share one timeout. Persist nudge state per authoritative turn, rate-limit it on the server, exclude the blocker and spectators from sending, and never let a repeated nudge reset the first-nudge timestamp used to unlock escalation. See references/game-ux-best-practices.md.
  • Guide players in context, but do not add an auto-opening first-time walkthrough by default. Players arrive at an activity with no manual and no setup screen (the For You feed, a push, a shared link), so each screen, phase, and state should say, right where the action is, what's happening, what the player should do next, and the goal — short contextual lines, not a wall of rules. Decide whether a first-time carousel is warranted case by case: reserve it for unfamiliar, multi-step, or costly-to-misunderstand play that the first actionable screen cannot teach clearly. Skip it for quick, restartable rounds and simple or familiar mechanics that players learn by doing; Poe Jump is the negative reference, because its rounds are short and its single core mechanic and rules are immediately legible. There is no cross-instance per-user memory: a privateOfUser row remembers dismissal only for that (instance, user), so a new instance would force the intro again. Treat that repetition as a product cost that weighs against adding a walkthrough, never as a general “once per player” solution. If the activity-specific decision is to add one, follow the conditional carousel and persistence guidance in references/game-ux-best-practices.md. Reserve the full ruleset for an opt-in ? "How to play" backstop, never a prerequisite to start. Especially make the waiting state explicit: when it's not the player's turn or they're blocked on someone else, show "Waiting for Aaron…" with their avatar and a gentle animation so a waiting screen never reads as a frozen/broken one — this is the in-activity complement to setTurn (which handles the manager indicator + push for whoever is up).
  • Let the decisive moment land before tileEnd() covers the playfield. While planning every terminal path, explicitly answer: What shows the player why they won or lost, which animation/feedback communicates it, and when is it safe for the host overlay to appear? Never call tileEnd() immediately when a loss is detected. Lock further input but keep the playfield visible; render the decisive state, finish the relevant animation and feedback, show brief cause-specific copy when the visuals alone are ambiguous, then leave a readable beat (typically about 800–1500 ms after the animation) before opening the overlay. Under reduced motion, shorten or skip movement but preserve the explanation and readable beat. Sequence from animation completion rather than racing it with a blind timer, and make the transition cancelable and exactly-once across unmounts, restarts, and repeated reactive updates. See references/game-ux-best-practices.md.
  • Persist competitive attempts before terminal presentation. Define when an attempt becomes committed, persist that in-flight boundary, and write a miss or terminal result as soon as it is detected. Delay only the decisive animation and host end overlay; never delay the mutation that consumes the attempt. On reopen, resume neutral checkpoints but conservatively consume unresolved committed attempts. See references/game-ux-best-practices.md.
  • For any scored, competitive, or leaderboard game, open the platform end surface with Poe.room.tileEnd({ leaderboardId }) — do NOT hand-roll an in-app results/leaderboard/"play again" screen. The host subscribes to that shared leaderboard in the calling activity's synced store, resolves profiles, and renders its persisted ordering, label, units, and displayScore. It also appends every other active human room member without a score as a blank row in room join order; agents are excluded. Pass actions: { nudge: true } only when that board remains playable; the host cannot infer whether an app-owned board is current or archived. On resolve, result.outcome === "playAgain" → restart your round; "review" (only if you passed actions: { review: { label } }) → show your own review surface and re-call tileEnd() with the same id; "closed" / "dismissed" → do nothing. For a shared-instance team game, also pass round: finishedRoundId and use dismissTileEnd when superseded. See @references/client-api.md.
  • Persist leaderboard scores and presentation from mutators. Use setLeaderboard(ctx, { leaderboardId, players | everyone, bestScore?, label?, unit? }) for bulk writes or setLeaderboardScore(ctx, { leaderboardId, userId, score, ... }) for one score. Writes default to mode: "merge" (keep each user's best); use mode: "replace" when a supplied user's current score must decrease without removing other entries, and mode: "overwrite" only to replace the complete board. During rendering, read through getLeaderboard(ctx, { leaderboardId }); do not read _leaderboards directly. tileEnd() carries no scores or presentation—it only selects the subscribed board and configures actions/round behavior. For a persistent leaderboard page without tile-end, call Poe.room.setShareLeaderboardId("daily"). setLeaderboardScore sends automatic high-score notifications (control them with the notifyOnHighScore third arg, alongside mode): on a new high score (a first-ever score or a strict improvement over the scoring user's own best) it sends every other active member a personalized activity + push ("Alice just beat your score with 9!") and posts one chat announcement when the #1 spot changes hands — sender-suppressed, so the scorer isn't pushed. When the board has a persisted label, every body names it ("Alice just beat your score on May 30 best attempts with 9!") — so an activity that mints one board per day or period should put the period IN the label ("May 30 best attempts"), never deictic wording like "Today's", which reads wrong in a notification opened the next morning. That announcement is built from the winning entry's displayScore; pass summaryInChatAnnouncement: true alongside mode to also post that entry's markdown as a summary block under the headline (off by default, because chat is read by the whole room while the tileEnd overlay is read only by members who open it). This is on by default; pass { notifyOnHighScore: false } to opt out when your activity already sends its own high-score notification (or the write is a routine/decreasing update). The bulk setLeaderboard never notifies.
  • For scored / leaderboard games, make the competition live — don't save all the social payoff for the end screen. A leaderboard shown only via tileEnd() makes a scored game feel single-player until it's over; casual leaderboard games are usually solo, so the social charge has to come from feeling who you're racing as you play. During the run, surface where the player stands against others and reward them the instant they pull ahead: a live rank / "score to beat" HUD, a pace delta against a reference run, or an on-screen ghost replaying another player's recorded run (the Flappy Crossing pattern). Only render a leaderboard HUD when it has at least one real score: if the board is empty, show no leaderboard title, panel, placeholder rows, or encouragement such as "Set the first record"; collapse the HUD and reclaim its space, then reveal the real top scores reactively after the first result. Celebrate each overtake in the moment (Poe.haptics.notification("success") + a transient banner with the passed player's name + avatar), and send the overtaken player a targeted high-signal push via notifyActivity({ targetUserIds, push }) ("Aaron just beat your high score!"). Store any ghost/replay traces as bounded, downsampled synced-store data (explicit named limit constant — never an unbounded per-frame trace) so the live layer also works offline. Use a personal-best or stock ghost only when an actual reference run exists; do not replace missing competition data with empty-state copy. This complements the end-of-activity overlay above — keep using tileEnd() for the authoritative ranked end screen. See references/game-ux-best-practices.md.

Skill-game design requirements

For any skill-based or scored game these are correctness requirements, not polish. Satisfy them while implementing, not as a later pass. Rationale, worked detail, and test guidance: references/game-ux-best-practices.md.

  • Generate the challenge per run from a persisted seed. Never ship one hard-coded repeating sequence (it becomes a memorization drill), and never randomize each element independently across its full range (it produces stretches nobody can clear).
  • Compute the fairness bound from your own movement constants — gravity and impulse, speed and turn rate, cooldowns. Consecutive elements must never demand more traversal than the player can physically achieve in the distance available. Derive that limit in code and assert it in a test; do not eyeball it.
  • Ramp from a forgiving opening to a floor. A first-time player should score within a few attempts; a practiced player should reach a satisfying run. Play both ends yourself and retune — unscoreable and unmissable are both failures.
  • Score once per obstacle, and only after the player has fully cleared it — never at its midpoint.
  • Drive simulation from a fixed or clamped timestep so identical inputs produce an identical run at 30 Hz and at 120 Hz. Never scale movement by an unbounded raw frame delta.
  • Match every collision shape to the shape actually rendered, within a small tolerance.

Player character and motion

Some activities give the player a single on-screen character to embody — an avatar they steer, a piece unmistakably theirs. Many don't: a board, a grid, a word puzzle, a drawing surface, anything where the player acts on the world rather than as something in it. If the activity has such a character, it is the one object the player looks at continuously for the whole session, and the following are requirements rather than polish. If it doesn't, skip this section — do not invent an avatar to satisfy it.

  • Draw a character, not a primitive. Where the player is embodied, that entity must be recognizable as the thing it represents: an assembled silhouette with a discernible front, a body, and at least one distinguishing feature, rendered in the activity's own palette. A bare rectangle, circle, or lone emoji standing in for it is a placeholder, not a design — and the same applies to obstacles and collectibles that carry the theme. Compose it from shapes you actually draw (layered canvas/vector paths, a generated sprite sheet, or a rigged set of parts), and keep the silhouette readable at the size it really occupies on a phone, not at the size you drew it.
  • Whatever the player controls must react to what they do and to what is happening to it. Every control input needs a visible response in the controlled object itself, distinct from the change in its position: it tilts or rotates toward its motion, compresses and extends as it accelerates or lands, cycles a limb / wing / thruster, or flinches and flashes on contact. Give it a resting state that is alive as well, so it never reads as a frozen decal while the player waits to start. Drive all of it from simulation state you already track — velocity, input, collision, terminal state — rather than from timers running independently of the physics, so the motion stays truthful when the simulation speeds up or slows down.
  • Confirm it reads and animates by looking at it. Capture the controlled object at its distinct states — idle, responding to input, and failing — and compare the frames. If the three are indistinguishable apart from position, the animation is missing and the activity is not done. Then ask whether someone who was never told what the character is would name it correctly from a single frame; if not, keep working on the art. Leave the captured frames in the workspace so the check is auditable.

Definition of done

These are gates, not suggestions. Run them in a loop until they pass, and expect a meaningful share of the session to go here rather than into the first implementation pass.

Audit every naturally accumulating ordered table before publishing: every append path passes a nonempty sortKey generated by ctx.getNextSortKey, the schema's pull window prioritizes that namespace, and UI reads use the stored order instead of loading and sorting the whole table. Safety caps do not exempt social feeds, message histories, or other accumulating lists. When updating an existing table, migrate every existing row to a nonempty sort key that preserves its historical order and falls inside the pull window; changing append paths only affects new rows.

  1. Tests, build, and doctor pass — run bun run verify once after focused tests pass. It type-checks, runs mutator/unit and Happy DOM tests, builds once, installs Chromium, runs the existing browser smoke checks, and runs doctor. test:all already type-checks; regenerate-screenshot and test:playwright already build, so do not precede them with duplicate checks. For an older workspace without verify, run the same six commands once: bun run type-check && bun run test && bun run build && bunx playwright install chromium && bunx playwright test && bun run doctor. Running the existing smoke checks does not require adding new browser-only tests or regenerating screenshots.

  2. Open the activity in a browser and use it yourself. A green test suite is not evidence that the activity is usable. At a 390×760 phone viewport and at both For You feed sizes above, drive every primary user journey through a meaningful, user-visible outcome using the real UI — for example creating or changing the activity's core artifact, observing a shared update from another player, receiving generated content, or completing a turn or run. Choose evidence that matches what the activity actually does; do not invent a score, round, terminal state, or reset flow for an activity that has none.

    For an ad-hoc Playwright script, import runVisualCheck from ./scripts/visual-check.ts (adjust the relative path) and call await runVisualCheck(async () => { /* browser checks */ }). Close the browser in the body's finally block. The wrapper prints the failure message and call log, then exits 1 without an uncaught library code frame. For an older workspace without the wrapper, use try/catch around the body, print error instanceof Error ? error.message : String(error) to stderr, and call process.exit(1); keep the same browser cleanup. Continue using regenerate-screenshot for the profile picture rather than a hand-written capture script.

  3. Verify what happens after that outcome without relying on a fresh page. For a naturally repeatable flow, exercise it again without reloading. For an ongoing or non-cyclic experience, perform the next meaningful operation from the resulting state and confirm that the prior state remains correct; for a deliberately one-shot flow, reopen it and confirm its persisted result. This is a reentrancy and continuity check, not a demand that every activity have rounds. When a journey hands off to a host-owned surface that the local dev host cannot render — for example, Poe.room.tileEnd() rejects locally — do not hand-roll a replacement to satisfy this gate. Confirm the unavailable handoff leaves the activity usable rather than wedged, and cover the real host response in a unit or Happy DOM test.

  4. Verify leave-and-return continuity mid-flow. Make meaningful progress, navigate away and reopen the same instance, then repeat with a full reload. Confirm the player returns to the ongoing experience with their progress intact and can continue; for shared play, include another player's update while they are away. Cover restoration with a fresh client/UI in the activity's tests and assert the authoritative persisted state, not just retained component state. Reopening must neither reset progress nor replay a completed action or undo a committed attempt.

  5. Look at the screenshots you capture. Inspect each for clipped, overlapping, or placeholder UI, text that is unreadable at phone size, two panels disagreeing in the same frame (a result card saying 0 beside a header still showing 2), and leftover debug chrome. Fix what looks wrong and re-capture. Capturing a screenshot without reading it is not verification. Leave the frames you inspected in the workspace, and claim only the viewports and modes you actually rendered. "Verified at three viewports in light and dark" with no frames on disk to show for it is a false report, and it is worse than honestly reporting the one viewport you did check — a reviewer can act on a gap they can see.

  6. Read the browser console from a full session and fix every error, then confirm the surface-failures convention above: each user-triggered async failure shows a toast or inline alert, not only a console line.

  7. Leave no literal TODO in code, listing metadata, or README.md.

  8. Re-run steps 1–6 after your final change. Only a check you observed passing after the last edit counts. Never report a check as passed because it should now pass — if you did not re-run it, say that instead.

Required context — read these now

Some harnesses inline @-referenced files automatically; many do not. If these files are not already in your context, read each one from disk before you write any code.

References

  • Read references/safe-area-insets.md before padding any element against the viewport edge — top bars, bottom bars, FABs, toasts.
  • Read references/scaffolding-a-new-app.md when scaffolding a new app.
  • Read references/multiplayer-api-recipe.md when building multiplayer state, seating players, assigning turns, or adapting synced-store UI reads to React, Preact, SolidJS, Vanilla JS, or Phaser.
  • Read references/composing-apps.md when the prompt might require multi-app composition (e.g. a March-Madness tournament where each match is a sub-app game, a Discord-like server with channels as sub-apps).
  • Read references/running-the-client-api-on-a-server-owned-by-the-user.md when running a client on a user-owned NodeJS server (bots, scripts).
  • Read references/vite-plugin.md when touching vite.config.ts, externals, or backend bundling.
  • Read references/assets.md when storing or retrieving static files: import "...?url" (Vite) vs Poe.getBundleAssetUrl() (runtime).
  • Read references/synced-store-client-reference.md for the SyncedStoreClient API surface (subscribe, query, mutate).
  • Read references/unit-tests.md before writing *.test.ts / *.test.happydom.tsx (createPoeTileTestHarness, fixtures, waitFor*).
  • Read references/e2e-tests.md before writing tests/e2e.test.playwright.ts (TestServer, waitForBlobFrame, multi-client patterns).
  • Read synced-store/references/schema-migrations.md before bumping schemaVersion or writing a migrateData step (harness.seed.syncedStoreInstance, EntryKey vs string pitfalls, push-driven upgrade).
  • Read references/publishing.md ONLY when your host's system prompt says nothing about publishing — it describes driving the upload, visibility, and channel commands yourself, which a host that publishes for you owns instead.
  • Read references/cli.md and references/cli-limitations.md before scaffolding or uploading (poe-tiles tiles init/publish/list).
  • Read references/game-ux-best-practices.md when building a game — conventions for first-time clarity / teaching in context, the help affordance, turn and waiting-state indicators, feedback, difficulty and fairness tuning (seeded generation bounded by the movement rules, ramping, scoring boundary, fixed timestep), end states, and live in-play competition for scored / leaderboard games (rank HUD, pace markers, ghosts, overtake feedback).
  • Read references/push-notification-guidelines.md before adding or reviewing any push-producing path — inventory all paths, estimate per-recipient volume, score aggressiveness, and eliminate duplicate or unbounded delivery.
  • Read references/turn-based-mutator.md before implementing a sequential turn-based game — copyable setTurn / clearTurn mutator placement and confirmed private-projection test.
  • Read references/troubleshooting.md when staring at an error string — bot-access reason codes (poe_link_required, …), docs-vs-installed-SDK drift, and doctor failures, each with the fix.
  • Read references/migrating-pre-rename-tiles.md when an app scaffolded before the Poe Tiles rename breaks (poeApp import errors, publish-to-app-platform, poe-tiles apps … unknown-command).
  • Read REST API for server-side endpoints managing apps from outside the platform (auto-generated OpenAPI; lives only in the docs site).