Skip to content

Tile Creator

The canonical (always up-to-date) version of this skill and the rest of the App Platform 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 app 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).

Important things you should do

  • Prefer using synced-store for game state / data that should be persisted and/or synced across multiple users.
  • When building new features, write the ui/logic first then add tests later
  • await store.waitForBootstrap() before rendering UI. 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 new instance opened with is-new set) 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 is-new instances that have no server state to fetch. Reserve waitForServerData() for tests / Node-side scripts where a server round-trip is actually required.
  • If onAddUser seeds first-render state, mirror the deterministic browser-safe handler into defineClientConfig({ hooks }); Poe.setupStore(config) runs it optimistically only for new creator launches (is-new="true"), then server data replaces the overlay.
  • Any time the user can try out a new state — publish by running bun run publish-to-poe-tiles from the app's directory. Report appUrl as a clickable markdown link.
  • Before the FIRST publish, fill in the listing-page fields the scaffold ships as TODO placeholders yourself — do NOT ask the user. Generate based on what the app actually does (derive from synced-store/schema.ts, ui/App.*, the user's original prompt): set .poe-app.json → displayName to a human-friendly title (e.g. "Texas Hold'em" rather than "texas-holdem"), rewrite README.md (long description), replace .poe-app.json → shortDescription with a ≤140-char sentence specific to the app, and run bunx playwright test screenshot.test.playwright.ts after bun run build to regenerate assets/screenshot.png. Treat any remaining literal TODO in those files as a bug — fix it. See references/scaffolding-a-new-app.md Step 5.5.
  • Apps scaffolded via poe-tiles apps init upload source code by default (visible to anyone who fetches it). The sourceBundle block in the app's .poe-app.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 apps publish. The CLI reads handle, app visibility, runtimeBundle.dir, and sourceBundle from .poe-app.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 ./scripts/doctor.sh to verify all dependencies are installed. Stop on non-zero exit.
  • 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.
  • Apps must remain polished and legible in both light and dark system modes. Use theme-aware colors (prefers-color-scheme, CSS variables, Tailwind dark: variants, or PDL semantic tokens where available) instead of a light-only or dark-only palette, and verify the app in both modes before declaring it done.
  • 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.
  • 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, safe from any context (iframe or top frame), routed cross-platform: iOS native bridge → UIFeedbackGenerator, Android/mobile web → navigator.vibrate, iOS Safari 17.4+ → switch-label tap fallback, desktop / older iOS Safari → silent no-op. Don't call navigator.vibrate directly — it skips the iOS native path entirely. 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.
  • In multi-player apps, render each participating user's display name and profile picture next to their in-app representation (e.g. their snake in a multi-player snake game, their cursor, their score row, their move). Read from $userInfo — see @../synced-store/references/getting-user-info-of-members.md.
  • 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), and asserting the terminal state 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 something happens that another player might care about — so the space bumps to the top of the recents list, lights up the per-space numeric badge in the sidebar, and (when warranted) rings the device. Apps that want standard badge behavior should opt into simpleUnread({ clearOn: "active" }), then use unread: "increment" without push for passive updates ("spymaster gave a clue", "Aaron reacted 🎉"); omit unread for cosmetic refreshes. Pick push for required actions ("your turn") 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. Target with targetUserIds (the next player, the previous record-holder, etc.) — default sender-suppression handles "don't push me for my own action" automatically. See @references/client-api.md "When to notify, and at what level".

Always-loaded context

References