DnD Map Editor
Front-end practice, part 3.
Introduction
I recently started running a DnD campaign for the first time in a while. This seemed like a good opportunity to build some more web-based tools for myself.
In other posts I talked about some pieces of technology I picked up or practiced, and that’s definitely going to pay off here, particularly:
- JavaScript
- TypeScript
- HTML Canvas
- ASP.NET Core (kinda)
- SignalR
- A tiny bit of SVG stuff because I’m too lazy to make real icons
I also used this as my first React project, since that seems to be a pretty popular framework for web applications these days.
A Brief Explanation of Dungeons & Dragons
If you’ve never played Dungeons & Dragons (insert obligatory joke about how great at sports you must be or whatever), it’s a pencil-and-paper roleplaying game - each player creates a character for themselves with a bunch of numbers representing how strong/intelligent/tough/etc. they are, what things they’re good at, and so forth. A lot of the gameplay consists of movement and combat on simplistic 2d maps of some fantasy world (think Lord of the Rings), with players rolling various types of dice to determine if their attacks hit, their magic spells work, etc.
One player is the ‘Dungeon Master’ (DM), who basically has to design all of the maps and control all of the evil monsters or whatever. It’s a weird role where you’re not really playing against the other players so much as you are writing a story for them to progress through and providing them with appropriate challenges.
In this case, I’m the DM. Also, there’s a pandemic going on, so while I do technically live with some of the players, others are joining remotely, so I can’t go with my old practice of using a whiteboard for everything.
Features Needed
Technically you might think of a map editor as just, like, Paint, or Photoshop, or whatever. In practice, though, everything in DnD takes place in idealized five-foot squares, with some simplifying assumptions, such as that that’s about how much space somebody takes up in a swordfight (as they move around and swing their sword and so forth). Along the same lines, as a DM, I’m not going to bother creating really intricate terrain - it’s all going to be coincidentally in increments of five feet. So it’s more like a spreadsheet where you are drawing the borders around the grid spaces. In fact, for the first session I hadn’t gotten any code put together, so that’s exactly what I did:
This was pretty clunky and took a while to edit, so what I really want is a sort-of-graphical editor that lets me very quickly click on a bunch of borders to draw walls, or color in spaces. So that’s the first thing I did.
This is basically the same map as the one above, but it took less time to put together.
Some design notes:
The “load” and “save” buttons are kind of a hack - they actually open up dialogs for uploading/downloading JSON files (which the map writes to). I used FileSaver.js to make this easier.
The map is being drawn with the canvas, rather than being represented by any kind of SVG/HTML. I did this because I figured I would want to do other things that might be non-trivial. At the moment one of the few of these is that, depending on whether I have the “wall” or “paint” tool selected, the cursor previews what I’m doing by either highlighting the area I’m pointing at or the boundary I’m pointing at.
As a sidenote that helps prove the programming truism that you never know when some randomly interesting problem will crop up, it actually took me a minute to work out some of the details for the walls.
First, if you have, say, a 20x20 grid of spaces, that means you can have a 21x21 grid of walls. Or, actually, you can have a 20x21 grid of horizontal walls and a 21x20 grid of vertical walls. (To be lazy I just did a 21x21 grid of both.) So the vertical wall at 0x0 is specifically the left side of that box, and the vertical wall at 1x0 is the right side (or, the left side of the next box over, if you prefer). This is slightly unfortunate because as you might imagine it led to me writing in a ton of off-by-one errors at one point or another. I eventually more or less fixed this by not representing the walls in memory as a set of arrays. Instead, they’re a list of wall locations, where each location is an x, y, z coordinate (the maps can have multiple levels), and “type” (meaning “vertical” or “horizontal).
The maps still write to files as though they were arrays (actually stringified to 1 and 0 values for where the walls are), because I thought it might be nice to have a human-readable version of them. (Not unlike in the CheckersDraughts post, I still question whether this was a good idea - you can’t exactly understand the map by looking at it, because there’s one block of text for vertical walls and one for horizontal walls. But it’s still relatively easy to follow - you won’t mix up two totally different maps, for example. And I already wrote the logic to convert to and from this format, and it works, so I don’t need to worry about it much for the moment.)
Second, going from “screen coordinate” to “which wall segment is under the cursor” was harder than I would have guessed. Consider the following:
Blog annoyance sidenote: by default screenshots do not include the mouse cursor in Windows, and there doesn’t seem to be an obvious way to change this other than to install a different screen-capture tool, so technically that’s just a cursor I edited back into that screenshot later. And it looks slightly different, which will bug me forever.
Anyway. For the software engineers reading this, what’s the formula that converts between pixel location and which wall you’re on? Assume you know the size of each square (in my case I ended up using 32-pixel squares because it’s a nice round number). It’s not exactly a Cracking the Coding Interview entry, but it’s not super obvious, either.
Here’s my solution:
GetWallIndex(): WallIndex {
let [mx, my] = this.mousePosition!;
let localX = mx % SQUARE;
let localY = my % SQUARE;
if (localY > localX) {
// Bottom/Left half
if (localX + localY < SQUARE) {
// Left wall
return new WallIndex(Math.floor(mx / SQUARE), Math.floor(my / SQUARE), this.level, WallType.Vertical);
} else {
// Bottom wall
return new WallIndex(Math.floor(mx / SQUARE), Math.ceil(my / SQUARE), this.level, WallType.Horizontal);
}
} else {
//Top/Right half
if (localX + localY < SQUARE) {
// Top wall
return new WallIndex(Math.floor(mx / SQUARE), Math.floor(my / SQUARE), this.level, WallType.Horizontal);
} else {
// Right wall
return new WallIndex(Math.ceil(mx / SQUARE), Math.floor(my / SQUARE), this.level, WallType.Vertical);
}
}
}
The rest of this is mostly pretty straightforward. The three “Tools” are the wall editor (which can give you normal walls, ones I use to represent doors, or dotted lines to represent… whatever you want), an area painter that lets you pick colors (I used React Color with the “Compact” color picker for this), and a “fog-of-war” painter that unfortunately looks really similar to the area painter if you choose “dark grey” but behaves totally differently. (The buttons for each “Tool” are the SVGs I mentioned, which is only even a tiny bit remotely interesting for the wall editor, which at least has two lines. I didn’t say it was impressive.)
You can toggle the whole screen between a “DM” mode and a “player” mode - the “player” mode only lists NPCs that are on the current level in the sidebar, hides their exact health (but slowly changes their name from black to red as they take damage), and only places them on the map if they are not hidden by fog-of-war. Also, walls only show up if they border at least one non-fog-of-war space. In the “DM” mode, you see all of the walls and NPCs and all of their stats.
Eventually I’ll actually host this properly and my players will be able to directly interact with it as they play, but because I haven’t gotten around to figuring out auth, really I’m just sharing one screen in “player” mode and using a non-shared screen in DM mode. I use SignalR and a local webserver so that if there’s a change made in one window it pretty-much-instantly propagates to the other window (literally by sending the whole, updated map on each change, but the maps are very small, so performance isn’t relevant).
Looking at the screenshots above, you can also probably see at a glance that the interface isn’t exactly polished, so I hope to improve upon that a lot.
What I Learned From This
- React
- Actually quite a lot of this
- Also more TypeScript, but mostly insofar as it relates to React - they work well together
- More practice rendering to the canvas (including some text this time)
- More SignalR/ASP.NET Core practice (mostly just around caching the map, recovering from errors, and connecting clients together)
Remaining Issues
So many! I’m basically adding features to this whenever I want to do something new with my campaign, which is… pretty much every session. For example, I added multiple layers and fog-of-war to the map for a recent session where my players were trying to stop a serial killer inside a large, burning building (basically a not-very-up-to-code medieval apartment complex).
There are a lot of features that would be nice to have, including some that made our last session a pain:
- Automatically remove/re-add fog-of-war based on player locations and light sources (the latter of which isn’t stored anywhere yet)
- Change the map to be more of a “viewport” that I can scroll around while the hidden areas still exist (this would also let me make much larger maps, or maybe even procedurally generate endless maps of things like cities and forests)
- Track player statuses beyond just hitpoints (how many spells per day a Wizard has left, or, how much longer a particular Mage Armor spell will last)
- Keep track of the passage of in-game time in general (each round of combat is six seconds, but time typically goes faster outside of combat, and some spells last 30 seconds or 5 minutes or 2 hours)
- Be able to click on a player and pull up some condensed stats for them (attack rolls, armor class, saving throws - anything I might need to know while determining if, say, a given Zombie hits them and they get a weird disease from it)
- Some kind of useful interface for looking up random game facts (any DM can tell you a session is like 10 minutes of combat interspersed with 20 minutes of players asking you to remind them what exactly gets added to a Concentration check, again? Is it just their level? Do they add their Charisma bonus? I don’t remember either, guys! I just have the Player’s Handbook open on my other screen!)
Basically what I’m saying is I’ll probably keep working on this forever, or at least as long as I’m DM’ing a campaign. It also needs a lot of refactoring, since it was my first React app and I’m still getting the hang of it.
Also, I would like to share the source code for this, and I will eventually, but right now it’s also sitting in a repo with a bunch of scripts related to this particular campaign, which if my players found would give them a ton of bonus info they’re not supposed to have yet, so eventually I’ll get around to cleaning that up.