Guest-First Auth: Let Kids Play, Ask Parents Later


A login screen is the wrong first impression for a kids puzzle app. Kids can’t type email addresses. Parents won’t bother unless they already know the app is worth it. We had both problems.

The fix: let kids play immediately, then earn the right to ask parents for an account.

The Old Flow

Open the app → login wall. That’s it. No account, no game. For a product aimed at 2–7 year olds, this is a conversion death sentence.

The New Flow

  1. First open — a welcome overlay with a single big “Play Now!” button. One tap, no form, straight into the game.
  2. Guest sessionlocalStorage tracks stars earned, puzzles completed, and themes played under kg_guest_session.
  3. Save prompts at the right moments — we surface nudges aimed at the parent, not the kid:
    • After puzzle 1: full-screen celebration with confetti and an inline registration form
    • After puzzle 3: bottom sheet slide-up
    • App going to background: visibilitychange fires a bottom sheet before the tab hides
    • Tapping a locked difficulty (4×4, 5×5): a centered modal
  4. Two-step registration — parent email + password, then optional kid personalization
  5. Progress migration — guest stars merge into MongoDB on registration or login

Each prompt fires at most once per 4-hour session. No nagging.

The Adventure Card

Step 2 of registration is the hook. The parent enters their kid’s name, picks an avatar (lion, unicorn, dragon, panda, fox), and taps “Make My Adventure Card!”

We send a prompt to our FLUX image generation endpoint:

cute lion hero character for a child named Maya,
vibrant cartoon, kids puzzle game art, colorful, friendly

While the AI generates, a dual-ring spinner plays. When the image arrives it reveals with an elastic GSAP animation inside a card frame — kid’s name, star count, “Puzzle Hero • Level 1”.

If generation fails (rate limit, Hugging Face downtime), we silently fall back to the static avatar PNG. The kid never sees an error.

The card is stored as a base64 data URL on the User document. It’s intentionally excluded from default API responses — /api/auth/me stays lightweight, the card only loads when the profile page requests it.

Rate Limiting Without Redis

Each Adventure Card generation hits Hugging Face’s API. We needed to limit this but didn’t want to add Redis for a single counter.

Solution: a adventureCardGeneratedAt timestamp on the MongoDB User document. Before generating, check if it’s within 24 hours. Simple, survives pod restarts, scales across EKS nodes, zero new dependencies.

const elapsed = Date.now() - user.adventureCardGeneratedAt.getTime();
if (elapsed < TWENTY_FOUR_HOURS_MS) {
  return Response.json({ success: false, error: "Rate limit" }, { status: 429 });
}

Backend Changes

  • User model — added adventureCard (string, max 500KB) and adventureCardGeneratedAt (Date)
  • GET /api/profile — new endpoint returning the full user including adventureCard, separate from /api/auth/me which stays lean
  • PUT /api/profile — extended to accept adventureCard with a 500,000-character length guard
  • Login — accepts rememberMe boolean; true sets a 30-day Max-Age cookie, false creates a session cookie
  • Register — returns a clear “already registered” message for duplicate emails

All covered by tests — 26 passing across auth, profile, health, and progress suites.

Save Prompt Components

Three components handle the different trigger contexts, all with GSAP animations:

  • SavePromptCelebration — full-screen with inline registration form and canvas-confetti
  • SavePromptBottomSheet — slides up from the bottom with a dismiss-on-backdrop tap
  • SavePromptModal — centered modal with backdrop blur for locked-feature gates

They receive onSave (navigates to /register) and onDismiss as props. No form logic inside the components except SavePromptCelebration which embeds the full registration form for the puzzle-1 moment.

Guest Progress Migration

When a parent registers or logs in, guest stars get merged into their MongoDB progress record. The heuristic: equal star split per theme played.

const starsPerTheme = Math.floor(guest.stars / guest.themesPlayed.length);
for (const theme of guest.themesPlayed) {
  completed[theme] = true;
  stars[theme] = starsPerTheme;
}
await api.put(API_ROUTES.PROGRESS_SAVE, { completed, stars });
clearGuestSession();

Not perfect, but fair enough. If migration fails, localStorage is kept as a fallback — better to lose a server sync than the kid’s progress.

What’s Next

  • Replace placeholder avatar PNGs with AI-generated character art
  • Social login (Google/Apple) to reduce registration friction further
  • A/B test the puzzle-1 celebration prompt vs. the puzzle-3 bottom sheet for conversion

The best onboarding is the game itself.