Skip to content

Add Google Authentication and Global Leaderboards #69

@jherrflexion

Description

@jherrflexion

Summary

FlexCoins currently runs as a fully client-side game with no user identity and no persistence — every session starts fresh. To drive engagement and replayability, we want to add Google Sign-In so players have a persistent identity, and a global leaderboard so they can compete on high scores.

This feature introduces the first backend infrastructure to the project: an API layer (Lambda + API Gateway via SST), DynamoDB tables for scores and leaderboard data, and a Google OAuth integration. The Godot client will authenticate on the start screen, track session performance, submit scores at session end, and display a leaderboard UI.

Prerequisite decision: Before implementation begins, we must resolve Open Question #3 — what metric defines "score." The data model, submission payload, and leaderboard display all depend on this choice. The current proposal uses total currency earned (cumulative across all add_currency calls, independent of spending). See Open Questions for alternatives. Assign a decision owner and resolve before this issue is picked up for implementation.

Scope

In scope

  • Google OAuth 2.0 sign-in flow (web export is primary; desktop exports as stretch)
  • Backend API for score submission and leaderboard retrieval (SST Lambda + API Gateway + DynamoDB)
  • Session score tracking in GameManager (total currency earned in a session)
  • Score submission at end-of-session (explicit "End Game" button in HUD)
  • Leaderboard UI: global top scores and current user's rank/personal best
  • Anonymous play remains possible (leaderboard features are gated behind sign-in, not gameplay)

Out of scope

  • Save/load of game state or upgrade progress across sessions
  • Social features (friends, sharing, chat)
  • Anti-cheat beyond basic server-side validation
  • Auth providers other than Google (Apple, GitHub, email/password)
  • Admin dashboard or moderation tools
  • Pagination beyond top-N scores

User Stories

  1. As a player, I want to sign in with my Google account on the start screen so that my scores are attributed to me.
  2. As a player, I want to skip sign-in and play anonymously, with the option to sign in later.
  3. As a signed-in player, I want my session score automatically submitted when I end a game so I don't have to do anything extra.
  4. As a player, I want to view a global leaderboard showing the top scores and each player's display name and avatar.
  5. As a signed-in player, I want to see my personal best score and current rank on the leaderboard, even if I'm not in the top N.
  6. As a signed-in player, I want to see a "New personal best!" notification when I beat my previous high score.
  7. As a returning player, I want to remain signed in across browser sessions so I don't have to re-authenticate every visit.

Technical Details

Authentication Flow

Web export (primary):

  1. Start screen displays a "Sign in with Google" button alongside the existing "Play" button.
  2. Clicking "Sign in with Google" initiates Google OAuth 2.0 Authorization Code flow via a same-window redirect. The client navigates to Google's authorization URL (constructed client-side or returned by an optional /auth/google endpoint) with a state parameter for CSRF protection. The state value must always be validated server-side in the /auth/callback handler.
  3. The OAuth redirect URI points to a /auth/callback Lambda that exchanges the authorization code for tokens, extracts the user profile (sub, name, picture), creates or updates the user record, mints a short-lived session JWT, and redirects back to the game URL with the JWT as a URL fragment (e.g., https://game.example.com/#token=<jwt>). Using a fragment rather than a query parameter ensures the token is not sent to the server on subsequent page loads. Google OAuth client ID and client secret are stored as SST Secrets and injected as environment variables into the /auth/callback Lambda.
  4. On page load, the Godot client (via JavaScriptBridge.eval()) checks for a JWT in the URL fragment. If present, it strips the fragment from the URL (using history.replaceState) and stores the JWT in-memory. This completes the sign-in flow.
  5. JWTs are signed with an HMAC-SHA256 secret stored as an SST Secret, verified by middleware on all authenticated endpoints. JWTs expire after 1 hour. The refresh token cookie has a 30-day expiry.
  6. The JWT is stored in the Godot client (in-memory) and passed as a Bearer token on all API requests.
  7. A refresh token is stored in an HttpOnly cookie (set by the /auth/callback response, before the redirect) to keep the user signed in across page reloads. This requires CORS with credentials: include on the client and Access-Control-Allow-Credentials: true on the server. Tradeoff: HttpOnly cookies are not accessible to JavaScript (preventing XSS theft) but require explicit CORS credential support on every API call.
  8. If the JWT expires mid-session, the next API call transparently refreshes the token using the HttpOnly cookie before retrying the original request (silent refresh — no user-facing interruption).

COOP/COEP compatibility note: The existing SST deployment sets Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp on CloudFront, which are required for Godot's SharedArrayBuffer threading. These headers sever the window.opener relationship between windows, making popup-based OAuth flows unworkable (postMessage back to the game window will fail). The redirect flow described above avoids this conflict entirely by keeping everything in a single window.

Desktop exports (stretch):

  • Use a localhost redirect URI (http://localhost:<port>/callback) with a temporary HTTP listener, or embed a minimal webview. This can be deferred to a follow-up issue.

Backend API (SST)

Extend the existing SST deployment with new constructs. All endpoints sit behind API Gateway with CORS configured as follows:

  • Allowed origin: The CloudFront distribution domain (e.g., https://d1234abcdef.cloudfront.net)
  • Allowed headers: Authorization, Content-Type
  • Allowed methods: GET, POST, OPTIONS
  • Credentials: Access-Control-Allow-Credentials: true (required for HttpOnly refresh token cookies)

Endpoints:

Method Path Auth Description
GET /auth/google None (Optional) Returns the Google OAuth authorization URL with a server-generated state parameter for CSRF protection. If the authorization URL is constructed client-side instead, the state parameter must still be validated server-side in the callback.
GET /auth/callback None Handles OAuth callback, validates state, exchanges code, sets HttpOnly refresh token cookie, redirects to game URL with JWT in URL fragment
GET /auth/me JWT Returns current user profile
POST /scores JWT Submit a session score
GET /leaderboard None Returns top N scores (default 50)
GET /leaderboard/me JWT Returns the authenticated user's rank and personal best

POST /scores request body:

{
  "session_id": "uuid-v4",
  "score": 12450,
  "session_duration_sec": 342
}

The score field in the API payload corresponds to session_score on the Godot side (the GameManager variable). This value represents the cumulative total of all currency earned during the session (every add_currency call is summed). It is not the player's current spendable balance — spending on upgrades does not reduce this value.

POST /scores response:

{
  "rank": 23,
  "personal_best": 18200,
  "is_new_personal_best": false
}

When is_new_personal_best is true, the client displays a "New personal best!" toast.

GET /leaderboard response:

{
  "entries": [
    {
      "rank": 1,
      "display_name": "Alice",
      "avatar_url": "https://lh3.googleusercontent.com/...",
      "score": 98500,
      "submitted_at": "2026-04-15T10:32:00Z"
    }
  ],
  "total_players": 412
}

GET /leaderboard/me response:

{
  "rank": 23,
  "display_name": "Bob",
  "score": 18200,
  "submitted_at": "2026-04-14T08:15:00Z"
}

Rank is calculated by counting Leaderboard table items with score greater than the user's score, plus one. This is an O(n) scan against the GSI, which is acceptable at the target scale (up to ~10,000 players). If the player base grows significantly, this can be replaced with a materialized rank or a purpose-built leaderboard service.

Data Model (DynamoDB)

Users table:

Attribute Type Description
user_id (PK) String Google sub claim
display_name String Google display name
avatar_url String Google profile picture URL
created_at String (ISO 8601) First sign-in timestamp
last_seen_at String (ISO 8601) Most recent sign-in

Scores table (historical session data — used for future analytics, not for leaderboard ranking):

Attribute Type Description
user_id (PK) String References Users table
session_id (SK) String UUID v4, ensures uniqueness per session
score Number Total currency earned in the session (cumulative add_currency, not current balance)
session_duration_sec Number How long the session lasted
submitted_at String (ISO 8601) Server-side timestamp

The Scores table retains all session history. It is optional for the initial release and primarily supports future analytics (session trends, play duration analysis). It has no GSI — leaderboard queries do not read from this table.

Leaderboard table (one row per user, personal best only):

Attribute Type Description
user_id (PK) String References Users table
score Number User's all-time best session score
display_name String Denormalized from Users table for fast reads
avatar_url String Denormalized from Users table for fast reads
submitted_at String (ISO 8601) When the personal best was achieved

Denormalized display_name and avatar_url fields are updated on each sign-in. Between sign-ins, leaderboard entries may show stale profile data. This is acceptable.

Leaderboard GSI (on the Leaderboard table):

  • Partition key: "GLOBAL" (fixed string)
  • Sort key: score
  • Projects: user_id, score, display_name, avatar_url, submitted_at
  • Query with ScanIndexForward=false to retrieve scores in descending order

Upsert pattern for score submission:

  1. Write session to Scores table (append-only historical record).
  2. Read the user's current row in the Leaderboard table.
  3. If no row exists or new_score > existing_score, put/overwrite the Leaderboard row with the new score.
  4. Query the GSI to determine the user's updated rank (count of items with score greater than the user's, plus one).

Scaling note: The single-partition "GLOBAL" GSI works well up to approximately 10,000 players. Beyond that, write throttling may occur due to DynamoDB's hot partition limits — it is write throughput to the single partition, not player count per se, that determines when this becomes a bottleneck. At that scale, introduce write sharding (e.g., GLOBAL#0 through GLOBAL#N partition keys with scatter-gather queries) or migrate to a purpose-built leaderboard service.

Godot Integration Points

Autoload registration order in project.godot: GameManager → AuthManager → DevTools. AuthManager must load after GameManager since it may read game state on initialization.

New scripts:

  • scripts/auth_manager.gd — Autoload. Manages auth state (signed_out, signing_in, signed_in), stores JWT in-memory, exposes sign_in(), sign_out(), get_user(). Emits signals: auth_state_changed, sign_in_succeeded, sign_in_failed. Handles silent token refresh when JWT expires. On load (web export), checks the URL fragment for a JWT returned by the OAuth redirect flow.
  • scripts/leaderboard_api.gd — Autoload or service node. Wraps HTTPRequest calls to /scores and /leaderboard endpoints. Handles JWT injection via Authorization: Bearer header, automatic retry (up to 3 attempts with exponential backoff), and error states. Listens to GameManager's session_ended signal to trigger score submission for signed-in users.

Modified scripts:

  • scripts/game_manager.gd — Add session_score: int (cumulative total of all currency earned via add_currency() — never decremented by spending on upgrades), session_id: String (UUID generated at session start), and a session_ended signal. Emit session_ended when the player explicitly ends the session. Note: session_score is the Godot-side variable that maps to the score field in the API payload. Session tracking (score accumulation, session ID generation) lives directly in GameManager — no separate session tracker script is needed.
  • scripts/start_screen.gd — Add sign-in button, display user avatar/name when authenticated, add "Leaderboard" button.
  • scripts/hud.gd — Add "End Game" button to the bottom bar (see End Game Button section under UI/UX).

New scenes:

  • scenes/leaderboard_panel.tscn — Modal panel (similar style to the existing UpgradePanel but wider at 700x600 to accommodate avatar thumbnails and rank numbers). Navy background, Yellow title ("Leaderboard"), close button in the header. Centered modal over a semi-transparent backdrop.

Web-specific considerations:

  • Use JavaScriptBridge.eval() to read the URL fragment on page load and extract the JWT returned by the OAuth redirect. After extracting the token, strip the fragment from the URL using history.replaceState to avoid leaking the token in browser history or referrer headers.
  • Store no sensitive tokens in JavaScript-accessible storage — the refresh token lives in an HttpOnly cookie managed by the server.

Server-Side Validation

The /scores endpoint performs basic sanity checks before accepting a submission:

  • score must be a non-negative integer, capped at a reasonable maximum (e.g., 1,000,000 per session)
  • session_duration_sec must be positive and less than 24 hours
  • session_id must not already exist for this user (no resubmission)
  • Rate limit: max 1 submission per user per 10 seconds

UI/UX

Start Screen (modified)

The existing start screen gains two new elements:

  • "Sign in with Google" button — Positioned below or alongside the Play button. Uses the standard Google branding guidelines (white background, Google "G" logo, "Sign in with Google" text). On success, replaced by a small user card showing avatar thumbnail and display name, plus a "Sign out" link.
  • "Leaderboard" button — Positioned near the Play button. Opens the leaderboard panel. Visible regardless of auth state (anonymous users can view but won't see their own rank).

All new buttons follow the existing Flexion brand color palette: Blue (#155FC8) for primary actions, Navy (#1F2937) for panel backgrounds, Yellow (#FAAE3B) for titles. The Google Sign-In button is an exception — it uses Google's required branding.

End Game Button

  • Position: In the HUD bottom bar (BottomBar HBoxContainer), alongside the existing Shop and Settings buttons.
  • Visibility: Only visible during active gameplay. Hidden on the start screen.
  • Style: Uses Flexion Blue (#155FC8) for normal state, matching the Shop and Settings button style defined in assets/ui_theme.tres.
  • Text: "End Game"
  • Behavior: On press, emits GameManager.session_ended, transitions back to the start screen, and triggers score submission via leaderboard_api.gd if the player is signed in.

Leaderboard Panel

  • Style: Matches the existing UpgradePanel — centered modal over a semi-transparent backdrop, Navy background, Yellow title ("Leaderboard"), close button in the header. Dimensions: 700x600 (wider than the 500x600 UpgradePanel to fit avatar thumbnails and rank numbers).
  • Content: A scrollable list of entries, each row showing: rank number, avatar (32x32 circle), display name, and score. Top 3 ranks are visually distinguished (Gold/Silver/Bronze accent on the rank number using Yellow #FAAE3B, Concrete #DFE3E6, and Orange #E05F1A respectively).
  • User's rank: If signed in and not in the visible top N, a sticky row at the bottom of the list shows "Your rank: #X — Score: Y" separated by a Concrete (#DFE3E6) divider.
  • Empty state: "No scores yet. Play a game and submit your score!" centered text.
  • Loading state: A simple "Loading..." label.
  • Error state: "Could not load leaderboard. Try again later." with a retry button.

Score Submission Flow

  1. Player ends a session via the "End Game" button in the HUD (primary trigger).
  2. If signed in, the score is submitted automatically in the background.
  3. Toast notifications:
    • Success: "Score submitted! Rank: #X" (Green #3BB273).
    • New personal best: "New personal best! Rank: #X" (Green #3BB273).
    • Failure (after 3 retry attempts): "Score submission failed. Your score was not saved." (Tango #DE4829).
  4. If anonymous, no submission occurs — a subtle prompt appears: "Sign in to save your score to the leaderboard."

Acceptance Criteria

  • A "Sign in with Google" button is visible on the start screen and initiates the OAuth redirect flow.
  • The OAuth redirect flow works with the existing COOP/COEP headers (same-origin / require-corp) required for Godot's SharedArrayBuffer threading.
  • After the OAuth redirect returns to the game, the JWT is extracted from the URL fragment and the fragment is stripped from the browser URL.
  • After successful sign-in, the user's display name and avatar are shown on the start screen.
  • Auth state persists across page reloads (web export) via HttpOnly refresh token cookie without requiring re-authentication.
  • JWTs expire after 1 hour and are signed with HMAC-SHA256; the refresh token cookie has a 30-day expiry.
  • If the JWT expires mid-session, the token is silently refreshed on the next API call with no user-facing interruption.
  • A "Sign out" action is available and clears the session.
  • Anonymous play works identically to the current game — sign-in is not required to play.
  • session_score in GameManager accumulates all currency earned (every add_currency call) and is not reduced by upgrade purchases.
  • An "End Game" button is visible in the HUD bottom bar during active gameplay, styled with Flexion Blue (#155FC8), and hidden on the start screen.
  • Pressing "End Game" emits GameManager.session_ended, transitions to the start screen, and triggers score submission if signed in.
  • POST /scores accepts a valid score submission (with score in the payload, mapped from GameManager's session_score), upserts the Leaderboard table if the score is a new personal best, and returns the user's rank and personal best.
  • POST /scores rejects submissions that fail validation (negative score, duplicate session_id, rate limit exceeded).
  • GET /leaderboard returns the top 50 scores with display names, avatars, and timestamps, sorted descending by score.
  • GET /leaderboard/me returns the authenticated user's rank (calculated as count of higher scores plus one) and personal best.
  • The leaderboard panel (700x600) opens from the start screen and displays entries matching the API response.
  • The user's own rank appears at the bottom of the leaderboard panel if they are signed in and not in the top 50.
  • Score is submitted automatically when a signed-in player clicks "End Game."
  • A toast notification confirms successful submission or reports failure (after up to 3 retries).
  • A "New personal best!" toast is shown when the submitted score exceeds the user's previous best.
  • Score submission failure after 3 retries shows an error toast.
  • All new UI elements use the Flexion brand color palette.
  • All new API endpoints are deployed via SST alongside the existing static web deployment.
  • CORS is configured with the CloudFront domain as allowed origin, credentials support enabled, and Authorization/Content-Type as allowed headers.
  • Google OAuth client ID and client secret are stored as SST Secrets.
  • Existing unit tests continue to pass (godot --headless --script res://tools/run_tests.gd).
  • Headless lint passes (godot --headless --script res://tools/lint_project.gd).

Open Questions

  1. Heartbeat/auto-save safety net: The "End Game" button is the primary session-end trigger. Should we also implement a periodic heartbeat or auto-save mechanism as a safety net for unexpected disconnects (e.g., browser tab closed, network loss)? Submitting scores on beforeunload is unreliable and not recommended.
  2. Leaderboard time scoping: Should the leaderboard be all-time only, or should we add daily/weekly views? (Suggest starting with all-time and adding time filters in a follow-up.)
  3. Score metric: Is total currency earned (cumulative add_currency, independent of spending) the right score metric? Alternatives: peak currency (before spending on upgrades), total coins caught, or a composite score. This must be decided before implementation begins — the data model, submission payload, and leaderboard display all depend on this choice. Assign a decision owner and resolve before this issue is picked up for implementation.
  4. Display name editing: Should users be able to set a custom display name, or always use their Google profile name?
  5. Desktop OAuth: What is the preferred approach for desktop exports — localhost redirect, OS browser with deep link, or defer to a future issue?
  6. Rate limiting strategy: API Gateway throttling per API key, or per-user rate limiting in the Lambda? Need to decide on limits.
  7. GDPR/privacy: Do we need a privacy policy page and consent prompt before collecting Google profile data?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions