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

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: falseto 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!





