Créateur de tuiles Poe

Échafaudez, implémentez, testez et déployez une nouvelle tuile Poe à partir d'une invite naturelle.

Spar Skills Guide Bot
DeveloppementIntermédiaire
1023/07/2026
Claude Code
#poe#tiles#scaffolding#deployment#testing

Recommandé pour


name: tile-creator description: Scaffold, implement, test, and deploy a new Poe tile from a prompt. allowed-tools: Bash, Glob, Grep, Read, Edit, Write, Agent user-invocable: true

<!-- owner: jyoung-q -->

Tile Creator

The canonical (always up-to-date) version of this skill and the rest of the Poe Tiles 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 Poe tile from a natural-language prompt. Scaffold → schema → UI → tests → deploy.

User Argument: {{user_input}}

If empty, ask "What app should I build?" and stop. Otherwise derive a kebab-case app name (e.g. "voting app" → voting-app).

Important things you should do

  • Prefer using synced-store for game state / data that should be persisted and/or synced across multiple users.
  • For AI-powered tiles (a chatbot, an AI opponent or game-master, on-the-fly content generation), call Poe bots through Poe.stream() (streamed chunks) or Poe.call() (an agentic tool-use loop with tools you define via Poe.createTool()) — build on the Poe API rather than wiring in your own model provider, and discover valid bot names with Poe.listModels(). Preflight the feature with Poe.requestPoeBotAccess(): a user whose Poe account isn't usable cannot make bot calls, and this one line has the platform prompt them to fix it (a host-owned modal with inline account linking) — { canUse: true } means proceed with the call in the same gesture, { canUse: false } means they declined (keep the feature visible, disabled). Don't fire the call 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/client-api.md Poe.stream / Poe.call / requestPoeBotAccess.
  • For feature changes in an existing tile, follow the feature-specific testing guidance (usually tests first). For brand-new tile 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-tile 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.
  • MUST: Ensure synced-store-backed UI live-updates. In render paths, use store.subscribe() or the framework live-query helpers (createLiveQuery, createLiveQueryResource, etc.) rather than store.query(). store.query() is a one-shot read and will not repaint when another user/device/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 tile.
  • Any time the user can try out a new state — publish from the app's directory with bun run publish-to-poe-tiles -- --change-summary "<concise user-facing summary>". Describe the meaningful changes in one short sentence; the Creator chat receipt includes it with the tile name and launch link. Report appUrl as a clickable markdown link.
  • Before the FIRST publish, 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 human-friendly title (e.g. "Texas Hold'em" rather than "texas-holdem"), 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 tile 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. Treat any remaining literal TODO in the text metadata as a bug — fix it. See references/scaffolding-a-new-app.md Step 5.5.
  • Before the FIRST publish, choose instancing from the tile'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 tiles such as Poe Jump, Hoops, and Duck Duck Duck, and daily tiles 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.
  • Before the FIRST publish, create the listing media too. Generate and commit a dedicated, full-bleed square icon that reflects the tile's actual visual identity, then set .poe-tile.json → profilePicture to it. Capture and commit 1–3 representative gallery screenshots, including at least one genuinely in-action or meaningfully populated state rather than an empty start/menu screen, and wire them into .poe-tile.json → screenshots. Inspect every image for loading UI, debug chrome, clipping, and stale content before publishing. See references/scaffolding-a-new-app.md Step 5.5.
  • 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.
  • Scaffolded apps publish flag-free: bun run publish-to-poe-tiles is just bun run build && poe-tiles tiles publish. The CLI reads handle, app visibility, listing metadata (including players, profilePicture, and screenshots), runtimeBundle.dir, and sourceBundle from .poe-tile.json in the current directory. Override any field with the corresponding flag (--handle, --visibility, --dir, etc.) for one-off publishes; explicit flags always win over config.
  • Before starting any task run poe-tiles doctor to verify the workspace is ready — works even before a tile exists (reports "not a Poe Tiles project folder" and points at tiles init instead of erroring). Once scaffolded, ./scripts/doctor.sh (or bun run doctor) is the project-scoped shortcut for the same check. Stop on non-zero exit; if the only blocker is missing publish auth, run poe-tiles login and wait for browser approval instead of asking for an API key.
  • 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 tile iframe automatically gets a default install that pads the tile'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 tile 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.
  • 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 tile 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 tile 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.
  • 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 entry.tsx for any player-facing tile. 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 tile 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.
  • If a hold or drag gesture is core gameplay, call suppressLongPressMagnifier(element) on that surface. applyNativeAppGestureOverrides() suppresses the callout/selection/drag, but on iOS it cannot stop the long-press magnifier loupe WebKit pops once a still finger begins its text-selection gesture — so a press-and-hold-to-charge canvas or a draggable board/piece shows a magnifier mid-play that ruins the feel. CSS can't reach it; only cancelling touchstart can, which is what this helper does (scoped to the one element, with a returned remover for cleanup). Import it from poe-tiles-sdk/v1/client.js and apply it to the specific gameplay element — it is a no-op outside iOS WebKit. Cancelling touchstart also cancels that element's synthesized click, double-tap zoom, and touch scrolling, so: for a purely pointer-driven surface (canvas games, drag-to-aim), use the plain call; for a dual-mode surface where tapping the same elements runs click handlers (tap-to-move board cells, tap-to-place racks), pass { preserveTaps: true } — quick still taps are re-dispatched as synthetic bubbling clicks so tap actions and keyboard activation keep working while holds/drags stay loupe-free. Never apply either mode to an element that scrolls natively, and never point preserveTaps at form fields or navigation links (the synthetic click is isTrusted: false and doesn't focus inputs). 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 local $users row for that user. If contacts are selectable, make that explicit with addFromContacts: true; selected contacts are added to the room before the result is returned, except when the app is in a dm-* room: the host moves the app to a 1:1 DM or new group room as needed instead. 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 tile, 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-tile 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.
  • Write at least one Playwright test at tests/e2e.test.playwright.ts that drives the app's primary flow from start to finish — for a game, that means starting a new instance, taking the moves needed to reach a terminal state (win / loss / round end), asserting the decisive state or cause is visible before the end-of-tile overlay, and asserting the overlay eventually renders. Single-player apps can use one client; multi-player apps drive each player from a separate TestServer client so sync is exercised end-to-end. See references/e2e-tests.md.
  • 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/sort update that should reach every member, including the actor, without adding a badge. Apps with chat/thread-style read state should declare customUnread() in both schema and client config, and apps that intentionally never use unread should declare noUnread(). 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".
  • When a notification should land the user somewhere specific, attach a tiny entry context — and validate it against your store on arrival. A tile 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 tile 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 tile — the platform already provides one. Chat is itself a platform app, and a multiplayer tile runs inside a room; when that room has a chat, the host surfaces it alongside your tile, so players already have a shared place to talk without you reimplementing messaging. Instead of an in-tile 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 tile 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. 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 tile findable. Use notifyActivity separately only when the move should change the sidebar preview or update additional members such as the actor or spectators. See @references/client-api.md setTurn() / clearTurn().
    • 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 a tile-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 tile 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 a tile-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 tile-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 a tile 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 tile-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-tile 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.
  • 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 tile'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. 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").
    • 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-tile overlay above — keep using tileEnd() for the authoritative ranked end screen. See references/game-ux-best-practices.md.

Always-loaded context

  • @../synced-store/SKILL.md — synced-store skill (schema, mutators, actions, testing).
  • @references/client-api.md — Client API: Poe.setupStore, Poe.stream, Poe.call, <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 / $users system tables; how to render display names and profile pictures.

References

Skills similaires