Skip to content
Skip to content
Goodspeed

BUILT INTO EVERY GOODSPEED APP

Gamification (Streaks, Points, Badges)

Streaks, a points system, and achievements/badges are all pre-built as Static components with a shared gamification.ts library, so apps get Day-1 retention mechanics without building the counter logic, streak-freeze, or celebration animations from scratch.

  • Tier: Common
  • Status: Config-toggled
  • Config: features.gamification.enabled

WHY IT MATTERS

Retention is the metric that separates products people keep from products they install and forget. The most durable lever for habit-driven apps is a well-designed streak mechanic: it turns a daily action into something a user actively does not want to break. But implementing this correctly is not as simple as incrementing a counter. The streak must handle consecutive-day detection correctly across timezone boundaries, provide a streak-freeze token so one missed day does not destroy a long run and trigger churn, reset correctly after a genuine multi-day gap, emit the right analytics events so you can observe where streaks break and intervene with a push notification, and persist state reliably even when the user has no network. Building all of that from scratch on a new app is several days of careful work before you have written a single line of the actual product. The gas-template ships lib/gamification.ts as a complete, tested engine that handles every one of those cases. recordDailyActivity() implements the full streak state machine: same-day calls are no-ops, consecutive days increment the count, a missed day checks for a freeze token before resetting, and a freeze token is awarded automatically every 7 consecutive days so sustained users accumulate a small buffer. Every state transition emits a PostHog event and a Sentry breadcrumb, so you have instrumentation from day one.

Points and achievements are built on the same library with the same AsyncStorage persistence. incrementPoints() adds to a running total and emits a typed points_earned event. checkAndUnlockAchievements() takes the current streak and point totals and evaluates them against a data-driven achievement schema, unlocking any newly qualifying achievements and persisting them atomically. The default schema ships six generic achievements (First Day, Week Warrior, Month Master, Century Club, Point Collector, Power User) that work for any app, and DevAgent extends this array with app-specific badges defined in gas.config when generating the app. React hooks for each dimension (useStreak, usePoints, useAchievements, useWeeklyScore) are exported alongside the raw async functions, so components can read and update gamification state with a standard hook interface without managing AsyncStorage directly. The StreakBadge.tsx component and the ScoreRing.tsx UI primitive in components/ui/ provide ready-to-use display layers, so the full loop from data to rendered UI ships without any custom component work.

HOW IT IS WIRED

Real code from the GAS template

The code below is drawn from lib/gamification.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.

// lib/gamification.ts -- streak state machine + achievement unlock
// recordDailyActivity(): same-day no-op, consecutive +1, freeze-token safety net.

export async function recordDailyActivity(): Promise<StreakData> {
  const streak = await load<StreakData>(K.streak, {
    current: 0, longest: 0, lastActivityDate: null, freezeTokens: 0,
  });

  const today = todayStr();
  if (streak.lastActivityDate === today) return streak;

  const yesterday = new Date();
  yesterday.setDate(yesterday.getDate() - 1);
  const yStr = yesterday.toISOString().split('T')[0];

  let newCurrent: number;
  if (streak.lastActivityDate === yStr) {
    newCurrent = streak.current + 1;                 // consecutive day
  } else if (streak.lastActivityDate !== null) {
    if (streak.freezeTokens > 0) {
      newCurrent = streak.current + 1;               // freeze token consumed
      streak.freezeTokens -= 1;
    } else {
      newCurrent = 1;                                // streak resets
    }
  } else {
    newCurrent = 1;                                  // first ever activity
  }

  const freezeBonus = newCurrent % 7 === 0 ? 1 : 0; // earn token every 7 days
  const updated: StreakData = {
    current: newCurrent,
    longest: Math.max(streak.longest, newCurrent),
    lastActivityDate: today,
    freezeTokens: streak.freezeTokens + freezeBonus,
  };
  await save(K.streak, updated);
  captureEvent('streak_recorded', { count: newCurrent });
  return updated;
}

Source: goodspeed-apps/gas-template lib/gamification.ts

HONEST LIMITS

When Gamification (Streaks, Points, Badges) is the wrong choice

Gamification works when the app's core loop is something the user wants to do repeatedly and intrinsically benefits from a streak or reward signal. Language learning, fitness tracking, journaling, meditation, reading habits, and coding education are natural fits: the mechanics feel like the product, not a bolt-on. For utility apps where the user wants to get in and get out, streaks become an obligation that generates resentment rather than retention. An expense reporting app that nags you for a 7-day streak is not motivating, it is irritating. The same applies to medical logging apps where missing a day is either medically significant or a user privacy choice, not a gamification opportunity. Apps in those categories should set features.gamification.enabled: false in gas.config so no storage keys are written and no analytics events fire. Even in apps where gamification fits, be careful about what action the streak tracks. Streaks tied to shallow engagement (opening the app) are quickly ignored and add no retention value after the novelty wears off. Streaks tied to the actual meaningful action (completed workout, published journal entry, finished lesson) are far stickier because breaking the streak feels personally significant. The template's recordDailyActivity() is a generic daily-activity recorder. The DevAgent wires it to the right trigger in the generated app's core loop based on the PRD. If you are integrating manually, call it only when the user completes the action that defines daily engagement in your product, not on app open.

Tier: Common · Config-toggled

  1. Evaluate your use case

    Check whether gamification (streaks, points, badges) aligns with your target audience, platform constraints, and regulatory environment before enabling it.

  2. Audit the config

    The `features.gamification.enabled` flag controls this feature. Set it to false in gas.config.ts to disable the feature entirely with no residual code paths.

  3. Seek alternatives

    If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.

APPS USING THIS FEATURE

Every generated Goodspeed app includes gamification (streaks, points, badges). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.

CAPABILITIES

Gamification (Streaks, Points, Badges) capability breakdown

Concrete dimensions of what the built-in gamification (streaks, points, badges) implementation covers. These reflect the actual template code, not a marketing summary.

ItemDescriptionStrength
Storage backendAll gamification state (streak, points, achievements, weekly score) is persisted in AsyncStorage under keys prefixed by the app slug, so multiple GAS apps on the same device never collide.AsyncStorage (local)
Streak freezeA freeze token is earned automatically every 7 consecutive days and consumed when the user misses a day. Streak resets to 1 only if a day is missed AND the token balance is zero.Token-based freeze
Achievement modelData-driven unlock conditions (streak threshold, points threshold, first_activity). DEFAULT_ACHIEVEMENTS ships 6 generic badges; DevAgent extends with app-specific schema at generation time.Data-driven unlock
React hooksuseStreak(), usePoints(), useAchievements(), useWeeklyScore(): each hook loads state on mount and exposes a mutator that updates AsyncStorage and re-renders the component.4 built-in hooks
Analytics integrationstreak_recorded, streak_broken, freeze_used, points_earned, and achievement_unlocked events fire automatically via captureEvent(). No call-site instrumentation required.Auto-instrumented

COMMON QUESTIONS

Does streak tracking work correctly across timezone changes and daylight saving time?

The streak engine uses todayStr(), which calls new Date().toISOString().split('T')[0] to produce a UTC date string like '2026-06-01'. This means the day boundary is always midnight UTC, not the user's local midnight. For most habit apps this is a reasonable tradeoff: a user in New York who completes their workout at 11:50 PM Eastern will see it land on the following UTC date, which can make a streak appear to break near midnight local time even though the user was active. If your app needs the day boundary to match the user's local midnight, replace todayStr() with a local-time equivalent using the Intl API or a library like date-fns. The streak freeze token provides a one-day buffer that absorbs most timezone-edge cases for users traveling across one or two time zones. For apps where the streak is a core monetization mechanic, swap in local-time day calculation and document the decision in gas.config so future maintainers understand why the default was changed.

Can achievements be synced to the server so users keep them if they reinstall?

The default implementation persists everything in AsyncStorage, which is device-local and ephemeral. If the user reinstalls the app, clears app data, or switches devices, their streak, points, and achievements start over. For apps where achievement persistence is a core retention mechanic, the right upgrade is to write gamification state into Supabase in a user_gamification table on every mutation, then seed AsyncStorage from the server on first load after sign-in. The async functions in lib/gamification.ts are designed so you can add a Supabase upsert inside save() or after each mutation function returns without changing any call sites. The template does not ship the server-sync layer by default because it requires a signed-in user, and gamification is intentionally designed to work even for anonymous sessions before account creation.

How do I add a custom badge specific to my app (for example, Completed 10 workouts)?

Add a new entry to the achievements array passed to checkAndUnlockAchievements(). The schema uses the UnlockCondition type with three condition types: streak (unlocks when streak.current reaches threshold), points (unlocks when points total reaches threshold), and first_activity (unlocks on the first call to recordDailyActivity). For conditions outside those three, such as a workout-completion count, track the count in a separate counter and evaluate the unlock condition before calling checkAndUnlockAchievements. The DevAgent wires app-specific achievements into the schema based on the PRD's defined actions. If you are integrating manually, define your achievement objects in a constants file, pass them as the third argument to checkAndUnlockAchievements(), and call the function after each relevant action. TypeScript enforces the shape via the Omit<Achievement, 'unlockedAt'>[] type, so any missing required fields produce a compile error.

Does the gamification engine affect app startup time?

No. All AsyncStorage reads in lib/gamification.ts are async and called only when a hook mounts or a mutation function is invoked. The engine does not run on app startup unless you explicitly call recordDailyActivity() in a root layout useEffect. The typical integration is to call recordDailyActivity() inside the screen or hook that represents the user completing their daily core action, not at launch. AsyncStorage reads are sub-millisecond on device once the underlying SQLite store is warm, and the gamification keys are small JSON objects; the streak object is under 200 bytes. Even if all four keys are read on mount simultaneously, the I/O cost is negligible. PostHog events emitted by the engine are buffered by the PostHog SDK and sent in background batches, so there is no synchronous network cost on any gamification call.

GET IT BUILT INTO YOUR APP

Score your idea and get gamification (streaks, points, badges) wired in from day one