Skip to content

Scaffolding a new Joiner activity

End-to-end workflow for creating a new app from a natural-language prompt. Scaffold → schema → UI → tests → ship.


Step 1: Scaffold

Pick a template: react, preact, solidjs, vanilla-js, phaserjs. Pick phaserjs for real-time 2D games with sprites, world coordinates, or arcade physics — it ships with the Phaser 3 dep, vite configured to inline assets and dynamic imports for the platform sandbox iframe, and a dynamic-import seam so happy-dom unit tests stay Phaser-free.

The react, preact, and solidjs templates ship Tailwind v4 preconfigured (tailwindcss + @tailwindcss/postcss deps, postcss.config.js, @import "tailwindcss" in tile/styles.css) — utility classes work out of the box. vanilla-js and phaserjs do not; if you need Tailwind there, wire it up the same way (deps + postcss config + @import "tailwindcss" in tile/styles.css) before relying on utility classes, otherwise they will silently render as no-op strings.

bash
poe-tiles tiles init <name> --template <t>
cd <name> && bun install

<name> becomes the publish handle, which is unique per creator and hard to change once a session is bound to it — so use the kebab-case base derived from the prompt plus a short random suffix (zombie-runner-k4x7). A bare descriptive handle collides with, or silently republishes over, an earlier activity from a similar prompt. The handle never has to be pretty: displayName (Step 5.5) replaces it on every user-facing surface.

Generated files

<name>/
├── tile/                      # entry point + backend wiring
│   └── src/
│       ├── entry.tsx          # Poe.setupStore(clientConfig); await store.waitForBootstrap(); render UI
│       └── backend.ts         # re-exports tileBackendConfig as default
├── synced-store/              # store contract
│   ├── data/
│   │   └── items.ts           # shared read helpers + table/read ctx types
│   ├── schema.ts
│   ├── mutators/
│   │   ├── index.ts           # compose exported mutator map
│   │   ├── remove-todo.ts
│   │   ├── set-todo.ts
│   │   └── types.ts
│   ├── mutators.test.ts       # colocated unit tests
│   ├── hooks.ts               # deterministic membership hooks, shared by client + backend
│   ├── client-config.ts
│   └── backend-config.ts
├── ui/                        # store-agnostic components
│   ├── App.{tsx,ts}
│   └── App.test.happydom.tsx  # colocated UI tests
├── tests/                     # Happy DOM setup/matchers + optional skipped browser examples
├── scripts/doctor.sh          # toolchain health check
├── client.ts                  # client-safe re-exports
├── package.json
├── tsconfig.json
├── vite.config.ts
└── playwright.config.ts

Step 1.5: Verify scaffold

After scaffolding and installing dependencies, set <project-dir> below to the absolute directory containing the generated package.json and .poe-tile.json. Do not run doctor from the parent directory before a project exists. Keep cd and the command that needs it in the same shell call; some agent tools reset their working directory between calls.

bash
cd <project-dir> && bun run verify

Follow the definition of done for the verification workflow and older-workspace fallback. Treat a fired timeout as a real failure — find and fix the hanging test rather than extending the limit.


Step 2: Schema and mutators

Read @../synced-store/SKILL.md now before continuing — Step 2 depends on it.

Walk every cross-turn / cross-player handoff and pick a visibility tier per piece of state. Skipping this is the #1 cause of mid-implementation rewrites.

Tiers:

  • Public (ctx.table(...)) — synced to everyone in the instance.
  • Per-user private (ctx.privateOfUser(userId).table(...)) — role/user-specific (active player's input, in-flight LLM prompt, drafts).
  • Server-only (ctx.serverOnly().table(...)) — secrets the server uses but never exposes (answer keys, RNG seeds).

Don't design around tiers with client-side hiding/encryption — synced-store enforces at the server boundary.

Before writing schema, classify every table as bounded or unbounded and state its primary read shape. When unbounded data is mostly presented as one ordered list (for example, chronological message/activity history or a newest-first social/photo feed), every accumulating row must have a nonempty sortKey; choose its namespace and ordering direction now even if the first UI does not yet paginate. A safety cap or quota does not make a naturally accumulating history bounded for this decision. Keep small always-needed rows at the empty sort key (sortKey: "") so they load eagerly. Eager rows and lazily loaded sort-key rows can always coexist in the same store. Read @../../synced-store/references/sort-keys.md for generation, pull windows, and tests.

Before writing schema, list every table with its tier AND every cross-player handoff with the table backing it; state the design to the user for confirmation. For turn-based games answer:

  • Active player's view this turn? → typically privateOfUser(activePlayer) written by the prior player's mutator.
  • Mid-turn state surviving refresh (e.g. in-flight bot call)? → privateOfUser(self), NOT sessionStorage / component state.
  • Hidden during play, revealed at end? → serverOnly() during play, copied to public on reveal.
  • Stalled turn policy? → ask whether the game should wait indefinitely or let another player activate a skip after an inactivity deadline. If skippable, confirm the activity-specific duration and result of skipping before designing the schema; do not assume that a seconds-long party game and a many-hours asynchronous game share a timeout.
  • Player leaves the room? → distinguish explicit membership removal from closing the activity or disconnecting, which must preserve resumable state. Decide whether onRemoveUser can remove the seat and repair turn order, teams, and win conditions without breaking fairness. If not, model a durable resolution state where the remaining players choose a new match or a game-specific alternative; do not silently choose for them.

If any answer is "figure out later," figure it out now.

Either keep generic app* names (simplest) or rename across synced-store/* + client.ts + tile/src/entry.{tsx,ts} + tile/src/backend.ts + ui/App.{tsx,ts} together.

Lift patterns from a reference app, not code: lobby+slot shape, onAddUsers / onRemoveUser hooks (wired in backend-config.ts), turn-validation order (throw BEFORE if (!ctx.isServer) return), entries().toArray() returning [EntryKey, T] with EntryKey.itemKey: string, store.query(tx => tx.userId) for user id.


Step 3: UI

Replace the stub in ui/App.{tsx,ts}.

  • App receives { store } prop. store.subscribe() for reads, store.mutate.<name>() for writes.

  • For the framework-specific React, Preact, SolidJS, Vanilla JS, and Phaser binding, follow the compact multiplayer API recipe.

  • Semantic HTML IDs on interactive elements (Playwright targets).

  • Do not create another Poe client in the UI. Pass store and, when host RPCs are needed, the entry point's real Poe object as props. Import only the scaffold's framework adapter (v1/react or v1/solid) directly in UI code.

  • store.userId is not exposed at the top level. Read via store.query(async (tx) => tx.userId).

  • store.subscribe(tx => tx.table("foo").entries().toArray(), entries => ...) returns Array<[EntryKey, T]>. Destructure as [k, v], key is k.itemKey (NOT as string).

  • Subscription queries track only exact keys and scan prefixes read through tx; changing a value captured outside tx does not invalidate the query. Keep the returned SubscriptionControl, call await subscription.refresh() after such a change, and call subscription() to unsubscribe during cleanup. subscribeToTable() returns the same control shape.

  • Keep the scaffold's applyNativeAppGestureOverrides() startup call for every player-facing activity. Remove it only when free text selection is a primary feature of a document-like or text-heavy activity.

  • Before choosing interaction handlers, inventory every non-scrolling surface where a finger may pause or drag (board, canvas, rack, card/piece field, joystick, control pad). Apply suppressLongPressMagnifier(element) even if the activity has no explicit hold gesture: slow taps enter the same iOS WebKit selection gesture. Use { preserveTaps: true } only when descendant taps depend on click; never suppress a native scroller, form field, or navigation link.

  • Avatars + names from $userInfo. Pull from the $userInfo system table — never raw user IDs. Pair $users membership with $userInfo lookup. Subscribe once, build Map<userId, PoeUserInfo>:

    ts
    // PoeUserInfo (from @poe/synced-store-system-mutators): { userId, username, displayName, profilePicture, isDev? }
    // displayName / profilePicture are required strings but may be EMPTY — always fall back.
    store.subscribe(
        (tx) => tx.table("$userInfo").entries().toArray(),
        (entries) => {
            const next = new Map<string, PoeUserInfo>();
            for (const [, v] of entries) next.set((v as PoeUserInfo).userId, v as PoeUserInfo);
            setUserInfo(next);
        },
    );
    // <img src={info.profilePicture || PLACEHOLDER} alt={info.displayName || info.username} />
    // Name fallback: displayName, then username, then a NEUTRAL label ("Player") —
    // never the raw userId. A not-yet-resolved member profile can carry internal
    // ids (`u_<hex>` / `private-...`) in displayName/username; skip those too:
    // const usable = [info?.displayName, info?.username].find(
    //   (v) => v && !/^u_[0-9a-f]{32}$/.test(v) && !v.startsWith("private-"),
    // );

    Use || not ?? so empty strings fall through.


Step 4: Mutator tests

Complete the UI before writing UI tests. Mutator tests bind to schema (Step 2), so write those now; Happy DOM UI tests wait until Step 5. UI churns fast Steps 2–3 — UI tests against in-flux UI get rewritten 3–5×.

Blank-mode test files are passing multiplayer examples, not placeholders. They demonstrate two clients, typed $users / $userInfo reads, deterministic onAddUsers seating, real harness stores, and configured jest-dom assertions. Tests are colocated with source:

  • synced-store/mutators.test.ts (now)createPoeTileTestHarness unit tests; create / update / delete / edge cases. Easily hits high coverage on synced-store/.
  • ui/App.test.happydom.tsx (Step 5) — happy-dom UI tests render <App> with a harness store. Only tests counting toward ui/App.tsx diff coverage (Bun --coverage skips browser code). With substantial UI: write one happy-dom test per major UI state (lobby / playing / generating / reveal). One assertion per state is the floor, not the bar for whether the state is actually tested; see test-quality.md. Drive transitions via store.mutate.<...> (faster, deterministic) or DOM clicks.

Step 5: UI tests + full check

UI is functionally complete. Write:

  1. Happy-dom in ui/App.test.happydom.tsx — one assertion per major UI state (the coverage floor, not the quality bar: test-quality.md). Then:
bash
cd <project-dir> && bun run verify

Iterate until green.

For every meaningful accepted user action that changes activity state or advances play, add a manager-harness assertion that the actor's Recents row bumps after server confirmation and the actor's unread count stays unchanged. If gameplay state is otherwise local, use a small activity mutator once at the semantic boundary rather than on every pointer frame. setTurn for the next player is not evidence for the actor: include the actor in a bumping notifyActivity fan-out or add an actor-only call with recency: "bump", no unread, and no push. Do not expect activity for rejected/no-op actions or transient pointer, hover, and unsaved-draft updates. Do not expect it for per-user preferences visible only to the acting user either — mute, volume, haptics, theme, and reduced motion write state but change only that person's presentation, so they persist without notifying. Shared settings that affect other players or play — house rules, difficulty, round length, board size, and similar room-wide configuration — still bump.

Finish required listing metadata and publish immediately after this check passes. There is no authorization step and nothing to carry across from one: a publish always creates an unlisted activity, and listing it publicly is a separate step you mention in one sentence beside the preview link, never a question you ask. Do not add an ad hoc screenshot or visual test suite unless an existing check exposed a visual defect. That bans a standing suite, not the throwaway capture the definition-of-done pass needs: driving the viewports and writing frames you then read is that gate, and those frames stay in the workspace rather than becoming part of the app's tests. The profile picture is part of that first publish; gallery screenshots and video are the only separate gate, deferred in Step 5.5 until the user is ready to present the activity as finished.

If the activity has an input, textarea, or [contenteditable], browser E2E is not sufficient to verify iOS keyboard layout. Open the activity in the actual iOS app, focus every editable control, and confirm that both the control and its primary action remain visible; docked chrome or a bottom sheet must move with the keyboard, and dismissing it must restore the layout without an extra gap.

Chromium and happy-dom cannot render the iOS magnifier. In the actual iOS app, press and hold every major gameplay surface, then try a slow tap and each drag path. Confirm that no magnifier loupe appears, normal taps still activate exactly once, drags still complete, and nearby scrolling and editable controls retain their native behavior.


Step 5.5: Fill in app metadata

Fill in the listing-page metadata yourself — do not ask the user. The scaffold ships text fields as TODO placeholders and cannot infer the supported player count. Derive every value from what the app actually does (its synced-store/schema.ts, ui/App.*, and the original prompt). After this step there must be zero literal TODO strings in the text metadata.

  1. .poe-tile.jsondisplayName — a placeholder summary name, not a final title: "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. Do not invent a real name here: the user names the activity only when they ask to list it publicly — that is the moment you propose a real title (see Step 6) — and a name they supplied at any point always wins over the placeholder. The placeholder is what the launch-link preview shows, so it should still summarize the activity; it need not be unique — the handle and activity ID carry uniqueness.

  2. README.md — long description rendered on the app's landing page. Rewrite the scaffold stub as a short, player-facing description: 2–4 sentences of prose, written for someone deciding whether to play, derived from the app's actual behaviour (schema/UI). Just the title + the paragraph — no ## What you can do bullet list and no ## Built on Synced-Store section. Keep under 16 KiB UTF-8.

  3. .poe-tile.jsonshortDescription — replace the placeholder with one specific sentence (≤140 chars). Say what the user does in the app, not what the app "is for". Examples: "Online chess for two players.", "Shared todo list synced across devices.", "Real-time multiplayer draw-and-guess party game." Avoid leading filler like "An app to…".

  4. .poe-tile.jsonplayers — for every game or participant-count-sensitive activity, declare the supported player-count facet. Count total seats, including humans and AI; derive the values from the rules and capacity the app actually implements, not the number of people currently in the room or a test fixture.

    • Set min to the fewest participants needed for a meaningful session.
    • Set max only when the rules, board, seats, or validated performance impose a real creator-declared upper bound. For an exact two-player game, use { "min": 2, "max": 2 }. When the implementation has no upper bound, omit max: { "players": { "min": 1 } } is shown as 1+. Never substitute the schema or platform ceiling, which would turn an uncapped activity into a misleading finite range.
    • Add recommended: { min, max } only when a narrower range is materially better than the full supported range (for example, a party game that supports 3–12 but plays best with 5–8). Keep it within the supported range.
    • Omit players for utilities where participant count does not describe the experience. Do not publish players: null for a new activity; null is only the explicit clear sentinel when republishing an existing activity.
    • This field also decides testability, not just the listing page: an activity the range calls multiplayer gets a multiplayer simulation link in publish output and in the Creator chat receipt, opening recommended.min (else min) dev-user phones already inside the activity. See cli.md.
  5. Leave discovery categories to the platform. Do not add a categories field to .poe-tile.json. Use schema v10; when editing older source, remove the obsolete top-level field before publishing. Single-player / Multiplayer remain derived from the supported players range.

Generate the profile picture automatically before the first publish; do not ask for permission or wait for public-publish authorization. Keep an existing icon during edit drafts; when finalizing an update to an already-public activity, regenerate it automatically only when it is missing or materially stale.

Create screenshots only when the user is ready to present the activity as finished, or when finalizing an update to an established activity. Visibility does not mark this boundary: the gate is the user's readiness to present the activity as finished. At that point, ask whether they want the optional, more expensive video work before starting it.

  1. .poe-tile.jsonprofilePicture — before the first publish, commit a square image that reflects the activity's actual theme and visual identity, then point profilePicture at the committed PNG/JPG/WEBP file (≤512 KB). Do not leave the scaffold's starter image or omit the field. Inspect the final file rather than trusting whatever produced it.

    The scaffold ships a generator for this — do not hand-roll a screenshot script. bun run regenerate-screenshot builds the app, boots it in a square 360×360 viewport at 2× DPR, and writes a 720×720 assets/screenshot.png; point profilePicture at that file. It drives tests/screenshot.test.playwright.ts, which is skipped in the normal test suite and exists only to produce this asset. It needs a Playwright browser, so run bunx playwright install chromium first if you have never done so in this environment.

    Prefer a first frame that actually reads as the activity: set up whatever board, lobby, or start state best represents it before the capture rather than shipping an empty screen.

    The icon art must be full-bleed: a square image with opaque, edge-to-edge artwork — no baked-in rounded corners, no transparent margin, no framing border. The listing/detail card renders the icon with object-cover inside an overflow-hidden rounded shell, so the card supplies the rounded corners. An icon that bakes in its own rounded background (transparent corners around a rounded rect) renders as a rounded card inside the card's rounding → a visible "double-rounded", inset look, unlike well-behaved activities whose square art rounds cleanly at the card edge. A regenerate-screenshot capture is already full-bleed; just make sure the captured state fills the frame.

    If the activity's UI itself is the strongest icon, generate a 720x720 square UI capture with bun run regenerate-screenshot, update .poe-tile.json → profilePicture to ./assets/screenshot.png, and commit the file. The test loads the activity inside the iframe sandbox and writes that file. If the default body readiness check produces a blank or half-rendered image, edit tests/screenshot.test.playwright.ts to wait for a specific selector (matching what the existing e2e.test.playwright.ts waits for is usually the right move). For canvas/3D apps, expose a deterministic scene-ready marker after the first rendered frame and wait for that marker. Re-run after meaningful UI changes only if the screenshot is being used as profilePicture.

    Mobile viewport / DPR. The scaffold sets viewport: { width: 360, height: 360 }, deviceScaleFactor: 2. The CSS viewport stays under Tailwind's sm breakpoint (640px) so apps render their true mobile layout (single column, no desktop side-panels), while the 2x DPR still produces a 720x720 PNG. Do not raise the CSS width to 720+ to "see more" — that triggers sm:/md: styles and the captured image will misrepresent the mobile app.

  2. .poe-tile.jsonscreenshots — when the user is ready to present the activity as finished, capture and commit 1–3 representative gallery images (each PNG/JPG/WEBP ≤512 KB), then add their ordered paths to the manifest. At least one image must show genuine gameplay or a meaningfully populated primary state — not merely a title screen, empty lobby, pristine starting board, or first frame. Add a setup/start image only when it explains something the in-action image cannot. Inspect the final crops for loading indicators, debug UI, host chrome, clipping, and stale content. For an established activity, update only missing or materially stale screenshots.

  3. .poe-tile.jsonvideo (optional) — when the user is ready to present the activity as finished, explain that video work costs more than screenshots. Ask whether they want a preview video and poster before creating them. If they decline, continue publishing without new video work. If they accept, capture a representative 10–15 second muted gameplay loop as a local MP4 (≤8 MB), create a local poster image (PNG/JPG/WEBP, ≤512 KB), set video to { "src": "<path>", "poster": "<path>" }, and use $schema v6 or later. For an already-public activity, ask only when its video is missing or materially stale; never create or regenerate it without an explicit yes.

Then verify: grep -rn 'TODO' README.md .poe-tile.json should return nothing. While iterating, complete item 6 and defer items 7–8 until the user is ready to present the activity as finished — visibility does not mark this boundary, so the gate is the user's readiness to present the activity as finished. Only after that proceed to Step 6.


Step 6: Publish

Publishing is your HOST's, not this step's. If your system prompt says how publishing works here — who uploads, when, and what the creator's buttons do — follow it and skip the rest of this step; a host that publishes for you records a duplicate version for every upload you run yourself. The commands below are for a creator driving the CLI with no such host. See publishing.md for the full mechanics.

Run the workspace doctor, then build + publish using a browser-login session from poe-tiles login. Publish every iteration:

bash
cd <app-dir> && bun run doctor
cd <app-dir> && bun run publish-to-poe-tiles -- --change-summary "<concise user-facing summary>"

Publishing never sets visibility. An activity is unlisted when first published and stays wherever it is on every publish after that, so iterating can never move it in or out of discovery.

Listing it is a separate, explicit command, never a question you ask them, and only ever run on their request:

bash
poe-tiles tiles visibility <tileHandleOrId> public

Use unlisted to take it back out. Unlisting removes a live activity from discovery and the user does not necessarily see it happen, so never run it as a step in ordinary iteration.

If the doctor reports missing publish auth, run poe-tiles login and wait for the user to approve the browser prompt. Do not ask first-time creators to paste a Poe API key into chat. POE_TILES_SESSION_TOKEN and legacy API keys are still supported for automation, but they are not the primary creator path.

Report the appUrl from the output as a clickable markdown link the user can try.



Final step: Report

Tell the user what was built, where the project lives, which checks passed (type check, unit, Happy DOM), and hand them the preview link.