5 min read

The Geometry of Dungeons

Tabletop RPGs tend to make a simplification that the world exists in 5-foot squares, which is OK in one dimension:

tibble(x=c(0,120), label=c("Wizard", "Person about \nto be on fire")) %>% 
  ggplot(aes(x=x,label=label,y=0)) + geom_label()

But diagonals are an issue.

Right now the default plot is an issue.

breaks <- function(limits){
  print(limits)
  seq(from=limits[1], to=limits[2], by=5)
}

move_to_center <- function(data){
  data %>% 
    mutate(x=x+2.5, y=y+2.5)
}
tibble(x=c(0,120), y=0, label=c("Wizard", "Person about \nto be on fire")) %>%
  move_to_center() %>% 
  ggplot(aes(x=x,y=y,colour=label)) + geom_point() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(0,120,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(0,5, by=5), minor_breaks = NULL, limits=c(0,5)) + coord_fixed()

Another modelling assumption in tabletop RPGs - a character fills their 5-foot square:

transform_to_rectangle <- function(data){
  data  %>% 
    mutate(xmin=x,ymin=y,xmax=xmin+5,ymax=ymin+5)
}
tibble(x=c(0,120), y=0, label=c("Wizard", "Person about \nto be on fire")) %>%
  transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill=label)) + geom_rect() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(0,120,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(0,5, by=5), minor_breaks = NULL, limits=c(0,5)) + coord_fixed()

But sometimes the wizard wants to shoot someone who is diagonal to them on the grid.

Euclid would say that our wizard can fire bolt someone inside this circle:

tibble(t = seq(0,2*pi,length.out = 1000), x=120*cos(t), y=120*sin(t)) %>% 
  select(-t) %>% 
  ggplot(aes(x=x,y=y)) + geom_path() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-120,120,by = 20), minor_breaks = seq(-120,120,by = 5)) +
  scale_y_continuous(breaks=seq(-120,120, by=20), minor_breaks = seq(-120,120,by = 5)) + coord_fixed() + geom_point(aes(x=2.5,y=2.5, colour="Wizard"))

120’ is too much to show the gap, so let’s move to our cleric with his 60’ Sacred Flame:


tibble(t = seq(0,2*pi,length.out = 1000), x=60*cos(t), y=60*sin(t)) %>% 
  select(-t) %>% 
  ggplot(aes(x=x,y=y)) + geom_path() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-60,60,by = 20), minor_breaks = seq(-60,60,by = 5)) +
  scale_y_continuous(breaks=seq(-60,60, by=20), minor_breaks = seq(-60,60,by = 5)) + coord_fixed() + geom_point(aes(x=2.5,y=2.5, colour="Cleric"))

The problem, more or less, is “is the monster in range?”:

tibble(t = seq(0,2*pi,length.out = 1000), x=60*cos(t), y=60*sin(t)) %>% 
  select(-t) %>% 
  ggplot(aes(x=x,y=y)) + geom_path() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-60,60,by = 20), minor_breaks = seq(-60,60,by = 5)) +
  scale_y_continuous(breaks=seq(-60,60, by=20), minor_breaks = seq(-60,60,by = 5)) + coord_fixed() + geom_point(aes(x=2.5,y=2.5, colour="Cleric")) + geom_point(aes(x=27.5,y=52.5, colour="Monster"))

If we say a monster is in range if the center of their tile is in range then this is our cleric’s range:

grid_cleric <-
  cross_df(list(x=seq(-70,70,by=5), y=seq(-70,70,by=5)))

grid_cleric %>% 
  mutate(in_range = sqrt(x^2+y^2) < 60) %>% 
  filter(in_range) %>% 
  mutate(in_range = "about to be set on fire") %>% 
  transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill=in_range)) + geom_rect() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-70,70,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-70,70, by=5), minor_breaks = NULL) + coord_fixed()

Or for our fire-loving wizard:

grid_wizard <-
  cross_df(list(x=seq(-130,130,by=5), y=seq(-130,130,by=5)))

grid_wizard %>% 
  mutate(in_range = sqrt(x^2+y^2) < 120) %>% 
  filter(in_range) %>% 
  mutate(in_range = "about to be set on fire") %>% 
  transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill=in_range)) + geom_rect() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-130,130,by = 20), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-130,130,by = 20), minor_breaks = NULL) + coord_fixed()

Aristotle believed that projectiles moved in straight lines with right angles, and we can force that geometry on our RPG. Then the distance from the origin is abs(x) + abs(y). Because Manhattan NY is in that sort of grid this is sometimes called ‘taxicab geometry’.


grid_cleric %>% 
  mutate(distance = abs(x) + abs(y)) %>% 
  filter(distance < 60) %>% 
  mutate(distance = "Do you see that Radiant light above us?") %>% 
transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill=distance)) + geom_rect() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-70,70,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-70,70, by=5), minor_breaks = NULL) + coord_fixed()

Though I now have this image of a fire bolt that refuses to move diagonally:

fire_bolt_frame <- function(frame){
  if(frame < 60){
    x_t<-frame
    y_t<-0
  }
  else{
    x_t=60
    y_t=frame-60
  }
  tribble(
    ~x, ~y, ~label,
    0, 0, "wizard",
    60, 60, "flammable object",
    x_t, y_t, "fire bolt"
  ) %>% 
    mutate(frame = frame)
}
map_dfr(0:120, fire_bolt_frame) %>% 
  move_to_center() %>% 
  ggplot(aes(x=x,y=y,colour=label)) + geom_point() + transition_manual(frames = frame ) + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-130,130,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-130,130, by=5), minor_breaks = NULL) + coord_fixed()

Or like this:

fire_bolt_frame <- function(frame){
 x_t = ceiling(frame/2) * 5
 y_t = floor(frame/2) * 5
  tribble(
    ~x, ~y, ~label,
    0, 0, "wizard",
    60, 60, "flammable object",
    x_t, y_t, "fire bolt"
  ) %>% 
    mutate(frame = frame)
}
map_dfr(0:24, fire_bolt_frame) %>% 
  move_to_center() %>% 
  ggplot(aes(x=x,y=y,colour=label)) + geom_point() + transition_manual(frames = frame ) + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-130,130,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-130,130, by=5), minor_breaks = NULL) + coord_fixed()

DnD 3.5 edition & Pathfinder has diagonals count for 1.5 (round down) distance, which is somewhere between the two extremes above.


pathfinder_grid <- 
  tibble(x=0,y=0, dist = 0)

iterate_distances <- function(grid){
  right <- grid %>% 
    mutate(x=x+1,dist=dist+1)
  left <- grid %>% 
    mutate(x=x-1, dist=dist+1)
  up <- grid %>% 
    mutate(y=y+1, dist=dist+1)
  down <- grid %>% 
    mutate(y=y-1, dist=dist+1)
  
  ur <- grid %>% 
    mutate(x=x+1,y=y+1, dist=dist+1.5)
  ul <- grid %>% 
    mutate(x=x-1,y=y+1, dist=dist+1.5)
  dr <- grid %>% 
    mutate(x=x+1,y=y-1, dist=dist+1.5)
  dl <- grid %>% 
    mutate(x=x-1,y=y-1, dist=dist+1.5)
  
  bind_rows(
    grid, right, up, left, down, ur, ul, dr, dl
  ) %>% 
    group_by(x,y) %>% 
    summarise(dist=min(dist)) %>% 
    ungroup()
}
for(i in seq(24)){
  pathfinder_grid <- iterate_distances(pathfinder_grid)
}

In this situation, our wizard’s target zone is:


pathfinder_grid %>% 
    mutate(dist=floor(dist)) %>% 
    mutate_all(function(x) x*5) %>% 
  filter(dist<120) %>% 
transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill=dist)) + geom_rect() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-200,200,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-200,200, by=5), minor_breaks = NULL) + coord_fixed() +
  scale_fill_viridis_c(option="B")

And our cleric’s is:

In this situation, our wizard’s target zone is:


pathfinder_grid %>% 
    mutate(dist=floor(dist)) %>% 
    mutate_all(function(x) x*5) %>% 
  filter(dist<60) %>% 
transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill=dist)) + geom_rect() + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-200,200,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-200,200, by=5), minor_breaks = NULL) + coord_fixed() +
  scale_fill_viridis_c(option="B")

As an animation:


map_dfr(1:20, function(frame){
  pathfinder_grid %>% 
    mutate(dist=floor(dist)) %>% 
    filter(dist<frame) %>% 
    mutate(frame=frame)
}) %>% 
  mutate(x=x*5,y=y*5) %>% 
  transform_to_rectangle() %>% 
  ggplot(aes(xmin=xmin,ymin=ymin,xmax=xmax,ymax=ymax, fill="boom")) + geom_rect() + transition_manual(frame) + theme_linedraw()  +
  scale_x_continuous(breaks=seq(-200,200,by = 5), minor_breaks = NULL) +
  scale_y_continuous(breaks=seq(-200,200, by=5), minor_breaks = NULL) + coord_fixed()