That's the whole thing explained. Here's the same component as a single, self-contained file -- copy it into any React/Next.js project and it works as-is.
FirstPersonMapControls.tsx "use client";
/**
* Drop-in MapLibre eye-level first-person walker -- move with W/A/S/D (or the
* on-screen buttons) and turn with the Left/Right arrow keys. W/S walk
* forward/back, A/D strafe sideways (they don't turn you), Left/Right rotate
* the view -- the classic keyboard-only FPS scheme for when there's no
* mouse-look.
*
* Walking into a building blocks you (enableCollision, on by default), using
* the building footprints from the vector tiles themselves.
*
* Setup: npm install maplibre-gl react react-dom
*
* Three things this component works around, in case you hit them elsewhere:
* 1. The worker script loads from a CDN (pin the version to match your
* installed maplibre-gl) because MapLibre's default worker-URL detection
* (import.meta.url) breaks under bundlers like Turbopack/Webpack.
* 2. True eye-level camera math computes a very high zoom (~21+) for a
* realistic look-ahead distance. Many vector styles only have native
* tile data up to zoom ~14 and stop rendering anything past a certain
* amount of overzoom. maxZoom below clamps the computed zoom so
* buildings/streets stay visible -- raise it if your style supports
* deeper zoom, lower it if things still look blank.
* 3. Collision resolves the full diagonal move first and only falls back to
* single-axis sliding. Testing each axis separately and applying both
* lets a diagonal walk corner-cut straight through a building.
*/
import * as maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { useEffect, useRef } from "react";
const KEYS = ["KeyW", "KeyA", "KeyS", "KeyD", "ArrowLeft", "ArrowRight"] as const;
type Key = (typeof KEYS)[number];
// Building footprints for collision. These names come from the OpenMapTiles
// schema the default style uses -- change them to match your own style.
const BUILDING_SOURCE = "openmaptiles";
const BUILDING_SOURCE_LAYER = "building";
type Ring = number[][];
// Standard ray-casting point-in-polygon test.
function pointInRing(pt: [number, number], ring: Ring): boolean {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const crosses =
yi > pt[1] !== yj > pt[1] &&
pt[0] < ((xj - xi) * (pt[1] - yi)) / (yj - yi) + xi;
if (crosses) inside = !inside;
}
return inside;
}
// Polygon = [outerRing, ...holeRings]. Inside the outer ring but inside a
// hole means outside the shape (courtyards, etc).
function pointInPolygon(pt: [number, number], rings: Ring[]): boolean {
if (!pointInRing(pt, rings[0])) return false;
for (let k = 1; k < rings.length; k++) {
if (pointInRing(pt, rings[k])) return false;
}
return true;
}
function pointInBuilding(pt: [number, number], geometry: GeoJSON.Geometry): boolean {
if (geometry.type === "Polygon") {
return pointInPolygon(pt, geometry.coordinates as Ring[]);
}
if (geometry.type === "MultiPolygon") {
return (geometry.coordinates as Ring[][]).some((rings) =>
pointInPolygon(pt, rings),
);
}
return false;
}
export interface FirstPersonMapControlsProps {
/** [lng, lat] to start walking from. */
origin?: [number, number];
/** Any MapLibre style URL. */
mapStyle?: string;
/** Eye height in meters. */
eyeHeight?: number;
/** Walking speed in meters/second. */
walkSpeed?: number;
/** Turning speed in radians/second. */
turnSpeed?: number;
/** How far ahead (meters) the camera looks. */
lookAhead?: number;
/** Zoom is clamped to this to keep the style rendering (see note above). */
maxZoom?: number;
/** Block walking through building footprints. Defaults to on. */
enableCollision?: boolean;
}
export default function FirstPersonMapControls({
origin = [10.1815, 36.7992],
mapStyle = "https://tiles.openfreemap.org/styles/liberty",
eyeHeight = 1.6,
walkSpeed = 4,
turnSpeed = 1.2, // rad/s -- ~69 deg/s, a full 360 in ~5s
lookAhead = 20,
maxZoom = 19,
enableCollision = true,
}: FirstPersonMapControlsProps) {
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const keysDownRef = useRef<Set<Key>>(new Set());
useEffect(() => {
if (!mapContainerRef.current) return;
maplibregl.setWorkerUrl(
"https://unpkg.com/maplibre-gl@6.6.0/dist/maplibre-gl-worker.mjs",
);
const originMercator = maplibregl.MercatorCoordinate.fromLngLat(origin);
const metresToMercatorUnits = originMercator.meterInMercatorCoordinateUnits();
const player = { x: 0, y: 0, heading: Math.PI / 2 };
const toLngLat = (x: number, y: number) =>
new maplibregl.MercatorCoordinate(
originMercator.x + x * metresToMercatorUnits,
originMercator.y - y * metresToMercatorUnits,
).toLngLat();
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: mapStyle,
center: origin,
zoom: maxZoom,
pitch: 89,
maxPitch: 89,
interactive: false,
canvasContextAttributes: { antialias: true },
attributionControl: false,
});
map.addControl(new maplibregl.AttributionControl({ compact: true }), "top-right");
mapContainerRef.current
.querySelector(".maplibregl-ctrl-attrib")
?.classList.remove("maplibregl-compact-show");
// Building footprints currently loaded nearby, refreshed as new tiles
// stream in while walking -- cheap to test against every frame, too
// expensive to re-query the vector source every frame.
let buildings: maplibregl.GeoJSONFeature[] = [];
const refreshBuildings = () => {
buildings = map.querySourceFeatures(BUILDING_SOURCE, {
sourceLayer: BUILDING_SOURCE_LAYER,
});
};
map.on("load", refreshBuildings);
map.on("sourcedata", (e) => {
if (e.sourceId === BUILDING_SOURCE && e.isSourceLoaded) refreshBuildings();
});
const collidesAt = (x: number, y: number) => {
if (!enableCollision) return false;
const lngLat = toLngLat(x, y);
const pt: [number, number] = [lngLat.lng, lngLat.lat];
return buildings.some((f) => f.geometry && pointInBuilding(pt, f.geometry));
};
const updateCamera = () => {
const eye = toLngLat(player.x, player.y);
const ahead = toLngLat(
player.x + Math.cos(player.heading) * lookAhead,
player.y + Math.sin(player.heading) * lookAhead,
);
const cam = map.calculateCameraOptionsFromTo(eye, eyeHeight, ahead, eyeHeight);
map.jumpTo({ ...cam, zoom: Math.min(cam.zoom ?? maxZoom, maxZoom) });
};
const keysDown = keysDownRef.current;
const onKeyDown = (e: KeyboardEvent) => {
if ((KEYS as readonly string[]).includes(e.code)) keysDown.add(e.code as Key);
};
const onKeyUp = (e: KeyboardEvent) => {
keysDown.delete(e.code as Key);
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastFrameTime = performance.now();
let frame = 0;
const loop = (now: number) => {
const dt = Math.min(0.05, (now - lastFrameTime) / 1000);
lastFrameTime = now;
if (keysDown.has("ArrowLeft")) player.heading += turnSpeed * dt;
if (keysDown.has("ArrowRight")) player.heading -= turnSpeed * dt;
let forward = 0;
if (keysDown.has("KeyW")) forward += walkSpeed * dt;
if (keysDown.has("KeyS")) forward -= walkSpeed * dt;
let strafe = 0;
if (keysDown.has("KeyD")) strafe += walkSpeed * dt;
if (keysDown.has("KeyA")) strafe -= walkSpeed * dt;
const dx =
Math.cos(player.heading) * forward + Math.sin(player.heading) * strafe;
const dy =
Math.sin(player.heading) * forward - Math.cos(player.heading) * strafe;
// Full diagonal move first, then fall back to sliding along one axis.
// Applying both axes independently would let a diagonal walk
// corner-cut through a building (each axis alone clears the footprint
// even when the combined step doesn't).
const nx = player.x + dx;
const ny = player.y + dy;
if (!collidesAt(nx, ny)) {
player.x = nx;
player.y = ny;
} else if (!collidesAt(nx, player.y)) {
player.x = nx;
} else if (!collidesAt(player.x, ny)) {
player.y = ny;
}
updateCamera();
frame = requestAnimationFrame(loop);
};
frame = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
keysDown.clear();
map.remove();
};
}, [origin, mapStyle, eyeHeight, walkSpeed, turnSpeed, lookAhead, maxZoom, enableCollision]);
const buttonStyle: React.CSSProperties = {
font: "700 18px system-ui, sans-serif",
color: "#e6edf3",
background: "linear-gradient(180deg, rgba(255,255,255,0.16), rgba(255,255,255,0.03))",
border: "1px solid rgba(255,255,255,0.14)",
borderRadius: 10,
width: 52,
height: 52,
boxShadow: "0 2px 5px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.18)",
touchAction: "none",
transition: "transform 0.08s ease, filter 0.08s ease",
};
return (
<>
<div ref={mapContainerRef} style={{ position: "absolute", inset: 0 }} />
{/* Plain CSS for press feedback -- simplest way to add a ":active" rule
from inside a single self-contained component file. */}
<style>{`.touch-dpad-btn:active { transform: scale(0.93); filter: brightness(1.2); }`}</style>
<div
style={{
position: "absolute",
bottom: 16,
left: "50%",
transform: "translateX(-50%)",
display: "grid",
gridTemplateColumns: "repeat(3, 52px)",
gridTemplateRows: "repeat(2, 52px)",
gap: 8,
padding: 10,
borderRadius: 16,
background: "rgba(13, 17, 23, 0.55)",
backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(8px)",
border: "1px solid rgba(255,255,255,0.08)",
boxShadow: "0 8px 24px rgba(0,0,0,0.35)",
}}
>
<button
type="button"
className="touch-dpad-btn"
style={{ ...buttonStyle, gridArea: "1 / 1" }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
keysDownRef.current.add("ArrowLeft");
}}
onLostPointerCapture={() => keysDownRef.current.delete("ArrowLeft")}
>
◀
</button>
<button
type="button"
className="touch-dpad-btn"
style={{ ...buttonStyle, gridArea: "1 / 2" }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
keysDownRef.current.add("KeyW");
}}
onLostPointerCapture={() => keysDownRef.current.delete("KeyW")}
>
W
</button>
<button
type="button"
className="touch-dpad-btn"
style={{ ...buttonStyle, gridArea: "1 / 3" }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
keysDownRef.current.add("ArrowRight");
}}
onLostPointerCapture={() => keysDownRef.current.delete("ArrowRight")}
>
▶
</button>
<button
type="button"
className="touch-dpad-btn"
style={{ ...buttonStyle, gridArea: "2 / 1" }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
keysDownRef.current.add("KeyA");
}}
onLostPointerCapture={() => keysDownRef.current.delete("KeyA")}
>
A
</button>
<button
type="button"
className="touch-dpad-btn"
style={{ ...buttonStyle, gridArea: "2 / 2" }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
keysDownRef.current.add("KeyS");
}}
onLostPointerCapture={() => keysDownRef.current.delete("KeyS")}
>
S
</button>
<button
type="button"
className="touch-dpad-btn"
style={{ ...buttonStyle, gridArea: "2 / 3" }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
keysDownRef.current.add("KeyD");
}}
onLostPointerCapture={() => keysDownRef.current.delete("KeyD")}
>
D
</button>
</div>
</>
);
}