Appearance
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) orcreateImageGenerator(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 withPoe.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 withPoe.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); usePoe.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 oncewaitForBootstrap()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 viaPoe.tiles.prepareNewInstances) unblock immediately without a server round-trip. AvoidwaitForServerData()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. ReservewaitForServerData()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.), notstore.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 viaPoe.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 withif (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:onAddUsersshould 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 ajoin/seatSelfmutator 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 → displayNameto 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. RewriteREADME.md(long description), replace.poe-tile.json → shortDescriptionwith a ≤140-char sentence specific to the app, and for every game or participant-count-sensitive activity populate.poe-tile.json → playerswith the supported total seats (humans + AI). Derivemin, optionalmax, and an optional genuinely betterrecommendedsub-range from the implemented rules — never from the current room, a test roster, or guesswork.maxis a real creator-declared gameplay or capacity bound; omit it when the implementation has no upper bound. Never use the schema or platform ceiling asmax: an uncapped one-player-or-more activity uses{ "players": { "min": 1 } }, which the listing renders as1+.playersis 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.minwhen declared, elsemin), so an under-declared range costs them the easiest way to test the activity. Treat any remaining literalTODOin 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.jsonschema (v10); if an older config contains a top-levelcategoriesfield, delete that field before publishing. Keep declaring supported player counts inplayers. - Before the FIRST upload, choose instancing from the activity's state semantics and set
.poe-tile.json → instancingexplicitly. 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 isper-launch, notper-instance, andinstancingrequires$schemav7. 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-screenshotwrites a square 720×720assets/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 initupload source code by default (visible to anyone who fetches it). ThesourceBundleblock in the app's.poe-tile.jsoncontrols 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.jsonand.poe-tile.json; do not assume the shell starts there. For a new activity, scaffold and install dependencies first. Then runpoe-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(orbun run doctor) is the shortcut when the command runs inside that project. For missing upload auth, usepoe-tiles loginonly 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.
- Fix genuine failures reported against the actual project before continuing.
- 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 callinstallKeyboardLayoutInset()frompoe-tiles-sdk/v1/client.jsonce 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, Tailwinddark: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 throughprefers-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 thanprefers-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.warnis only diagnostic and must not be the only handling. Background sync errors are already handled for you — the platform surfaces everystorebackground error as a toast and reports it, for any activity that callsPoe.setupStore(). Do not subscribe tostore.onBackgroundErrorjust 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, passPoe.setupStore(config, { backgroundErrorToast: false })so your UI replaces the platform toast rather than doubling it.
Poeis not a global. It is created once in the app's entry file (tile/src/entry.tsorentry.tsx) viacreatePoe({ environment })— the only module that importspoe-tiles-sdk. EveryPoe.*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 withPoeAPIorPoeAPI["haptics"]frompoe-tiles-sdk/v1/client.js— animport typecosts 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()frompoe-tiles-sdk/v1/client.jsinto renderer construction (rendererOptions()+configureRenderer()) and gate rendering — never simulation or input — withshouldRenderFrame()/renderFrame(), keeping ONE budget per document across game-round remounts (useacquireSharedRenderBudget()/adoptSharedRenderBudgetRenderer()when mounts rebuild the renderer). If an engine owns the loop (Phaser), mapresolveRenderBudgetDefaults()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, setcanvas.width = Math.round(cssWidth * pixelRatio)(and the same for height), thenctx.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 whatresolveRenderBudgetDefaults()must drive, alongsidefps.limitandrender.powerPreference— a Phaser game configured at a fixed worldwidth/heightwithScale.FITrenders one buffer pixel per world unit and lets CSS do the rest, which ships blurred (Phaser'szoomdoes not fix it; that scales the displayed size, not the buffer). Take the ratio from the budget's resolvedpixelRatiorather than rawwindow.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.hapticsfor 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 matchingenv(...)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 rawenv(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 insideinput/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 frompoe-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. CallsuppressLongPressMagnifier(element)on each such surface. Use the plain call for pointer-driven surfaces and{ preserveTaps: true }when quick taps on descendants rely onclickhandlers. Never apply it to native scrolling, form fields, or navigation links: cancellingtouchstartalso 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-motionuse 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 withPoe.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 withexcludeUserIds; show occupied seats asPlayingwithplayingUserIds. 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$usersrow 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; passaddFromContacts: falseonly when the product explicitly calls for choosing existing room members. In adm-*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 withassertRoomMember(ctx, { userId }), and usenotifyUsersAddedToTile(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 theonAddUsershook) 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 asetTurn/pushnotification 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
notifyActivitywithrecency: "bump". If one fan-out already includes the actor, make that call bump; if recipient attention differs ortargetUserIdsexcludes the actor, add an actor-only call withtargetUserIds: [ctx.userId],recency: "bump", and nounreadorpush.setTurnfor 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 throughcreatePoeTileInManagerTestHarnessthat the actor's Recents position/timestamp advances after server confirmation while the actor's unread count does not. - In multi-player apps, call
notifyActivitywhenever durable user-visible activity needs a custom sidebar preview or should reach people beyond the next turn-holder.setTurnalready moves each newly marked recipient's room to the top of Recents, increments unread, and optionally pushes, so do not addnotifyActivityonly for discoverability. Useunread: "increment"withoutpushfor passive updates other people should count as unread ("spymaster gave a clue", "Aaron reacted 🎉"). Every explicit, user-invoked nudge action targeting another player must passunread: "increment"with the sametargetUserIds;pushalone does not increment unread. Verify a nudge withcreatePoeTileInManagerTestHarness: the recipient's manager unread and confirmed private_poe_unreadprojection each increment, while the sender's unread does not. OmittargetUserIds,unread, andpushfor 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; passrecency: "bump"explicitly only when the badge-less event is genuinely "the latest thing that happened here"). Leaveunreadomitted in both the schema and client config to use the SDK default. Do not addexpectNoUnread()merely because the activity currently sends no notifications; the default keeps latersetTurnandnotifyActivity({ unread: "increment" })additions working. UsecustomUnread()in both places only for app-owned chat/thread read state. UseexpectNoUnread()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 callsunread: "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 forexpectNoUnread(), 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"). UsepostToChatwhen the event should also appear as one app-owned announcement in the containing chat; do not add app-local room guards just for this becausenotifyActivityskips 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; reservepostToChatfor 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 akinddiscriminator — capped at 4 KB, no secrets) on thenotifyActivitypush, then read it at launch withPoe.consumeEntryContext()({ source: "push" | "banner" | "badge" | "direct", notification? }, consumed once per mount, route-mounted activity only). Treatcontextas 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-nestedtiles.openChildchild (which always readsdirect), forward what it needs viaopenProps. See @references/client-api.mdPoe.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.mdnotifyActivity. - In turn-based games, declare whose turn it is with
setTurn/clearTurn. Import them withimport { clearTurn, setTurn } from "poe-tiles-sdk/v1/client.js";, write the new match state first, then call the helper in the same mutator without anisServerguard. A newsetTurnmark 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 withnotifyActivitymerely to make the activity findable. UsenotifyActivityseparately 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] }).replacedefaults totrue, 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 withawait 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— passpushas just{ body }. BothsetTurnandnotifyActivityfill the title at delivery as"<tile>: <room/opponent>"(matching the recipient's recents-row title). Pass an explicittitleonly to override that.
- Pass the turn forward with
- 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
privateOfUserrow 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 tosetTurn(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 calltileEnd()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, anddisplayScore. It also appends every other active human room member without a score as a blank row in room join order; agents are excluded. Passactions: { 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 passedactions: { review: { label } }) → show your own review surface and re-calltileEnd()with the same id;"closed"/"dismissed"→ do nothing. For a shared-instance team game, also passround: finishedRoundIdand usedismissTileEndwhen 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 orsetLeaderboardScore(ctx, { leaderboardId, userId, score, ... })for one score. Writes default tomode: "merge"(keep each user's best); usemode: "replace"when a supplied user's current score must decrease without removing other entries, andmode: "overwrite"only to replace the complete board. During rendering, read throughgetLeaderboard(ctx, { leaderboardId }); do not read_leaderboardsdirectly.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, callPoe.room.setShareLeaderboardId("daily").setLeaderboardScoresends automatic high-score notifications (control them with thenotifyOnHighScorethird arg, alongsidemode): 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 persistedlabel, 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'sdisplayScore; passsummaryInChatAnnouncement: truealongsidemodeto also post that entry'smarkdownas a summary block under the headline (off by default, because chat is read by the whole room while thetileEndoverlay 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 bulksetLeaderboardnever 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 vianotifyActivity({ 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 usingtileEnd()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.
Tests, build, and doctor pass — run
bun run verifyonce 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:allalready type-checks;regenerate-screenshotandtest:playwrightalready build, so do not precede them with duplicate checks. For an older workspace withoutverify, 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.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
runVisualCheckfrom./scripts/visual-check.ts(adjust the relative path) and callawait runVisualCheck(async () => { /* browser checks */ }). Close the browser in the body'sfinallyblock. 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, printerror instanceof Error ? error.message : String(error)to stderr, and callprocess.exit(1); keep the same browser cleanup. Continue usingregenerate-screenshotfor the profile picture rather than a hand-written capture script.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.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.
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
0beside a header still showing2), 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.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.
Leave no literal
TODOin code, listing metadata, orREADME.md.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.
- ../synced-store/SKILL.md — synced-store skill (schema, mutators, actions, testing).
- references/client-api.md — Client API:
Poe.setupStore,Poe.listModels,<poe-tile>,Poe.room.pickMembers,notifyActivity,setTurn/clearTurn,assertRoomMember,notifyUsersAddedToTile, etc. - references/backend-api.md — Backend API:
defineSchema,defineBackendConfig, mutators, actions, system tables. - references/sandbox-limitations.md — Sandboxed iframe limits: storage APIs, URL navigation, cross-frame DOM,
window.location.origin. - ../synced-store/references/getting-user-info-of-members.md —
$userInfo/$userssystem tables; how to render display names and profile pictures.
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) vsPoe.getBundleAssetUrl()(runtime). - Read references/synced-store-client-reference.md for the
SyncedStoreClientAPI 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
schemaVersionor writing amigrateDatastep (harness.seed.syncedStoreInstance,EntryKeyvs 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/clearTurnmutator 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 (
poeAppimport 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).