Skip to content

Walk around a map in first person

Drive an eye-height, level first-person camera over 3D buildings with the keyboard or on-screen buttons

import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.1.0/dist/maplibre-gl.mjs';

const EYE_HEIGHT = 1.6;
const WALK_SPEED = 4;
const TURN_SPEED = 2;
const LOOK_AHEAD = 20;
const ORIGIN = [-87.629252, 41.876706];

const originMercator = maplibregl.MercatorCoordinate.fromLngLat(ORIGIN);
const metresToMercatorUnits = originMercator.meterInMercatorCoordinateUnits();
const player = {x: 0, y: 0, heading: Math.PI / 2};

function toLngLat(x, y) {
    return new maplibregl.MercatorCoordinate(
        originMercator.x + x * metresToMercatorUnits,
        originMercator.y - y * metresToMercatorUnits
    ).toLngLat();
}

const map = new maplibregl.Map({
    container: 'map',
    style: 'https://tiles.openfreemap.org/styles/liberty',
    center: ORIGIN,
    zoom: 18,
    pitch: 90,
    maxPitch: 95,
    centerClampedToGround: false,
    interactive: false
});

function updateCamera() {
    const eye = toLngLat(player.x, player.y);
    const ahead = toLngLat(
        player.x + Math.cos(player.heading) * LOOK_AHEAD,
        player.y + Math.sin(player.heading) * LOOK_AHEAD
    );
    map.jumpTo(map.calculateCameraOptionsFromTo(eye, EYE_HEIGHT, ahead, EYE_HEIGHT));
}

// Keyboard on desktop, on-screen buttons on touch, both feed the same key set.
const keysDown = new Set();
addEventListener('keydown', (event) => keysDown.add(event.code));
addEventListener('keyup', (event) => keysDown.delete(event.code));
for (const button of document.querySelectorAll('#controls button')) {
    const code = button.dataset.key;
    button.addEventListener('pointerdown', (event) => {
        event.preventDefault();
        // Capture so the single lostpointercapture event below covers release,
        // leaving the button, and touch cancellation.
        button.setPointerCapture(event.pointerId);
        keysDown.add(code);
    });
    button.addEventListener('lostpointercapture', () => keysDown.delete(code));
}

let lastFrameTime = performance.now();
function loop(now) {
    const dt = Math.min(0.05, (now - lastFrameTime) / 1000);
    lastFrameTime = now;

    if (keysDown.has('KeyA')) player.heading += TURN_SPEED * dt;
    if (keysDown.has('KeyD')) player.heading -= TURN_SPEED * dt;
    let step = 0;
    if (keysDown.has('KeyW')) step += WALK_SPEED * dt;
    if (keysDown.has('KeyS')) step -= WALK_SPEED * dt;
    player.x += Math.cos(player.heading) * step;
    player.y += Math.sin(player.heading) * step;

    updateCamera();
    requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
<!DOCTYPE html>
<html lang="en">
<head>
<title>Walk around a map in first person</title>
<meta property="og:description" content="Drive an eye-height, level first-person camera over 3D buildings with the keyboard or on-screen buttons" />
<meta property="og:category" content="Camera & Animation" />
<meta property="og:created" content="2026-07-25" />
<meta charset='utf-8'>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<link rel='stylesheet' href='https://unpkg.com/maplibre-gl@6.1.0/dist/maplibre-gl.css' />
<style>
    body { margin: 0; padding: 0; }
    html, body, #map { height: 100%; }
    #controls {
        position: absolute;
        bottom: 16px;
        left: 50%;
        transform: translateX(-50%);
        display: grid;
        grid-template-columns: repeat(3, 52px);
        grid-auto-rows: 52px;
        gap: 6px;
        touch-action: none;
    }
    #controls button {
        font: 600 18px system-ui, sans-serif;
        color: #fff;
        background: rgba(0, 0, 0, .5);
        border: none;
        border-radius: 8px;
    }
    #controls .up { grid-area: 1 / 2; }
    #controls .left { grid-area: 2 / 1; }
    #controls .down { grid-area: 2 / 2; }
    #controls .right { grid-area: 2 / 3; }
</style>
</head>
<body>
<div id="map"></div>
<div id="controls">
    <button type="button" class="up" data-key="KeyW">W</button>
    <button type="button" class="left" data-key="KeyA">A</button>
    <button type="button" class="down" data-key="KeyS">S</button>
    <button type="button" class="right" data-key="KeyD">D</button>
</div>

<script type="module">
import * as maplibregl from 'https://unpkg.com/maplibre-gl@6.1.0/dist/maplibre-gl.mjs';

const EYE_HEIGHT = 1.6;
const WALK_SPEED = 4;
const TURN_SPEED = 2;
const LOOK_AHEAD = 20;
const ORIGIN = [-87.629252, 41.876706];

const originMercator = maplibregl.MercatorCoordinate.fromLngLat(ORIGIN);
const metresToMercatorUnits = originMercator.meterInMercatorCoordinateUnits();
const player = {x: 0, y: 0, heading: Math.PI / 2};

function toLngLat(x, y) {
    return new maplibregl.MercatorCoordinate(
        originMercator.x + x * metresToMercatorUnits,
        originMercator.y - y * metresToMercatorUnits
    ).toLngLat();
}

const map = new maplibregl.Map({
    container: 'map',
    style: 'https://tiles.openfreemap.org/styles/liberty',
    center: ORIGIN,
    zoom: 18,
    pitch: 90,
    maxPitch: 95,
    centerClampedToGround: false,
    interactive: false
});

function updateCamera() {
    const eye = toLngLat(player.x, player.y);
    const ahead = toLngLat(
        player.x + Math.cos(player.heading) * LOOK_AHEAD,
        player.y + Math.sin(player.heading) * LOOK_AHEAD
    );
    map.jumpTo(map.calculateCameraOptionsFromTo(eye, EYE_HEIGHT, ahead, EYE_HEIGHT));
}

// Keyboard on desktop, on-screen buttons on touch, both feed the same key set.
const keysDown = new Set();
addEventListener('keydown', (event) => keysDown.add(event.code));
addEventListener('keyup', (event) => keysDown.delete(event.code));
for (const button of document.querySelectorAll('#controls button')) {
    const code = button.dataset.key;
    button.addEventListener('pointerdown', (event) => {
        event.preventDefault();
        // Capture so the single lostpointercapture event below covers release,
        // leaving the button, and touch cancellation.
        button.setPointerCapture(event.pointerId);
        keysDown.add(code);
    });
    button.addEventListener('lostpointercapture', () => keysDown.delete(code));
}

let lastFrameTime = performance.now();
function loop(now) {
    const dt = Math.min(0.05, (now - lastFrameTime) / 1000);
    lastFrameTime = now;

    if (keysDown.has('KeyA')) player.heading += TURN_SPEED * dt;
    if (keysDown.has('KeyD')) player.heading -= TURN_SPEED * dt;
    let step = 0;
    if (keysDown.has('KeyW')) step += WALK_SPEED * dt;
    if (keysDown.has('KeyS')) step -= WALK_SPEED * dt;
    player.x += Math.cos(player.heading) * step;
    player.y += Math.sin(player.heading) * step;

    updateCamera();
    requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>