November 23, 202512 min

City Scavenger Hunt: Geohints and Checkpoints with Nuxt 4 + TypeScript + Mapbox

Build a GPS-powered scavenger hunt with geohints, geofenced checkpoints, and gorgeous 3D maps using Nuxt 4, TypeScript, and Mapbox GL JS v3.

City Scavenger Hunt: Geohints and Checkpoints with Nuxt 4 + TypeScript + Mapbox

nuxt-mapbox-scavenger-hunt

Let’s build a GPS-powered city scavenger hunt that gives players “geohints” (distance + direction) to the next checkpoint instead of explicit turn-by-turn navigation. We’ll use Nuxt 4 + TypeScript with Mapbox GL JS v3 for a silky 3D globe, terrain, and fog.

We’ll cover:

  • Geolocation tracking and geofenced checkpoints
  • Geohints: human-friendly distance + compass direction
  • Mapbox GL JS v3 globe projection, fog, and 3D terrain
  • Clean, typed Nuxt 4 setup with composables and server routes

Tags: Nuxt 4, Mapbox GL JS v3, TypeScript, Turf.js, Geolocation, 3D Terrain

Time to read: 12 min

Prerequisites

  • Node.js 18 or newer
  • A free Mapbox account and access token: https://account.mapbox.com
  • Familiarity with basic Nuxt and Vue composition API

1) Create a fresh Nuxt 4 app

npx nuxi init nuxt4-scavenger
cd nuxt4-scavenger
npm install

Run it:

npm run dev

Verify it works at http://localhost:3000.

2) Install dependencies

We’ll use Mapbox GL JS v3 and Turf for geodesic math.

npm i mapbox-gl @turf/turf

3) Configure Nuxt (Mapbox token, CSS, and perf)

Create a .env file with your Mapbox token:

MAPBOX_TOKEN=pk.your_mapbox_token_here

Update nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  typescript: { strict: true },
  runtimeConfig: {
    public: {
      mapboxToken: process.env.MAPBOX_TOKEN || ''
    }
  },
  css: [
    'mapbox-gl/dist/mapbox-gl.css'
  ],
  app: {
    head: {
      link: [
        { rel: 'preconnect', href: 'https://api.mapbox.com' },
        { rel: 'preconnect', href: 'https://events.mapbox.com' }
      ]
    }
  }
});

Why this matters:

  • runtimeConfig exposes your Mapbox token to the client safely via public.
  • Including the Mapbox CSS globally avoids FOUC and ensures style consistency.

4) Server route: Checkpoints as GeoJSON

We’ll serve a fixed set of checkpoints (London in this example) as a FeatureCollection so it’s easy to swap cities later.

Create server/api/checkpoints.get.ts:

// server/api/checkpoints.get.ts
import type { FeatureCollection, Feature, Point } from 'geojson';

type Props = {
  id: string;
  name: string;
  description: string;
  radius: number; // meters
  order: number;
};

export default defineEventHandler(() => {
  // London sample set. Feel free to swap to your city.
  const features: Feature<Point, Props>[] = [
    {
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [-0.128082, 51.507978] }, // Trafalgar Square
      properties: {
        id: 'tsq',
        name: 'Trafalgar Square',
        description: 'Find the famous lions guarding the column.',
        radius: 50,
        order: 0
      }
    },
    {
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [-0.124626, 51.509980] }, // Covent Garden Market
      properties: {
        id: 'cvg',
        name: 'Covent Garden',
        description: 'Where street performers gather—follow the music.',
        radius: 45,
        order: 1
      }
    },
    {
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [-0.134040, 51.510067] }, // Piccadilly Circus
      properties: {
        id: 'pcc',
        name: 'Piccadilly Circus',
        description: 'Bright lights and a famous fountain.',
        radius: 50,
        order: 2
      }
    },
    {
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [-0.127644, 51.511574] }, // Leicester Square
      properties: {
        id: 'lqs',
        name: 'Leicester Square',
        description: 'Cinemas and premieres—find the red carpet.',
        radius: 40,
        order: 3
      }
    },
    {
      type: 'Feature',
      geometry: { type: 'Point', coordinates: [-0.114931, 51.507280] }, // Waterloo Bridge (north end)
      properties: {
        id: 'wtb',
        name: 'Waterloo Bridge',
        description: 'A bridge with stunning vistas—count the landmarks.',
        radius: 55,
        order: 4
      }
    }
  ];

  const fc: FeatureCollection<Point, Props> = {
    type: 'FeatureCollection',
    features
  };

  return fc;
});

5) Composables: Geolocation and Geomath

We’ll make two composables: one to watch the user’s location; another to compute distance, bearing, and friendly hints.

Create composables/useGeolocation.ts:

// composables/useGeolocation.ts
export function useGeolocation(options?: PositionOptions) {
  const coords = ref<GeolocationCoordinates | null>(null);
  const error = ref<GeolocationPositionError | null>(null);
  const started = ref(false);
  let watchId: number | null = null;

  const start = () => {
    if (!process.client || !('geolocation' in navigator)) {
      error.value = {
        name: 'GeolocationUnsupported',
        message: 'Geolocation is not supported.',
        code: 0
      } as GeolocationPositionError;
      return;
    }
    if (watchId !== null) return;

    watchId = navigator.geolocation.watchPosition(
      (pos) => {
        coords.value = pos.coords;
      },
      (err) => {
        error.value = err;
      },
      {
        enableHighAccuracy: true,
        maximumAge: 1000,
        timeout: 10000,
        ...(options || {})
      }
    );
    started.value = true;
  };

  const stop = () => {
    if (watchId !== null && process.client && 'geolocation' in navigator) {
      navigator.geolocation.clearWatch(watchId);
      watchId = null;
      started.value = false;
    }
  };

  onBeforeUnmount(stop);

  return { coords, error, started, start, stop };
}

Create composables/useGeo.ts:

// composables/useGeo.ts
import * as turf from '@turf/turf';
import type { Feature, Point } from 'geojson';

export type LngLatTuple = [number, number];

export function metersBetween(a: LngLatTuple, b: LngLatTuple): number {
  const km = turf.distance(turf.point(a), turf.point(b), { units: 'kilometers' });
  return km * 1000;
}

export function bearingDegrees(a: LngLatTuple, b: LngLatTuple): number {
  return turf.bearing(turf.point(a), turf.point(b));
}

export function cardinalFromBearing(deg: number): string {
  const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
  // normalize 0..360
  const d = ((deg % 360) + 360) % 360;
  const idx = Math.round(d / 45) % 8;
  return dirs[idx];
}

export function formatMeters(m: number): string {
  if (m < 1000) return `${Math.round(m)} m`;
  return `${(m / 1000).toFixed(m < 5000 ? 1 : 0)} km`;
}

export function geofencePolygon(center: LngLatTuple, radiusMeters: number) {
  const km = radiusMeters / 1000;
  return turf.circle(center, km, { steps: 64, units: 'kilometers' });
}

export function insideGeofence(point: LngLatTuple, center: LngLatTuple, radiusMeters: number): boolean {
  return metersBetween(point, center) <= radiusMeters;
}

export function toFeature(point: LngLatTuple, props: Record<string, any> = {}): Feature<Point> {
  return {
    type: 'Feature',
    geometry: { type: 'Point', coordinates: point },
    properties: props
  };
}

6) Client-only Mapbox component (Mapbox GL JS v3 features)

We will:

  • Render a 3D globe with fog and terrain (v3 niceties).
  • Draw geofence circle for the active checkpoint.
  • Show a dashed geohint line from the player to the active checkpoint.
  • Use HTML markers for checkpoints and a pulsing dot for the user.

Create components/MapboxMap.client.vue:

<script setup lang="ts">
import mapboxgl from 'mapbox-gl';
import type { FeatureCollection, Point, Feature } from 'geojson';
import { geofencePolygon, toFeature } from '@/composables/useGeo';

type Props = {
  checkpoints: FeatureCollection<Point, { id: string; name: string; description: string; radius: number; order: number }>;
  activeIndex: number;
  userLngLat: [number, number] | null;
};

const props = defineProps<Props>();
const mapEl = ref<HTMLDivElement | null>(null);
const map = shallowRef<mapboxgl.Map | null>(null);
const markers = ref<mapboxgl.Marker[]>([]);
const userMarker = shallowRef<mapboxgl.Marker | null>(null);

const runtime = useRuntimeConfig();

function ensureMap() {
  if (map.value || !mapEl.value) return;
  mapboxgl.accessToken = runtime.public.mapboxToken;

  const first = props.checkpoints.features[0]?.geometry.coordinates ?? [0, 0];

  map.value = new mapboxgl.Map({
    container: mapEl.value,
    style: 'mapbox://styles/mapbox/outdoors-v12',
    center: first,
    zoom: 14,
    pitch: 60,
    bearing: 20,
    projection: { name: 'globe' } // Mapbox GL JS v3 globe projection
  });

  map.value.on('style.load', () => {
    // Terrain & fog (v3 goodies)
    if (!map.value) return;
    map.value.addSource('mapbox-dem', {
      type: 'raster-dem',
      url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
      tileSize: 512,
      maxzoom: 14
    } as any);
    map.value.setTerrain({ source: 'mapbox-dem', exaggeration: 1.5 });
    map.value.setFog({
      color: 'rgba(186,210,235,0.6)',
      'high-color': '#245bde',
      'horizon-blend': 0.2,
      'space-color': '#000000',
      'star-intensity': 0.1
    } as any);
  });

  map.value.on('load', () => {
    if (!map.value) return;

    // Checkpoint HTML markers
    props.checkpoints.features.forEach((f, i) => {
      const el = document.createElement('div');
      el.className = 'checkpoint-marker';
      el.innerHTML = `<span>${i + 1}</span>`;
      const m = new mapboxgl.Marker(el).setLngLat(f.geometry.coordinates as [number, number]).addTo(map.value!);
      markers.value.push(m);
    });

    // Geofence and hint line sources/layers
    map.value.addSource('geofence', {
      type: 'geojson',
      data: geofencePolygon(
        props.checkpoints.features[props.activeIndex].geometry.coordinates as [number, number],
        props.checkpoints.features[props.activeIndex].properties.radius
      )
    });

    map.value.addLayer({
      id: 'geofence-fill',
      type: 'fill',
      source: 'geofence',
      paint: { 'fill-color': '#2dd4bf', 'fill-opacity': 0.18 }
    });

    map.value.addLayer({
      id: 'geofence-stroke',
      type: 'line',
      source: 'geofence',
      paint: { 'line-color': '#14b8a6', 'line-width': 2, 'line-dasharray': [2, 2] }
    });

    map.value.addSource('hint-line', {
      type: 'geojson',
      data: { type: 'FeatureCollection', features: [] }
    });

    map.value.addLayer({
      id: 'hint-line-layer',
      type: 'line',
      source: 'hint-line',
      layout: { 'line-cap': 'round', 'line-join': 'round' },
      paint: { 'line-color': '#f59e0b', 'line-width': 3, 'line-dasharray': [0, 2, 1] }
    });
  });
}

function updateActiveStyles() {
  // Toggle active class on checkpoint markers
  markers.value.forEach((m, i) => {
    const el = m.getElement();
    if (i === props.activeIndex) el.classList.add('active');
    else el.classList.remove('active');
  });

  // Update geofence polygon
  if (map.value && map.value.getSource('geofence')) {
    const active = props.checkpoints.features[props.activeIndex];
    const poly = geofencePolygon(active.geometry.coordinates as [number, number], active.properties.radius);
    (map.value.getSource('geofence') as mapboxgl.GeoJSONSource).setData(poly as any);

    // Softly fly camera toward active checkpoint
    map.value.flyTo({
      center: active.geometry.coordinates as [number, number],
      zoom: 16,
      speed: 0.7,
      curve: 1.5,
      essential: true // respect prefers-reduced-motion
    });
  }
}

function updateUserMarkerAndHintLine() {
  if (!map.value || !props.userLngLat) return;

  // User marker (pulsing dot via CSS)
  if (!userMarker.value) {
    const el = document.createElement('div');
    el.className = 'user-marker';
    userMarker.value = new mapboxgl.Marker({ element: el }).setLngLat(props.userLngLat).addTo(map.value);
  } else {
    userMarker.value.setLngLat(props.userLngLat);
  }

  // Hint line to active checkpoint
  const active = props.checkpoints.features[props.activeIndex];
  const fc: FeatureCollection = {
    type: 'FeatureCollection',
    features: [
      {
        type: 'Feature',
        geometry: {
          type: 'LineString',
          coordinates: [props.userLngLat, active.geometry.coordinates as [number, number]]
        },
        properties: {}
      } as Feature
    ]
  };

  const src = map.value.getSource('hint-line') as mapboxgl.GeoJSONSource | undefined;
  if (src) src.setData(fc as any);
}

onMounted(() => {
  ensureMap();
});

watch(() => props.activeIndex, () => {
  if (!map.value) return;
  updateActiveStyles();
  updateUserMarkerAndHintLine();
});

watch(() => props.userLngLat, () => {
  updateUserMarkerAndHintLine();
});
</script>

<template>
  <div class="map-root">
    <div ref="mapEl" class="map-container" />
  </div>
</template>

<style scoped>
.map-root, .map-container {
  width: 100%;
  height: 100%;
  min-height: 60vh;
  border-radius: 10px;
  overflow: hidden;
}

/* Checkpoint markers */
.checkpoint-marker {
  width: 28px;
  height: 28px;
  border-radius: 14px;
  background: #334155;
  color: white;
  display: grid;
  place-items: center;
  font-weight: 700;
  border: 2px solid #94a3b8;
  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
.checkpoint-marker.active {
  background: #22c55e;
  border-color: #16a34a;
  transform: scale(1.1);
}

/* Pulsing user dot */
.user-marker {
  width: 18px;
  height: 18px;
  background: #0ea5e9;
  border: 2px solid white;
  border-radius: 50%;
  box-shadow: 0 0 0 rgba(14,165,233, 0.7);
  animation: pulse 2s infinite;
}
@keyframes pulse {
  0% { box-shadow: 0 0 0 0 rgba(14,165,233, 0.7); }
  70% { box-shadow: 0 0 0 15px rgba(14,165,233, 0); }
  100% { box-shadow: 0 0 0 0 rgba(14,165,233, 0); }
}
</style>

Notes:

  • We use Mapbox GL JS v3 globe projection, fog, and terrain DEM.
  • We keep everything client-only to avoid SSR issues.

7) The Hunt page: geohints, progress, and arrival detection

Create pages/hunt.vue:

<script setup lang="ts">
import type { FeatureCollection, Point } from 'geojson';
import { useGeolocation } from '@/composables/useGeolocation';
import { metersBetween, bearingDegrees, cardinalFromBearing, formatMeters, insideGeofence } from '@/composables/useGeo';

type Props = {};
defineProps<Props>();

const { data: checkpointsData } = await useFetch<FeatureCollection<Point, { id: string; name: string; description: string; radius: number; order: number }>>('/api/checkpoints');

const checkpoints = computed(() => checkpointsData.value);
const total = computed(() => checkpoints.value?.features.length ?? 0);

// Progress is persisted locally
const progress = useState<number>('hunt-progress', () => 0);
onMounted(() => {
  const saved = localStorage.getItem('hunt-progress');
  if (saved) {
    const n = parseInt(saved, 10);
    if (!Number.isNaN(n)) progress.value = n;
  }
});
watch(progress, (v) => localStorage.setItem('hunt-progress', String(v)));

const activeIndex = computed(() => Math.min(progress.value, Math.max(0, (total.value || 1) - 1)));
const activeCheckpoint = computed(() => checkpoints.value?.features[activeIndex.value]);

const { coords, error, started, start, stop } = useGeolocation();

const userLngLat = computed<[number, number] | null>(() => {
  if (!coords.value) return null;
  return [coords.value.longitude, coords.value.latitude];
});

// Derive geohints
const distanceMeters = computed(() => {
  if (!userLngLat.value || !activeCheckpoint.value) return null;
  return metersBetween(userLngLat.value, activeCheckpoint.value.geometry.coordinates as [number, number]);
});

const bearing = computed(() => {
  if (!userLngLat.value || !activeCheckpoint.value) return null;
  return bearingDegrees(userLngLat.value, activeCheckpoint.value.geometry.coordinates as [number, number]);
});

const direction = computed(() => {
  if (bearing.value == null) return '-';
  return cardinalFromBearing(bearing.value);
});

const friendlyDistance = computed(() => {
  if (distanceMeters.value == null) return '-';
  return formatMeters(distanceMeters.value);
});

const arrived = computed(() => {
  if (!userLngLat.value || !activeCheckpoint.value) return false;
  const center = activeCheckpoint.value.geometry.coordinates as [number, number];
  const radius = activeCheckpoint.value.properties.radius;
  return insideGeofence(userLngLat.value, center, radius);
});

function nextCheckpoint() {
  if (activeIndex.value < (total.value - 1)) {
    progress.value = activeIndex.value + 1;
  }
}

watch(arrived, (isIn) => {
  if (isIn) {
    // Auto-advance with a short delay so the user sees the arrival state
    setTimeout(() => {
      nextCheckpoint();
    }, 1200);
  }
});
</script>

<template>
  <div class="page">
    <header class="header">
      <div>
        <h1>City Scavenger Hunt</h1>
        <p class="muted">Geohints + Checkpoints with Nuxt 4, TypeScript & Mapbox GL JS v3</p>
      </div>
      <div class="actions">
        <button v-if="!started" class="btn primary" @click="start">Start Hunt</button>
        <button v-else class="btn" @click="stop">Pause</button>
      </div>
    </header>

    <section v-if="!checkpoints || !checkpoints.features.length" class="empty">
      Loading checkpoints...
    </section>

    <section v-else class="grid">
      <div class="panel">
        <div class="stats">
          <div class="stat">
            <div class="label">Checkpoint</div>
            <div class="value">{{ activeIndex + 1 }} / {{ total }}</div>
          </div>
          <div class="stat">
            <div class="label">Distance</div>
            <div class="value">{{ friendlyDistance }}</div>
          </div>
          <div class="stat">
            <div class="label">Direction</div>
            <div class="value">{{ direction }}</div>
          </div>
        </div>

        <div class="hint">
          <h3>{{ activeCheckpoint?.properties.name }}</h3>
          <p>{{ activeCheckpoint?.properties.description }}</p>
          <p class="meta">Arrival radius: {{ activeCheckpoint?.properties.radius }} m</p>
          <div v-if="arrived" class="arrived">Arrived! Advancing...</div>
        </div>

        <div class="controls">
          <button class="btn" :disabled="activeIndex === 0" @click="progress = activeIndex - 1">Back</button>
          <button class="btn" :disabled="activeIndex >= total - 1" @click="nextCheckpoint">Skip</button>
        </div>
      </div>

      <div class="map">
        <MapboxMap
          v-if="checkpoints"
          :checkpoints="checkpoints"
          :activeIndex="activeIndex"
          :userLngLat="userLngLat"
        />
      </div>
    </section>

    <footer class="footer">
      <p class="muted">
        Tip: Mapbox GL JS v3 globe + fog + terrain are enabled. Location data stays local to your device.
      </p>
      <p v-if="error" class="error">Geolocation error: {{ error.message }}</p>
    </footer>
  </div>
</template>

<style scoped>
.page {
  display: grid;
  gap: 1rem;
  padding: 1rem;
}
.header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
}
.muted { color: #64748b; }
.actions { display: flex; gap: 0.5rem; }
.btn {
  padding: 0.6rem 0.9rem;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  background: white;
  cursor: pointer;
}
.btn.primary {
  background: #0ea5e9;
  color: white;
  border-color: #0284c7;
}
.grid {
  display: grid;
  grid-template-columns: minmax(280px, 420px) 1fr;
  gap: 1rem;
}
.panel {
  display: grid;
  gap: 1rem;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  padding: 1rem;
  background: #fff;
}
.stats {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.5rem;
}
.stat .label { font-size: 0.8rem; color: #64748b; }
.stat .value { font-weight: 700; font-size: 1.2rem; }
.hint .meta { font-size: 0.85rem; color: #64748b; }
.arrived {
  margin-top: 0.5rem;
  padding: 0.5rem 0.75rem;
  background: #dcfce7;
  color: #166534;
  border: 1px solid #86efac;
  border-radius: 8px;
  font-weight: 600;
  display: inline-block;
}
.map {
  min-height: 60vh;
}
.controls { display: flex; gap: 0.5rem; }
.footer { margin-top: 0.5rem; }
.error { color: #b91c1c; }
@media (max-width: 900px) {
  .grid { grid-template-columns: 1fr; }
}
</style>

8) Try it out

  • npm run dev
  • Press “Start Hunt” to grant geolocation.
  • Move around (or fake it with DevTools’ Sensors) and watch:
    • Geohints update (distance + cardinal direction)
    • Dashed line points to the next checkpoint
    • Arrival auto-advances to the next checkpoint when you enter the geofence

Chrome DevTools tip:

  • Open Command Palette → Show Sensors → enable geolocation overrides and drag the pin to simulate movement.

9) What’s “new” here in 2025?

  • Mapbox GL JS v3 rendering pipeline with globe projection, fog, and terrain for a more immersive 3D experience.
  • Smooth camera transitions with flyTo and globe projection work beautifully together for a “game-like” feel.
  • Nuxt 4’s modern DX with strict TypeScript, server routes, and composables keeps the codebase small and maintainable.

10) Optional enhancements

  • Multi-city hunts: add a ?city=... param to your API route and swap datasets.
  • Haptics: on mobile, vibrate when entering a geofence.
  • Persistent leaderboard: wire a Firestore collection for completions and show active players on the map (clustered).
  • Offline UX: cache non-Mapbox assets; keep in mind Mapbox tiles/APIs have terms that limit offline caching.

11) Production notes

  • Add your app domain to the Mapbox token URL restrictions.
  • Be mindful of Mapbox pricing and rate limits when you scale.
  • Consider a “low motion” toggle and pass essential: false to reduce camera motion based on user preferences.

Conclusion

You just built a modern, location-based scavenger hunt with Nuxt 4 + TypeScript and Mapbox GL JS v3. The geohints mechanic is simple, fun, and privacy-friendly, while the 3D globe, fog, and terrain make the experience feel premium.

Happy hunting!