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
- NUI
- Leaflet
- Lua
- Maps
On this page
- What you will build
- The resource: fxmanifest and files
- Leaflet from a CDN, or bundled
- Game coordinates on a Leaflet map
- Add the GTA V tiles
- Markers and blips from game coordinates
- Show the player’s position
- Draw a route and a zone
- Click the map, get GTA coordinates in Lua
- Keep it fast
- Common questions
- How do I convert GTA V coordinates for a Leaflet map?
- Why is my Leaflet map grey in a FiveM NUI?
- Why does my NUI page get a 404 for its own files?
- Where can I get GTA V map tiles for Leaflet?
- Host your own map tiles on FiveDock
- Zip and upload
- Point Leaflet at your tiles
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:
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.
fx_version 'cerulean'
game 'gta5'
client_script 'client.lua'
ui_page 'html/index.html'
files {
'html/index.html',
'html/style.css',
'html/app.js',
}my-map/
├── fxmanifest.lua
├── client.lua
└── html/
├── index.html
├── style.css
└── app.jsLeaflet 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:
<!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,
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:
// 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:
// 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:
// 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:
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)// 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:
// 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:
// 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', {});
});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
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:
# 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:
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:
cd tiles
zip -r ../my-map.zip . -x '6/*'
zip -r ../my-map-6.zip 6# my-city = your workspace slug (it is also your S3 bucket name)
rclone copy ./tiles fivedock:my-city/maps/my-map --transfers 16Point 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:
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.