Appearance
Game UX Best Practices
Conventions for games built on Poe Tiles. Apply these unless the app's design has a specific reason to deviate.
Teach in context — don't gate the first session on a manual. Players land in a tile cold (For You feed, a push, a shared link) with no setup screen and no patience for a wall of rules. Make each screen, phase, and state self-explanatory: right where the relevant control is, say what is happening now, what the player should do next, and what they are trying to achieve — short contextual lines like "Pick a card to beat the 7" or "Tap a square to claim it", revealed progressively as the game advances rather than all at once. A first-time player should be able to make their next move from the screen alone, without opening anything.
Keep a
?"How to play" backstop in the top-right corner — the full rules, controls, and win condition, openable anytime without leaving the tile. This is the reference a player chooses to open for detail; it complements but does not replace teaching in context (above), and must never be a prerequisite to start playing.Default to no auto-opening first-time walkthrough; decide case by case. First rely on in-context teaching and the opt-in rules backstop above. Add a walkthrough only when the tile has an unfamiliar goal, multiple concepts or phases, a hidden twist, or a long/costly first attempt that the first actionable screen cannot explain clearly. Skip it when rounds are quick and restartable, the mechanic and rules are simple or familiar, and a player can learn safely within seconds by doing. Poe Jump is the negative reference: its short rounds and immediately legible single mechanic do not justify an intro carousel. There is no cross-instance per-user memory for a tile. A
privateOfUsersynced-store row is scoped to(instance, user), so it can suppress the walkthrough on later opens of that same instance and carry the preference across devices, but a different instance starts with no seen bit and would show it again. Do not describe this as “once per player”; treat repetition across new instances as a product cost that strongly weighs against adding an auto-opened walkthrough to ephemeral or frequently recreated tiles. Do not uselocalStorage,sessionStorage, orIndexedDBas a workaround; the blob-iframe sandbox blocks them.If the tile-specific decision is to add a walkthrough (or the user explicitly requests one), make it a tight, skippable 2–4 step carousel: one idea per step, a hero visual drawn from the tile's own art, progress dots, Back/Next/Got it, and an X to close — not a scrolling rules wall. Auto-open it at the first relevant actionable moment, make it dismissible at any step, persist dismissal per
(instance, user), and keep the same content re-openable behind the?backstop. Make it swipeable because progress dots plus a card read as a swipeable carousel on a phone: use pointer events so touch and mouse-drag share one path, settouch-action: pan-y, move only when the gesture clears a small threshold (~45px) and is more horizontal than vertical, and do not dismiss by swiping past the final step.Show the other players — faces, not just names. In any multi-player game, render the avatars of the people the user is playing with and against where the action is: at the table, on the board, beside each move, score row, and turn indicator, and on the end-of-game screen. Seeing real faces is what makes a session feel like playing with people rather than against software. Read names/avatars from
$userInfo(see getting-user-info-of-members.md), and design for the user who hasn't set a photo (initials fallback), never a broken image.Let players invite and add others from inside the game. When a game needs more players — to fill a seat, pick an opponent, or start a match — put a visible "Invite" / "Add player" control in the game itself (typically on the start/lobby screen, or right on an empty seat), opening the host people picker (
Poe.room.pickMembers()). When an empty seat or slot visually offers the invitation (for example, a slot card with a+), make the whole slot one accessible tap target so the icon, label, and surrounding card all open the picker; don't wire only a smaller nested "Invite player" button. Cover a tap on the slot surface in an interaction test. Don't make players leave the game and hunt through the surrounding platform chrome to add someone — the game often renders where that chrome isn't visible (the For You feed, a fresh instance).Set up async, offline play — seat players who aren't here yet. A player can be added and assigned a seat, side, or turn while offline; the game is then ready for them when they return. Prefer letting someone set up a turn-based match up front (assign the opponent's seat, make the first move, pass the turn) over requiring both players to be online at the same moment to start. Pair this with a "your turn" push (
setTurn) so the absent player is pulled back in.Show whose turn it is prominently in multi-player turn-based games — display name + avatar of the active player, near the top of the screen. Don't make players hunt for it.
Make the waiting state explicit — a waiting screen must never look like a frozen one. When it's not the player's turn, or they're otherwise blocked (waiting on an opponent's move, on others to ready up, on a round to start), say so on screen: "Waiting for Aaron to play…" with that player's avatar and a subtle animation (pulsing dots, a shimmer) so it reads as waiting, not broken. A waiting player and a hung app look identical unless you distinguish them — so always do. This is the in-tile complement to
setTurn, which drives the manager's "Your Turn" indicator and the push for whoever is up; here you reassure whoever is waiting.Keep a stalled turn from blocking the game forever, and try a nudge before escalation when it fits. Before implementation, ask whether inactivity should block indefinitely, offer only a reminder, or eventually allow skip/default action/forfeit/removal. Choose the initial nudge delay from the game's cadence: synchronous party games may wait seconds, while asynchronous games may wait hours. A nudge is a targeted, context-rich push to the blocking player; show confirmation to the sender, attach no entry overlay, and enforce a server-enforced resend cooldown so multiple waiting players cannot spam the target. If escalation exists, require a grace period after the first nudge before revealing the control; repeated nudges may update
lastNudgedAtbut must not resetfirstNudgedAt. Persist turn identity, turn start, and nudge state in shared data; validate the deadline, caller eligibility, and still-current turn server-authoritatively, including target membership and concurrent calls. Reset the state only when the authoritative turn advances, not when a client opens or refreshes. Test pre-delay rejection, resend throttling, blocker/spectator rejection, stale-turn calls, the grace boundary, simultaneous nudges, and simultaneous skip attempts with an injected or frozen clock.Handle actual player removal without confusing it with temporary absence. Closing the tile or losing the connection is not leaving; preserve the player's seat and resumable state. React only to explicit room-membership removal in
onRemoveUser, and decide the policy before defining the schema. In a lobby, normally vacate the seat. During play, remove the player and continue only when the rules stay fair after atomically repairing the active turn, teams, owned pieces/cards, nudge state, and win conditions. If removal would invalidate the match or a safe repair is ambiguous, persist a visible resolution state and let the remaining players choose a new match or a game-specific alternative such as replacement, forfeit, or abandonment — never silently choose an outcome. Make the hook idempotent and safe under simultaneous removals; never strand the turn on an inactive member. Test lobby removal, removal of both active and inactive players, each offered resolution, repeated removal, and simultaneous removals withharness.removeUser(...).Make the primary action obvious. At any moment there should be one clear thing the player can do next (tap to roll, drag to place, pick a card). If you can't point at it in one sentence, the UI is too ambiguous.
Prefer dragging pieces over click-then-click controls. For pieces, cards, tokens, or tiles that move between positions, make direct drag-and-drop the primary touch / pointer interaction; do not make clicking/tapping the piece and then its destination the default. Keep tap-to-select then tap-destination and keyboard controls as accessible fallbacks. During the drag, lift or track the piece, highlight legal destinations, and snap it back with feedback after an invalid drop. Use Pointer Events so one path covers touch and mouse, and test the drag path plus the fallback.
Highlight tappable inputs. When only a subset of elements are interactive at a given moment (e.g. in a card game where only some cards are legally playable), visually distinguish them — glow, border, opacity, scale. Non-interactive elements should look non-interactive. Players shouldn't have to tap to discover what's allowed.
Give immediate feedback on every input — visual state change, haptic (
Poe.haptics.impact("light")/Poe.haptics.selection()for ticks,Poe.haptics.notification("success"|"error")for outcomes — see client-api.md), or sound. Never let a tap feel like it did nothing, even if the move is illegal (show why it was rejected).Enact physical actions; don't teleport to results. If the UI represents a die rolling, coin flipping, deck shuffling/dealing/drawing, spinner spinning, piece moving, or projectile being thrown, animate that action before the final state appears. Animation is presentation, not authority: determine and persist the result first (server-authoritatively for shared state), then animate toward that known result; never use separate visual RNG. Keep the sequence brisk, prevent duplicate action while it plays, add matching haptic/sound feedback when appropriate, and honor reduced motion with a shortened transition that preserves cause and effect. Test with controlled animation completion or timers, including the reduced-motion path; never use sleeps.
Make the end-of-tile overlay the conclusion, not the reveal. Before implementing a terminal path, explicitly plan the reveal: what will show why the player won or lost, which animation/visual marker/copy/feedback will communicate it, and when the host overlay may safely cover the playfield. On a loss, disable further input but keep the decisive state visible; finish the collision, capture, timer-expiry, missed-target, or other terminal animation; add cause-specific copy when the visuals are not self-explanatory; then hold the result for a readable beat (typically about 800–1500 ms) before calling
Poe.room.tileEnd(). Sequence the call from animation completion plus the readable hold instead of starting a blind timer in parallel with the animation. Underprefers-reduced-motion, shorten or skip movement but keep the visual explanation and pause. Make the transition cancelable and exactly-once across unmounts, restarts, and repeated reactive updates. Test the ordering with controlled timers or animation-completion hooks: the decisive cause must be visible beforetileEnd()is requested, andtileEnd()must eventually be requested.Reach a terminal state cleanly — use the platform end-of-tile overlay. Wins, losses, draws, and final scores need an unmistakable end screen with a "play again" affordance — don't just freeze the board, and don't hand-roll the screen. Report the result through
Poe.room.tileEnd()and let the host render its end-of-tile overlay: every active player ranked by result with avatar + name (so it is your leaderboard for scored games), plus Play Again and pick-another-tile. See client-api.md.Make the competition present during play, not just at the end — for scored / leaderboard games. A leaderboard shown only on the end screen makes a scored game feel single-player until it's over. Casual leaderboard games are usually played solo, so the social charge has to come from feeling who you're racing as you go — bring other players' scores into the live run and give the player a jolt the instant they pull ahead. Pick the lightest mechanic that fits the game:
- Live rank / "score to beat" HUD — a small, non-interactive overlay (
pointer-events: noneso taps fall through to the game) showing the current top few scores with names + avatars, or the player's live rank ("3rd of 8"), updating reactively as their score climbs and as others post new scores. No scores means no leaderboard HUD: when the board has zero real entries, do not render empty-state copy ("Set the first record", "No scores yet"), placeholder rows, a leaderboard heading, or an empty panel. Collapse the region so gameplay reclaims the space, and reveal the real top scores reactively when the first result arrives. (This is how Poe Jump shows a top-3 board during play.) - Pace marker — for time/distance runs, show a live "+4.2m ahead / −1.1m behind" delta (green ahead, red behind) against a reference run — the current leader's, or the player's own best — sampled at the same elapsed time. (This is how Top-Down Racer races you against the best stored run.)
- Ghost — replay another player's recorded run on-screen as a visual competitor moving in parallel, labeled with their name + avatar. (Flappy Crossing's ghosts.) Record the run as a bounded, downsampled trace and cap it with an explicit named limit constant — never persist an unbounded per-frame trace (Flappy Crossing caps a run at 200 hops; Top-Down Racer at 1200 samples). The leaderboard / best-score rows and any ghost traces are ordinary shared-instance synced-store data, so the live layer keeps working offline and replays from the local cache.
- Celebrate the overtake in the moment. When the live score crosses a stored opponent's score, fire a brief positive beat right then —
Poe.haptics.notification("success")(see client-api.md), a transient banner with the passed player's name + avatar ("Passed Aaron! 🎉"), and/or a sound. This in-the-moment payoff is the whole point; don't make the player wait for the end screen to learn they pulled ahead. - Tell the person you passed. When a finished run beats another player's stored best, send them a targeted high-signal push with
notifyActivity({ targetUserIds: [overtakenUserId], push })("Aaron just beat your high score!"), and reservepostToChatfor a true milestone (a new #1). Default sender-suppression means you never notify yourself. (Both Flappy Crossing and Poe Jump do this.) - Keep the live layer meaningful when the player is first or alone. Race the player's own personal-best ghost or a deliberately seeded stock ghost when that reference run exists. On a genuinely new board with no score or trace, let the primary gameplay stand alone until the first result rather than fabricating a text-only competition state. Read identities/avatars from
$userInfoand degrade to initials, never a broken image (the "Show the other players" convention above, applied to a mostly-solo game).
This is the in-play complement to the end-of-tile overlay (above): keep reporting the terminal result through
Poe.room.tileEnd()for the authoritative ranked end screen — the live layer adds presence during the run, it does not replace the end screen.- Live rank / "score to beat" HUD — a small, non-interactive overlay (
Persist enough state to resume. A player who closes the app mid-game and comes back via a push notification should land back in the same position, not a fresh instance.
Replay the opponent's last turn on open — for turn-based games. When a player opens a turn-based game and it's now their turn (whether from a "your turn" push or from the surrounding platform chrome), don't just render the resumed board statically — first briefly show what the opponent just did so the player has the context to decide their move. Re-animate or highlight the last move (the piece that moved and where, the card played, the tiles placed, the score change), then hand control to the player. The player was away when it happened; the notification text ("Aaron moved knight to f3") sets expectation, but the move itself must be legible in the game — a player forced to reverse-engineer what changed from a static board is a player who'll misread the state. Keep it short (a single beat, ~600–1200 ms), make it skippable/tappable-through for players who already know, and under
prefers-reduced-motionuse a static highlight of the changed elements instead of movement. To land the player on the right screen for this, attach entry context to the push (see client-api.mdpush.context); persist enough turn history to reconstruct the last move (the resume state above), not just the current position.