I’ve been playing Dungeons and Dragons as a player for quite a while, but when the latest adventure came to a close I decided to step up to the plate and run the next campaign in my own Homebrew setting!

I really wanted to give my players a world map that allowed them to zoom and pan around to explore the world I had created with markers on towns and cities, but couldn’t find anything that fit the build, until I discovered Leaflet.js (which is even mobile friendly!)

If you’re wanting to see the end results of this project before starting, you can see the map and code at the bottom of this post. This guide will work on both Mac and Windows and whilst the guide does involve typing a little bit of code, everything is detailed below. You’ll also need Photoshop, anything higher than CS2 will work.

Getting Your World Map Ready

For the purposes of this guide, I’m going to assume that you’ve already got a world map ready to work on! If you don’t currently have one then you can generate lovely looking maps on Azgaar’s Fantasy Map Generator.

Also if anyone is interested, I will happily write a post about how I made the world map that you can see throughout this article. Let me know in the comment section!

After finalising your world map you need to convert your single image map into a collection of tiles–this is to save on bandwidth, but also provides tiles for the different zoom levels, as less detail is needed when zoomed out.

The first thing you’ll want to do is create a project folder, this will contain everything needed for the end result to upload!

To create the tiles you’ll need to use the photoshop-google-maps-tile-cutter script by bramus. This script is straightforward but can take a while depending on the size of your map. Other than the export path, you’ll want the script export settings to match the settings below. Additionally, you can change the background colour to match your water.

NOTE: Make sure that your map size is a multiple of 256, just increase your canvas size to the next multiple. (My map is 15360 x 15360)

This script also creates a Google Maps version of the image, however, due to some API changes it no longer works properly–this was the first route I went down before encountering these issues.

After you’ve created all of your tiles you’ll want to run the end results through a .png optimiser. This gets rid of unneeded data without any quality reduction. My program of choice for this is PNGYU which is based on pngquant and allows batch compressing!

To do this, drag the whole exported folder into PNGYU, and use the following settings.

When this has finished exporting, you’ll want to place the end result in its own folder within your project folder. I recommend naming this folder map, the end result should look something like this.

Getting Started With Leaflet.js

Now that you’ve tiled and compressed your map it’s now time to get started with Leaflet.js. The first thing you’ll want to do is jump over to their website, leafletjs.com. The website is extremely useful and well documented, so if you’re after additional features I recommend having a look!

Navigate to the downloads page and download the latest version (Leaflet 1.5.1 at the time of writing). After downloading you’ll want to open up your project folder and create a new folder called scripts. Then extract the .zip into that folder.

After doing this you’ll start creating the actual map! You can use any text editor, but I recommend using Notepad++ or Atom. Firstly create a new file in your chosen editor and insert the below text, with the only variable to change being the background to the same colour you used earlier.

<!DOCTYPE html> <html = style="height: 100%;"> <head> <title>DnD World Map</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="scripts/leaflet.css"> <script src="scripts/leaflet.js"></script> </head> <body style="height: 100%;margin: 0;"> <div id="map" style="width: 100%; height: 100%; background: #000000;"></div> <script type="text/javascript"> //Creating the Map var map = L.map('map').setView([0, 0], 0); L.tileLayer('map/{z}/{x}/{y}.png', { continuousWorld: false, noWrap: true, minZoom: 0, maxZoom: 10, }).addTo(map); //Coordinate Finder var marker = L.marker([0, 0], { draggable: true, }).addTo(map); marker.bindPopup('LatLng Marker').openPopup(); marker.on('dragend', function(e) { marker.getPopup().setContent(marker.getLatLng().toString()).openOn(map); }); //Markers </script> </body> </html>

After adding this to the file, save it to your project folder and name it index.html. If you open the file now you should be greeted by your world map! There are a couple of variables that you will need to adjust depending on the size of your world map image.

For me, the Photoshop Script created folders from 0 to 6 – which correspond to different zoom levels. Your index.html file will open to your map at zoom level 0, which for me was far too zoomed out! To fix this I changed the minZoom variable to 3–which corresponds to pressing the + button 3 times. Adjust this until you are happy with the minimum zoom value.

You will also notice that if you press the ‘Zoom In’ button several times your map will disappear completely. This is because you will only be able to zoom in the number of folders available. To fix this, you will want to set your maxZoom to the highest folder number–for me this valve is 6. If you don’t like how close you can zoom in, decrease this number further.

After you’re happy with the minimum and maximum zoom levels you can then delete the folders outside of this range, this will save you space by not uploading unnecessary files.

Creating Map Markers

Now that you’ve added and configured your map in Leaflet.js it’s time to add some markers! You’ll notice that there is already one marker in the centre of your map named LatLng. This is what you’ll be using to create additional markers.

For this section I recommend creating a spreadsheet to list all of your different towns and places that you are wanting to put markers on. The spreadsheet should include:

A unique ID

X Coordinate (First number in bracket)

Y Coordinate (Second number in bracket)

Drag the marker around the map and make note of the X,Y coordinates and give each location a unique ID–I went from East to West across my map to make sure I collated all places. Don’t worry if you miss any, you can come back and add them later!

Now that you’ve created a list of all your markers/waypoints you can begin to add them to the map! You will need to create an individual line for each, and you will want to add them underneath //Markers in your HTML file. Here are several examples from my map from the above spreadsheet.

var el_gulndar = L.marker([36.0135, -106.3916]).bindPopup('<b>Gulndar</b>').addTo(map); var el_teglhus = L.marker([44.4965, -100.7666]).bindPopup('<b>Teglhus</b>').addTo(map); var el_ochri_college = L.marker([48.5166,-103.4692]).bindPopup('<b>Ochri College</b>').addTo(map);

As you can see in my example, it has created three markers on my world map which when clicked on show their place-name. The generic syntax for creating these markers is as follows, with the words in capitals needing to be changed.

var UNIQUE ID = L.marker([X VALUE, Y VALUE]).bindPopup('PLACE NAME').addTo(map)

After you’re happy that you have all of your markers in the right place and working, you can delete the text between //Coordinate Finder and //Markers as this is no longer required.

Grouping Markers

After adding your markers your world map might look messy due to the number of markers covering it, this is where grouping markers together is extremely useful!

The first stage to grouping your markers will be to remove some code from each marker. Currently, your markers include .addTo(map) which means that marker will be visible. Remove .addTo(map) from all of your markers, but make sure to leave the ; at the end of each line. This means that all markers will now disappear from your map.

How you decide to group your markers is entirely up to you! I broke mine up into the following categories:

Mage Colleges

Trading Posts

Cities Towns

Forts/Castles

Temples

Once you have finalised your groups you’ll want to create your group variables–you will need one of these for each group. At the bottom of your marker section add the following code, again with the variables you need to change in capitals:

//Marker Groups var mg_GROUPNAME = L.layerGroup([LIST,OF,MARKERS]); var mg_ANOTHERGROUP = L.layerGroup([SOME,MORE,PLACES]); //Marker Overlay var overlays={ "GROUPNAME" : mg_GROUPNAME, "ANOTHERGROUP" : mg_ANOTHERGROUP, } //GROUP CONTROLS L.control.layers(null, overlays).addTo(map);

After changing all of the above variables the full marker code looked like this:

//Markers var el_gulndar = L.marker([36.4919, -114.038]).bindPopup('<b>Gulndar</b>'); var el_teglhus = L.marker([45.3058, -108.413]).bindPopup('<b>Teglhus</b>'); var el_ochri_college = L.marker([48.5166,-111.2255]).bindPopup('<b>Ochri College</b>'); //Marker Groups var mg_towns = L.layerGroup([el_gulndar,el_teglhus]); var mg_towers = L.layerGroup([el_ochri_college]); //Marker Overlay var overlays={ "Towns" : mg_towns, "Towers" : mg_towers, } //GROUP CONTROLS L.control.layers(null, overlays).addTo(map);

When you now load your map, you will notice that all of your markers have disappeared! In order to see the markers, use the menu at the top right of the screen to hide and show marker groups!

Now depending on how you’re planning to use this map, you’ll either want to upload the contents of your project folder to the root directory of your website. This will mean the map will load instantly when you type in your url.

If you are wanting to do something similar to the below map, instead rename and upload the whole project folder then create an iframe on your website site using the below code structure:

<iframe src="https://URL/PROJECT FOLDER" width="100%" height="600"></iframe>

This project has been something I was really happy to complete. After nearly giving up I am extremely happy that I found Leaflet.js! I know that the above can be a little bit confusing so if you have any questions, drop a comment below and I’d be more than happy to answer them!

Project Result

Project Code

<!DOCTYPE = html> <html style="height: 100%;"> <head> <title>Oakla</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="stylesheet" href="scripts/leaflet.css"> <script src="scripts/leaflet.js"></script> </head> <body style="height: 100%; margin: 0;"> <div id="map" style="width: 100%; height: 100%; background: #53679f;"></div> <script type="text/javascript"> //Creating the Map var map = L.map('map').setView([0, 0], 0); L.tileLayer('tiles/{z}/{x}/{y}.png', { continuousWorld: false, noWrap: true, minZoom: 3, maxZoom: 6, }).addTo(map); //Eldrin var el_gulndar = L.marker([36.0135, -106.3916]).bindPopup('<b>Gulndar</b>'); var el_teglhus = L.marker([44.4965, -100.7666]).bindPopup('<b>Teglhus</b>'); var el_ochri_college = L.marker([48.5166,-103.4692]).bindPopup('<b>Ochri College</b>'); var el_circle_of_the_land = L.marker([32.2871,-85.8691]).bindPopup('<b>Druid Camp</b>'); var el_westhaven = L.marker([18.3128,-109.6875]).bindPopup('<b>Westhaven</b>'); var el_glaenarm = L.marker([19.394,-92.5488]).bindPopup('<b>Glaenarm</b>'); var el_mirstone = L.marker([14.4346,-64.9511]).bindPopup('<b>Mirstone</b>'); var el_butterpond = L.marker([2.8991,-120.6738]).bindPopup('<b>Butterpond</b>'); var el_pirin_post = L.marker([10.9196,-87.6269]).bindPopup('<b>Pirn Post</b>'); var el_mistrith_keep = L.marker([-2.1088,-93.6035]).bindPopup('<b>Mistrith Keep</b>') var el_zimban = L.marker([-18.0623,-106.6113]).bindPopup('<b>Zimban</b>'); var el_noblar = L.marker([-7.2752,-88.8574]).bindPopup('<b>Noblar</b>'); var el_hommet_post = L.marker([-13.4751,-95.4052]).bindPopup("<b>Hommet's Trading Post</b>"); var el_fangdor_fortress = L.marker([-12.8974,-125.332]).bindPopup('<b>Fangdor Fortress</b>'); var el_tel_kibil = L.marker([-20.8793,-79.8925]).bindPopup('<b>Tel Kibil</b>'); var el_saradim = L.marker([-37.7185,-107.4902]).bindPopup('<b>Saradim</b>'); var el_ankoret = L.marker([-30.1451,-95.1855]).bindPopup('<b>Ankoret</b>'); var el_bulasi_stables = L.marker([-41.9676,-94.2187]).bindPopup('<b>Bulasi Stables</b>'); var el_cinders_college = L.marker([-50.5971,-91.4721]).bindPopup('<b>Cinders College</b>'); var el_delarin_stronghold = L.marker([-36.7388,-83.9355]).bindPopup('<b>Delarin Stronghold</b>'); var el_the_golden_palace = L.marker([-26.0567,-69.3237]).bindPopup('<b>The Golden Palace</b>'); var el_the_soot_healer = L.marker([-0.2536,-70.9497]).bindPopup('<b>The Soot Healer</b>'); var el_yarrow = L.marker([-26.1948,-52.8002]).bindPopup('<b>Yarrow</b>'); var el_harron = L.marker([-42.8598,-44.165]).bindPopup('<b>Harron</b>'); var el_tenbrie = L.marker([-55.8136,-46.0986]).bindPopup('<b>Tenbrie</b>'); var el_umberlee = L.marker([-51.9442,-35.4638]).bindPopup('<b>Temple of Umberlee</b>'); var el_dawsbury_post = L.marker([-17.4345,-40.6274]).bindPopup('<b>Dasbury Post</b>'); var el_whitebridge_pass = L.marker([-3.8423,-40.5615]).bindPopup('<b>Whitebridge Pass</b>'); var el_reedwater = L.marker([21.5144,-27.1801]).bindPopup('<b>Reedwater</b>'); var el_nythi_asari = L.marker([-25.0258,-25.1147]).bindPopup('<b>Nythi Asari</b>'); var el_layla_asari = L.marker([-12.1252,-12.788]).bindPopup('<b>Layla Asari</b>'); var el_mystra = L.marker([-9.1671,-27.3339]).bindPopup('<b>Temple of Mystra</b>'); var el_helm = L.marker([-0.2197,-53.4811]).bindPopup('<b>Temple of Helm</b>'); var el_circle_of_the_moon = L.marker([-39.3852,-37.2656]).bindPopup('<b>Druid Camp</b>'); //Maystrus Isle var ma_emyi_dorei = L.marker([9.2973,-0.1977]).bindPopup('<b>Emyi Dorei</b>'); var ma_seiche_college = L.marker([26.3524,1.7138]).bindPopup('<b>Seiche College</b>'); //Amspar Key var am_port_clulx = L.marker([-41.4262,-17.1606]).bindPopup('<b>Port Clulx</b>'); var am_port_khel = L.marker([-28.8446,6.8994]).bindPopup('<b>Port Khel</b>'); //Ordrin var or_jarrens_outpost = L.marker([14.4346,36.4746]).bindPopup("<b>Jarren's Outpost</b>"); var or_myrefall = L.marker([13.5819,21.0058]).bindPopup('<b>Myrefall</b>'); var or_hythe = L.marker([9.102,55.8105]).bindPopup('<b>Hythe</b>'); var or_pavvs_stable = L.marker([-2.3065,42.2094]).bindPopup("<b>Pavv's Stable</b>"); var or_guthram = L.marker([-4.5435,54.9975]).bindPopup('<b>Guthram</b>'); var or_ballymena = L.marker([-12.0393,33.6621]).bindPopup('<b>Ballymena</b>'); var or_eastcliff_crossroad = L.marker([-28.1495,35.2441]).bindPopup('<b>Eastcliff Crossroad</b>'); var or_erast = L.marker([-34.3071,60.1611]).bindPopup('<b>Erast</b>'); var or_grasshope = L.marker([-37.3177,20.2368]).bindPopup('<b>Grasshope</b>'); var or_thornheart_college = L.marker([-44.9803,31.311]).bindPopup('<b>Thornheart College</b>'); var or_torrine = L.marker([-22.1467,61.7431]).bindPopup('<b>Torrine</b>'); var or_port_venzor = L.marker([-30.4486,87.2534]).bindPopup('<b>Port Venzor</b>'); var or_greenflower = L.marker([-42.391,76.8603]).bindPopup('<b>Greenflower</b>'); var or_bellmare = L.marker([-30.6946,125.0683]).bindPopup('<b>Bellmare: City of Fire</b>'); var or_anyor = L.marker([-15.0296,111.3793]).bindPopup('<b>Anyor</b>'); var or_aynor_post = L.marker([-10.5958,122.5854]).bindPopup('<b>Aynor Post</b>'); var or_ormskirk = L.marker([-2.7455,111.1376]).bindPopup('<b>Ormskirk</b>'); var or_garens_well = L.marker([9.1888,106.5893]).bindPopup("<b>Garen's Well</b>"); var or_port_wormbourne = L.marker([20.3652,131.7919]).bindPopup("<b>Port Wormbourne</b>"); var or_tamsworth = L.marker([20.5916,117.7075]).bindPopup("<b>Tamsworth</b>"); var or_nuxvar = L.marker([20.5505,103.0078]).bindPopup("<b>Nuxvar</b>"); var or_orion = L.marker([29.3438,87.9565]).bindPopup("<b>Orion: Capital of Oakla</b>"); var or_skystead_nook = L.marker([19.9113,80.4638]).bindPopup("<b>Skystead Nook</b>"); var or_bramblewoods = L.marker([4.4778,73.7622]).bindPopup("<b>Bramble Woods</b>"); var or_lancer_gate = L.marker([7.6238,69.6972]).bindPopup("<b>Lancer Gate</b>"); var or_leira = L.marker([44.887,54.9755]).bindPopup("<b>Temple of Leira</b>"); var or_kelemvor = L.marker([-12.1252,79.475]).bindPopup("<b>Shrine of Kelemvor</b>"); var or_mask = L.marker([-42.9242,71.2792]).bindPopup("<b>Shine of Mask</b>"); var or_palace_plenty = L.marker([-15.2205,12.1289]).bindPopup("<b>Eiflin: The Palace Plenty</b>"); var or_eldath = L.marker([-1.9112,11.3818]).bindPopup("<b>Temple of Eldath</b>"); var or_bhel_thoram = L.marker([28.652,39.0673]).bindPopup("<b>Bhel Thoram</b>"); var or_kelgrum = L.marker([39.09563,52.0532]).bindPopup("<b>Kelgrum</b>"); var or_gar_dural = L.marker([23.7652,58.3154]).bindPopup("<b>Gar Dural</b>"); var or_bhel_thoram2 = L.marker([27.3717,76.5087]).bindPopup("<b>Bhel Thoram</b>"); var or_dblook_college = L.marker([31.0152,100.0195]).bindPopup("<b>Dblook College</b>"); var or_gilnium_vineyards = L.marker([35.2994,111.5991]).bindPopup("<b>Gilnium Vineyards</b>"); var or_pine_castle = L.marker([-4.5216,89.3408]).bindPopup("<b>Pine Castle</b>"); var or_fort_eaglecrest = L.marker([-8.2114,112.6538]).bindPopup("<b>Fort Eaglecrest</b>"); var or_knifeedge_creek = L.marker([-20.8177,109.0942]).bindPopup("<b>Knife-edge Creek</b>"); var or_healthy_horse_post = L.marker([-11.4154,58.8427]).bindPopup("<b>The Healthy Horse Post</b>"); var or_malar = L.marker([52.6563,96.8774]).bindPopup("<b>Temple of Malar</b>"); var or_kejgrav = L.marker([54.7246,73.3007]).bindPopup("<b>Kejgrav</b>"); var or_rukule_cross = L.marker([47.2344,88.1103]).bindPopup("<b>Rukule Cross</b>"); var or_norreborg = L.marker([43.0046,108.9843]).bindPopup("<b>Nørborg</b>"); var or_world_rod = L.marker([43.7234,77.7512]).bindPopup("<b>World Rod?</b>"); //Marker Groups var mg_mage_colleges = L.layerGroup([el_ochri_college,el_cinders_college,ma_seiche_college,or_thornheart_college,or_dblook_college]); var mg_trading_posts = L.layerGroup([el_pirin_post,el_hommet_post,el_bulasi_stables,el_dawsbury_post,or_jarrens_outpost,or_pavvs_stable,or_eastcliff_crossroad,or_aynor_post,or_garens_well,or_skystead_nook,or_gilnium_vineyards,or_healthy_horse_post,or_rukule_cross]); var mg_cities = L.layerGroup([el_gulndar,el_glaenarm,el_butterpond,el_noblar,el_ankoret,el_yarrow,el_tenbrie,el_layla_asari,ma_emyi_dorei,or_myrefall,or_hythe,or_ballymena,or_grasshope,or_torrine,or_port_venzor,or_bellmare,or_ormskirk,or_port_wormbourne,or_orion,or_bhel_thoram,or_bhel_thoram2,or_kejgrav]); var mg_towns = L.layerGroup([el_westhaven,el_mirstone,el_zimban,el_tel_kibil,el_saradim,el_harron,el_reedwater,el_nythi_asari,am_port_clulx,am_port_khel,or_guthram,or_erast,or_greenflower,or_anyor,or_tamsworth,or_nuxvar,or_bramblewoods,or_kelgrum,or_gar_dural,or_norreborg]); var mg_forts = L.layerGroup([el_teglhus,el_mistrith_keep,el_fangdor_fortress,el_delarin_stronghold,el_the_golden_palace,el_whitebridge_pass,or_lancer_gate,or_palace_plenty,or_pine_castle,or_fort_eaglecrest,or_knifeedge_creek]); var mg_temples = L.layerGroup([el_circle_of_the_land,el_the_soot_healer,or_eldath,el_mystra,el_helm,or_leira,or_kelemvor,el_circle_of_the_moon,el_umberlee,or_mask,or_malar,or_world_rod]); //Marker Overlay var overlays = { "Mage Colleges" : mg_mage_colleges, "Trading Posts" : mg_trading_posts, "Cities" : mg_cities, "Towns" : mg_towns, "Forts/Castles" : mg_forts, "Temples" : mg_temples, } //Marker Group Control L.control.layers(null, overlays).addTo(map); </script> </body> </html>

We don’t run ads on Tech Trail. So if you’ve found this or any of our other posts useful, then why not buy us a coffee!