GAMIFICATION GUIDES

Using Trophy To Build A Gamified Study App

Author
Charlie Hopkins-BrinicombeCharlie Hopkins-Brinicombe

Gamification can be a really powerful feature of any educational platform. When done right, it’s proven by platforms like Duolingo and Khan Academy to consistently engage students and boost retention.

The trouble is that a lot of the features that these platforms have made famous like achievements, streaks and progress report emails can take weeks to build. This is why we built Trophy, to help developers build these gamification features much faster.

To demonstrate this I built an example web application that hypothetical students could use to practice flashcards. Feel free to use this example app to start a new ed-tech project, or just as inspiration.

This post has all the important snippets but for a full step-by-step walkthrough of how I built this app, check out the official tutorial, or peek at the source code to see the finished product. To see it working in practice take a look at the live demo.

0:00
/0:11

Tech Stack

I made use of some really nice tools to build this project including everyone's (current…) favourite ui library shadcn/ui. Also motion.dev is a really nice package for animations.

Gamification Strategy

The main reason I built this app was for the gamification and Trophy’s APIs and SDKs did all the heavy lifting, so the example app didn’t need any complex gamification logic in its source code. Here’s a breakdown of the features I built and what Trophy took care of behind the scenes.

Multi-Stage Achievements

The example app features multi-stage achievements with the following milestones:

  • 10 flashcards viewed
  • 50 flashcards viewed
  • 100 flashcards viewed
  • 250 flashcards viewed

Each time a user reaches unlocks an achievement the following occurs:

  • A new badge is displayed in their account
  • An in-app notification is shown
  • A sound effect is played

All the progress tracking is done by Trophy across the entire user base. It keeps track of which achievements have been unlocked by who, and when, and gave me back time to spend on building out a great user experience. It also helpfully hosts all the achievement badges on a global CDN so I can just stick the URLs in my src tags.

Daily Streaks

The example project also uses daily streaks where users must look at least one flashcard a day to keep their streak. For each consecutive day the user looks at least one flashcard, their streak increases by one. But if they miss a day, they lose their streak and have to start over.

Each time a user successfully extends their streak the following happens:

  • An in-app notification is shown
  • A sound effect is played

Again, Trophy handles all the streak tracking and calculations behind the scenes. The most important thing here is that Trophy tracks and normalizes streaks across the whole user base regardless of their time zone and the the APIs you use to fetch streak data work according to the users local time zone. This saved so much nasty timezone logic in the UI and also on the server.

Automated Email Sequences

The example app also has built in email sequences to automatically send notifications to users to based on the following use cases:

  • Congratulating users when they unlock new achievements
  • Summarizing progress on a weekly basis

Trophy handles all the tricky parts around sending emails from a custom domain I set up and using the proper inbox-friendly markup (<table> not <div>) to stay out of the spam folder. Plus it handles sending emails at the perfect time in each recipient's local time zone so they’re more likely to open them.

Building The App

The full tutorial has all the snippets and goes through all the bugs and edge cases but here I’ll just share the most important bits of the app that power the gamification.

Trophy has SDKs in most major programming languages but here I was using NextJS so I installed the Node SDK with npm:

npm i @trophyso/node

To track each user's progress and power the gamification features I set up a NextJS server action to fire each time a user views a flashcard. That way I could tell Trophy that the interaction happened and it could keep a running count of how many flashcard each user had viewed:

"use server";

import { TrophyApiClient } from "@trophyso/node";
import { EventResponse } from "@trophyso/node/api";

// Set up Trophy SDK with API key
const trophy = new TrophyApiClient({
  apiKey: process.env.TROPHY_API_KEY as string,
});

/**
 * Track a flashcard viewed event in Trophy
 * @returns The event response from Trophy
 */
export async function viewFlashcard(): Promise<EventResponse | null> {
  try {
    return await trophy.metrics.event("flashcards-viewed", {
      user: {
        // Mock email
        email: "user@example.com",

        // Mock timezone
        tz: "Europe/London",

        // Mock user ID
        id: "18",
      },

      // Event represents a single user viewing 1 flashcard
      value: 1,
    });
  } catch (error) {
    console.error(error);
    return null;
  }
}

The response to this call to the Trophy SDK would return back any changes to the users achievements or streaks as a result of them viewing that new flashcard:

{
  "eventId": "0040fe51-6bce-4b44-b0ad-bddc4e123534",
  "metricId": "d01dcbcb-d51e-4c12-b054-dc811dcdc623",
  "total": 10,
  "achievements": [
    {
      "metricId": "5100fe51-6bce-6j44-b0hs-bddc4e123682",
      "completed": [
        {
          "id": "5100fe51-6bce-6j44-b0hs-bddc4e123682",
          "name": "Elementary",
          "metricId": "5100fe51-6bce-6j44-b0hs-bddc4e123682",
          "metricValue": 10,
          "metricName": "flashcards viewed",
          "achievedAt": "2020-01-01T00:00:00Z"
        }
      ]
    }
  ],
  "currentStreak": {
    "frequency": "daily",
    "length": 1,
    "expires": "2025-04-12",
    "extended": true,
    "periodEnd": "2025-04-05",
    "periodStart": "2025-03-31",
    "started": "2025-04-02"
  }
}

This made it super easy to read the response and fire off some pop-ups and play sound effects in a simple useEffect:

"use client";

import {
  Carousel,
  CarouselContent,
  CarouselPrevious,
  CarouselNext,
  type CarouselApi,
} from "@/components/ui/carousel";
import { IFlashcard } from "@/types/flashcard";
import Flashcard from "./flashcard";
import { useEffect, useState } from "react";
import { Progress } from "@/components/ui/progress";
import { viewFlashcard } from "./actions";

interface Props {
  flashcards: IFlashcard[];
}

export default function Flashcards({ flashcards }: Props) {
  const [flashIndex, setFlashIndex] = useState(0);
  const [api, setApi] = useState<CarouselApi>();

  useEffect(() => {
    if (!api) {
      return;
    }

    // Initialize the flash index
    setFlashIndex(api.selectedScrollSnap() + 1);

    api.on("select", () => {
      // Update the flash index when the carousel is scrolled
      setFlashIndex(api.selectedScrollSnap() + 1);

      // Track the flashcard viewed event
      viewFlashcard();
    });
  }, [api]);

  return (
    <div className="flex flex-col items-center justify-center gap-4 max-w-md">
      <Progress value={(flashIndex / flashcards.length) * 100} />
      <Carousel className="w-full" setApi={setApi}>
        <CarouselContent>
          {flashcards.map((flashcard) => (
            <Flashcard key={flashcard.id} flashcard={flashcard} />
          ))}
        </CarouselContent>
        <CarouselPrevious />
        <CarouselNext />
      </Carousel>
    </div>
  );
}

I then used a couple more server actions to fetch data from Trophy about the achievements the user had unlocked so far, and about their streak for the last 14 days:

/**
 * Get the achievements for a user
 * @returns The achievements for the user
 */
export async function getAchievements(): Promise<
  MultiStageAchievementResponse[] | null
> {
  try {
    return await trophy.users.allachievements(USER_ID);
  } catch (error) {
    console.error(error);
    return null;
  }
}

/**
 * Get the streak for a user
 * @returns The streak for the user
 */
export async function getStreak(): Promise<StreakResponse | null> {
  try {
    return await trophy.users.streak(USER_ID, {
      historyPeriods: 14,
    });
  } catch (error) {
    console.error(error);
    return null;
  }
}

And finally I built some fun UI to display all this data in a dialog which served as the students ‘study center’:

import { Separator } from "@/components/ui/separator";
import {
  MultiStageAchievementResponse,
  StreakResponse,
} from "@trophyso/node/api";
import { Flame, GraduationCap } from "lucide-react";
import Image from "next/image";
import dayjs from "dayjs";
import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
} from "@/components/ui/dialog";

interface Props {
  achievements: MultiStageAchievementResponse[] | null;
  streak: StreakResponse | null;
}

export default function StudyJourney({ achievements, streak }: Props) {
  const sundayOffset = 7 - ((new Date().getDay() + 6) % 7);

  const adjustedStreakHistory =
    streak?.streakHistory?.slice(
      sundayOffset - 1,
      streak.streakHistory.length
    ) || Array(14).fill(null);

  return (
    <div className="absolute top-10 right-10 z-50 cursor-pointer">
      <Dialog>
        <DialogTrigger>
          <div className="h-12 w-12 cursor-pointer duration-100 border-1 border-gray-300 shadow-sm transition-all rounded-full relative hover:bg-gray-100">
            <GraduationCap className="h-6 w-6 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-gray-800" />
          </div>
        </DialogTrigger>
        <DialogContent className="flex flex-col gap-3 min-w-[500px]">
          {/* Heading */}
          <DialogHeader>
            <DialogTitle>Your study journey</DialogTitle>
            <DialogDescription>
              Keep studying to extend your streak and earn new badges
            </DialogDescription>
          </DialogHeader>

          {/* Streak */}
          <div className="flex flex-col gap-2 items-center justify-between pt-2">
            <div className="flex flex-col items-center gap-4">
              <div className="relative h-24 w-24">
                <svg className="h-full w-full" viewBox="0 0 100 100">
                  <circle
                    className="stroke-primary/20"
                    strokeWidth="10"
                    fill="transparent"
                    r="45"
                    cx="50"
                    cy="50"
                  />
                  <circle
                    className="stroke-primary transition-all"
                    strokeWidth="10"
                    strokeLinecap="round"
                    fill="transparent"
                    r="45"
                    cx="50"
                    cy="50"
                    strokeDasharray={`${(streak?.length || 0) * 10} 1000`}
                    transform="rotate(-90 50 50)"
                  />
                </svg>
                <div className="absolute inset-0 flex items-center justify-center">
                  <span className="text-2xl font-bold text-primary">
                    {streak?.length || 0}
                  </span>
                </div>
              </div>
              <div className="flex flex-col text-center">
                <h3 className="text-lg font-semibold">
                  {streak && streak.length > 0
                    ? `Your study streak`
                    : `No study streak`}
                </h3>
                <p className="text-sm text-gray-500">
                  {streak && streak.length > 0
                    ? `${streak.length} day${
                        streak.length > 1 ? "s" : ""
                      } in a row`
                    : `Start a streak`}
                </p>
              </div>
            </div>
            <div className="flex flex-col">
              <div className="grid grid-cols-7 gap-1">
                {["M", "T", "W", "T", "F", "S", "S"].map((day, i) => (
                  <div
                    key={i}
                    className="h-10 flex items-center justify-center"
                  >
                    <span className="text-sm text-gray-500">{day}</span>
                  </div>
                ))}
              </div>
              <div className="grid grid-cols-7 gap-1">
                {adjustedStreakHistory.map((day, i) => {
                  if (day === null) {
                    return (
                      <div
                        key={i}
                        className="h-10 w-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center"
                      >
                        <Flame className="h-6 w-6 text-gray-200" />
                      </div>
                    );
                  }

                  return (
                    <div
                      key={i}
                      className={`h-10 w-10 rounded-lg ${
                        day.length > 0 ? "bg-primary" : "bg-primary/10"
                      } flex items-center justify-center`}
                    >
                      <Flame
                        className={`h-6 w-6 ${
                          day.length > 0 ? "text-white" : "text-primary/30"
                        }`}
                      />
                    </div>
                  );
                })}
              </div>
            </div>
          </div>

          <Separator />

          {/* Achievements */}
          {achievements && achievements.length > 0 ? (
            <div className="flex flex-col gap-3">
              <div>
                <p className="text-lg font-semibold">Your badges</p>
              </div>
              <div className="grid grid-cols-3 gap-2 w-full">
                {achievements?.map((achievement) => (
                  <div
                    key={achievement.id}
                    className="p-2 rounded-md border border-gray-200 flex flex-col gap-1 items-center shadow-sm"
                  >
                    <Image
                      src={achievement.badgeUrl as string}
                      alt={achievement.name as string}
                      width={100}
                      height={100}
                      className="rounded-full border-gray-300"
                    />
                    <p className="font-semibold">{achievement.name}</p>
                    <p className="text-gray-500 text-sm">
                      {dayjs(achievement.achievedAt).format("MMM D, YYYY")}
                    </p>
                  </div>
                ))}
              </div>
            </div>
          ) : (
            <div className="flex flex-col gap-1 items-center justify-center min-h-[100px] p-3 w-full mt-3">
              <p className="font-semibold text-lg">No badges yet</p>
              <p className="text-sm text-gray-500 text-center max-w-[200px]">
                Keep studying to unlock more badges!
              </p>
            </div>
          )}
        </DialogContent>
      </Dialog>
    </div>
  );
}

Follow The Tutorial

Building this app was a lot of fun. For a step-by-step walkthrough check out the full tutorial. Or peek at the source code or live demo to see the finished product.

If instead you just want to get started building your own gamification experience, create a free account and follow the Trophy quick start guide.

Trophy gamification platform