Wintersalmon | Blog

Guest mode + viewport-aware screens + wireframe-driven UI

5 min read

The platform from the previous post worked, but three gaps capped the games-as-vehicle hypothesis at once: friends couldn’t join without an account, mobile re-rendered on every scroll, and the UI had drifted a month from its own wireframes. Three March task-logs closed those gaps before any new game.

TL;DR

  • Guest share-links: POST /api/v2/games/:roomId/share-link returns a URL with a raw secret; server stores only secretHash. TTL 5 min via explicit check + TTL index.
  • Guest UUID derived from secret + normalizedUsername + gameType + roomNumber, so refresh keeps the same seat.
  • Viewport mode resolved once per screen mount at DESKTOP_MIN_WIDTH = 1025. No live resize flips; each shared screen splits into *Mobile and *Desktop siblings.
  • Wireframes (docs/wireframes/board-game-client/) are the source of truth; 35-test wireframe-sync.spec.ts Playwright suite asserts labels and structure.
  • Both fixes leaned on in-memory E2E (e2e/_shared/, mongodb-memory-server).

POST /api/v1/auth/guest already existed; what was missing was a way to land on a specific room without typing a 6-digit code. Three v2 endpoints:

POST /api/v2/games/:roomId/share-link    // host creates, auth required
GET  /api/v2/share-links/resolve         // public validate + expiry
POST /api/v2/share-links/join            // post-guest-login
  • Host call returns a URL with gameType, roomNumber, raw secret; server stores secretHash only (sha256).
  • TTL 5 min — enforced by expiresAt > now in the handler plus a TTL index for cleanup.
  • resolve is public so the guest screen shows “this link expired” before forcing a login.

Guest identity took the longest. The client derives the guest UUID from secret + normalizedUsername + gameType + roomNumber, calls POST /api/v1/auth/guest, and persists at cloudnest_guest_share_v1:{gameType}:{roomNumber}. Same browser, same UUID, same seat server-side. The user gets isGuest: true and guestDisplayName so I can filter guests out of player stats later.

e2e/chess-v2-inmemory/guest-share-link.spec.ts covers four scenarios: host copies, guest joins, refresh keeps the seat, expired link blocks the join. The expired case mattered most — it’s the only one users hit by accident.

Mobile broke; the fix was picking a mode once at mount

The phone bug was Tailwind’s fault and mine. Shared screens used sm:* classes; alkagi’s MultiplayerGame had useCanvasSize on window.resize. When mobile Safari collapses the address bar, innerHeight jumps, resize fires, the canvas re-measures, the board re-renders. Visible flash on every scroll.

Options were debouncing every resize handler or splitting the component by shape. I picked the second:

const DESKTOP_MIN_WIDTH = 1025;

function resolveScreenMode(): "mobile" | "desktop" {
  if (typeof window === "undefined") return "mobile";
  return window.innerWidth >= DESKTOP_MIN_WIDTH ? "desktop" : "mobile";
}
  • Resolved once per screen mount. Refresh re-evaluates. Live resize doesn’t flip the mode.
  • Each shared screen splits into siblings — HomeScreenMobile/HomeScreenDesktop, same for LobbyScreen, GameRoomScreen, auth, and games. Router-level components are thin selectors.

Rollout: resolver + tests, shared screens, games (one per PR), then desktop and mobile Playwright projects so regressions fail CI. The playground was normalised to desktop-only — it’s for me, on my desk. HomeScreen.tsx became three files; better than writing clamp() Tailwind forever.

Wireframes had drifted; the fix was a habit, not code

docs/wireframes/board-game-client/ already existed — index.html, design-system.html, user-flows.html. A month out of sync. Fix: promote the wireframe to step one of any UI change. One resync round caught the screens up:

  • RegisterScreen lost confirmPassword, gained name (이름).
  • LoginScreen retitled from a generic platform header to its actual purpose.
  • GuestJoinScreen collapsed two buttons into one — saved-credential dual-button UX was overkill once auto-detection worked.
  • HomeScreen gained per-game card icons (GameTypeConfig got icon/iconBg) and clickable cards instead of card+button pairs.
  • LobbyScreen rendered 방 #XXXX from the last four chars of the room ID via deriveRoomDisplayName(), instead of leaking the host UUID.
  • GameRoomScreen got host/participant avatars, ”?” placeholders for empty seats, and the share-link button where the wireframe said.

A 35-test wireframe-sync.spec.ts Playwright suite asserts labels and structure. Cheap to run because of how E2E was wired the week before — see Sidebar 1.

e2e/_shared/ wires the function handler graph in-process (not Docker compose) and uses mongodb-memory-server — one ephemeral mongod per Playwright worker, isolated by construction. The whole board-game-inmemory project finishes well under a minute. The Docker-backed multiplayer-chess suite needed ./test-functions.sh start plus a healthcheck wait — fine for one run, painful for thirty-five.

Mid-April: ErrorBoundary.tsx wraps <App /> and each lazy game <Screen /> — a throw shows a 오류가 발생했습니다 card with refresh CTA instead of killing the nav shell. use-ws-reconnect-timeout.ts returns true after 30 s of connectionStatus === "reconnecting"; GameRoomScreen then shows a modal with “새로고침” and “로비로 돌아가기”. Boundaries catch JS crashes; the timeout catches WS hangs.

What this changes going forward

The wireframe-as-source-of-truth habit got reused on April 28: docs/wireframes/games-design-system.html shipped as a single visual spec for all seven games. Every remodel session now begins by porting the canonical render<Game>Board() IIFE from HTML into the React component. Same habit, bigger surface.

#ux #wireframes

AI workflow note

Claude enforced the wireframe-first habit. For each UI change I opened the wireframe HTML and the React component side by side, sketched the change in HTML first (Claude editing inline), then asked for the component diff against the updated wireframe. The responsive-vs-explicit-split decision went through the architect agent — I asked it to argue both sides, and the resize-thrash and canvas-flash points came back as the deciding factors, not anything I’d led with. Reusable habit: when changing a screen, ask Claude to first list every component that imports it, then propose the change against that list. Kills “I forgot this call site” bugs that used to land in PR review.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.