# Project export: Ghost Runner

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2026
- Tagline: Stop choosing between 'The Game' and 'The Run.' Ghost Runner turns every step into a strategic edge. Treat your body as the ultimate hardware. Level up for real. Don’t just play the hero, be one.
- Devpost: https://devpost.com/software/ghost-runner-imy4e1
- GitHub: https://github.com/raymonsadhra/Ghost-Runner
- Video: https://www.youtube.com/embed/7UMvIjduhEA?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner (Most Wild Project (Wildest Project Idea))
- Team: 3 GitHub contributor(s) — Atharv Gupta (21 commits), raymonsadhra (9 commits), TigerlordZ99 (2 commits)

## Devpost submission (written by the team)

### Inspiration

The inspiration for Ghost Runner came from a staggering reality of modern life: the sedentary epidemic. In 2026, the average adult spends over 9 hours a day sitting—a habit that has become as harmful to cardiovascular health as smoking. We realized that for students and office workers, the "lonely jog" or a walk around the block feels like a chore because it lacks the immediate feedback and dopamine hits of the digital world. We wanted to create an "escape hatch" from the chair. By blending the addictive mechanics of gaming with the physical necessity of movement, we sought to turn the mundane act of standing up and running into a high-stakes, cinematic quest where you aren't just a user—you’re the hero.

### What it does

Ghost Runner is an immersive, eyes-free AR fitness experience designed to break the cycle of sitting. It uses spatial audio and real-time GPS to turn your environment into a competitive arena. The "Creep" Mechanic: The app projects a "Digital Ghost" (your past PR or a friend's record) into your 360-degree soundstage. As the Ghost closes the gap, you’ll hear rhythmic footsteps and heavy breathing getting louder and closer in 3D space. Psychological Pacing: If the Ghost gets within 2 meters, a pulsing heartbeat triggers in your ears, activating your natural "fight-or-flight" response to push you through the fatigue. Hands-Free HUD: While it features a beautiful neon radar for selecting "Ghosts" and viewing 3D Mapbox routes, the experience is designed to be fully auditory, allowing you to focus on the terrain, not your phone. Victory Soundscapes: Crossing the finish line ahead of your Ghost rewards you with an immersive, spatial crowd cheer, providing the instant gratification that traditional fitness tracking lacks.

### How we built it

React Native (Expo) for the mobile app Firebase Authentication for login/signup Cloud Firestore for data storage (users, runs, friends) Expo Location + Maps for GPS tracking Expo AV + Haptics for audio feedback React Navigation for app navigation

### Challenges we ran into

GPS Jitter: Standard GPS data is noisy, often making the Ghost "teleport" or jump around. We had to implement a Kalman Filter to smooth the path data for a realistic audio experience. Audio Latency: Delivering real-time 3D sound that responds to micro-changes in position was a hurdle. We had to optimize our React Native bridge to ensure the footsteps didn't "lag" behind the GPS updates. Battery Efficiency: Running high-frequency GPS, 3D mapping, and spatial audio simultaneously is a massive drain. we optimized the background processing to ensure the app could survive long-distance runs.

### Accomplishments we're proud of

We are incredibly proud of creating a "Heads-Up" AR experience that doesn't require glasses or a screen. Successfully mapping a digital coordinate to a physical sound that "creeps up" behind a user was a massive technical win. We've managed to turn the most boring part of a healthy lifestyle into something that feels like a scene from a thriller movie.

### What we learned

We learned that sound is a more powerful motivator than sight for high-intensity activity. Designing for "Audio UX" taught us how to use volume and panning to communicate complex data (like distance and speed) without words. We also gained deep experience in geospatial math and the intricacies of mobile sensor fusion.

### What's next

Social "Territory War": A map-based mode where students can "claim" areas of campus by outrunning the local "Ghost" of that zone. Haptic Feedback: Integrating watch haptics to "nudge" the user when the Ghost is about to pass them. Smart "Sedentary" Alerts: Connecting to desktop activity to trigger a "Ghost Chase" the moment the app detects you’ve been sitting for over 60 minutes.

## README (from the GitHub repository)

# Ghost Runner

Race your past self with a live ghost overlay and spatial audio cues.

## What is included
- GPS route recording with live distance and pace stats.
- Ghost racing engine that compares your current distance to a past run.
- Spatial audio manager wired for breathing, footsteps, heartbeat, and victory.
- Screens: Home, Run, Ghost Select, Ghost Run, Summary.

## Setup (SDK 54)
1) Install dependencies:
```bash
npm install
```
2) Align Expo packages:
```bash
npx expo install --fix
```
3) Install the Babel preset used by `babel.config.js`:
```bash
npm install --save-dev babel-preset-expo
```
4) Configure Firebase env vars (see below).
5) Add audio files and wire them in `src/config/audioSources.js`.
6) Start the app:
```bash
npm run start
```

## Firebase config
This project reads config from Expo public env vars:
- `EXPO_PUBLIC_FIREBASE_API_KEY`
- `EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN`
- `EXPO_PUBLIC_FIREBASE_PROJECT_ID`
- `EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET`
- `EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID`
- `EXPO_PUBLIC_FIREBASE_APP_ID`

You can set these in a local `.env` file or your shell environment.

## Audio assets
Place your audio files under `assets/audio/` and update:
- `src/config/audioSources.js`

Example:
```js
export const audioSources = {
  breathing: require('../../assets/audio/heavy-breathing-14431.mp3'),
  footsteps: require('../../assets/audio/heavy-walking-footsteps-352771.mp3'),
  heartbeat: require('../../assets/audio/thudding-heartbeat-372487.mp3'),
  cheer: require('../../assets/audio/crowd-cheering-383111.mp3'),
};
```

## Project structure
```
App.js
src/
  config/
  screens/
  services/
  utils/
```

## Notes
- `react-native-maps` may require platform-specific config for iOS and Android.
- The ghost engine currently compares route distance by elapsed time.
- Firebase Auth is not initialized yet; runs save with `userId: "anon"` until auth is wired.
- Runs are stored locally in AsyncStorage and synced to Firestore when available.

## Expo Go + SDK compatibility
- Expo Go on iOS only supports the latest SDK. This project targets SDK 54.
- If the QR does not scan, open Expo Go and use “Enter URL” with the `exp://` link.

## Troubleshooting
- `EMFILE: too many open files`: install Watchman (`brew install watchman`) or run `ulimit -n 8192` then restart Metro.
- Watchman recrawl warning: `watchman watch-del '<project>' ; watchman watch-project '<project>'`.


## Detected evidence (automated analysis)

Indexed codebase: 42 recognized source files, 261 KB.
- Firebase (technology) — detected in the code
- JavaScript (language) — detected in the code
- React (technology) — detected in the code

## Codebase structure (from repository index)

### Files (45 of 45)

```
.gitignore
App.js
app.json
assets/audio/README.md
babel.config.js
FIRESTORE_RULES.md
metro.config.js
package.json
README.md
scripts/seed-firebase-runs.js
src/components/OutsiderBackground.js
src/config/audioSources.js
src/config/bossCharacters.js
src/config/cosmetics.js
src/firebase.js
src/screens/AnalyticsScreen.js
src/screens/FriendsScreen.js
src/screens/GhostRunScreen.js
src/screens/GhostSelectScreen.js
src/screens/HomeScreen.js
src/screens/LeaderboardScreen.js
src/screens/ProfileScreen.js
src/screens/RunHistoryScreen.js
src/screens/RunScreen.js
src/screens/SummaryScreen.js
src/screens/UserRunHistoryScreen.js
src/services/AudioManager.js
src/services/bossGhostService.js
src/services/fakeRunGenerator.js
src/services/firebaseService.js
src/services/friendService.js
src/services/GhostRacer.js
src/services/goalService.js
src/services/localGhostStore.js
src/services/localRunStore.js
src/services/LocationTracker.js
src/services/powerUpService.js
src/services/profileService.js
src/services/rewardService.js
src/services/seedFakeRuns.js
src/theme.js
src/utils/distanceUtils.js
src/utils/geoUtils.js
src/utils/leveling.js
src/utils/timeUtils.js
```

### Dependencies

- package.json: @expo/metro-config@^54.0.13, @react-native-async-storage/async-storage@2.2.0, @react-navigation/bottom-tabs@^6.6.1, @react-navigation/native@^6.1.9, @react-navigation/stack@^6.3.20, babel-preset-expo@^54.0.9, dotenv@^17.2.3, expo@~54.0.0, expo-av@~16.0.8, expo-blur@^15.0.8, expo-haptics@~15.0.8, expo-linear-gradient@~15.0.8, expo-location@~19.0.8, firebase@^10.14.1, react@19.1.0, react-native@0.81.5, react-native-chart-kit@^6.12.0, react-native-gesture-handler@~2.28.0, react-native-maps@1.20.1, react-native-safe-area-context@~5.6.0, react-native-screens@~4.16.0, react-native-svg@^15.12.1

### Recent commits (newest first)

- remove friends button
- Merge pull request #8 from raymonsadhra/bugs
- ui fixes r good
- good shit so far
- more imporvementsfejslkfda
- bugs
- cancel request + leaderboard fix
- Merge pull request #7 from raymonsadhra/logout
- Logout work
- hot bar
- Merge pull request #6 from raymonsadhra/friends
- friends
- Merge pull request #5 from raymonsadhra/login
- Merge branch 'main' into login
- login more fix
- menu bar
- Merge pull request #4 from raymonsadhra/login
- fixed
- login page lowk work
- Merge pull request #3 from raymonsadhra/profile

## Key source files (fetched from GitHub, selected and truncated for size)

### FIRESTORE_RULES.md

```markdown
# Firestore Security Rules Setup

## Data Structure
The app now uses the following structure:
- **Users** collection: Contains user documents
  - Each user document has: `userId`, `createdAt`, `totalRuns`, `totalDistance`, `lastRunAt`
  - **Runs** subcollection: Contains all runs for that user
    - Each run document has: `points`, `distance`, `distanceKm`, `duration`, `durationMin`, `pace`, `timestamp`, `createdAt`, `ghostMeta`, `isGhostRun`
  - **Friends** subcollection: Accepted friends for that user
    - Each friend document has: `userId`, `displayName`, `createdAt`, `status`
  - **FriendRequests** subcollection: Pending friend requests
    - Each request document has: `userId`, `displayName`, `email`, `direction`, `status`, `createdAt`

## Problem
You're seeing the error: "Missing or insufficient permissions"

This means your Firestore security rules are blocking read/write access to the `Users` collection and `Runs` subcollection.

## Solution

### Step 1: Open Firebase Console
1. Go to https://console.firebase.google.com/
2. Select your project
3. Click on **Firestore Database** in the left sidebar
4. Click on the **Rules** tab

### Step 2: Update Security Rules

Replace your current rules with one of these options:

#### Option A: Allow All (For Development/Testing Only)
```javascript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if true;
    }
  }
}
```

⚠️ **WARNING**: This allows anyone to read/write to your database. Only use for development!

#### Option B: Allow All for Users and Runs (For Testing with Anonymous Users)
```javascript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /Users/{userId} {
      allow read, write: if true;
      match /Runs/{runId} {
        allow read, write: if true;
      }
    }
  }
}
```

#### Option C: Allow Authenticated Users Only (Recommended for Production)
```javascript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /Users/{userId} {
      allow read, write: if request.auth != null && request.auth.uid == userId;
      match /Runs/{runId} {
        allow read, write: if request.auth != null && request.auth.uid == userId;
      }
      match /Friends/{friendId} {
        allow read, write: if request.auth != null && request.auth.uid == userId;
      }
      match /FriendRequests/{requestId} {
        allow read, write: if request.auth != null && request.auth.uid == userId;
      }
    }
  }
}
```

#### Option D: Auth + Friends Requests + Public User Lookup (Recommended for Friends)
```javascript
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /Users/{userId} {
      // Allow read of user profiles for friend search / leaderboard.
      allow read: if request.auth != null;
      allow write: if request.auth != null && request.auth.uid == userId;

      mat
[truncated — 2863 more characters]
```

### package.json

```
{
  "name": "ghost-runner",
  "version": "0.1.0",
  "private": true,
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "seed:runs": "node scripts/seed-firebase-runs.js"
  },
  "dependencies": {
    "@react-native-async-storage/async-storage": "2.2.0",
    "@react-navigation/bottom-tabs": "^6.6.1",
    "@react-navigation/native": "^6.1.9",
    "@react-navigation/stack": "^6.3.20",
    "expo": "~54.0.0",
    "expo-av": "~16.0.8",
    "expo-blur": "^15.0.8",
    "expo-haptics": "~15.0.8",
    "expo-linear-gradient": "~15.0.8",
    "expo-location": "~19.0.8",
    "firebase": "^10.14.1",
    "react": "19.1.0",
    "react-native": "0.81.5",
    "react-native-chart-kit": "^6.12.0",
    "react-native-gesture-handler": "~2.28.0",
    "react-native-maps": "1.20.1",
    "react-native-safe-area-context": "~5.6.0",
    "react-native-screens": "~4.16.0",
    "react-native-svg": "^15.12.1"
  },
  "devDependencies": {
    "@expo/metro-config": "^54.0.13",
    "babel-preset-expo": "^54.0.9",
    "dotenv": "^17.2.3"
  }
}

```

### App.js

```javascript
import React, { useEffect, useState } from 'react';
import { StatusBar, Text, View, TextInput, TouchableOpacity, StyleSheet, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';

import { auth, db } from './src/firebase';
import { onAuthStateChanged, signInWithEmailAndPassword, signInAnonymously, createUserWithEmailAndPassword, signOut } from 'firebase/auth';
import { doc, setDoc, serverTimestamp, getDoc } from 'firebase/firestore';

import HomeScreen from './src/screens/HomeScreen';
import RunScreen from './src/screens/RunScreen';
import GhostSelectScreen from './src/screens/GhostSelectScreen';
import GhostRunScreen from './src/screens/GhostRunScreen';
import SummaryScreen from './src/screens/SummaryScreen';
import RunHistoryScreen from './src/screens/RunHistoryScreen';
import ProfileScreen from './src/screens/ProfileScreen';
import AnalyticsScreen from './src/screens/AnalyticsScreen';
import LeaderboardScreen from './src/screens/LeaderboardScreen';
import UserRunHistoryScreen from './src/screens/UserRunHistoryScreen';
import FriendsScreen from './src/screens/FriendsScreen';

import { theme } from './src/theme';
import { audioSources } from './src/config/audioSources';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
// import GlassTabBar from './src/components/GlassTabBar';

const Stack = createStackNavigator();
const Tab = createBottomTabNavigator();

// --------- Login Screen ---------
function LoginScreen() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);
  const [isSignUp, setIsSignUp] = useState(false);

  const createUserDocument = async (userId, userEmail, userName) => {
    try {
      const userRef = doc(db, 'Users', userId);
      const userSnap = await getDoc(userRef);
      const baseName = (userName || 'Runner').trim() || 'Runner';
      const baseNameLower = baseName.toLowerCase();
      const emailLower = userEmail ? userEmail.toLowerCase() : null;
      
      if (!userSnap.exists()) {
        // Create user document in Firestore
        const userData = {
          userId,
          name: baseName,
          displayName: baseName,
          nameLower: baseNameLower,
          displayNameLower: baseNameLower,
          createdAt: serverTimestamp(),
          totalRuns: 0,
          totalDistance: 0,
          lastRunAt: null,
        };
        
        // Only add email if provided (not for anonymous users)
        if (userEmail) {
          userData.email = userEmail;
          userData.emailLower = emailLower;
        }
        
        await setDoc(userRef, userData);
        console.log(`Created user document in Firestore: ${userId}`);
      } else {
        // Update existing user document with name/email if they're missing
        const userData = userSnap.data();
        const updates = {};
        
        if (!userData.name || !userData.displayName) {
          updates.name = baseName || userData.name || 'Runner';
          updates.displayName = baseName || userData.displayName || 'Runner';
        }
        
        if (userEmail && !userData.email) {
          updates.email = userEmail;
        }

        if (!userData.nameLower) {
          updates.nameLower = (userData.name || baseName).toLowerCase();
        }
        if (!userData.displayNameLower) {
          updates.displayNameLower = (userData.displayName || baseName).toLowerCase();
        }
        if (userEmail && !userData.emailLower) {
          updates.emailLower = emailLower;
        }
        
        if (Object.keys(updates).length > 0) {
          await setDoc(userRef, { ...userData, ...updates }, { merge: true });
        }
      }
    } catch (error) {
      console.error('Error creating/updating user document:', error);
      // Don't throw - auth succeeded, Firestore doc creation is secondary
    }
  };

  const handleEmailAuth = async () => {
    if (!email.trim() || !password.trim()) {
      setError('Please enter both email and password');
      return;
    }

    if (isSignUp && !name.trim()) {
      setError('Please enter your name');
      return;
    }

    setError(null);
    setLoading(true);
    try {
      if (isSignUp) {
        // Sign up
        const userCredential = await createUserWithEmailAndPassword(auth, email.trim(), password);
        // Create user document in Firestore
        await createUserDocument(userCredential.user.uid, email.trim(), name.trim());
      } else {
        // Sign in
        const userCredential = await signInWithEmailAndPassword(auth, email.trim(), password);
        // Ensure user document exists (in case it was created before we added this feature)
        const userRef = doc(db, 'Users', userCredential.user.uid);
        const userSnap = await getDoc(userRef);
        if (!userSnap.exists()) {
          await createUserDocument(userCredential.user.uid, email.trim(), 'Runner');
        }
      }
    } catch (err) {
      console.error(`${isSignUp ? 'Sign up' : 'Sign in'} error:`, err);
      if (err.code === 'auth/invalid-credential' || err.code === 'auth/user-not-found') {
        setError('User not found. Switch to "Sign Up" to create an account.');
      } else if (err.code === 'auth/email-already-in-use') {
        setError('Account already exists. Switch to "Sign In" instead.');
      } else if (err.code === 'auth/weak-password') {
        setError('Password is too weak. Use at least 6 characters.');
      } else if (err.code === 'auth/invalid-email') {
        setError('Invalid email address.');
      } else if (err.code === 'auth/wrong-passwo
[truncated — 17651 more characters]
```

### babel.config.js

```javascript
module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
  };
};

```

### metro.config.js

```javascript
// metro.config.js
const { getDefaultConfig } = require('@expo/metro-config');

const defaultConfig = getDefaultConfig(__dirname);

defaultConfig.resolver.sourceExts.push('cjs');
defaultConfig.resolver.unstable_enablePackageExports = false;

module.exports = defaultConfig;

```

### src/firebase.js

```javascript
// firebase.js
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  apiKey: process.env.EXPO_PUBLIC_FIREBASE_API_KEY || '',
  authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN || '',
  projectId: process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID || '',
  storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET || '',
  messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || '',
  appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID || '',
};

// Initialize Firebase App
const app = initializeApp(firebaseConfig);

// Firestore
export const db = getFirestore(app);

// Auth (Expo-compatible - web SDK handles persistence automatically)
export const auth = getAuth(app);

```

### src/theme.js

```javascript
export const theme = {
  colors: {
    ink: '#0B0A0E',
    mist: '#F5F2FF',
    slate: '#1B1824',
    primary: '#FF2D7A',
    secondary: '#7C5CFF',
    accent: '#55D7FF',
    danger: '#FF5757',
    bg: '#0B0A0E',
    surface: '#15131C',
    surfaceElevated: '#1D1A26',
    text: '#F5F2FF',
    textMuted: '#B4AEC4',
    textSoft: '#7C7589',
    neonGreen: '#52FF9C',
    neonPink: '#FF2D7A',
    neonPurple: '#7C5CFF',
    neonBlue: '#55D7FF',
    glowPink: 'rgba(255, 45, 122, 0.35)',
    glowGreen: 'rgba(82, 255, 156, 0.25)',
    glowPurple: 'rgba(124, 92, 255, 0.32)',
    glowBlue: 'rgba(85, 215, 255, 0.25)',
  },
  gradients: {
    base: ['#1A1621', '#0B0A0E'],
    green: ['#0E2A1B', '#0B0A0E'],
    purple: ['#2A0D3D', '#0B0A0E'],
    pink: ['#2B0F1E', '#0B0A0E'],
    blue: ['#0B1A2F', '#0B0A0E'],
  },
  spacing: {
    xs: 6,
    sm: 12,
    md: 18,
    lg: 26,
    xl: 34,
    xxl: 44,
  },
  radius: {
    sm: 12,
    md: 18,
    lg: 26,
    xl: 34,
    pill: 999,
  },
};

```

### scripts/seed-firebase-runs.js

```javascript
/**
 * Seed fake runs into Firebase from the command line.
 *
 * Prerequisites:
 *   - .env (or .env.local) with EXPO_PUBLIC_FIREBASE_API_KEY, EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN,
 *     EXPO_PUBLIC_FIREBASE_PROJECT_ID, EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET,
 *     EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, EXPO_PUBLIC_FIREBASE_APP_ID
 *   - Firestore rules that allow writes (e.g. Option A or B in FIRESTORE_RULES.md)
 *
 * Run: node scripts/seed-firebase-runs.js
 * Or:  npm run seed:runs
 */

require('dotenv').config();
const { initializeApp } = require('firebase/app');
const {
  getFirestore,
  doc,
  setDoc,
  getDoc,
  collection,
  addDoc,
  serverTimestamp,
} = require('firebase/firestore');

const BASE_LAT = 37.7749;
const BASE_LNG = -122.4194;

const LEADERBOARD_IDS = ['anon', '1', '2', '3', '4', '5'];
const LEADERBOARD_NAMES = { 1: 'Runner 1', 2: 'Runner 2', 3: 'Runner 3', 4: 'Runner 4', 5: 'Runner 5' };
const RUN_NAMES = ['Morning Loop', 'Evening Jog', 'Park Run', 'Weekend Long', 'Tempo Tuesday', 'Recovery Run', 'Trail Run', 'Sunset 5K', null, null];

function generateFakePoints(distanceMeters) {
  const stepM = 8;
  const numPoints = Math.max(10, Math.floor(distanceMeters / stepM));
  const half = Math.floor(numPoints / 2);
  const points = [];
  let lat = BASE_LAT;
  let lng = BASE_LNG;
  const dLat = (0.00008 * (0.5 + Math.random())) / 2;
  const dLng = (0.0001 * (0.5 + Math.random())) / 2;
  for (let i = 0; i < numPoints; i++) {
    points.push({ latitude: lat, longitude: lng });
    if (i < half) { lat += dLat; lng += dLng; } else { lat -= dLat; lng -= dLng; }
  }
  return points;
}

function generateFakeRun() {
  const distanceMeters = 800 + Math.random() * 11200;
  const paceMinPerKm = 5 + Math.random() * 2.5;
  const distanceKm = distanceMeters / 1000;
  const duration = Math.round(distanceKm * paceMinPerKm * 60);
  const now = Date.now();
  const maxAgo = 60 * 24 * 60 * 60 * 1000;
  const timestamp = now - Math.floor(Math.random() * maxAgo);
  const points = generateFakePoints(distanceMeters);
  const name = Math.random() < 0.35 ? (RUN_NAMES[Math.floor(Math.random() * RUN_NAMES.length)] || null) : null;
  const distanceKmR = distanceMeters / 1000;
  const durationMin = duration / 60;
  const pace = distanceKmR > 0 ? durationMin / distanceKmR : 0;
  return {
    userId: null, // set per user
    points,
    distance: Math.round(distanceMeters),
    distanceKm: Math.round(distanceKmR * 100) / 100,
    duration,
    durationMin: Math.round(durationMin * 100) / 100,
    pace: Math.round(pace * 100) / 100,
    timestamp,
    ghostMeta: null,
    isGhostRun: false,
    name: name || null,
  };
}

async function ensureUser(db, userId) {
  const ref = doc(db, 'Users', userId);
  const snap = await getDoc(ref);
  if (!snap.exists()) {
    await setDoc(ref, {
      userId,
      createdAt: serverTimestamp(),
      totalRuns: 0,
      totalDistance: 0,
      lastRunAt: null,
    });
    console.log(`  Created user: ${userId}`);
  }
}

async function main() {
  const apiKey = process.env.EXPO_PUBLIC_FIREBASE_API_KEY;
  const projectId = process.env.EXPO_PUBLIC_FIREBASE_PROJECT_ID;
  if (!apiKey || !projectId) {
    console.error('Missing EXPO_PUBLIC_FIREBASE_API_KEY or EXPO_PUBLIC_FIREBASE_PROJECT_ID in .env');
    process.exit(1);
  }

  const app = initializeApp({
    apiKey,
    authDomain: process.env.EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN || '',
    projectId,
    storageBucket: process.env.EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET || '',
    messagingSenderId: process.env.EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || '',
    appId: process.env.EXPO_PUBLIC_FIREBASE_APP_ID || '',
  });
  const db = getFirestore(app);

  const runsPerUser = 10;
  let saved = 0;
  let failed = 0;

  for (const userId of LEADERBOARD_IDS) {
    await ensureUser(db, userId);
    for (let i = 0; i < runsPerUser; i++) {
      const run = generateFakeRun();
      const runDoc = {
        userId,
        points: run.points,
        distance: run.distance,
        distanceKm: run.distanceKm,
        duration: run.duration,
        durationMin: run.durationMin,
        pace: run.pace,
        timestamp: run.timestamp,
        createdAt: serverTimestamp(),
        ghostMeta: run.ghostMeta,
        isGhostRun: run.isGhostRun,
        name: run.name,
      };
      try {
        await addDoc(collection(db, 'Users', userId, 'Runs'), runDoc);
        saved++;
        const userRef = doc(db, 'Users', userId);
        const userSnap = await getDoc(userRef);
        if (userSnap.exists()) {
          const d = userSnap.data();
          await setDoc(userRef, {
            ...d,
            totalRuns: (d.totalRuns || 0) + 1,
            totalDistance: (d.totalDistance || 0) + run.distance,
            lastRunAt: serverTimestamp(),
          }, { merge: true });
        }
      } catch (e) {
        failed++;
        console.error(`  Failed to add run for ${userId}:`, e.message);
      }
    }
    if (LEADERBOARD_NAMES[userId]) {
      try {
        await setDoc(doc(db, 'Users', userId), { displayName: LEADERBOARD_NAMES[userId] }, { merge: true });
      } catch (_) {}
    }
  }

  console.log(`\nDone. Saved: ${saved}, Failed: ${failed}`);
  process.exit(failed > 0 ? 1 : 0);
}

main();

```

### src/config/audioSources.js

```javascript
export const audioSources = {
  breathing: require('../../assets/audio/heavy-breathing-14431.mp3'),
  footsteps: require('../../assets/audio/heavy-walking-footsteps-352771.mp3'),
  heartbeat: require('../../assets/audio/thudding-heartbeat-372487.mp3'),
  cheer: require('../../assets/audio/crowd-cheering-383111.mp3'),
  ghostDistant: null,
  bossTheme: null,
};

```

### src/services/GhostRacer.js

```javascript
import {
  calculateDistanceAtTime,
  calculateTotalDistance,
  findPositionAtTime,
} from '../utils/geoUtils';

export class GhostRacer {
  constructor(ghostRoute = []) {
    this.ghostRoute = ghostRoute;
  }

  getGhostPosition(elapsedMs) {
    return findPositionAtTime(this.ghostRoute, elapsedMs);
  }

  getGhostDistance(elapsedMs) {
    return calculateDistanceAtTime(this.ghostRoute, elapsedMs);
  }

  calculateDelta(currentRoute, elapsedMs) {
    const currentDistance = calculateTotalDistance(currentRoute);
    const ghostDistance = this.getGhostDistance(elapsedMs);
    return currentDistance - ghostDistance;
  }
}

```

[31 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]