Build a Swipe-to-Judge Trivia App
Build a gesture-driven fact-checking game with Expo and Firebase.
Introduction
30 Second Summary
You see wild claims everywhere. Social media, group chats, trivia nights. Your brain is constantly judging whether something sounds true or completely made up.
In this project, you will build a mobile trivia game called "Fact or Cap" where you swipe through cards showing bold claims to judge them as true or false. Your app reads from a cloud database and uses native gesture physics for satisfying card-swiping interactions.
What You'll Build
A gesture-driven React Native mobile app built with Expo, backed by a Firebase Firestore database, featuring swipeable fact cards and a monochromatic navy blue design.
By the end of this project, you'll have:
- A multi-screen Expo app with file-based routing for category selection, the swipe game, and a results screen.
- A Firestore database seeded with categorized facts, explanations, and image URLs that your app reads in real time.
- A custom swipeable card component with Pan gesture physics including drag, tilt, snap-back, and dismiss animations.
- Secret Mission: Write high scores to Firestore and display a per-category leaderboard screen.
Are there any prerequisites?
You need a Google account (for Firebase), an Android phone with the Expo Go app installed, and Node.js on your computer. No prior React Native experience is required.
Set Up Your Expo Project
To build a mobile app that reads data from a cloud database and responds to swipe gestures, you need a development framework that handles the complexity of native device features for you.
That framework is Expo. In this step, you will scaffold a new Expo project, install the libraries for gestures, animations, and database access, then verify everything runs on your phone.
In this step, get ready to:
- Create a new Expo project with SDK 54 and install dependencies.
- Verify the app runs on your Android phone via Expo Go.
- Clean up the default template and set up the file structure for your app.
Create the Expo project and install dependencies
Expo provides a scaffolding tool called create-expo-app that generates a ready-to-run project with React Native, TypeScript, and file-based routing already configured.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Expo project by running this command:
npx create-expo-app@latest fact-or-cap
What happens when you run this?
The tool will prompt you to select an Expo SDK version. Select SDK 54 when prompted. SDK 54 is compatible with Expo Go on physical devices, which is how you will test your app throughout this project.
- Once the project is created, navigate into it by running:
cd fact-or-cap
- Install the gesture and animation libraries by running:
npx expo install react-native-gesture-handler react-native-reanimated
What are these libraries?
react-native-gesture-handler provides native-quality touch gestures like swipes and drags. react-native-reanimated powers smooth animations that run on the device's UI thread for 60fps performance.
Together, these two libraries let you build the card-swiping mechanic that is the core of this app.
- Install the Firebase SDK by running:
npx expo install firebase
Why the Firebase JS SDK?
The Firebase JS SDK works directly with Expo Go. It does not require native code or development builds. React Native Firebase would require custom native configuration that is not compatible with Expo Go.
Install command failing?
Make sure you ran cd fact-or-cap first. The install commands must run from inside the project directory.
If you see network errors, check your internet connection and try again. Help me fix my install error
Verify the project runs on your phone
Before making any changes, confirm the default app loads on your physical device. This proves your development environment is working end-to-end.
- Start the development server by running:
npx expo start
- Open the Expo Go app on your Android phone.
- Scan the QR code displayed in your terminal.
You should see the default Expo app load on your phone with a tab bar at the bottom.
What is Expo Go?
Expo Go is a pre-built app on your phone that can run any Expo project during development. You scan the QR code from your terminal, and Expo Go downloads and runs your JavaScript bundle. No app store deployment needed.
App not loading on your phone?
Make sure your phone and computer are on the same Wi-Fi network. If they are and it still does not connect, try running npx expo start --tunnel instead. Tunnel mode routes traffic through the cloud, which works on restrictive networks but is slower.
Help me connect Expo Go to my dev server
- Once you confirm the app is running, press Ctrl+C in your terminal to stop the dev server for now.
Clean up the default template and set up the file structure
The default template includes example screens you do not need. You will replace them with the three screens for your app: a home screen for category selection, a game screen for swiping cards, and a results screen for the final score.
- Open your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or Cursor) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the fact-or-cap folder, then click Open.
You should see the project files in the Explorer sidebar on the left.
- In the Explorer sidebar, expand the src/app folder.
- Right-click on explore.tsx and select Delete.
- Confirm the deletion when prompted.
Now create the route files and directories your app needs. Switch back to your terminal and run:
- Create the new route files and directories by running these commands:
# Create the game and results route files
touch src/app/game.tsx
touch src/app/results.tsx
# Create the lib directory and Firebase config file
mkdir -p src/lib
touch src/lib/firebase.ts
# Create the data directory for type definitions
mkdir -p src/data
What is this file structure for?
- src/app/index.tsx is the home screen where players pick a category.
- src/app/game.tsx is the swipe game screen.
- src/app/results.tsx shows the score at the end.
- src/lib/firebase.ts will hold the Firebase connection config.
- src/components/ already exists from the template. You will add the swipeable card component here later.
- src/data/ will store TypeScript type definitions for your facts.
- Now replace the default content in src/app/index.tsx with a minimal placeholder. Open src/app/index.tsx in your editor and replace all its content with:
import { View, Text } from 'react-native';
export default function HomeScreen() {
return (
<View>
<Text>Home</Text>
</View>
);
}
- Open src/app/game.tsx and add this placeholder:
import { View, Text } from 'react-native';
export default function GameScreen() {
return (
<View>
<Text>Game</Text>
</View>
);
}
- Open src/app/results.tsx and add this placeholder:
import { View, Text } from 'react-native';
export default function ResultsScreen() {
return (
<View>
<Text>Results</Text>
</View>
);
}
- Save all three files.
- Confirm your file structure by running:
ls src/app src/lib src/data
You should see game.tsx, index.tsx, results.tsx, and _layout.tsx listed under src/app. You should see firebase.ts under src/lib. The src/data directory will show as empty.
Missing files or directories?
Make sure you ran the touch and mkdir commands from the fact-or-cap project root. If your terminal is in a different directory, run cd ~/Desktop/fact-or-cap first and try again.
Help me fix my project file structure
Your project skeleton is ready. Next up, you will connect this app to a Firebase database and load it with trivia facts to play with.
Set Up Firebase and Seed Firestore
Your Expo project is scaffolded and running on your phone. Now it's time to give your app a data source.
Every trivia card in Fact or Cap needs a claim, a category, an answer, and an image. Firestore is a cloud database from Firebase that stores all this data so your app can fetch it on demand from any device.
In this step, get ready to:
- Create a Firebase project and set up a Firestore database.
- Connect your app to Firebase using the SDK configuration.
- Seed Firestore with categorized fact data for the game.
Create a Firebase project and configure Firestore
Firebase is Google's app development platform. You need a Firebase project to access Firestore, which is where your fact data will live.
- Open your browser and navigate to console.firebase.google.com.
- Sign in with your Google account.
- Click the button to create a new project.
- Enter fact-or-cap as the project name.
- Toggle off the Google Analytics option (you don't need it for this project).
- Click Create project and wait for it to provision.
- Click Continue when the project is ready.
You should now see your Firebase project dashboard. Next, you will create the Firestore database inside this project.
- In the left navigation panel, expand Build and select Firestore Database.
- Click Create database.
- Select a database location close to you.
- For the Security Rules starting mode, select Test mode.
- Click Create.
Why test mode?
Test mode allows any client to read and write data without authentication. This is perfect for development, but you would lock it down with proper Security Rules before releasing a production app.
Is this going to cost me anything?
No. The Firebase Spark plan is completely free. It includes 1 GiB of storage and 50,000 reads per day, which is far more than you will use during development.
Register a web app and configure the Firebase SDK
Firebase needs to know which app is connecting to your project. You register a web app to get a configuration object containing your project's connection details.
- Navigate back to your Firebase project overview page by clicking the Firebase logo in the top-left corner.
- In the center of the project overview page, click the Web icon (it looks like </>) to launch the setup workflow.
- Enter fact-or-cap-app as the app nickname.
- Leave the Firebase Hosting checkbox unchecked.
- Click Register app.
Firebase now displays a code snippet containing your firebaseConfig object. You need the values inside this object.
- Copy the entire firebaseConfig object shown on screen. Keep this tab open.
- Switch back to VS Code and open src/lib/firebase.ts.
- Replace the entire contents of the file with the following code, pasting your own config values in place of the placeholders:
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
// Your Firebase project configuration
const firebaseConfig = {
apiKey: 'your-api-key-here',
authDomain: 'your-project-id.firebaseapp.com',
projectId: 'your-project-id',
storageBucket: 'your-project-id.appspot.com',
messagingSenderId: 'your-sender-id-here',
appId: 'your-app-id-here',
};
// Initialize the Firebase app with your config
const app = initializeApp(firebaseConfig);
// Create and export the Firestore database instance
export const db = getFirestore(app);
What does this code do?
- initializeApp() creates a connection to your specific Firebase project using the config values you pasted in.
- getFirestore() creates a Firestore database client tied to that Firebase app.
- Exporting db means any file in your project can import this single database instance and use it to read or write data.
- Replace the placeholder strings ('your-api-key-here', 'your-project-id', etc.) with the actual values from your Firebase Console.
- Save src/lib/firebase.ts.
Seeing import errors in your editor?
If VS Code underlines the firebase/app or firebase/firestore imports in red, make sure you installed the Firebase package in the previous step. You can verify by checking that firebase appears in your package.json dependencies.
Help me fix Firebase import errors in my project
✔️ Awesome, I've got everything!
Great. Double check you have saved src/lib/firebase.ts with your real Firebase config values.
ⓧ I'd like to double check the full code
Here is the complete src/lib/firebase.ts file. Make sure your version matches this structure (with your own config values in place of the placeholders):
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
const firebaseConfig = {
apiKey: 'your-api-key-here',
authDomain: 'your-project-id.firebaseapp.com',
projectId: 'your-project-id',
storageBucket: 'your-project-id.appspot.com',
messagingSenderId: 'your-sender-id-here',
appId: 'your-app-id-here',
};
const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);
Seed the Firestore database with fact data
Your app needs trivia data to display on the cards. You will manually add fact documents to Firestore across three categories: Science, History, and Nature.
- Switch back to the Firebase Console in your browser.
- In the left navigation, expand Build and select Firestore Database.
- Make sure you are on the Data tab.
- Click Start collection.
- Enter facts as the collection ID.
- Click Next.
Now you will add your first document. Each document represents one trivia fact with five fields.
- Click Auto-ID to generate a document ID.
- Add a field named claim with type string and value Octopuses have three hearts.
- Add a field named isTrue with type boolean and value true.
- Add a field named explanation with type string and value Two branchial hearts pump blood to the gills, and one systemic heart pumps it to the body.
- Add a field named category with type string and value Science.
- Add a field named imageUrl with type string and paste a publicly available image URL (for example, search "octopus" on Unsplash and copy the image address).
- Click Save.
Your first fact document is created. Now repeat this process to add more facts.
- Click Add document to add another document to the facts collection.
- Add at least 3-4 facts for each category (Science, History, Nature) so you have enough cards to play a round.
Example facts to get you started
Here are some example facts you can use. Remember to find a relevant image URL for each one.
Science:
- "Lightning never strikes the same place twice" isTrue: false Explanation: "Tall structures like the Empire State Building get struck dozens of times per year."
- "Sound travels faster in water than in air" isTrue: true Explanation: "Sound waves move about 4.3 times faster in water because water molecules are packed closer together."
History:
- "The Great Wall of China is visible from space" isTrue: false Explanation: "The wall is only about 6 meters wide, far too narrow to see from orbit with the naked eye."
- "Cleopatra lived closer in time to the Moon landing than to the construction of the Great Pyramid" isTrue: true Explanation: "The Great Pyramid was built around 2560 BC, while Cleopatra lived around 30 BC."
- "Vikings wore horned helmets in battle" isTrue: false Explanation: "No archaeological evidence supports horned helmets. The image comes from 19th-century romanticized artwork."
Nature:
- "Honey never spoils" isTrue: true Explanation: "Archaeologists have found 3000-year-old honey in Egyptian tombs that was still perfectly edible."
- "Goldfish have a three-second memory" isTrue: false Explanation: "Studies show goldfish can remember things for months and can be trained to respond to stimuli."
- "A group of flamingos is called a flamboyance" isTrue: true Explanation: "The collective noun for flamingos is indeed a flamboyance, fitting given their vibrant pink color."
What about the image URLs?
For each fact, search for a relevant image on Unsplash (unsplash.com). Open the image you like, right-click on it, and select "Copy image address" to get a publicly accessible URL. Paste this URL as the imageUrl value for each document.
- After adding all your facts, verify you have at least 9-12 documents total in the facts collection (3-4 per category).
- Click through a few documents in the Firestore data viewer to confirm each one has all five fields: claim, isTrue, explanation, category, and imageUrl.
Missing fields or wrong data types?
Make sure isTrue is set to type boolean (not string). If you accidentally typed "true" as a string, delete the document and recreate it with the correct boolean type. The game logic relies on this field being a real boolean value.
Also confirm that category values are consistent across documents. Use exact capitalization: Science, History, Nature. A mismatch like "science" vs "Science" will cause your app to show no facts for that category.
Help me fix my Firestore document fields
Your Firebase backend is ready. You have a Firestore database seeded with categorized trivia facts that your app can query. Next up, you will build the home screen that displays these categories and lets users start a game.
Build the Category Selection Home Screen
Your database is loaded with facts and ready to serve data to your app. Now it's time to build the first thing your users will see: the home screen.
The home screen needs to display category options that players tap to start a game. You will define a color palette, set up the root layout for gesture support, and build the category selection UI.
In this step, get ready to:
- Create a color constants file with the navy monochromatic palette.
- Set up the root layout with gesture handling and a Stack navigator.
- Build the home screen with tappable category cards that navigate to the game.
Create the navy-themed layout wrapper
Before building any screens, you need two foundational pieces. A color constants file keeps your palette consistent across every screen, and a root layout file tells Expo Router how to arrange your screens.
- Create a new file at src/constants/colors.ts and paste in the color palette:
export const Colors = {
darkNavy: '#0A1628',
navy: '#1B2B4D',
mediumBlue: '#2E4374',
lightBlue: '#4A6FA5',
accentBlue: '#7CA1D4',
white: '#FFFFFF',
correctGreen: '#4CAF50',
incorrectRed: '#F44336',
};
What does this code do?
This file exports a single Colors object with all the color hex values you will use throughout the app.
Using a constants file means you never have to remember a hex code. You reference Colors.darkNavy instead of typing #0A1628 in every file.
- Save src/constants/colors.ts.
File not saving?
Make sure the src/constants/ directory exists. If your editor shows an error, create the folder first, then create the file inside it.
help me create the constants directory
✔️ Awesome, I've got everything!
Great. Let's move on to the root layout.
ⓧ I'd like to double check the full code
export const Colors = {
darkNavy: '#0A1628',
navy: '#1B2B4D',
mediumBlue: '#2E4374',
lightBlue: '#4A6FA5',
accentBlue: '#7CA1D4',
white: '#FFFFFF',
correctGreen: '#4CAF50',
incorrectRed: '#F44336',
};
Now you need a root layout that wraps all screens with gesture support. GestureHandlerRootView is required at the top of your component tree for swipe gestures to work later.
- Replace the contents of src/app/_layout.tsx with the following layout code:
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Colors } from '@/constants/colors';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: Colors.darkNavy },
}}
/>
</GestureHandlerRootView>
);
}
What does this code do?
- GestureHandlerRootView wraps the entire app so that Pan gestures (the card swipes you will build) can be detected on any screen.
- Stack from expo-router arranges your route files (index.tsx, game.tsx, results.tsx) as a navigation stack.
- headerShown: false hides the default navigation header so you have full control over screen layout.
- contentStyle sets the dark navy background for all screens globally. No screen will flash white on transition.
- Save src/app/_layout.tsx.
- Check your phone. The app should now show a blank dark navy screen (the index.tsx file is still empty).
Seeing a white screen or error?
Make sure the import path @/constants/colors matches your file. The @/ alias resolves to the src/ directory in Expo projects.
If you see a bundler error, check that you saved both files (colors.ts and _layout.tsx) before refreshing.
help me fix a white screen or import error in my Expo layout
✔️ Awesome, I've got everything!
Nice. Your layout is ready for gestures and dark-themed screens.
ⓧ I'd like to double check the full code
import { Stack } from 'expo-router';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Colors } from '@/constants/colors';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: Colors.darkNavy },
}}
/>
</GestureHandlerRootView>
);
}
Build the home screen with category cards
The home screen is where players pick a trivia category to start a round. It displays the app title, a subtitle explaining the swipe mechanic, and tappable cards for each category.
- Replace the contents of src/app/index.tsx with this component code:
import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native';
import { router } from 'expo-router';
import { Colors } from '@/constants/colors';
// Define the three trivia categories
const categories = ['Science', 'History', 'Nature'];
export default function HomeScreen() {
// Navigate to the game screen with the selected category
const handleCategoryPress = (category: string) => {
router.push({ pathname: '/game', params: { category } });
};
return (
<View style={styles.container}>
<Text style={styles.title}>Fact or Cap</Text>
<Text style={styles.subtitle}>Swipe right for Fact, left for Cap</Text>
<FlatList
data={categories}
keyExtractor={(item) => item}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.categoryCard}
onPress={() => handleCategoryPress(item)}
>
<Text style={styles.categoryText}>{item}</Text>
</TouchableOpacity>
)}
/>
</View>
);
}
What does this code do?
- categories is a hardcoded array of the three categories that match what you seeded in Firestore.
- FlatList renders each category as a tappable card. It handles scrolling automatically if you add more categories later.
- handleCategoryPress uses router.push() from Expo Router to navigate to the game screen, passing the chosen category as a route parameter.
You will not see a styled result yet because the styles object referenced in the JSX does not exist yet. The next section adds the stylesheet.
Style the home screen with the monochromatic palette
Every React Native component references styles through a StyleSheet object. This stylesheet uses multiple shades of navy to create visual hierarchy without introducing extra colors.
- Add this stylesheet at the bottom of src/app/index.tsx, below the closing brace of the HomeScreen function:
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.darkNavy,
paddingTop: 80,
paddingHorizontal: 20,
},
title: {
fontSize: 36,
fontWeight: 'bold',
color: Colors.white,
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: Colors.accentBlue,
textAlign: 'center',
marginBottom: 40,
},
list: {
gap: 16,
},
categoryCard: {
backgroundColor: Colors.navy,
borderRadius: 16,
padding: 24,
alignItems: 'center',
borderWidth: 1,
borderColor: Colors.mediumBlue,
},
categoryText: {
fontSize: 20,
fontWeight: '600',
color: Colors.white,
},
});
How does the monochromatic palette create hierarchy?
The screen background uses the darkest shade (darkNavy). The category cards use a slightly lighter shade (navy) so they stand out from the background without clashing.
Borders use mediumBlue for subtle separation, and the subtitle uses accentBlue to draw attention without competing with the white title text. All the depth comes from varying one hue.
- Save src/app/index.tsx.
- Check your phone. You should see the "Fact or Cap" title in white, a blue subtitle, and three navy cards labeled Science, History, and Nature.
- Tap any category card. The app navigates to a blank screen (the game screen is still empty).
Cards not showing or seeing a red error?
If you see a "styles is not defined" error, make sure the const styles = StyleSheet.create(...) block is placed outside the HomeScreen function, below its closing brace.
If tapping a card does nothing, check that you imported router from expo-router (not expo-router/router).
help me debug my home screen categories not rendering or navigation not working
✔️ Awesome, I've got everything!
Great. Double check you saved index.tsx before moving on.
ⓧ I'd like to double check the full code
import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native';
import { router } from 'expo-router';
import { Colors } from '@/constants/colors';
const categories = ['Science', 'History', 'Nature'];
export default function HomeScreen() {
const handleCategoryPress = (category: string) => {
router.push({ pathname: '/game', params: { category } });
};
return (
<View style={styles.container}>
<Text style={styles.title}>Fact or Cap</Text>
<Text style={styles.subtitle}>Swipe right for Fact, left for Cap</Text>
<FlatList
data={categories}
keyExtractor={(item) => item}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<TouchableOpacity
style={styles.categoryCard}
onPress={() => handleCategoryPress(item)}
>
<Text style={styles.categoryText}>{item}</Text>
</TouchableOpacity>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.darkNavy,
paddingTop: 80,
paddingHorizontal: 20,
},
title: {
fontSize: 36,
fontWeight: 'bold',
color: Colors.white,
textAlign: 'center',
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: Colors.accentBlue,
textAlign: 'center',
marginBottom: 40,
},
list: {
gap: 16,
},
categoryCard: {
backgroundColor: Colors.navy,
borderRadius: 16,
padding: 24,
alignItems: 'center',
borderWidth: 1,
borderColor: Colors.mediumBlue,
},
categoryText: {
fontSize: 20,
fontWeight: '600',
color: Colors.white,
},
});
Your home screen is alive with tappable categories and a clean navy palette. Next up, you will build the swipeable fact card component that makes this app feel like a native game.
Build the Swipeable Fact Card Component
The core interaction in Fact or Cap is the swipe. When the player drags a card left or right, the card should tilt, show a directional indicator, and either snap back to center or fly off-screen.
To make this feel natural, you need gesture tracking and physics-based animations working together. In this step, you will build a reusable FactCard component that combines react-native-gesture-handler for touch input with react-native-reanimated for smooth 60fps animations.
In this step, get ready to:
- Create the FactCard component with image and claim text display.
- Add a Pan gesture with animated card physics (drag, tilt, snap-back, and dismiss).
- Add visual FACT/CAP swipe indicators that fade in as the user drags.
Create the FactCard component structure
Each fact card needs to display an image from Firestore and the claim text below it. You will build this as a standalone component so the game screen can render one card at a time.
- Create a new file at src/components/FactCard.tsx and add the following imports and setup code:
import { View, Text, Image, StyleSheet, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
runOnJS,
} from 'react-native-reanimated';
import { Colors } from '@/constants/colors';
// Get the device screen width for swipe calculations
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// Distance in pixels the user must drag to trigger a swipe
const SWIPE_THRESHOLD = 150;
// Define the props this component expects
interface FactCardProps {
claim: string;
imageUrl: string;
onSwipe: (swipedRight: boolean) => void;
}
What does this code do?
This sets up the foundation for the card. SCREEN_WIDTH tells the animation system how far to fling the card off-screen. SWIPE_THRESHOLD (150 pixels) is the minimum drag distance that counts as a deliberate swipe rather than an accidental touch.
The FactCardProps interface defines three inputs: the claim text to display, an image URL, and a callback function that fires when the user completes a swipe (passing true for right, false for left).
- Below the interface, add the component's styles at the bottom of the file:
const styles = StyleSheet.create({
card: {
backgroundColor: Colors.navy,
borderRadius: 20,
overflow: 'hidden',
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
image: {
width: '100%',
height: 220,
backgroundColor: Colors.mediumBlue,
},
content: {
padding: 24,
},
claim: {
fontSize: 20,
fontWeight: '700',
color: Colors.white,
textAlign: 'center',
lineHeight: 28,
},
overlay: {
position: 'absolute',
top: 20,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 3,
},
factOverlay: {
right: 20,
borderColor: Colors.correctGreen,
},
capOverlay: {
left: 20,
borderColor: Colors.incorrectRed,
},
overlayText: {
fontSize: 24,
fontWeight: 'bold',
color: Colors.white,
},
});
What do these styles do?
The card style creates a navy rounded rectangle with a drop shadow. The image fills the full width at 220px height with a mediumBlue fallback background while loading.
The overlay, factOverlay, and capOverlay styles position the FACT and CAP indicator labels at the top corners of the card. You will wire these up in the third substep.
- Save src/components/FactCard.tsx.
- You won't see a visual change yet. The component needs gesture logic and a return statement before it can render.
Add the Pan gesture with animated physics
Gesture callbacks in React Native Gesture Handler run on the UI thread by default when paired with React Native Reanimated. This means animations stay at 60fps even while JavaScript is busy.
- Between the interface definition and the styles constant, add the exported component function with gesture logic:
export default function FactCard({ claim, imageUrl, onSwipe }: FactCardProps) {
// Shared values live on the UI thread for smooth animation
const translateX = useSharedValue(0);
const isActive = useSharedValue(false);
// Define the Pan gesture with drag, dismiss, and snap-back behavior
const panGesture = Gesture.Pan()
.onBegin(() => {
isActive.value = true;
})
.onChange((event) => {
translateX.value = event.translationX;
})
.onFinalize((event) => {
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
// Card dragged past threshold - dismiss it
const swipedRight = translateX.value > 0;
translateX.value = withTiming(
swipedRight ? SCREEN_WIDTH : -SCREEN_WIDTH,
{ duration: 300 }
);
runOnJS(onSwipe)(swipedRight);
} else {
// Card not dragged far enough - spring back to center
translateX.value = withSpring(0);
}
isActive.value = false;
});
// Animated style: move, tilt, and scale the card based on drag
const cardStyle = useAnimatedStyle(() => {
const rotation = interpolate(
translateX.value,
[-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
[-15, 0, 15]
);
return {
transform: [
{ translateX: translateX.value },
{ rotate: `${rotation}deg` },
{ scale: withSpring(isActive.value ? 1.02 : 1) },
],
};
});
What does the gesture logic do?
The translateX shared value tracks how far the card has moved horizontally. The Pan gesture updates this value on every frame as the user drags.
When the user lifts their finger (onFinalize), the code checks if the drag exceeded 150 pixels. If yes, it animates the card off-screen with withTiming and calls onSwipe via runOnJS (needed because gesture callbacks run on the UI thread, but React state updates must happen on the JS thread). If not, withSpring(0) bounces the card back to center.
The cardStyle uses interpolate to add a tilt effect. Dragging halfway across the screen rotates the card up to 15 degrees, giving the swipe a natural physics feel.
Why runOnJS?
Gesture callbacks run on the native UI thread for performance. The onSwipe callback updates React state, which must happen on the JavaScript thread. runOnJS bridges this gap by scheduling the function call on the JS thread.
- Save src/components/FactCard.tsx.
- You won't see a change yet. The next substep adds the swipe indicators and the JSX return statement.
Add visual swipe indicators
Players need visual feedback as they drag. A "FACT" label should appear on the right when swiping right, and a "CAP" label on the left when swiping left.
- Still inside the FactCard function, directly below the cardStyle animated style, add the overlay animated styles:
// FACT overlay fades in as user drags right
const factOverlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[0, SWIPE_THRESHOLD],
[0, 1]
),
}));
// CAP overlay fades in as user drags left
const capOverlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[-SWIPE_THRESHOLD, 0],
[1, 0]
),
}));
What does interpolate do here?
The interpolate function maps one range of values to another. For factOverlayStyle, as translateX goes from 0 to 150, the opacity goes from 0 to 1. This means the FACT label smoothly fades in as the user drags right.
The capOverlayStyle works in reverse. As translateX goes from 0 to -150 (dragging left), the CAP label opacity goes from 0 to 1.
- Below the overlay styles (still inside the function), add the return statement that renders the card:
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, cardStyle]}>
<Image source={{ uri: imageUrl }} style={styles.image} />
<View style={styles.content}>
<Text style={styles.claim}>{claim}</Text>
</View>
<Animated.View style={[styles.overlay, styles.factOverlay, factOverlayStyle]}>
<Text style={styles.overlayText}>FACT</Text>
</Animated.View>
<Animated.View style={[styles.overlay, styles.capOverlay, capOverlayStyle]}>
<Text style={styles.overlayText}>CAP</Text>
</Animated.View>
</Animated.View>
</GestureDetector>
);
}
What does this JSX do?
The GestureDetector wraps the entire card and listens for Pan gestures. Inside, Animated.View receives both the static styles.card and the dynamic cardStyle (which handles movement, rotation, and scale).
The two overlay Animated.View elements are positioned absolutely on top of the card. Their opacity is controlled by the drag direction, so the correct label appears as feedback before the user commits to a swipe.
- Save src/components/FactCard.tsx.
- The component is complete but not yet imported into any screen. You will wire it into the game screen in the next step to see it in action.
Seeing a TypeScript error?
If your editor shows a red underline on the runOnJS import or the Animated default import, make sure you installed react-native-reanimated with npx expo install (not npm install). Expo install ensures you get the version compatible with your SDK.
help me fix TypeScript errors in my FactCard component
✔️ Awesome, I've got everything!
Great. Double check you have saved src/components/FactCard.tsx and that your editor shows no red error underlines.
ⓧ I'd like to double check the full code
import { View, Text, Image, StyleSheet, Dimensions } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
runOnJS,
} from 'react-native-reanimated';
import { Colors } from '@/constants/colors';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SWIPE_THRESHOLD = 150;
interface FactCardProps {
claim: string;
imageUrl: string;
onSwipe: (swipedRight: boolean) => void;
}
export default function FactCard({ claim, imageUrl, onSwipe }: FactCardProps) {
const translateX = useSharedValue(0);
const isActive = useSharedValue(false);
const panGesture = Gesture.Pan()
.onBegin(() => {
isActive.value = true;
})
.onChange((event) => {
translateX.value = event.translationX;
})
.onFinalize((event) => {
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const swipedRight = translateX.value > 0;
translateX.value = withTiming(
swipedRight ? SCREEN_WIDTH : -SCREEN_WIDTH,
{ duration: 300 }
);
runOnJS(onSwipe)(swipedRight);
} else {
translateX.value = withSpring(0);
}
isActive.value = false;
});
const cardStyle = useAnimatedStyle(() => {
const rotation = interpolate(
translateX.value,
[-SCREEN_WIDTH / 2, 0, SCREEN_WIDTH / 2],
[-15, 0, 15]
);
return {
transform: [
{ translateX: translateX.value },
{ rotate: `${rotation}deg` },
{ scale: withSpring(isActive.value ? 1.02 : 1) },
],
};
});
const factOverlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[0, SWIPE_THRESHOLD],
[0, 1]
),
}));
const capOverlayStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateX.value,
[-SWIPE_THRESHOLD, 0],
[1, 0]
),
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.card, cardStyle]}>
<Image source={{ uri: imageUrl }} style={styles.image} />
<View style={styles.content}>
<Text style={styles.claim}>{claim}</Text>
</View>
<Animated.View style={[styles.overlay, styles.factOverlay, factOverlayStyle]}>
<Text style={styles.overlayText}>FACT</Text>
</Animated.View>
<Animated.View style={[styles.overlay, styles.capOverlay, capOverlayStyle]}>
<Text style={styles.overlayText}>CAP</Text>
</Animated.View>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: Colors.navy,
borderRadius: 20,
overflow: 'hidden',
elevation: 5,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
image: {
width: '100%',
height: 220,
backgroundColor: Colors.mediumBlue,
},
content: {
padding: 24,
},
claim: {
fontSize: 20,
fontWeight: '700',
color: Colors.white,
textAlign: 'center',
lineHeight: 28,
},
overlay: {
position: 'absolute',
top: 20,
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 3,
},
factOverlay: {
right: 20,
borderColor: Colors.correctGreen,
},
capOverlay: {
left: 20,
borderColor: Colors.incorrectRed,
},
overlayText: {
fontSize: 24,
fontWeight: 'bold',
color: Colors.white,
},
});
Your swipeable card component is ready. In the next step, you will wire it into the game screen, feed it real data from Firestore, and build the full game loop with answer reveals and score tracking.
Build Game Logic, Answer Reveals, and Results
You have a swipeable card that responds to gestures and a home screen that navigates to the game route. Now it's time to wire everything together into a playable game loop.
In this step, you'll build the game screen that fetches facts from Firestore, feeds them to your FactCard, scores each swipe, and reveals whether the player was right or wrong. Then you'll build a results screen that shows the final score.
In this step, get ready to:
- Wire up the game screen to fetch and display Firestore facts.
- Add answer reveal overlays with score tracking.
- Build the results screen with final score and replay option.
Wire up the game screen with Firestore data
The game screen needs to read which category the player chose, then fetch only the facts for that category from Firestore. It also needs state variables to track progress through the deck.
- In src/app/game.tsx, replace the empty placeholder with the following imports, interface, and data-fetching logic:
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { useEffect, useState } from 'react';
import { collection, query, where, getDocs } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import { Colors } from '@/constants/colors';
import FactCard from '@/components/FactCard';
// Define the shape of a fact document from Firestore
interface Fact {
id: string;
claim: string;
isTrue: boolean;
explanation: string;
category: string;
imageUrl: string;
}
export default function GameScreen() {
// Read the category passed from the home screen
const { category } = useLocalSearchParams<{ category: string }>();
const [facts, setFacts] = useState<Fact[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [score, setScore] = useState(0);
const [showExplanation, setShowExplanation] = useState(false);
const [lastAnswer, setLastAnswer] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
// Fetch facts for the selected category when the screen loads
useEffect(() => {
const fetchFacts = async () => {
const q = query(
collection(db, 'facts'),
where('category', '==', category)
);
const snapshot = await getDocs(q);
const fetchedFacts: Fact[] = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Fact[];
setFacts(fetchedFacts);
setLoading(false);
};
fetchFacts();
}, [category]);
What does this code do?
useLocalSearchParams reads the category query parameter that the home screen passed via router.push(). This tells the game which facts to load.
The useEffect builds a Firestore query filtering the facts collection by category, fetches the matching documents, and stores them in state. The loading flag flips to false once data arrives.
The file is not complete yet. Your editor will show errors below the useEffect because the component has no return statement. The import lines at the top should not show errors. If they do, double-check that your file paths match.
Add the answer reveal overlay
When the player swipes a card, the game needs to compare their answer to the fact's isTrue value, update the score, and briefly show whether they were correct before advancing to the next card.
- Below the useEffect block you just added, paste the handleSwipe function:
const handleSwipe = (swipedRight: boolean) => {
const currentFact = facts[currentIndex];
// Compare swipe direction to whether the fact is true
const isCorrect = swipedRight === currentFact.isTrue;
if (isCorrect) {
setScore((prev) => prev + 1);
}
setLastAnswer(isCorrect);
setShowExplanation(true);
// Show the explanation for 2.5 seconds, then advance
setTimeout(() => {
setShowExplanation(false);
setLastAnswer(null);
if (currentIndex + 1 >= facts.length) {
// All cards done - navigate to results
router.replace({
pathname: '/results',
params: {
score: String(isCorrect ? score + 1 : score),
total: String(facts.length),
category: category,
},
});
} else {
setCurrentIndex((prev) => prev + 1);
}
}, 2500);
};
What does handleSwipe do?
The FactCard calls onSwipe(true) for a right swipe ("Fact") and onSwipe(false) for a left swipe ("Cap"). This function compares that boolean to currentFact.isTrue to determine correctness.
After updating the score, it flips showExplanation to true so the UI switches from the card to a reveal overlay. After 2500 milliseconds, it either navigates to the results screen (if all cards are done) or advances to the next card.
- Below handleSwipe, add the render logic that displays a loading spinner, an empty-state message, or the game UI:
if (loading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color={Colors.accentBlue} />
</View>
);
}
if (facts.length === 0) {
return (
<View style={styles.container}>
<Text style={styles.emptyText}>No facts found for {category}</Text>
</View>
);
}
const currentFact = facts[currentIndex];
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.categoryLabel}>{category}</Text>
<Text style={styles.progress}>
{currentIndex + 1} / {facts.length}
</Text>
</View>
{!showExplanation ? (
<FactCard
key={currentFact.id}
claim={currentFact.claim}
imageUrl={currentFact.imageUrl}
onSwipe={handleSwipe}
/>
) : (
<View style={styles.explanationCard}>
<Text
style={[
styles.resultText,
{ color: lastAnswer ? Colors.correctGreen : Colors.incorrectRed },
]}
>
{lastAnswer ? 'Correct!' : 'Wrong!'}
</Text>
<Text style={styles.claimText}>{currentFact.claim}</Text>
<Text style={styles.verdictText}>
{currentFact.isTrue ? 'This is a FACT' : 'This is CAP'}
</Text>
<Text style={styles.explanationText}>
{currentFact.explanation}
</Text>
</View>
)}
<View style={styles.footer}>
<Text style={styles.scoreText}>Score: {score}</Text>
</View>
</View>
);
}
What's happening in the render logic?
The component has three possible render states. While data loads, it shows a spinner. If Firestore returns no facts for the category, it shows an empty message.
During gameplay, the showExplanation boolean toggles between showing the FactCard (swipeable) and the explanation overlay (with the Correct/Wrong result, the claim, the verdict, and the educational explanation).
- Complete game.tsx by adding the styles below the closing bracket of the component function:
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.darkNavy,
paddingTop: 60,
paddingHorizontal: 20,
justifyContent: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
position: 'absolute',
top: 60,
left: 20,
right: 20,
},
categoryLabel: {
fontSize: 18,
fontWeight: '600',
color: Colors.accentBlue,
},
progress: {
fontSize: 16,
color: Colors.lightBlue,
},
emptyText: {
fontSize: 18,
color: Colors.white,
textAlign: 'center',
},
explanationCard: {
backgroundColor: Colors.navy,
borderRadius: 20,
padding: 24,
alignItems: 'center',
gap: 12,
},
resultText: {
fontSize: 28,
fontWeight: 'bold',
},
claimText: {
fontSize: 18,
color: Colors.white,
textAlign: 'center',
fontWeight: '600',
},
verdictText: {
fontSize: 16,
color: Colors.accentBlue,
fontWeight: '600',
},
explanationText: {
fontSize: 14,
color: Colors.lightBlue,
textAlign: 'center',
lineHeight: 20,
},
footer: {
position: 'absolute',
bottom: 40,
left: 20,
right: 20,
alignItems: 'center',
},
scoreText: {
fontSize: 18,
color: Colors.white,
fontWeight: '600',
},
});
- Save src/app/game.tsx.
- On your phone, tap a category from the home screen. You should see a fact card appear with an image and claim text. Swipe it right or left past the threshold. An answer reveal overlay should appear for about 2.5 seconds showing whether you were correct, the claim, the verdict, and an explanation.
Card not appearing or app crashing?
- If you see "No facts found for Science" (or your chosen category), check that the category field values in your Firestore documents match exactly (case-sensitive) with the category names in src/app/index.tsx.
- If the app crashes on navigation, make sure your useLocalSearchParams line uses the angle bracket generic <{ category: string }> so TypeScript knows the param shape.
- If you see a blank screen with no spinner, confirm that your src/lib/firebase.ts config values match what Firebase Console shows in your project settings.
Why isn't my game screen loading facts from Firestore?
✔️ Awesome, I've got everything!
Great. Double-check you have saved game.tsx and can swipe through at least one card.
ⓧ I'd like to double check the full code
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { useEffect, useState } from 'react';
import { collection, query, where, getDocs } from 'firebase/firestore';
import { db } from '@/lib/firebase';
import { Colors } from '@/constants/colors';
import FactCard from '@/components/FactCard';
interface Fact {
id: string;
claim: string;
isTrue: boolean;
explanation: string;
category: string;
imageUrl: string;
}
export default function GameScreen() {
const { category } = useLocalSearchParams<{ category: string }>();
const [facts, setFacts] = useState<Fact[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [score, setScore] = useState(0);
const [showExplanation, setShowExplanation] = useState(false);
const [lastAnswer, setLastAnswer] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchFacts = async () => {
const q = query(
collection(db, 'facts'),
where('category', '==', category)
);
const snapshot = await getDocs(q);
const fetchedFacts: Fact[] = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
})) as Fact[];
setFacts(fetchedFacts);
setLoading(false);
};
fetchFacts();
}, [category]);
const handleSwipe = (swipedRight: boolean) => {
const currentFact = facts[currentIndex];
const isCorrect = swipedRight === currentFact.isTrue;
if (isCorrect) {
setScore((prev) => prev + 1);
}
setLastAnswer(isCorrect);
setShowExplanation(true);
setTimeout(() => {
setShowExplanation(false);
setLastAnswer(null);
if (currentIndex + 1 >= facts.length) {
router.replace({
pathname: '/results',
params: {
score: String(isCorrect ? score + 1 : score),
total: String(facts.length),
category: category,
},
});
} else {
setCurrentIndex((prev) => prev + 1);
}
}, 2500);
};
if (loading) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color={Colors.accentBlue} />
</View>
);
}
if (facts.length === 0) {
return (
<View style={styles.container}>
<Text style={styles.emptyText}>No facts found for {category}</Text>
</View>
);
}
const currentFact = facts[currentIndex];
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.categoryLabel}>{category}</Text>
<Text style={styles.progress}>
{currentIndex + 1} / {facts.length}
</Text>
</View>
{!showExplanation ? (
<FactCard
key={currentFact.id}
claim={currentFact.claim}
imageUrl={currentFact.imageUrl}
onSwipe={handleSwipe}
/>
) : (
<View style={styles.explanationCard}>
<Text
style={[
styles.resultText,
{ color: lastAnswer ? Colors.correctGreen : Colors.incorrectRed },
]}
>
{lastAnswer ? 'Correct!' : 'Wrong!'}
</Text>
<Text style={styles.claimText}>{currentFact.claim}</Text>
<Text style={styles.verdictText}>
{currentFact.isTrue ? 'This is a FACT' : 'This is CAP'}
</Text>
<Text style={styles.explanationText}>
{currentFact.explanation}
</Text>
</View>
)}
<View style={styles.footer}>
<Text style={styles.scoreText}>Score: {score}</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.darkNavy,
paddingTop: 60,
paddingHorizontal: 20,
justifyContent: 'center',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
position: 'absolute',
top: 60,
left: 20,
right: 20,
},
categoryLabel: {
fontSize: 18,
fontWeight: '600',
color: Colors.accentBlue,
},
progress: {
fontSize: 16,
color: Colors.lightBlue,
},
emptyText: {
fontSize: 18,
color: Colors.white,
textAlign: 'center',
},
explanationCard: {
backgroundColor: Colors.navy,
borderRadius: 20,
padding: 24,
alignItems: 'center',
gap: 12,
},
resultText: {
fontSize: 28,
fontWeight: 'bold',
},
claimText: {
fontSize: 18,
color: Colors.white,
textAlign: 'center',
fontWeight: '600',
},
verdictText: {
fontSize: 16,
color: Colors.accentBlue,
fontWeight: '600',
},
explanationText: {
fontSize: 14,
color: Colors.lightBlue,
textAlign: 'center',
lineHeight: 20,
},
footer: {
position: 'absolute',
bottom: 40,
left: 20,
right: 20,
alignItems: 'center',
},
scoreText: {
fontSize: 18,
color: Colors.white,
fontWeight: '600',
},
});
Navigate to results and display the score
Once the player swipes through all cards in a category, the game navigates to the results screen. This screen reads the score, total, and category from the route params and displays a final summary.
- In src/app/results.tsx, replace the empty placeholder with the following component code:
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { Colors } from '@/constants/colors';
export default function ResultsScreen() {
// Read score, total, and category from route params
const { score, total, category } = useLocalSearchParams<{
score: string;
total: string;
category: string;
}>();
const scoreNum = parseInt(score, 10);
const totalNum = parseInt(total, 10);
const percentage = Math.round((scoreNum / totalNum) * 100);
// Return a performance message based on the percentage
const getMessage = () => {
if (percentage === 100) return 'Perfect! You are a fact-checking genius!';
if (percentage >= 75) return 'Great job! You really know your stuff!';
if (percentage >= 50) return 'Not bad! Keep learning!';
return 'Keep practicing! Knowledge is power!';
};
return (
<View style={styles.container}>
<Text style={styles.title}>Round Complete!</Text>
<Text style={styles.category}>{category}</Text>
<View style={styles.scoreCard}>
<Text style={styles.scoreNumber}>
{score}/{total}
</Text>
<Text style={styles.percentage}>{percentage}%</Text>
</View>
<Text style={styles.message}>{getMessage()}</Text>
<TouchableOpacity
style={styles.button}
onPress={() => router.replace('/')}
>
<Text style={styles.buttonText}>Play Again</Text>
</TouchableOpacity>
</View>
);
}
What does this component do?
useLocalSearchParams pulls the score, total, and category values that the game screen passed when it called router.replace(). Route params arrive as strings, so parseInt converts them to numbers for the percentage calculation.
The getMessage() function picks a celebratory message based on how well the player did. The "Play Again" button uses router.replace('/') to navigate back to the home screen, replacing the results route in the stack so the player starts fresh.
- Below the component's closing bracket, add the styles:
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.darkNavy,
paddingTop: 100,
paddingHorizontal: 20,
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: Colors.white,
marginBottom: 8,
},
category: {
fontSize: 18,
color: Colors.accentBlue,
marginBottom: 40,
},
scoreCard: {
backgroundColor: Colors.navy,
borderRadius: 20,
padding: 40,
alignItems: 'center',
width: '100%',
marginBottom: 24,
borderWidth: 1,
borderColor: Colors.mediumBlue,
},
scoreNumber: {
fontSize: 48,
fontWeight: 'bold',
color: Colors.white,
},
percentage: {
fontSize: 24,
color: Colors.accentBlue,
marginTop: 8,
},
message: {
fontSize: 18,
color: Colors.lightBlue,
textAlign: 'center',
marginBottom: 40,
lineHeight: 26,
},
button: {
backgroundColor: Colors.mediumBlue,
paddingHorizontal: 40,
paddingVertical: 16,
borderRadius: 12,
},
buttonText: {
fontSize: 18,
fontWeight: '600',
color: Colors.white,
},
});
- Save src/app/results.tsx.
- On your phone, play a full round. Select a category, swipe through all the cards, and after the last card's reveal you should land on the results screen. It shows "Round Complete!", the category name, your score as a fraction and percentage, a performance message, and a "Play Again" button.
- Tap Play Again. You should return to the home screen with the category list.
Not reaching the results screen?
- If the app freezes after the last card, make sure the setTimeout in handleSwipe checks currentIndex + 1 >= facts.length (not > facts.length). Off-by-one errors will skip the navigation.
- If you see a blank results page, confirm the file is saved as src/app/results.tsx (not inside a subfolder). Expo Router uses the file path as the route.
- If the score shows NaN, check that game.tsx passes String(...) wrapped values for score and total in the params object.
Why isn't my results screen showing the score after gameplay?
✔️ Awesome, I've got everything!
Great. Confirm you have saved results.tsx and the full game loop works end to end.
ⓧ I'd like to double check the full code
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import { Colors } from '@/constants/colors';
export default function ResultsScreen() {
const { score, total, category } = useLocalSearchParams<{
score: string;
total: string;
category: string;
}>();
const scoreNum = parseInt(score, 10);
const totalNum = parseInt(total, 10);
const percentage = Math.round((scoreNum / totalNum) * 100);
const getMessage = () => {
if (percentage === 100) return 'Perfect! You are a fact-checking genius!';
if (percentage >= 75) return 'Great job! You really know your stuff!';
if (percentage >= 50) return 'Not bad! Keep learning!';
return 'Keep practicing! Knowledge is power!';
};
return (
<View style={styles.container}>
<Text style={styles.title}>Round Complete!</Text>
<Text style={styles.category}>{category}</Text>
<View style={styles.scoreCard}>
<Text style={styles.scoreNumber}>
{score}/{total}
</Text>
<Text style={styles.percentage}>{percentage}%</Text>
</View>
<Text style={styles.message}>{getMessage()}</Text>
<TouchableOpacity
style={styles.button}
onPress={() => router.replace('/')}
>
<Text style={styles.buttonText}>Play Again</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.darkNavy,
paddingTop: 100,
paddingHorizontal: 20,
alignItems: 'center',
},
title: {
fontSize: 32,
fontWeight: 'bold',
color: Colors.white,
marginBottom: 8,
},
category: {
fontSize: 18,
color: Colors.accentBlue,
marginBottom: 40,
},
scoreCard: {
backgroundColor: Colors.navy,
borderRadius: 20,
padding: 40,
alignItems: 'center',
width: '100%',
marginBottom: 24,
borderWidth: 1,
borderColor: Colors.mediumBlue,
},
scoreNumber: {
fontSize: 48,
fontWeight: 'bold',
color: Colors.white,
},
percentage: {
fontSize: 24,
color: Colors.accentBlue,
marginTop: 8,
},
message: {
fontSize: 18,
color: Colors.lightBlue,
textAlign: 'center',
marginBottom: 40,
lineHeight: 26,
},
button: {
backgroundColor: Colors.mediumBlue,
paddingHorizontal: 40,
paddingVertical: 16,
borderRadius: 12,
},
buttonText: {
fontSize: 18,
fontWeight: '600',
color: Colors.white,
},
});
You now have a complete game loop. Players choose a category, swipe through fact cards, see answer reveals, and get a final score. Next up, you'll take on a secret mission to add a leaderboard powered by Firestore writes.
Secret mission
Firebase Leaderboard
Your game tracks scores locally, but what if players could compete against each other? In this secret mission, you will add Firestore writes to save high scores and build a per-category leaderboard screen that ranks the top 10 players.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on the Firebase Spark (free) plan and local files. There are no ongoing costs unless you manually upgrade to the Blaze plan.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Firebase project with Firestore database (Spark/free plan).
- Local fact-or-cap project directory on your machine.
Keep everything running
No action needed. Choose this if you are still actively building or want to extend the app further.
- The Firebase project stays on the free Spark plan indefinitely. No charges will accrue unless you manually upgrade to the Blaze plan.
- Your Firestore data (the facts and scores collections) will remain accessible whenever you need them.
- Your local project files stay on your machine until you manually delete them.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your Firebase data and local files persist for when you return.
- In your terminal, press Ctrl+C to stop the Expo dev server.
- Your Firestore database remains intact on Firebase servers at no cost.
- When you are ready to resume, navigate back into the fact-or-cap folder and run the command below.
npx expo start
Scan the QR code with Expo Go and you are back where you left off.
Delete - I don't want to use this again
Remove all project resources to start completely fresh. This cannot be undone.
- Delete the Firebase project by following these steps:
- Open the Firebase Console at console.firebase.google.com.
- Select your fact-or-cap project.
- Click the gear icon next to Project Overview to open Project Settings.
- Scroll to the bottom of the General tab.
- Click Delete project and follow the confirmation prompts.
What happens when you delete?
Deleting the Firebase project removes it across both Firebase and Google Cloud. All Firestore data (your facts and scores collections) will be permanently erased after a 30-day grace period.
- Delete the local project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf fact-or-cap
- On Windows, run:
cd ~\Desktop
Remove-Item -Recurse -Force fact-or-cap
- Confirm the folder is gone by running:
ls
You should no longer see fact-or-cap in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete gesture-driven mobile trivia game from scratch using React Native, Expo, and Firebase.
You've learned how to:
- Built a multi-screen React Native app with file-based routing using Expo Router.
- Implemented a custom Pan gesture with animated card physics including drag, tilt, snap-back, and dismiss interactions.
- Connected to Firebase Firestore and read cloud data into your app using the Firebase JS SDK to power a complete game loop with category selection, answer reveals, and score tracking.
- Secret Mission: Added Firestore writes and a per-category leaderboard screen with ranked scores.
Ready to quiz yourself?