Skip to content

Game UX Best Practices

Conventions for games built on Joiner. 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 an activity 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 activity. 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 activity 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 an activity. A privateOfUser synced-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 activities. Do not use localStorage, sessionStorage, or IndexedDB as a workaround; the blob-iframe sandbox blocks them.

    If the activity-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 activity'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, set touch-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-activity 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 lastNudgedAt but must not reset firstNudgedAt. 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 activity 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 with harness.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).

  • At the moments the player earns or loses something, that choice becomes a requirement to combine channels. Ordinary input acknowledgement can pick any one of the three above. Scoring, failing, and completing a round need a sound and at least one of the others: haptics do not exist on desktop and are off on a phone with the switch flipped, and a visual flourish is easy to miss when the player is looking elsewhere on the board. Give distinct outcomes distinct cues — a score and a crash must not be the same cue at a different pitch. Synthesize short cues with Web Audio rather than shipping audio files unless the sound genuinely needs recording, vary the pitch slightly across repeats so a long run does not become a metronome, and never loop background music by default. Create or resume the context from a real user gesture and call resumeAudioContext(context) from poe-tiles-sdk/v1/client.js before scheduling on a reused context — WebKit parks a context in the non-standard "interrupted" state after an alarm or call, so checking only for "suspended" leaves the activity silent until it is reopened. Offer a mute control and persist the choice. Sound reinforces; it must never be the only carrier of information. Assert the cues with a behavioural test: sound is invisible to a screenshot pass, so it is the one piece of feedback a visual review can never catch.

  • Choose the peak level deliberately, and give the core loop an escalating cue. You cannot hear your own activity, so both of these are decisions to make in code rather than checks to make by ear. A cue peaking below roughly a tenth of full scale vanishes under ambient noise on a phone speaker, which makes it decoration rather than feedback; pick a level that carries, keep the loudest cue short enough not to startle, and hold it in a named constant instead of scattering literals so it can be reviewed and retuned in one place. Separately: if the activity has one action the player repeats as its core loop — a drop, a stack, a shot, a match — then succeeding at that action earns its own cue, and a run of successes must sound different from a single one, escalating through rising pitch or added harmony. A game built on repetition is carried by the sound acknowledging the streak, not merely the event; a single flat blip per success leaves the reward loop silent at exactly the moment it should be most satisfying. If the activity has no such repeated action, skip this part.

  • Show a change to the score where the action happened, not only in the HUD. A player mid-action is looking at their hands and at the thing they just hit, not at a counter in a corner, so a number that only ticks in the HUD is invisible at the exact moment it was earned. When an accepted action changes a score, a streak, a life count, or a clock the player is playing for, spawn the delta at the point of contact — +1, +12, x3 — hold it for a readable beat, and let it leave on its own. When a streak or a multiplier is what made the delta large, the delta must show that it was multiplied rather than only its total, or the player learns nothing about why this hit paid more than the last one. Keep these transient and non-interactive (pointer-events: none) so they never swallow a tap, cap how many can exist at once with a named constant so a long run cannot flood the frame, and honour prefers-reduced-motion by holding the number still instead of animating it. Assert behaviourally that the event carries the delta and the multiplier, not merely that the running total changed.

  • Every object the player acts on needs a before and an after that differ in more than position. The player-character rule covers the thing the player controls; this covers the things the player hits. An object that simply disappears on contact reads as a bug rather than as a hit: the player cannot tell a successful slice, capture, or destruction from an object that fell off the screen, or from their own input being dropped. Give the successful action a visible consequence that inherits the motion which caused it — halves that separate along the stroke, debris that keeps the object's velocity, a burst at the contact point — and give the failed or ignored case its own distinct visual so the two can never be confused. These are code decisions rather than eye checks, because a headless screenshot taken at one instant rarely lands on the frames that carry the effect: choose the count, lifetime, and travel from named constants, and capture a frame during the effect rather than only before and after, so your own review pass can see that the two states really differ.

  • 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.

  • Derive difficulty and fairness from the movement rules instead of guessing at them. Generate the challenge — obstacle gaps, spawn positions, hazard timing, target placement — fresh per run from a seed you persist so a run stays reproducible. The two failure modes are opposites and both read as broken: one hard-coded repeating sequence turns a skill game into a memorization drill, while re-randomizing each element independently across its full range produces stretches the player physically cannot clear. Bound the step between consecutive elements by what the player can actually traverse in the distance available, computed from the movement constants you already chose (gravity and flap impulse, speed and turn rate, cooldowns) rather than eyeballed. Give the opening a forgiving first challenge, then ramp — tighten spacing, raise speed, add hazards as the score climbs — and stop the ramp at a floor so late play stays a test of skill rather than a coin flip. Aim for a first-time player scoring within a few attempts and a practiced player reaching a satisfying run, then play both ends yourself and retune: unscoreable and unmissable are both failures. Award a point 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 on a 30 Hz and a 120 Hz display; never scale movement by an unbounded raw frame delta (this is the correctness counterpart to the battery guidance in client-api.md). Cover each property with a behavioral test that fails if it regresses: every generated course is traversable under the movement bound, a point scores exactly once and only on full clearance, and each collision shape matches the shape actually rendered within a small tolerance.

  • Make the end-of-activity 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. Under prefers-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 before tileEnd() is requested, and tileEnd() must eventually be requested.

  • Reach a terminal state cleanly — use the platform end-of-activity 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-activity overlay: every active player ranked by result with avatar + name (so it is your leaderboard for scored games), plus Play Again and pick-another-activity. 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: none so 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 thenPoe.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 reserve postToChat for 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 $userInfo and 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-activity 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.

  • Persist enough state to resume by default, including solo play. Navigating away, closing the activity, reloading, or returning via a notification must restore the ongoing match/run in the same instance, not restart it or require the player to join again. Persist the phase, board/progress, score, player identity/seat, and active run or turn identity in synced-store as applicable; scope personal progress to the user within the instance. Save at meaningful state transitions, not only on unload, which may never fire. For real-time play, use bounded checkpoints rather than per-frame writes. Restore from durable state after bootstrap; initialization must not overwrite an existing session, and completed sessions must remain completed until an explicit replay/reset. Do not use localStorage, sessionStorage, or IndexedDB inside the activity; the sandbox blocks them.

  • Restore the correct state, not a stale snapshot. Shared play may advance while a player is away: show the current authoritative board/turn/result when they return. Persist deadlines for real-time timers so reopening cannot grant extra time; for pausable solo play, persist remaining time or simulation progress and resume from a safe checkpoint. Keep committed attempts authoritative as below — recovery is not an undo mechanism.

  • Make committed attempts authoritative in scored or competitive play. Define the commitment boundary explicitly (for example, releasing a shot or jump), persist an in-flight marker at that boundary, and clear it atomically only when a successful checkpoint is recorded. Persist a miss or terminal result as soon as it is detected; delay only animation and the host end overlay. On reopen, resume from a neutral checkpoint, but resolve a still-committed attempt conservatively as consumed rather than turning navigation into undo. Make terminal mutations idempotent and test leaving/reopening both before and after outcome confirmation.

  • 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-motion use 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.md push.context); persist enough turn history to reconstruct the last move (the resume state above), not just the current position.