Follow along as we build out a gamified study platform using Trophy with achievements, a daily streak and engaging automated email campaigns.
In this tutorial we’ll build an example study platform using Trophy for gamification. If you want to just skip to the end then feel free to check out the template repository or the live demo.
Want to skip the setup? Head straight to the fun
part.
First we need to create a new NextJS project:
Copy
npx create-next-app@latest
Feel free to configure this new project however you like but for the purposes of this tutorial we’ll pretty much stick with the defaults:
Copy
What is your project named? my-appWould you like to use TypeScript? YesWould you like to use ESLint? YesWould you like to use Tailwind CSS? YesWould you like your code inside a `src/` directory? YesWould you like to use App Router? (recommended) YesWould you like to use Turbopack for `next dev`? YesWould you like to customize the import alias (`@/*` by default)? No
Next, we’ll initialize a new install of everyones favourite UI library, shadcn/ui:
It looks like you are using React 19.Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).? How would you like to proceed? › - Use arrow-keys. Return to submit.❯ Use --force Use --legacy-peer-deps
For the purposes of this tutorial I chose --force but you should choose whichever setting you feel suits your requirements.
For the purposes of this tutorial, we’re going to be using some simple types with an in-memory data store. In a production application you’d probably want to consider storing this information in a database.
Here we’ll have a very simple type that stores information about each flashcard where we’ll use the front property to store questions that the student wants to learn the answer to, and the back property to store the answers to each question:
Then to get us started we’ll store a few flashcards in memory centered around learning capital cities:
src/data.ts
Copy
import { IFlashcard } from "./types/flashcard";export const flashcards: IFlashcard[] = [ { id: "1", front: "What is the capital of France?", back: "Paris", }, { id: "2", front: "What is the capital of Germany?", back: "Berlin", }, { id: "3", front: "What is the capital of Italy?", back: "Rome", }, { id: "4", front: "What is the capital of Spain?", back: "Madrid", }, { id: "5", front: "What is the capital of Portugal?", back: "Lisbon", }, { id: "6", front: "What is the capital of Greece?", back: "Athens", }, { id: "7", front: "What is the capital of Turkey?", back: "Ankara", }, { id: "8", front: "What is the capital of Poland?", back: "Warsaw", }, { id: "9", front: "What is the capital of Romania?", back: "Bucharest", }, { id: "10", front: "What is the capital of Bulgaria?", back: "Sofia", }, { id: "11", front: "What is the capital of Hungary?", back: "Budapest", }, { id: "12", front: "What is the capital of Czechia?", back: "Prague", }, { id: "13", front: "What is the capital of Slovakia?", back: "Bratislava", }, { id: "14", front: "What is the capital of Croatia?", back: "Zagreb", }, { id: "15", front: "What is the capital of Serbia?", back: "Belgrade", }, { id: "16", front: "What is the capital of Montenegro?", back: "Podgorica", }, { id: "17", front: "What is the capital of North Macedonia?", back: "Skopje", }, { id: "18", front: "What is the capital of Kosovo?", back: "Pristina", }, { id: "19", front: "What is the capital of Albania?", back: "Tirana", }, { id: "20", front: "What is the capital of Bosnia and Herzegovina?", back: "Sarajevo", },];
With some basic data set up, we need to add a way for users to flick through their flashcards.
For this we’ll use the carousel and card components from shadcn/ui so we need to add these to our project:
Copy
npx shadcn@latest add carousel card
Then, we’ll create a new <Flashcards /> component that combines these into a working solution, specifying that we can pass along any list of IFlashcard objects as props
Now this is great, but it’s not much use as a study app right now as there’s no way to see if you got the answer right! We need to add a way to flip flashcards over and check our answer…
To make this simpler, we’ll first create a <Flashcard /> component that will be responsible for all the logic for each flashcard:
Now we’re ready to add interactivity to each flashcard. Here’s what we’ll do:
First, we’ll add a side state variable that will hold the current side of the flashcard that’s showing.
Next, we’ll add an onClick() callback to the <Card /> component that will update the side state to back when clicked if the front of the card is currently showing.
Finally, we’ll conditional render the text in the <Card /> based on the value of the side state variable.
Then, we’ll use Motion to add a neat flip animation to the card when we click on it. For this we first need to install the package into our project:
Copy
npm install motion
If you think about it, when you flip a flashcard, you tend to do it in the Y-axis. So here we’ll use a <motion.div /> with a light spring animation in the y-axis to create the effect:
You’ll notice we also added a couple of styles here. These do a couple of things:
Ensure that when a <Card /> is flipping, the ‘backface’ isn’t visible during the animation with backface-visibility: hidden;
As the <Card /> component is a child of the <motion.div />, usually it would appear flat when it’s parent rotates in 3D. Adding transform-style: preserve-3d; to the <Card /> ensures it keeps it’s 3D effect when it’s parent animates.
Sweet! Our project is now starting to feel like a real study tool! However the keen eyed (or maybe not so keen…) will notice there’s one major bug here. When we flip a card over, the answer on the back appears in reverse 😢…
I you think about it, when you write a flashcard, you actually write the answer on the back in the opposite direction to the question on the front.
And as we’re using motion to literally flip over our card in the Y-axis, we need to make sure we write our answers backwards as well.
First, we’ll add a little CSS snippet to handle writing text backwards:
The next step is to add some UI to show the user how many flashcards they’ve looked at, and how many in the set they have left. We’ll use a simple progress bar to achieve this.
Before we can start tracking this level of information, we need to set up tracking for a new state variable that holds the index of the flashcard the user is currently looking at. We’ll use the carousel api to hook into the functionality here and keep our state variable up to date:
src/app/flashcards.tsx
Copy
"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";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); // Update the flash index when the carousel is scrolled api.on("select", () => { setFlashIndex(api.selectedScrollSnap() + 1); }); }, [api]); return ( <Carousel className="w-full" setApi={setApi}> <CarouselContent> {flashcards.map((flashcard) => ( <Flashcard key={flashcard.id} flashcard={flashcard} /> ))} </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> );}
Then we need to add the progress component from shadcn/ui to our project:
Copy
npx shadcn@latest add progress
Finally we can add a progress bar above the carousel:
Copy
"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";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); // Update the flash index when the carousel is scrolled api.on("select", () => { setFlashIndex(api.selectedScrollSnap() + 1); }); }, [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> );}
Sweet! Now things are really starting to come together.
First off, we need a new Trophy account. Then we can start to piece together the various parts of our gamification experience.
In Trophy, Metrics represent different interactions users can make and can drive features like Achievements, Streaks and Emails.
In Trophy we track user interactions by sending Events from our code to Trophy against a specific metric.
When events are recorded for a specific user, any achievements linked to the specified metric will be unlocked if the requirements are met, daily streaks will be automatically calculated and kept up to date, and any configured emails will be scheduled.
This is what makes building gamified experiences with Trophy so easy, is does all the work for you behind the scenes.
Recording an event against a metric for a specific user makes use of Trophy’s metric event API, which in practice looks like this:
That’s how in just a few lines of code we can power a whole suite of gamification features. However, before we can start sending events, we need to set up our new Trophy account.
Here’s how we’ll setup our Trophy account for our study app:
First, we’ll set up a Flashcards Viewed metric
Next, we’ll setup achievements linked to this metric
Then, we’ll configure a daily streak linked to this metric
Finally, we’ll configure automated email sequences for this metric
Let’s get into it…
Head into the Trophy metrics page and create a new metric, making sure to specify flashcards-viewed as the metric key. This is what we’ll use to reference the metric in our code when sending events.
Next, create one achievement for each of the following milestones:
10 flashcards (Elementary)
50 flashcards (Novice)
100 flashcards (Scholar)
250 flashcards (Expert)
Free free to download and use this zip of badges (ready made for this example
app):
flashcard_badges.zip
Next, head into the streaks page and set up a daily streak.
Lastly, head into the emails page and turn on the two types of emails we want.
Achievement unlocked emails
Recap emails (weekly)
Trophy also gives us a preview of the emails which is nice. Plus, feel free to configure a nice logo and brand colors in the branding page. These settings are automatically used in all emails sent by Trophy.
Before you can start sending emails, you’ll need to configure your sending
address with Trophy. But this can be done
later on if preferred.
First we need to grab our API key from the Trophy integration page and add this as a server-side only environment variable.
Make sure you don’t expose your API key in client-side code
.env.local
Copy
TROPHY_API_KEY='*******'
Next, we’ll install the Trophy SDK:
Copy
npm install @trophyso/node
Then whenever a user views a flashcard, we simply send an event to Trophy with details of the user that performed the action (which we’ll mock), and the number of flashcards they viewed (1 in this case).
In NextJS we’ll do this with a server action that makes the call to Trophy to send the event, and we’ll call this action when the user moves to the next flashcard in the carousel.
First, create the server action:
src/app/actions.ts
Copy
"use server";import { TrophyApiClient } from "@trophyso/node";import { EventResponse } from "@trophyso/node/api";// Set up Trophy SDK with API keyconst 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; }}
Then call it when the user views the next flashcard:
src/app/flashcards.tsx
Copy
"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> );}
We can validate this is working by checking the Trophy dashboard which should show our first user tracked in the Top Users table:
Next, we’ll add some UI to show off our gamification features in practice.
The response to the API call that we make to track events in Trophy helpfully gives us back any changes to the users progress as a result of that event:
The achievements array which is a list of newly unlocked achievements as a result of the event.
The currentStreak object which is the users most up to date streak data after the event has taken place.
This makes it really easy for us to react to changes in the users progress and do whatever we want. In this example, each time Trophy tells us a user has unlocked a new achievement, or extended their streak we’ll:
First, we’ll show a toast when users unlock new achievements. For this we need to add the sonner component from shadcn/ui to our project:
Copy
npx shadcn@latest add sonner
Then we’ll create the <Toast /> UI component we need to display toasts and a toast() utility function to trigger them:
src/lib/toast.tsx
Copy
"use client";import React from "react";import { toast as sonnerToast } from "sonner";import Image from "next/image";interface ToastProps { id: string | number; title: string; description: string; image?: { src: string; alt: string; };}/** * A fully custom toast that still maintains the animations and interactions. * @param toast - The toast to display. * @returns The toast component. */export function toast(toast: Omit<ToastProps, "id">) { return sonnerToast.custom((id) => ( <Toast id={id} title={toast.title} description={toast.description} image={toast.image} /> ));}/** * A fully custom toast that still maintains the animations and interactions. * @param props - The toast to display. * @returns The toast component. */export function Toast(props: ToastProps) { const { title, description, image } = props; return ( <div className="flex rounded-lg bg-white shadow-lg ring-1 ring-black/5 w-full md:max-w-[364px] items-center p-4"> {image && ( <div className="mr-4 flex-shrink-0"> <Image src={image.src} alt={image.alt} width={40} height={40} className="rounded-md" /> </div> )} <div className="flex flex-1 items-center"> <div className="w-full"> <p className="text-sm font-medium text-gray-900">{title}</p> <p className="mt-1 text-sm text-gray-500">{description}</p> </div> </div> </div> );}
Next, we need to update the page.tsx file with the main <Toaster /> component. This is the component from sonner which is responsible for displaying our toasts on the screen when we trigger them:
src/app/page.tsx
Copy
import { flashcards } from "@/data";import Flashcards from "./flashcards";import { Toaster } from "@/components/ui/sonner";export default function Home() { return ( <div className="flex flex-col items-center justify-center h-screen"> <Flashcards flashcards={flashcards} /> <Toaster /> </div> );}
Next, to make sure NextJS shows our badges, we need to configure Trophy’s image host as a trusted domain:
We’ll use the same methods to show similar toasts when a user extendeds their streak. As we’ve set up a daily streak in Trophy, this will trigger the first time a user views a flashcard each day.
If we wanted to experiment with a different streak cadence, like a weekly
streak, then none of this code changes - the great thing about Trophy is
everything can be configured from within the dashboard and doesn’t require any
code changes.
All we need to do is read the currentStreak.extended property from Trophy and handle showing another toast with a new if statement:
src/app/flashcards.tsx
Copy
"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";import { toast } from "@/lib/toast";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", async () => { // Update flashIndex when the carousel is scrolled setFlashIndex(api.selectedScrollSnap() + 1); // Track the flashcard viewed event const response = await viewFlashcard(); if (!response) { return; } // Show toast if the user has unlocked any new achievements if (response.achievements?.length) { response.achievements.forEach((metricAchievements) => { if (metricAchievements.completed?.length) { metricAchievements.completed.forEach((achievement) => { toast({ title: achievement.name as string, description: `Congratulations! You've viewed ${achievement.metricValue} flashcards!`, image: { src: achievement.badgeUrl as string, alt: achievement.name as string, }, }); }); } }); } // Show toast if user has extended their streak if (response.currentStreak?.extended) { toast({ title: "You're on a roll!", description: `Keep going to keep your ${response.currentStreak.length} day streak!`, }); } }); }, [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> );}
Now the first time a user views a flashcard each day, they’ll see one of our streak extended toasts:
Gamification is first and foremost about increasing user retention. The best way to do this is to make the features we build as engaging as possible. A great way to do this that’s often overlooked is with sound effects.
Here we’ll add two distinct sound effects for each case where we show a toast to further engage the user. This helps distinguish the different toasts and helps users build up an expectation of what each sound means.
The sounds used in this example are in the repository in the public/sounds
directory. Feel free to use them or pick others you like!
To load the sound files into our application, we’ll create a ref for each one, then we simply call the play() method on each when we want to actually play the sound:
src/app/flashcards.tsx
Copy
"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, useRef } from "react";import { Progress } from "@/components/ui/progress";import { viewFlashcard } from "./actions";import { toast } from "@/lib/toast";interface Props { flashcards: IFlashcard[];}export default function Flashcards({ flashcards }: Props) { const [flashIndex, setFlashIndex] = useState(0); const [api, setApi] = useState<CarouselApi>(); const achievementSound = useRef<HTMLAudioElement | null>(null); const streakSound = useRef<HTMLAudioElement | null>(null); useEffect(() => { achievementSound.current = new Audio("/sounds/achievement_unlocked.mp3"); streakSound.current = new Audio("/sounds/streak_extended.mp3"); }, []); useEffect(() => { if (!api) { return; } // Initialize the flash index setFlashIndex(api.selectedScrollSnap() + 1); api.on("select", async () => { // Update flashIndex when the carousel is scrolled setFlashIndex(api.selectedScrollSnap() + 1); // Track the flashcard viewed event const response = await viewFlashcard(); if (!response) { return; } if (response.achievements?.length) { // Play the achievement sound only once for all new achievements if (achievementSound.current) { achievementSound.current.currentTime = 0; achievementSound.current.play(); } // Show toasts if the user has unlocked any new achievements response.achievements.forEach((metricAchievements) => { if (metricAchievements.completed?.length) { metricAchievements.completed.forEach((achievement) => { toast({ title: achievement.name as string, description: `Congratulations! You've viewed ${achievement.metricValue} flashcards!`, image: { src: achievement.badgeUrl as string, alt: achievement.name as string, }, }); }); } }); } if (response.currentStreak?.extended) { // Play the streak sound if (streakSound.current) { streakSound.current.currentTime = 0; streakSound.current.play(); } // Show toast if the user has extended their streak toast({ title: "You're on a roll!", description: `Keep going to keep your ${response.currentStreak.length} day streak!`, }); } }); }, [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> );}
The last peice of UI we’ll add will be a dialog to display the user’s study progress, including their streak and any achievements they’ve unlocked so far.
First, we’ll add a couple of new server actions to fetch the users streak and achievements from Trophy. As we’re using a daily sterak here, we’ll fetch the last 14 days of streak data from Trophy to give us enough to work with in our UI:
src/app/actions.ts
Copy
"use server";import { TrophyApiClient } from "@trophyso/node";import { EventResponse, MultiStageAchievementResponse, StreakResponse,} from "@trophyso/node/api";const USER_ID = "39";const FLASHCARDS_VIEWED_METRIC_KEY = "flashcards-viewed";// Set up Trophy SDK with API keyconst 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_METRIC_KEY, { user: { // Mock email email: "user@example.com", // Mock timezone tz: "Europe/London", // Mock user ID id: USER_ID, }, // Event represents a single user viewing 1 flashcard value: 1, }); } catch (error) { console.error(error); return null; }}/** * 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; }}
Then we’ll call these actions on the server when the page is requested:
Then we’ll create a new <StudyJourney /> component that takes in the achievements and streak data as props and renders it nicely in a dialog. Before adding this we need to add the shadcn/ui dialog component to our project, and while we’re at it, we’ll add the seperator component too:
Copy
npx shadcn@latest add dialog separator
We’ll also need dayjs to help us with displaying dates nicely as well:
Copy
npm i dayjs
Now we have everything we need to build our study journey component:
Congrats! If you’re reading this you made it to the end of the tutorial and built yourself a fully-funtioning study platform. Of course there’s loads more we could do to this, so here’s a few ideas:
Persist flashcards to a database
Create multiple flashcard sets for other topics
Add authentication
Allow users to create their own flashcard sets
If you had fun or think you learned something along the way then give the repo
a star on GitHub!