Skip to content
Blog

Interactive GTA V map for FiveM NUI with Leaflet

Build a zoomable GTA V map in a FiveM NUI with Leaflet: a CRS for game coordinates, blips, the live player position, routes, zones and click-to-waypoint.

By Termi

11 min read
  • NUI
  • Leaflet
  • Lua
  • Maps
On this page

Most FiveM map UIs start the same way: someone screenshots the pause menu map, drops it into an <img> and spends an evening fighting pixel offsets. Leaflet already knows how to show a huge image as zoomable tiles, draw markers, lines and circles, and tell you where the user clicked. All it needs is a coordinate system that matches GTA V.

This guide builds a NUI map for a FiveM resource from scratch: the Leaflet setup, the CRS that converts game coordinates, blips, the live player position from Lua, a route, a zone, and a click on the map that sets a waypoint. The GTA V tiles are hosted on the FiveDock CDN and free to use, so you get a working map without hosting anything yourself.

What you will build

A resource with a NUI page that opens a large GTA V map when you type /map. The map:

  • follows the player, with the position sent from client Lua four times a second,
  • shows blips for places you define in game coordinates,
  • draws a route between two points and a circular zone,
  • sends the game coordinates of a click back to Lua, where we set a waypoint.

Everything uses Leaflet 1.9, which runs fine in the NUI browser.

The resource: fxmanifest and files

A NUI resource is an ordinary resource with a ui_page. Every file the page loads from the resource must also be listed under files, otherwise the NUI browser gets a 404 for it.

fxmanifest.lua
fx_version 'cerulean'
game 'gta5'

client_script 'client.lua'

ui_page 'html/index.html'

files {
    'html/index.html',
    'html/style.css',
    'html/app.js',
}
files
my-map/
├── fxmanifest.lua
├── client.lua
└── html/
    ├── index.html
    ├── style.css
    └── app.js

Leaflet from a CDN, or bundled

The quickest start is loading Leaflet from a CDN. The NUI browser loads remote scripts and caches them like any other browser. Pin the version and keep the integrity hashes, so a file that changed on the CDN never runs:

html/index.html
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <link
      rel="stylesheet"
      href="https://unpkg.com/[email protected]/dist/leaflet.css"
      integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
      crossorigin=""
    />
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div id="map"></div>
    <script
      src="https://unpkg.com/[email protected]/dist/leaflet.js"
      integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
      crossorigin=""
    ></script>
    <script src="app.js"></script>
  </body>
</html>
html/style.css
html,
body {
  margin: 0;
  height: 100%;
  background: transparent;
}

#map {
  position: absolute;
  inset: 5vh 5vw;
  display: none;
  border-radius: 12px;
  /* the sea colour of the tiles, behind the edges and tiles still loading */
  background: #0fa8d2;
}

#map.open {
  display: block;
}

If you would rather not depend on a third-party CDN, bundle it: download leaflet.js and leaflet.css into html/vendor/, point the two tags at them and add 'html/vendor/*' to files. If your NUI is a Vite or React app, npm install leaflet and import it instead; the rest of this guide stays the same.

Leaflet’s default marker pins are images that live next to its CSS, and in a NUI they have to be in files too. This guide uses circle markers, which need no images at all.

Game coordinates on a Leaflet map

Leaflet thinks in latitude and longitude on a round Earth. GTA V is flat and measured in metres, with x growing to the east and y to the north. L.CRS.Simple is Leaflet’s flat coordinate system. We keep its projection, L.Projection.LonLat, which uses longitude as x and latitude as y, and swap in a transformation that lines game coordinates up with the map tiles:

html/app.js
// app.js, part 1: the coordinate system
const GTA_CRS = L.extend({}, L.CRS.Simple, {
  projection: L.Projection.LonLat,
  scale: (zoom) => Math.pow(2, zoom),
  zoom: (scale) => Math.log(scale) / Math.LN2,
  transformation: new L.Transformation(0.02072, 117.3, -0.0205, 172.8),
  infinite: true,
});

// A GTA world position (x, y) is L.latLng(y, x) on this map.
function toLatLng(x, y) {
  return L.latLng(y, x);
}

// ...and back: lng is the game x, lat is the game y.
function toGame(latlng) {
  return { x: latlng.lng, y: latlng.lat };
}

What the numbers mean: at zoom 0 the whole map is the first tile, 256 × 256 pixels, and a game position (x, y) lands on the pixel (0.02072 · x + 117.3, −0.0205 · y + 172.8). Each zoom level doubles that (scale is 2 to the power of the zoom), so at zoom 5 the map is 8,192 pixels wide. The y factor is negative because game y points north while pixel y points down. The FiveDock tiles are cut for exactly this calibration.

One rule to remember

Leaflet takes [lat, lng], so a game position goes in as [y, x]. Call toLatLng(x, y) everywhere and you will never swap them by accident.

Add the GTA V tiles

Create the map with the CRS and add the tile layer. FiveDock hosts a GTA V tile set for anyone to use, at zoom levels 0 to 5:

html/app.js
// app.js, part 2: the map and the tiles
const TILE_SIZE = 256;
const MAP_SIZE = 8192;
const NATIVE_ZOOM = 5;

const map = L.map('map', {
  crs: GTA_CRS,
  minZoom: 2,
  maxZoom: 6,
  zoomControl: false,
  preferCanvas: true,
});

// The tiles hold one MAP_SIZE × MAP_SIZE image at zoom 5, from their top-left corner.
const worldBounds = L.latLngBounds(
  map.unproject([0, MAP_SIZE], NATIVE_ZOOM),
  map.unproject([MAP_SIZE, 0], NATIVE_ZOOM),
);

L.tileLayer('https://cdn.fivedock.dev/o/fivedock/maps/styleatlas/{z}/{x}/{y}.jpg', {
  tileSize: TILE_SIZE,
  minZoom: 0,
  maxZoom: 6,
  maxNativeZoom: NATIVE_ZOOM,
  bounds: worldBounds,
  attribution: 'Map © Rockstar Games',
}).addTo(map);

map.setMaxBounds(worldBounds.pad(0.1));
map.setView(toLatLng(0, 0), 3);

Two options do the heavy lifting. bounds matters with this CRS: infinite: true means Leaflet has no idea where the world ends, and without bounds it would ask for tiles far outside the map that can only come back as 404s. maxNativeZoom lets players zoom one level past the last tile level: Leaflet scales the zoom 5 tiles up instead of requesting tiles that do not exist.

Markers and blips from game coordinates

Anything you know the game coordinates of can go on the map. With preferCanvas, circle markers are drawn on one canvas, so even hundreds of them stay smooth:

html/app.js
// app.js, part 3: blips
const blips = [
  { label: 'Mission Row PD', x: 441.0, y: -982.0, color: '#3b82f6' },
  { label: 'Pillbox Hill Medical', x: 298.6, y: -584.5, color: '#ef4444' },
  { label: 'LSIA', x: -1037.0, y: -2737.0, color: '#f59e0b' },
];

for (const blip of blips) {
  L.circleMarker(toLatLng(blip.x, blip.y), {
    radius: 6,
    color: '#ffffff',
    weight: 2,
    fillColor: blip.color,
    fillOpacity: 1,
  })
    .bindTooltip(blip.label)
    .addTo(map);
}

The coordinates are approximate; stand somewhere in game and print GetEntityCoords(PlayerPedId()) to get exact ones. If you prefer icons, L.marker with an L.icon that points at an image in your html/ folder works the same way, as long as the image is listed in files.

Show the player’s position

The NUI page cannot read game state, so client Lua has to send it. SendNUIMessage delivers a table to the page as a message event. Open the map with a command and, while it is open, send the position four times a second:

client.lua
local isOpen = false

RegisterCommand('map', function()
    isOpen = true
    SetNuiFocus(true, true)
    SendNUIMessage({ action = 'open' })
end, false)

CreateThread(function()
    while true do
        if isOpen then
            local coords = GetEntityCoords(PlayerPedId())
            SendNUIMessage({ action = 'player', x = coords.x, y = coords.y })
            Wait(250)
        else
            Wait(1000)
        end
    end
end)
html/app.js
// app.js, part 4: messages from Lua
const mapElement = document.getElementById('map');
let player = null;
let followPlayer = true;

window.addEventListener('message', (event) => {
  const data = event.data;

  if (data.action === 'open') {
    mapElement.classList.add('open');
    map.invalidateSize();
    followPlayer = true;
  }

  if (data.action === 'close') {
    mapElement.classList.remove('open');
  }

  if (data.action === 'player') {
    const position = toLatLng(data.x, data.y);
    if (player) {
      player.setLatLng(position);
    } else {
      player = L.circleMarker(position, {
        radius: 7,
        color: '#ffffff',
        weight: 2,
        fillColor: '#22c55e',
        fillOpacity: 1,
      }).addTo(map);
    }
    if (followPlayer) map.panTo(position, { animate: false });
  }
});

// stop following once the player drags the map around
map.on('dragstart', () => {
  followPlayer = false;
});

Note the map.invalidateSize() call. Leaflet measures its container when the map is created, and ours starts hidden. Without that call you get a grey map with a single tile in a corner.

Draw a route and a zone

Lines and circles take the same converted coordinates. A route is a polyline through game positions; a zone is a circle with a radius in game units:

html/app.js
// app.js, part 5: a route and a zone
// [x, y] in game coordinates: Mission Row PD to Pillbox Hill Medical
const route = [
  [441.0, -982.0],
  [402.0, -860.0],
  [298.6, -584.5],
];

L.polyline(
  route.map(([x, y]) => toLatLng(x, y)),
  { color: '#22c55e', weight: 4, opacity: 0.9 },
).addTo(map);

// radius in game units, which are metres
L.circle(toLatLng(441.0, -982.0), {
  radius: 80,
  color: '#3b82f6',
  fillOpacity: 0.15,
}).addTo(map);

When the CRS is not the Earth, L.circle measures its radius in the units of the CRS itself, so radius: 80 is 80 metres in the game. A real route, from your own waypoints or a GPS script, is just a longer list of points.

Click the map, get GTA coordinates in Lua

A click gives you a LatLng, and with our CRS its lng is the game x and its lat the game y. Post it to a NUI callback:

html/app.js
// app.js, part 6: clicks and closing, back to Lua
function post(name, body) {
  return fetch(`https://${GetParentResourceName()}/${name}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=UTF-8' },
    body: JSON.stringify(body),
  });
}

map.on('click', (event) => {
  post('mapClick', toGame(event.latlng));
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') post('close', {});
});
client.lua
RegisterNUICallback('mapClick', function(data, cb)
    SetNewWaypoint(data.x + 0.0, data.y + 0.0)
    cb('ok')
end)

RegisterNUICallback('close', function(_, cb)
    isOpen = false
    SetNuiFocus(false, false)
    SendNUIMessage({ action = 'close' })
    cb('ok')
end)

GetParentResourceName() is available in every NUI page and returns the name of your resource. The + 0.0 makes sure the values reach the native as floats. The same pattern works for anything you want to pick on a map: a spawn point, a drop-off, a crime scene for your MDT.

Keep it fast

  • Leaflet requests only the tiles in view at the current zoom plus a small margin, never the whole pyramid, so opening the map costs a handful of small images, not megabytes.
  • NUI is Chromium with a normal HTTP cache. FiveDock keyed links (/o/…) may be cached for 5 minutes and are then revalidated with an ETag, so an unchanged tile costs a tiny 304 instead of a download.
  • The tiles are not in files, so they add nothing to what players download when they join your server.
  • Four position updates a second look smooth on a map; sending one every frame keeps Lua and the NUI busy for nothing.
  • preferCanvas: true draws circle markers, lines and zones on one canvas instead of hundreds of SVG elements.
  • Keep one map and toggle its visibility instead of rebuilding it. Creating a new map on every open throws away the tiles and markers you already have.

Common questions

How do I convert GTA V coordinates for a Leaflet map?

Use L.CRS.Simple with its L.Projection.LonLat projection and your own L.Transformation calibrated to the tiles, and pass every game position as [y, x], because Leaflet takes latitude first. With the FiveDock tiles, a game position (x, y) lands on the pixel (0.02072 · x + 117.3, −0.0205 · y + 172.8) at zoom 0, and every zoom level doubles that. A small toLatLng(x, y) helper keeps you from swapping the two.

Why is my Leaflet map grey in a FiveM NUI?

Leaflet measures its container when the map is created, and a NUI map usually starts hidden, so it sizes itself to nothing and shows a grey area with one tile in a corner. Call map.invalidateSize() right after you show the map. If the console fills with 404s for tiles, set bounds on the tile layer so Leaflet stops asking for tiles outside the map.

Why does my NUI page get a 404 for its own files?

Every file the NUI page loads from the resource, scripts, stylesheets and images alike, has to be listed under files in fxmanifest.lua, next to ui_page. A file that is not listed is never sent to the client, so the NUI browser gets a 404 for it. Leaflet’s default marker images count too, which is why this guide uses circle markers.

Where can I get GTA V map tiles for Leaflet?

FiveDock hosts a GTA V tile set at zoom levels 0 to 5 that anyone can use in a FiveM NUI, with nothing to host yourself. For your own style, such as a satellite render or a map with custom roads, cut {z}/{x}/{y} tiles with a tool like gdal2tiles.py -p raster --xyz and put them on a CDN.

Host your own map tiles on FiveDock

The shared tiles cover the base game. If you have your own style (a satellite render, a dark theme, a map with your custom roads and MLOs), host it the same way. Leaflet wants one folder per zoom level, one folder per column inside it and one image per tile: {z}/{x}/{y}.png (or .jpg, like the base map). Any tool that writes XYZ tiles works, for example gdal2tiles.py -p raster --xyz. Keep the framing of the base map (the same area of the world, one 8,192 × 8,192 pixel image at zoom 5, starting in the top-left corner of the tiles) and the calibration above still fits; if your image covers a different area or sits at a different zoom, the transformation numbers change with it.

Zip and upload

Zip what is inside the tile folder, not the folder itself: folders inside an archive are kept, so tiles/ would end up in every link:

bash
# tiles/0/0/0.png, tiles/1/0/0.png, ... tiles/5/31/31.png
cd tiles && zip -r ../my-map.zip .

FiveDock unpacks .zip and .rar archives on upload. In the panel, open Files, create the folder maps/my-map and open it, then click Upload and drop the zip. Keep file names is on by default for archives; leave it on. The archive holds the zoom folders themselves, not tiles/, so every tile becomes a file with a stable key such as maps/my-map/3/2/1.png. The same upload over the API, with a token that has the files:write scope:

bash
curl -X POST https://api.fivedock.dev/api/v3/file/archive \
  -H "Authorization: Bearer fd_live_…" \
  -F "[email protected]" \
  -F "path=maps/my-map"

The response lists every stored file under uploaded and anything that was skipped under skipped, with a reason.

Big tile sets

An archive can hold up to 5,000 files. The base map from zoom 0 to 5 is 1,365 tiles and fits in one. Cut one level deeper and zoom 6 alone adds 4,096, so the set no longer fits: put that level in an archive of its own (run from the same folder, the zoom folder stays in the keys). Every further level is four times bigger again; for sets that big, copy the whole folder over the S3 endpoint with rclone:

bash
cd tiles
zip -r ../my-map.zip . -x '6/*'
zip -r ../my-map-6.zip 6
rclone
# my-city = your workspace slug (it is also your S3 bucket name)
rclone copy ./tiles fivedock:my-city/maps/my-map --transfers 16

Point Leaflet at your tiles

Every tile now has a link that never changes, https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png, where <your-workspace> is your workspace slug from the panel. Swap it into the tile layer:

html/app.js
L.tileLayer('https://cdn.fivedock.dev/o/<your-workspace>/maps/my-map/{z}/{x}/{y}.png', {
  tileSize: TILE_SIZE,
  minZoom: 0,
  maxZoom: 6,
  maxNativeZoom: NATIVE_ZOOM,
  bounds: worldBounds,
}).addTo(map);

Found a bad tile? Upload a fixed one with the same key, for example maps/my-map/4/3/2.png. It replaces the old file behind the same link, and players see it within about five minutes, once their cached copy expires.

That is the whole map: the six app.js parts above, in order, in one file. ensure the resource, join, and type /map. The same keyed links work for anything else your UI shows, from item images to mugshots.

All articles