Maps and Navigation

From Infinite Worlds
Jump to navigation Jump to search

Introduction[edit]

It is common for world designers to want to implement a map into their world, so the player can navigate around it. Unfortunately, although humans are quite good at navigation and spatial reasoning, LLMs struggle. Concepts that seem natural to a human, such as "In order to go from A to B, I must travel through C" are very difficult for LLMs, and they often end up hallucinating.

The solution to this problem is to make it so the storyteller AI does not have to work out how to navigate between locations. Instead, the world designer creates a map, identifying certain locations as important landmarks that the player will likely want to navigate to. Each turn, the navigation models use the player's current location to construct a location report, giving details of:

  • The player's current location
  • The locations a player can directly travel to from their current location
  • The routes a player can follow to reach landmarks that are multiple steps away

Routes are calculated using Bounded Bellman-Ford for all models. Not all routes to all landmarks are calculated each turn - each landmark has a maximum distance to have its route calculated, and it is recommended that more major landmarks have higher maximum distances than less important landmarks. Landmarks with a higher maximum distance will have their routes calculated from further away.

Model types[edit]

Below are listed several models that can be used to add a navigation system into the world. Each model comes with step-by-step instructions for adding it to a world by hand. There are three methods of implementing a map, each of which comes in single-map and multi-map versions:

  • A grid is a map drawn as a block of text, one character per square. The player walks it a square at a time. It is the simplest to develop and maintain, but can be slow to play as each turn can only move a single tile at a time.
  • A graph represents a map in a more abstract format. It allows for more complex maps and makes changing the travel scale smoother, but some find it tricky to develop in a non-visual format.
  • A hybrid allows the user to give a grid as input, and then it calculates a graph at the start of a playthrough. An easier alternative to creating a graph, but does not allow for as complex options as directly creating a graph.

The single-map versions are simpler, and the recommended starting point for someone wanting to try out a navigation system. The multi-map versions are intended for when the world designer wants to change the scale of the map. For example, they may want one map that displays the whole country where each grid square is a mile across, and another map that displays a town where each grid square is a minute's walk across.

Single-map models[edit]

Grid[edit]

This model is straightforward - there is a grid, and at any given point the player is at a single point on the grid. Each turn they can move to an adjacent point North, South, East or West. Routes to landmarks are calculated if the Manhattan distance to that landmark is below the threshold.

Installation — click to show the steps

1. Create the tracked items. Add these seven. The three values given as below are long, and follow the table.

Name Type Visible to Auto-update Starting value
Legend YAML the player — below
Map Text the player — below
Travel units Text nobody — hours
Landmarks YAML the player — below
Map position X Number the player and AI yes 11
Map position Y Number the player and AI yes 9
Location Report Text the player — leave it empty

Legend.

'^':
  name: mountain
  description: An impassable mountain.
  movement: false
  movement_cost: 0
'~':
  name: water
  description: A deep, impassable stretch of water.
  movement: false
  movement_cost: 0
'.':
  name: grassland
  description: Long grass with few trees.
  movement: true
  movement_cost: 2
'T':
  name: forest
  description: A dense forest of closely-spaced trees.
  movement: true
  movement_cost: 5
'O':
  name: a town
  description: A small walled town.
  movement: true
  movement_cost: 1
'=':
  name: a road
  description: A dirt road that makes for easy travel.
  movement: true
  movement_cost: 1

Each entry gives a square its name and description, says whether it can be walked on, and sets movement_cost. The movement cost is used to compute what the optimal route to each landmark is - it attempts to find the route with the lowest total cost, not necessarily the route with the fewest tiles. If you want to have it prioritize the route with the fewest tiles, then give each square the same movement cost.

Travel units is what those costs are measured in. It has no effect on pathfinding, but it does have an effect on the storyteller AI: a total of 16 comes out as "16 hours travel". Change it to days, miles, or whatever suits your world.

Map. Create your own, or paste this in:

~^^^^^^^^^^^^^^^^^^^^^^^^^~~
~^^^^^........^^^^^^^^^^^^~~
~^^......TTTTTTT...^^^^^^^~~
~^.....TTTTTTTTTTTT....^^^~~
~.....TTTTTTTTTTTTTT===O.^~~
~.....TTTTTTTTTTTTT==.....~~
~~O=====TTTTTTTTT===......~~
~~.....===TTTT====......~~~~
~~~......======......~~~~~~~
~~~~.......~~....~~~~~~~~~~~
~~~~~.....~~~~~~~~~~~~~~~~~~
~~~~~~~....~~~~~~~~~~~~~~~~~
~~~~~~~~..~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

In order for the map to function, the following rules must be respected:

  • Every line must be exactly the same length, making the overall map rectangular
  • Every character must exist in the legend
  • The border must consist of impassable squares

Landmarks.

- name: Cairnmouth
  x: 24
  y: 5
  max_distance: 35
  description: A large town built in the foothills of the mountains.
- name: Saltfield
  x: 3
  y: 7
  max_distance: 30
  description: A small fishing community.
- name: The Standing Stones
  x: 10
  y: 13
  max_distance: 5
  description: Nine leaning stones of ancient origin in a rough circle.

max_distance is how far away, in Manhattan distance, that landmark can be before the route to it is calculated. Note that the route is calculated only using squares within that distance, so when the landmark is at or near its max distance, the optimal route may change as the player approaches and a lower cost route becomes possible.

It is important to remember that X is 1 on the western edge of the map, and increases as the player moves to the east. Y is 1 on the northern edge of the map, and increases as the player moves to the south.

Map position X and Map position Y give the player's current position and are maintained by the storyteller AI. Paste this in as the update instructions to Map position X:

This is my east-west position on the map.
If I moved east this turn, increase it by 1.
If I moved west this turn, decrease it by 1.
If I did not move east or west, leave it exactly as it is.
Never change it by more than 1 in a single turn, however far I say I am going.

and this for Map position Y:

This is my north-south position on the map.
If I moved south this turn, increase it by 1.
If I moved north this turn, decrease it by 1.
If I did not move north or south, leave it exactly as it is.
Never change it by more than 1 in a single turn, however far I say I am going.

2. Create the trigger. Add a trigger called Evaluate location. Set it to fire at the start of the game, to also fire mid-game, and to trigger more than once.

We want this to fire every turn, but IW requires us to give it at least one condition before it will fire mid-game. Add a condition of 'Pawscript expression is true' and set the Pawscript expression to 'true'.

Then add a single Run a script effect and paste this in:

# Where the player is, and the row of characters they are standing in.
set $px = $map_position_x
set $py = $map_position_y
set $rows = $map.lines()
set $key = ($px) & "," & ($py)

# A landmark standing on a square outranks the terrain under it, so index them by square.
set $lm = record()
for each $m in $landmarks
  $lm.item(($m.x) & "," & ($m.y)) = $m

# The square underfoot.
set $here_terrain = $legend.item($rows.item($py).characters().item($px))
set $here_name = first_available($lm.item($key, "").name, $here_terrain.name)
set $here_desc = first_available($lm.item($key, "").description, $here_terrain.description)
$location_report = format_text("I am currently at {$here_name}: {$here_desc}")
$location_report &= "\n\nMy immediate surroundings are:"

# The four squares around the player, each named and marked passable or not.
set $north_terrain = $legend.item($rows.item($py - 1).characters().item($px))
set $north_key = ($px) & "," & ($py - 1)
set $north_name = first_available($lm.item($north_key, "").name, $north_terrain.name)
set $north_move = if($north_terrain.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* NORTH: {$north_name}. {$north_move}")
set $south_terrain = $legend.item($rows.item($py + 1).characters().item($px))
set $south_key = ($px) & "," & ($py + 1)
set $south_name = first_available($lm.item($south_key, "").name, $south_terrain.name)
set $south_move = if($south_terrain.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* SOUTH: {$south_name}. {$south_move}")
set $east_terrain = $legend.item($rows.item($py).characters().item($px + 1))
set $east_key = ($px + 1) & "," & ($py)
set $east_name = first_available($lm.item($east_key, "").name, $east_terrain.name)
set $east_move = if($east_terrain.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* EAST: {$east_name}. {$east_move}")
set $west_terrain = $legend.item($rows.item($py).characters().item($px - 1))
set $west_key = ($px - 1) & "," & ($py)
set $west_name = first_available($lm.item($west_key, "").name, $west_terrain.name)
set $west_move = if($west_terrain.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* WEST: {$west_name}. {$west_move}")

# Only landmarks within their own max_distance are worth routing to. The largest of
# those distances is how far the search has to reach.
set $live = list()
set $radius = 0
for each $m in $landmarks
  if ($m.x - $px).abs() + ($m.y - $py).abs() <= $m.max_distance
    $live.append($m)
    if $m.max_distance > $radius
      set $radius = $m.max_distance

# Bounded Bellman-Ford, walked outwards from the player one square per pass. A square is
# queued again whenever a cheaper way into it turns up, which is what lets a long road beat
# a short crossing of expensive ground. $trail records the letters taken to reach it.
set $cost = record()
set $tiles = record()
set $trail = record()
$cost.item($key) = 0
$tiles.item($key) = 0
$trail.item($key) = ""
set $fx = record()
set $fy = record()
$fx.item($key) = $px
$fy.item($key) = $py
set $frontier = list($key)

for each $pass in range($radius)
  set $next = list()
  for each $k in $frontier
    set $k_key = $k
    set $kx = $fx.item($k_key)
    set $ky = $fy.item($k_key)
    set $k_cost = $cost.item($k_key)
    set $k_tiles = $tiles.item($k_key)
    set $k_trail = $trail.item($k_key)
    set $n_terrain = $legend.item($rows.item($ky - 1).characters().item($kx))
    if $n_terrain.movement
      set $n_key = ($kx) & "," & ($ky - 1)
      set $n_cost = $k_cost + $n_terrain.movement_cost
      if $n_cost < $cost.item($n_key, 999999)
        $cost.item($n_key) = $n_cost
        $tiles.item($n_key) = $k_tiles + 1
        $trail.item($n_key) = $k_trail & "N"
        $fx.item($n_key) = $kx
        $fy.item($n_key) = $ky - 1
        $next.append($n_key)
    set $n_terrain = $legend.item($rows.item($ky + 1).characters().item($kx))
    if $n_terrain.movement
      set $n_key = ($kx) & "," & ($ky + 1)
      set $n_cost = $k_cost + $n_terrain.movement_cost
      if $n_cost < $cost.item($n_key, 999999)
        $cost.item($n_key) = $n_cost
        $tiles.item($n_key) = $k_tiles + 1
        $trail.item($n_key) = $k_trail & "S"
        $fx.item($n_key) = $kx
        $fy.item($n_key) = $ky + 1
        $next.append($n_key)
    set $n_terrain = $legend.item($rows.item($ky).characters().item($kx + 1))
    if $n_terrain.movement
      set $n_key = ($kx + 1) & "," & ($ky)
      set $n_cost = $k_cost + $n_terrain.movement_cost
      if $n_cost < $cost.item($n_key, 999999)
        $cost.item($n_key) = $n_cost
        $tiles.item($n_key) = $k_tiles + 1
        $trail.item($n_key) = $k_trail & "E"
        $fx.item($n_key) = $kx + 1
        $fy.item($n_key) = $ky
        $next.append($n_key)
    set $n_terrain = $legend.item($rows.item($ky).characters().item($kx - 1))
    if $n_terrain.movement
      set $n_key = ($kx - 1) & "," & ($ky)
      set $n_cost = $k_cost + $n_terrain.movement_cost
      if $n_cost < $cost.item($n_key, 999999)
        $cost.item($n_key) = $n_cost
        $tiles.item($n_key) = $k_tiles + 1
        $trail.item($n_key) = $k_trail & "W"
        $fx.item($n_key) = $kx - 1
        $fy.item($n_key) = $ky
        $next.append($n_key)
  set $frontier = $next

# The route to a landmark is just the trail of single steps recorded on the way there.
set $routes = ""
for each $m in $live
  set $m_key = ($m.x) & "," & ($m.y)
  if $m_key != $key and $tiles.item($m_key, 999999) <= $m.max_distance
    set $m_cost = $cost.item($m_key)
    set $m_route = $trail.item($m_key).characters().join("-")
    set $routes = $routes & format_text("\n* {$m.name} ({$m_cost} {$travel_units} travel): {$m_route}")

if $routes != ""
  $location_report &= "\n\nRoutes from here:"
  $location_report &= $routes
  $location_report &= "\nEach route is the single steps to take, in order, one square each: N is north, S is south, E is east, W is west. So E-E-N means move east, then east again, then north."

3. Create the instruction block. Add an instruction block called Location instructions and paste this in as its content:

<<$location_report>>

Whenever I attempt to move, determine if I am attempting to move North, South, East or West. If I attempt to move somewhere I cannot move to, then I stay where I am.
Use any routes listed above when I ask the way somewhere, and do not invent a route to anywhere that is not listed.

An example of a location report generated by this model is:

I am currently at a road: A dirt road that makes for easy travel.

My immediate surroundings are:
* NORTH: forest. I can go this way.
* SOUTH: grassland. I can go this way.
* EAST: a road. I can go this way.
* WEST: a road. I can go this way.

Routes from here:
* Cairnmouth (17 hours travel): E-E-E-E-N-E-E-E-N-E-E-N-E-N-E-E-E
* Saltfield (10 hours travel): W-N-W-W-N-W-W-W-W-W
* The Standing Stones (9 hours travel): W-S-S-S-S
Each route is the single steps to take, in order, one square each: N is north, S is south, E is east, W is west. So E-E-N means move east, then east again, then north.

An interactive example of this model can be found here.

Graph[edit]

The main difference between a single-map and a multi-map graph model is the calculation of routes. Routes are only calculated for landmarks on the same map.

Installation — click to show the steps

1. Create the tracked items. Add these four. Maps is long, and follows the table.

Name Type Visible to Auto-update Starting value
Maps YAML the player — below
Current map Text the player and AI — Saltfield
Current location Text the player and AI yes The Quayside
Location update instructions Text nobody — leave it empty
Location Report Text the player — leave it empty

It takes both Current map and Current location to say where the player is, since the same name may exist on more than one map. Current map has auto-update off, because the storyteller AI never sets it — the trigger in step 2 does.

Maps.

Saltfield:
  travel_units: minutes
  locations:
    The Market Square:
      landmark: true
      max_distance: 60
      description: Stalls under awnings, and more noise than anywhere else in the town.
    The Quayside:
      landmark: true
      max_distance: 60
      description: Tar, rope, and a dozen boats tied along the water.
    The Gate Yard:
      description: Mud and cartwheel ruts, just inside the east gate.
    The Castle Gate:
      description: A barbican of gray stone, and a guard who knows your face.
      to_map: The Castle
      to_location: The Entrance Hall
  links:
  - between:
    - The Quayside
    - The Market Square
    direction: E
    travel_time: 22
    description: Up the hill from the water, by the fish market.
  - between:
    - The Market Square
    - The Gate Yard
    direction: E
    travel_time: 26
    description: The main street, east from the square.
  - between:
    - The Market Square
    - The Castle Gate
    direction: N
    travel_time: 14
    description: A wide way north, climbing to the castle.
The Castle:
  travel_units: minutes
  locations:
    The Entrance Hall:
      description: Banners, a cold floor, and a stair going up.
    The Inner Gate:
      description: The gate seen from within, and the town beyond it.
      to_map: Saltfield
      to_location: The Market Square
    The Great Hall:
      landmark: true
      max_distance: 60
      description: Long tables, and a fire that is never quite enough.
    The Kitchens:
      description: Steam, shouting, and somewhere to be unnoticed.
    The Tower Room:
      landmark: true
      max_distance: 60
      description: One window, and the whole town laid out below it.
  links:
  - between:
    - The Entrance Hall
    - The Inner Gate
    direction: S
    travel_time: 2
    description: Back down the passage to the gate.
  - between:
    - The Entrance Hall
    - The Great Hall
    direction: N
    travel_time: 3
    description: Through the double doors at the far end.
  - between:
    - The Great Hall
    - The Kitchens
    direction: E
    travel_time: 2
    description: A screened passage behind the high table.
  - between:
    - The Great Hall
    - The Tower Room
    travel_time: 6
    description: A spiral stair, and a long climb.

Everything about a map lives under its name: what its distances are measured in, the places on it, and the links between those places. Two maps may use the same units and still be separate maps, as these two do — being separate is about being separate places, not about scale.

Names need only be unique within a map. Map transitions are one-way, but we keep the restriction that a transition cannot point to a location that also has a transition. Compass direction is optional again.

A link belongs to exactly one map, because it is written inside one. Crossing between maps is what a transition is for, and a link that spanned two would have a travel time in two different units at once — nesting the links makes that impossible to write rather than a rule to remember.

Current location. Paste this, and nothing else, into its update instructions, as in the graph model:

<<$location_update_instructions>>
These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:
* `The Quayside`
* `The Gate Yard`
* `The Castle Gate`
If I did not move to one of them, leave this exactly as it is.

2. Create the trigger. Add a trigger called Evaluate location, set as in the earlier models: fire at the start of the game, also fire mid-game, trigger more than once, and a condition of 'Pawscript expression is true' with the expression 'true'.

Then add a single Run a script effect and paste this in. Crossing between maps and writing the report are both done here, in that order:

# --- Cross between maps, if the player is standing on a way out. ----------------
# The AI travels to the gate like any other place and never sets the map. This turns
# arriving there into a crossing on the turn after.
set $spot = $maps.item($current_map).locations.item($current_location)

if $spot.to_map.exists()
  $current_location = $spot.to_location
  $current_map = $spot.to_map

# --- Write the report for whichever map the player is now on. -------------------
set $here_map = $current_map
set $here = $current_location
set $units = $maps.item($here_map).travel_units
set $where = $maps.item($here_map).locations
set $spot = $where.item($here)
$location_report = format_text("I am currently at {$here}: {$spot.description}")

# Only this map's links. A place that leads elsewhere is labelled where it stands,
# rather than offered as a separate kind of move.
set $exits = ""
set $reach = ""
for each $l in $maps.item($here_map).links
  set $a = $l.between.item(1)
  set $b = $l.between.item(2)
  set $a_label = $a
  if $where.item($a).to_map.exists()
    set $a_label = $a & " (leads to " & $where.item($a).to_map & ")"
  set $b_label = $b
  if $where.item($b).to_map.exists()
    set $b_label = $b & " (leads to " & $where.item($b).to_map & ")"
  if $a = $here
    if $l.direction.exists()
      set $word = choose($l.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
      set $exits = $exits & format_text("\n* {$b_label}: {$l.travel_time} {$units} {$word}. {$l.description}")
    else
      set $exits = $exits & format_text("\n* {$b_label}: {$l.travel_time} {$units}. {$l.description}")
    set $reach = $reach & format_text("\n* `{$b}`")
  if $b = $here
    if $l.direction.exists()
      set $word = choose($l.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
      set $exits = $exits & format_text("\n* {$a_label}: {$l.travel_time} {$units} {$word}. {$l.description}")
    else
      set $exits = $exits & format_text("\n* {$a_label}: {$l.travel_time} {$units}. {$l.description}")
    set $reach = $reach & format_text("\n* `{$a}`")

if $exits != ""
  $location_report &= "\n\nMy immediate surroundings are:"
  $location_report &= $exits

# The reachable places, bare, for Current location's update instructions. A way into
# another map appears here as an ordinary place; the crossing above does the rest.
if $reach = ""
  $location_update_instructions = "There is nowhere I can travel to from here. Leave this exactly as it is."
else
  $location_update_instructions = "These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:" & $reach & "\nIf I did not move to one of them, leave this exactly as it is."

# Bounded Bellman-Ford over this map's links only. Routes stop at the edge of the map,
# because costs on two maps are in different units and adding them means nothing.
set $cost = record()
set $trail = record()
$cost.item($here) = 0
$trail.item($here) = ""
set $frontier = list($here)

for each $pass in range($where.count())
  set $next = list()
  for each $k in $frontier
    set $k_cost = $cost.item($k)
    set $k_trail = $trail.item($k)
    for each $l in $maps.item($here_map).links
      set $a = $l.between.item(1)
      set $b = $l.between.item(2)
      if $a = $k
        set $n_cost = $k_cost + $l.travel_time
        if $n_cost < $cost.item($b, 999999)
          $cost.item($b) = $n_cost
          if $l.direction.exists()
            set $word = choose($l.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
            set $leg = $l.travel_time & " " & $units & " " & $word & " to " & $b
          else
            set $leg = $l.travel_time & " " & $units & " to " & $b
          if $k_trail = ""
            $trail.item($b) = $leg
          else
            $trail.item($b) = $k_trail & ", then " & $leg
          $next.append($b)
      if $b = $k
        set $n_cost = $k_cost + $l.travel_time
        if $n_cost < $cost.item($a, 999999)
          $cost.item($a) = $n_cost
          if $l.direction.exists()
            set $word = choose($l.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
            set $leg = $l.travel_time & " " & $units & " " & $word & " to " & $a
          else
            set $leg = $l.travel_time & " " & $units & " to " & $a
          if $k_trail = ""
            $trail.item($a) = $leg
          else
            $trail.item($a) = $k_trail & ", then " & $leg
          $next.append($a)
  set $frontier = $next

# Landmarks on this map, reachable inside their own max_distance.
set $routes = ""
for each $n in $where.keys()
  set $m = $where.item($n)
  if $m.landmark.exists() and $m.landmark and $n != $here
    if $cost.item($n, 999999) <= $m.max_distance
      set $m_cost = $cost.item($n)
      set $m_trail = $trail.item($n)
      set $routes = $routes & format_text("\n* {$n} ({$m_cost} {$units}): {$m_trail}")

if $routes != ""
  $location_report &= "\n\nRoutes from here, on this map:"
  $location_report &= $routes
  $location_report &= "\nFollow each leg in the order given."

There is no build step, unlike the models that start from a grid. The graph is what you wrote, so there is nothing to work out — the script reads the map you are standing on and ignores the rest.

3. Create the instruction block. Add an instruction block called Location instructions and paste this in as its content:

<<$location_report>>

I can only travel to the places listed above, and traveling to one of them covers the distance given before I arrive.
If the report says I can go into another map, I may do that, and I arrive at whatever place it names.
Use any routes listed above when I ask the way somewhere. Those routes only cover the map I am on; to reach somewhere on another map I must first travel to the way in.
Do not invent a route, a place, a way between places, or a way into another map that is not listed.

An example of a location report generated by this model is:

I am currently at The Market Square: Stalls under awnings, and more noise than anywhere else in the town.

My immediate surroundings are:
* The Quayside: 22 minutes West. Up the hill from the water, by the fish market.
* The Gate Yard: 26 minutes East. The main street, east from the square.
* The Castle Gate (leads to The Castle): 14 minutes North. A wide way north, climbing to the castle.

Routes from here, on this map:
* The Quayside (22 minutes): 22 minutes West to The Quayside
Follow each leg in the order given.

-- then I travel to the castle gate, and at the start of the next turn --

I am currently at The Entrance Hall: Banners, a cold floor, and a stair going up.

My immediate surroundings are:
* The Inner Gate (leads to Saltfield): 2 minutes South. Back down the passage to the gate.
* The Great Hall: 3 minutes North. Through the double doors at the far end.

Routes from here, on this map:
* The Great Hall (3 minutes): 3 minutes North to The Great Hall
* The Tower Room (9 minutes): 3 minutes North to The Great Hall, then 6 minutes to The Tower Room
Follow each leg in the order given.

An interactive version of this model can be found here.

Hybrid[edit]

A hybrid model attempts to combine the ease of creation of a grid model with the ease of play of a graph model. The world designer can design their world as a grid and determine which grid tiles should be nodes. Then, the hybrid model will calculate edges (including their costs and directions), and use this to construct a graph.

The graph is rebuilt from the grid on every turn, so anything that edits the map partway through a playthrough takes effect straight away. Direct edits to the graph will not persist, as they will just be overwritten.

Installation — click to show the steps

1. Create the tracked items. Add these seven. The three values given as below are long, and follow the table.

Name Type Visible to Auto-update Starting value
Legend YAML the player — below
Map Text the player — below
Travel units Text nobody — miles
Locations YAML the player — below
Current location Text the player and AI yes Saltfield
Location update instructions Text nobody — leave it empty
Location Report Text the player — leave it empty

Legend.

^:
  name: mountain
  movement: false
  movement_cost: 0
'~':
  name: water
  movement: false
  movement_cost: 0
.:
  name: grassland
  movement: true
  movement_cost: 2
T:
  name: forest
  movement: true
  movement_cost: 5
O:
  name: a town
  movement: true
  movement_cost: 1
'=':
  name: a road
  movement: true
  movement_cost: 1

As in the grid model, but with no descriptions. Terrain is no longer described to the storyteller AI — the places are — so all the legend needs is a name, whether the square can be walked on, and what it costs to cross.

Map. Create your own, or paste this in:

~^^^^^^^^^^^^^^^^^^^^^^^^^~~
~^^^^^........^^^^^^^^^^^^~~
~^^......TTTTTTT...^^^^^^^~~
~^.....TTTTTTTTTTTT....^^^~~
~.....TTTTTTTTTTTTTT===O.^~~
~.....TTTTTTTTTTTTT==.....~~
~~O=====TTTTTTTTT===......~~
~~.....===TTTT====......~~~~
~~~......======......~~~~~~~
~~~~.......~~....~~~~~~~~~~~
~~~~~.....~~~~~~~~~~~~~~~~~~
~~~~~~~....~~~~~~~~~~~~~~~~~
~~~~~~~~..~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The same three rules as the grid model: every line the same length, every character in the legend, and the border made entirely of impassable squares.

Locations.

Saltfield:
  x: 3
  y: 7
  landmark: true
  max_distance: 60
  description: A small fishing community on the western shore.
Gull Point:
  x: 8
  y: 2
  description: A headland of bare rock and seabirds.
The Strand:
  x: 7
  y: 7
  description: The coast road where it runs along the top of the beach.
Millford:
  x: 12
  y: 9
  description: A watermill, a bridge over the ford, and a dozen cottages.
The Forest Track:
  x: 12
  y: 5
  description: Close-grown pines, and the road narrowed to a rut between them. Nothing
    good happens here after dark.
The Long Rise:
  x: 21
  y: 6
  description: The coast road again, climbing east into the foothills.
The Standing Stones:
  x: 10
  y: 13
  landmark: true
  max_distance: 25
  description: Nine leaning stones of ancient origin in a rough circle.
Cairnmouth:
  x: 24
  y: 5
  landmark: true
  max_distance: 60
  description: A large town built in the foothills of the mountains.

Every place worth being is listed here, keyed by name, with the x and y of the square it sits on. Counting starts at 1 from the western and northern edges, the same as the grid model.

This list is the whole of your control over how the map plays. A road with nothing marked on it becomes one long link and is crossed in a single move; put a place halfway along it and it becomes two moves with somewhere to stop in between. That is how you decide what is a journey and what is only a distance — and it is why anywhere you want something to happen needs to be a location, since the player is only ever at one of these.

Current location. Paste this, and nothing else, into its update instructions, as in the graph model:

<<$location_update_instructions>>

2. Create the trigger. Add a trigger called Evaluate location, set as in the earlier models: fire at the start of the game, also fire mid-game, trigger more than once, and a condition of 'Pawscript expression is true' with the expression 'true'.

Then add a single Run a script effect and paste this in. It does both halves of the work — building the graph from the grid, then reading it — so there is no second trigger and no ordering to get wrong:

# --- Build the graph from the grid. ---------------------------------------------
# Rebuilt every turn, so an edit to the map takes effect immediately.
# A blank line is not a row. One left at the top or bottom of the item -- easily picked up
# when copying -- would otherwise shift every square by a row and leave a map of width 0,
# which fails silently: no territories, no links, and a report with no exits.
set $rows = list()
for each $line in $map.lines()
  if $line != ""
    $rows.append($line)
set $height = $rows.count()
set $width = $rows.item(1).characters().count()

# A search outward from every marked place at once, so each walkable square learns which
# place is nearest and how far. That carves the map into territories, one per place.
# A square on the border is reached but never stepped off, which is what stops the search
# leaving the map.
set $owner = record()
set $dist = record()
set $from_x = record()
set $from_y = record()
set $frontier = list()
for each $place_name in $locations.keys()
  set $spot = $locations.item($place_name)
  set $seed = ($spot.x) & "," & ($spot.y)
  $owner.item($seed) = $place_name
  $dist.item($seed) = 0
  $from_x.item($seed) = $spot.x
  $from_y.item($seed) = $spot.y
  $frontier.append($seed)

for each $pass in range($width * $height)
  set $next = list()
  for each $cell in $frontier
    set $cell_x = $from_x.item($cell)
    set $cell_y = $from_y.item($cell)
    set $cell_dist = $dist.item($cell)
    set $cell_owner = $owner.item($cell)
    set $terrain = $legend.item($rows.item($cell_y - 1).characters().item($cell_x))
    if $terrain.movement
      set $next_key = ($cell_x) & "," & ($cell_y - 1)
      set $next_dist = $cell_dist + $terrain.movement_cost
      if $next_dist < $dist.item($next_key, 999999)
        $dist.item($next_key) = $next_dist
        $owner.item($next_key) = $cell_owner
        $from_x.item($next_key) = $cell_x
        $from_y.item($next_key) = $cell_y - 1
        $next.append($next_key)
    set $terrain = $legend.item($rows.item($cell_y + 1).characters().item($cell_x))
    if $terrain.movement
      set $next_key = ($cell_x) & "," & ($cell_y + 1)
      set $next_dist = $cell_dist + $terrain.movement_cost
      if $next_dist < $dist.item($next_key, 999999)
        $dist.item($next_key) = $next_dist
        $owner.item($next_key) = $cell_owner
        $from_x.item($next_key) = $cell_x
        $from_y.item($next_key) = $cell_y + 1
        $next.append($next_key)
    set $terrain = $legend.item($rows.item($cell_y).characters().item($cell_x + 1))
    if $terrain.movement
      set $next_key = ($cell_x + 1) & "," & ($cell_y)
      set $next_dist = $cell_dist + $terrain.movement_cost
      if $next_dist < $dist.item($next_key, 999999)
        $dist.item($next_key) = $next_dist
        $owner.item($next_key) = $cell_owner
        $from_x.item($next_key) = $cell_x + 1
        $from_y.item($next_key) = $cell_y
        $next.append($next_key)
    set $terrain = $legend.item($rows.item($cell_y).characters().item($cell_x - 1))
    if $terrain.movement
      set $next_key = ($cell_x - 1) & "," & ($cell_y)
      set $next_dist = $cell_dist + $terrain.movement_cost
      if $next_dist < $dist.item($next_key, 999999)
        $dist.item($next_key) = $next_dist
        $owner.item($next_key) = $cell_owner
        $from_x.item($next_key) = $cell_x - 1
        $from_y.item($next_key) = $cell_y
        $next.append($next_key)
  set $frontier = $next

# Wherever two territories touch, the places that own them are neighbors, and the link
# between them costs the cheapest crossing found anywhere along that border.
set $edge = record()
for each $scan_y in range($height)
  for each $scan_x in range($width)
    set $scan_key = ($scan_x) & "," & ($scan_y)
    set $cell_owner = $owner.item($scan_key, "")
    if $cell_owner != ""
      set $next_key = ($scan_x + 1) & "," & ($scan_y)
      set $next_owner = $owner.item($next_key, "")
      if $next_owner != "" and $next_owner != $cell_owner
        set $cross = $dist.item($scan_key) + $legend.item($rows.item($scan_y).characters().item($scan_x + 1)).movement_cost + $dist.item($next_key)
        if $cross < $edge.item($cell_owner & "|" & $next_owner, 999999)
          $edge.item($cell_owner & "|" & $next_owner) = $cross
          $edge.item($next_owner & "|" & $cell_owner) = $cross
      set $next_key = ($scan_x) & "," & ($scan_y + 1)
      set $next_owner = $owner.item($next_key, "")
      if $next_owner != "" and $next_owner != $cell_owner
        set $cross = $dist.item($scan_key) + $legend.item($rows.item($scan_y + 1).characters().item($scan_x)).movement_cost + $dist.item($next_key)
        if $cross < $edge.item($cell_owner & "|" & $next_owner, 999999)
          $edge.item($cell_owner & "|" & $next_owner) = $cross
          $edge.item($next_owner & "|" & $cell_owner) = $cross

# Cheapest cost between every pair, going through anywhere. Used next to spot links that
# are no better than routing round through somewhere else.
set $far = record()
for each $pair in $edge.keys()
  $far.item($pair) = $edge.item($pair)
for each $via in $locations.keys()
  for each $from in $locations.keys()
    for each $to_place in $locations.keys()
      set $round = $far.item($from & "|" & $via, 999999) + $far.item($via & "|" & $to_place, 999999)
      if $round < $far.item($from & "|" & $to_place, 999999)
        $far.item($from & "|" & $to_place) = $round

# Keep a link only if nothing beats it. Bearings come from the two places' grid squares,
# rounded to the nearest of the eight compass points.
set $built = list()
set $done = record()
for each $pair in $edge.keys()
  if $done.item($pair, "") = ""
    set $ends = $pair.split("|")
    set $end_a = $ends.item(1)
    set $end_b = $ends.item(2)
    $done.item($pair) = "y"
    $done.item($end_b & "|" & $end_a) = "y"
    set $direct = $edge.item($pair)
    set $spare = "n"
    for each $detour in $locations.keys()
      if $detour != $end_a and $detour != $end_b
        if $far.item($end_a & "|" & $detour, 999999) + $far.item($detour & "|" & $end_b, 999999) <= $direct
          set $spare = "y"
    if $spare = "n"
      set $spot_a = $locations.item($end_a)
      set $spot_b = $locations.item($end_b)
      set $east = $spot_b.x - $spot_a.x
      set $north = $spot_a.y - $spot_b.y
      set $new_link = record()
      $new_link.between = list($end_a, $end_b)
      $new_link.travel_time = $direct
      if $north.abs() * 2 < $east.abs()
        $new_link.direction = if($east > 0, "E", "W")
      else if $east.abs() * 2 < $north.abs()
        $new_link.direction = if($north > 0, "N", "S")
      else
        $new_link.direction = if($north > 0, if($east > 0, "NE", "NW"), if($east > 0, "SE", "SW"))
      $built.append($new_link)

set $derived_links = $built

# --- Read the graph and write the report. --------------------------------------
set $here = $current_location
set $here_loc = $locations.item($here)
$location_report = format_text("I am currently at {$here}: {$here_loc.description}")

# Every link touching this place, named from the player's side. Derived links carry no
# description of their own, so only the distance and bearing are reported.
set $exits = ""
set $reach = ""
for each $link in $derived_links
  set $place_a = $link.between.item(1)
  set $place_b = $link.between.item(2)
  if $place_a = $here
    set $word = choose($link.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
    set $exits = $exits & format_text("\n* {$place_b}: {$link.travel_time} {$travel_units} {$word}.")
    set $reach = $reach & format_text("\n* `{$place_b}`")
  if $place_b = $here
    set $word = choose($link.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
    set $exits = $exits & format_text("\n* {$place_a}: {$link.travel_time} {$travel_units} {$word}.")
    set $reach = $reach & format_text("\n* `{$place_a}`")

if $exits != ""
  $location_report &= "\n\nMy immediate surroundings are:"
  $location_report &= $exits

# What Current location's update instructions substitute in.
if $reach = ""
  $location_update_instructions = "There is nowhere I can travel to from here. Leave this exactly as it is."
else
  $location_update_instructions = "These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:" & $reach & "\nIf I did not move to one of them, leave this exactly as it is."

# Bounded Bellman-Ford over the links just built.
set $cost = record()
set $trail = record()
$cost.item($here) = 0
$trail.item($here) = ""
set $frontier = list($here)

for each $pass in range($locations.count())
  set $next = list()
  for each $node in $frontier
    set $node_cost = $cost.item($node)
    set $node_trail = $trail.item($node)
    for each $link in $derived_links
      set $place_a = $link.between.item(1)
      set $place_b = $link.between.item(2)
      if $place_a = $node
        set $step_cost = $node_cost + $link.travel_time
        if $step_cost < $cost.item($place_b, 999999)
          $cost.item($place_b) = $step_cost
          set $word = choose($link.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
          set $leg = $link.travel_time & " " & $travel_units & " " & $word & " to " & $place_b
          if $node_trail = ""
            $trail.item($place_b) = $leg
          else
            $trail.item($place_b) = $node_trail & ", then " & $leg
          $next.append($place_b)
      if $place_b = $node
        set $step_cost = $node_cost + $link.travel_time
        if $step_cost < $cost.item($place_a, 999999)
          $cost.item($place_a) = $step_cost
          set $word = choose($link.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
          set $leg = $link.travel_time & " " & $travel_units & " " & $word & " to " & $place_a
          if $node_trail = ""
            $trail.item($place_a) = $leg
          else
            $trail.item($place_a) = $node_trail & ", then " & $leg
          $next.append($place_a)
  set $frontier = $next

# Landmarks reachable inside their own max_distance, with the legs to get there.
set $routes = ""
for each $mark_name in $locations.keys()
  set $mark = $locations.item($mark_name)
  if $mark.landmark.exists() and $mark.landmark and $mark_name != $here
    if $cost.item($mark_name, 999999) <= $mark.max_distance
      set $mark_cost = $cost.item($mark_name)
      set $mark_trail = $trail.item($mark_name)
      set $routes = $routes & format_text("\n* {$mark_name} ({$mark_cost} {$travel_units}): {$mark_trail}")

if $routes != ""
  $location_report &= "\n\nRoutes from here:"
  $location_report &= $routes
  $location_report &= "\nFollow each leg in the order given."

3. Create the instruction block. Add an instruction block called Location instructions and paste this in as its content:

<<$location_report>>

I can only travel to the places listed above, and traveling to one of them covers the distance given before I arrive.
Use any routes listed above when I ask the way somewhere, and do not invent a route, a place, or a way between places that is not listed.

An example of a location report generated by this model is:

I am currently at Millford: A watermill, a bridge over the ford, and a dozen cottages.

My immediate surroundings are:
* The Forest Track: 20 miles North.
* The Strand: 7 miles West.
* The Long Rise: 12 miles East.
* The Standing Stones: 10 miles Southwest.

Routes from here:
* Saltfield (11 miles): 7 miles West to The Strand, then 4 miles West to Saltfield
* The Standing Stones (10 miles): 10 miles Southwest to The Standing Stones
* Cairnmouth (16 miles): 12 miles East to The Long Rise, then 4 miles East to Cairnmouth
Follow each leg in the order given.

An interactive example of this model can be found here.

Multi-map models[edit]

Grid[edit]

Each of the maps in this model is a grid, each with its own legend, its own scale and its own landmarks. It is possible to re-use a single character in multiple grids with multiple different meanings.

Each legend entry can map to a point on another map using the optional to_map, to_x and to_y attributes. This is a one-way link, any links back will also have to be specified. It also does not have to be unique, multiple tiles in one map can redirect to the same tile in another map.

Installation — click to show the steps

1. Create the tracked items. Add these four. Maps is long, and follows the table.

Name Type Visible to Auto-update Starting value
Maps YAML the player — below
Current map Text the player and AI — The Island
Map position X Number the player and AI yes 4
Map position Y Number the player and AI yes 7
Location Report Text the player — leave it empty

It takes all three of the position items to say where the player is: a pair of coordinates means nothing without knowing which map they are on. Current map is the one item with auto-update off, because the storyteller AI never sets it — the trigger in step 2 does.

Maps.

The Island:
  grid: |-
    ~^^^^^^^^^^^^^^^^^^^^^^^^^~~
    ~^^^^^........^^^^^^^^^^^^~~
    ~^^......TTTTTTT...^^^^^^^~~
    ~^.....TTTTTTTTTTTT....^^^~~
    ~.....TTTTTTTTTTTTTT===O.^~~
    ~.....TTTTTTTTTTTTT==.....~~
    ~~S=====TTTTTTTTT===......~~
    ~~.....===TTTT====......~~~~
    ~~~......======......~~~~~~~
    ~~~~.......~~....~~~~~~~~~~~
    ~~~~~.....~~~~~~~~~~~~~~~~~~
    ~~~~~~~....~~~~~~~~~~~~~~~~~
    ~~~~~~~~..~~~~~~~~~~~~~~~~~~
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  travel_units: hours
  legend:
    ^:
      name: mountain
      description: An impassable mountain.
      movement: false
      movement_cost: 0
    '~':
      name: water
      description: A deep, impassable stretch of water.
      movement: false
      movement_cost: 0
    .:
      name: grassland
      description: Long grass with few trees.
      movement: true
      movement_cost: 2
    T:
      name: forest
      description: A dense forest of closely-spaced trees.
      movement: true
      movement_cost: 5
    '=':
      name: a road
      description: A dirt road that makes for easy travel.
      movement: true
      movement_cost: 1
    O:
      name: a town
      description: A small walled town.
      movement: true
      movement_cost: 1
    S:
      name: the gates of Saltfield
      movement: true
      movement_cost: 1
      description: The road ends at the east gate, and the town begins.
      to_map: Saltfield
      to_x: 15
      to_y: 5
  locations:
    Cairnmouth:
      x: 24
      y: 5
      landmark: true
      max_distance: 60
      description: A large town built in the foothills of the mountains.
    The Strand:
      x: 7
      y: 7
      description: The coast road where it runs along the top of the beach.
    Millford:
      x: 12
      y: 9
      description: A watermill, a bridge over the ford, and a dozen cottages.
    The Long Rise:
      x: 21
      y: 6
      description: The coast road, climbing east into the foothills.
Saltfield:
  grid: |-
    ~~#######N######
    ~~H............#
    ~~H...#.##.....#
    ~~H...#..#.....#
    ~~H...#MM#.....E
    ~~H...#MM#.....#
    ~~H...####.....#
    ~~H............#
    ~~H....####....#
    ~~H....####....#
    ~~###U##########
  travel_units: minutes
  legend:
    '#':
      name: a building
      description: Close-packed housing, with no way through.
      movement: false
      movement_cost: 0
    '~':
      name: the harbor water
      description: Moored boats, and open water beyond them.
      movement: false
      movement_cost: 0
    .:
      name: a street
      description: Packed dirt, running between the houses.
      movement: true
      movement_cost: 2
    M:
      name: the market square
      description: Awnings, stalls, and the noise of the town.
      movement: true
      movement_cost: 1
    H:
      name: the quayside
      description: Tar and rope, along the edge of the water.
      movement: true
      movement_cost: 2
    N:
      name: the north gate
      movement: true
      movement_cost: 2
      description: A narrow arch in the north wall, opening onto the dunes.
      to_map: The Island
      to_x: 3
      to_y: 6
    E:
      name: the east gate
      movement: true
      movement_cost: 2
      description: The main gate, and the road inland beyond it.
      to_map: The Island
      to_x: 4
      to_y: 7
    U:
      name: the south gate
      movement: true
      movement_cost: 2
      description: A low gate onto the salt flats south of the town.
      to_map: The Island
      to_x: 3
      to_y: 8
  locations:
    The Quayside:
      x: 3
      y: 5
      landmark: true
      max_distance: 60
      description: Tar, rope, and a dozen boats tied along the water.
    The Market Square:
      x: 8
      y: 5
      landmark: true
      max_distance: 60
      description: Stalls under awnings, and more noise than anywhere else in the town.
    The Gate Yard:
      x: 15
      y: 5
      description: Mud and cartwheel ruts, just inside the east gate.
    The Shambles:
      x: 13
      y: 9
      description: A lane of butchers behind the eastern houses.

Each map carries its own grid, its own travel_units, its own legend and its own locations. The legends are genuinely separate: . is grassland on the island and a street in the town. A symbol means only what the map it appears on says it means.

The grid rules hold as in the single-map grid, with some changes:

  • Every line of a map must be the same length
  • Every character must appear in that map's own legend
  • The border must be impassable or lead to another map
  • If a tile leads to another map, the tile it leads to in that map cannot lead anywhere else

Under locations, places are keyed by name, with the x and y of the square they sit on. Names need only be unique within a map. A landmark's name and description replace the terrain's when the player is on or beside it, so a square with a landmark on it is described as the landmark.

to_map, to_x and to_y are what make a square a way into another map. The square keeps its own terrain and is walked onto like any other; standing on it at the end of a turn is what carries the player across. Two restrictions come with that: a transition may not land on the border of the map it arrives at — arrive one square in — and a transition may not land on another transition, which would carry the player straight back and leave them crossing back and forth every turn without ever arriving.

Map position X and Map position Y work as in the single-map grid, as long as the player does not make any map transitions. Note that it is also possible to transition to the same map you are already in, using to_x and to_y to cover more than one tile a turn.

Paste this into the update instructions for X:

This is my east-west position on the map I am on.
If I moved east this turn, increase it by 1.
If I moved west this turn, decrease it by 1.
If I did not move east or west, leave it exactly as it is.
Never change it by more than 1 in a single turn, however far I say I am going.

And into Y's:

This is my north-south position on the map I am on.
If I moved south this turn, increase it by 1.
If I moved north this turn, decrease it by 1.
If I did not move north or south, leave it exactly as it is.
Never change it by more than 1 in a single turn, however far I say I am going.

2. Create the trigger. Add a trigger called Evaluate location, set as in the earlier models: fire at the start of the game, also fire mid-game, trigger more than once, and a condition of 'Pawscript expression is true' with the expression 'true'.

Then add a single Run a script effect and paste this in. Crossing between maps and writing the report are both done here, in that order, so there is no second trigger and no ordering to get wrong:

# --- Cross between maps, if the player is standing on a way out. ----------------
# The AI moves the player one square at a time and never sets the map. Walking onto a gate
# square is therefore an ordinary move; this turns it into a crossing on the turn after.
set $legend = $maps.item($current_map).legend
set $rows = $maps.item($current_map).grid.lines()
set $here_t = $legend.item($rows.item($map_position_y).characters().item($map_position_x))

if $here_t.to_map.exists()
  $map_position_x = $here_t.to_x
  $map_position_y = $here_t.to_y
  $current_map = $here_t.to_map

# --- Write the report for whichever map the player is now on. -------------------
set $here_map = $current_map
set $px = $map_position_x
set $py = $map_position_y
set $units = $maps.item($here_map).travel_units
set $legend = $maps.item($here_map).legend
# A blank line is not a row. One left at the top or bottom of the item -- easily picked up
# when copying -- would otherwise shift every square by a row and leave a map of width 0,
# which fails silently: no territories, no links, and a report with no exits.
set $rows = list()
for each $line in $maps.item($here_map).grid.lines()
  if $line != ""
    $rows.append($line)
set $height = $rows.count()
set $width = $rows.item(1).characters().count()
set $where = $maps.item($here_map).locations

# A landmark on a square outranks the terrain under it.
set $lm = record()
for each $n in $where.keys()
  set $s = $where.item($n)
  $lm.item(($s.x) & "," & ($s.y)) = $n

set $key = ($px) & "," & ($py)
set $here_t = $legend.item($rows.item($py).characters().item($px))
set $mine = $lm.item($key, "")
if $mine != ""
  set $spot = $where.item($mine)
  $location_report = format_text("I am currently at {$mine}: {$spot.description}")
else
  $location_report = format_text("I am currently at {$here_t.name}: {$here_t.description}")
$location_report &= "\n\nMy immediate surroundings are:"

# The four squares around the player. A square that leads somewhere says so.
set $n_t = $legend.item($rows.item($py - 1).characters().item($px))
set $n_name = first_available($lm.item(($px) & "," & ($py - 1), ""), $n_t.name)
if $n_t.to_map.exists()
  set $n_name = $n_name & " (leads to " & $n_t.to_map & ")"
set $n_move = if($n_t.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* NORTH: {$n_name}. {$n_move}")
set $n_t = $legend.item($rows.item($py + 1).characters().item($px))
set $n_name = first_available($lm.item(($px) & "," & ($py + 1), ""), $n_t.name)
if $n_t.to_map.exists()
  set $n_name = $n_name & " (leads to " & $n_t.to_map & ")"
set $n_move = if($n_t.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* SOUTH: {$n_name}. {$n_move}")
set $n_t = $legend.item($rows.item($py).characters().item($px + 1))
set $n_name = first_available($lm.item(($px + 1) & "," & ($py), ""), $n_t.name)
if $n_t.to_map.exists()
  set $n_name = $n_name & " (leads to " & $n_t.to_map & ")"
set $n_move = if($n_t.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* EAST: {$n_name}. {$n_move}")
set $n_t = $legend.item($rows.item($py).characters().item($px - 1))
set $n_name = first_available($lm.item(($px - 1) & "," & ($py), ""), $n_t.name)
if $n_t.to_map.exists()
  set $n_name = $n_name & " (leads to " & $n_t.to_map & ")"
set $n_move = if($n_t.movement, "I can go this way.", "I cannot go this way.")
$location_report &= format_text("\n* WEST: {$n_name}. {$n_move}")

# How far the search has to reach: the largest max_distance among this map's landmarks.
set $radius = 0
for each $n in $where.keys()
  set $s = $where.item($n)
  if $s.landmark.exists() and $s.landmark
    if $s.max_distance > $radius
      set $radius = $s.max_distance

# Bounded Bellman-Ford over this map's grid only. A border square is reached but never
# stepped off, which is what keeps the search inside the map now that a border square
# can be walked on.
set $cost = record()
set $tiles = record()
set $trail = record()
set $fx = record()
set $fy = record()
$cost.item($key) = 0
$tiles.item($key) = 0
$trail.item($key) = ""
$fx.item($key) = $px
$fy.item($key) = $py
set $frontier = list($key)

for each $pass in range($radius)
  set $next = list()
  for each $k in $frontier
    set $kx = $fx.item($k)
    set $ky = $fy.item($k)
    set $k_cost = $cost.item($k)
    set $k_tiles = $tiles.item($k)
    set $k_trail = $trail.item($k)
    if $kx > 1 and $kx < $width and $ky > 1 and $ky < $height
      set $t = $legend.item($rows.item($ky - 1).characters().item($kx))
      if $t.movement
        set $nk = ($kx) & "," & ($ky - 1)
        set $n_cost = $k_cost + $t.movement_cost
        if $n_cost < $cost.item($nk, 999999)
          $cost.item($nk) = $n_cost
          $tiles.item($nk) = $k_tiles + 1
          $trail.item($nk) = $k_trail & "N"
          $fx.item($nk) = $kx
          $fy.item($nk) = $ky - 1
          $next.append($nk)
      set $t = $legend.item($rows.item($ky + 1).characters().item($kx))
      if $t.movement
        set $nk = ($kx) & "," & ($ky + 1)
        set $n_cost = $k_cost + $t.movement_cost
        if $n_cost < $cost.item($nk, 999999)
          $cost.item($nk) = $n_cost
          $tiles.item($nk) = $k_tiles + 1
          $trail.item($nk) = $k_trail & "S"
          $fx.item($nk) = $kx
          $fy.item($nk) = $ky + 1
          $next.append($nk)
      set $t = $legend.item($rows.item($ky).characters().item($kx + 1))
      if $t.movement
        set $nk = ($kx + 1) & "," & ($ky)
        set $n_cost = $k_cost + $t.movement_cost
        if $n_cost < $cost.item($nk, 999999)
          $cost.item($nk) = $n_cost
          $tiles.item($nk) = $k_tiles + 1
          $trail.item($nk) = $k_trail & "E"
          $fx.item($nk) = $kx + 1
          $fy.item($nk) = $ky
          $next.append($nk)
      set $t = $legend.item($rows.item($ky).characters().item($kx - 1))
      if $t.movement
        set $nk = ($kx - 1) & "," & ($ky)
        set $n_cost = $k_cost + $t.movement_cost
        if $n_cost < $cost.item($nk, 999999)
          $cost.item($nk) = $n_cost
          $tiles.item($nk) = $k_tiles + 1
          $trail.item($nk) = $k_trail & "W"
          $fx.item($nk) = $kx - 1
          $fy.item($nk) = $ky
          $next.append($nk)
  set $frontier = $next

# Routes stop at the edge of this map. Costs on two maps are in different units, so
# adding them would produce a number that means nothing.
set $routes = ""
for each $n in $where.keys()
  set $s = $where.item($n)
  if $s.landmark.exists() and $s.landmark
    set $sk = ($s.x) & "," & ($s.y)
    if $sk != $key and $tiles.item($sk, 999999) <= $s.max_distance
      set $s_cost = $cost.item($sk)
      set $s_route = $trail.item($sk).characters().join("-")
      set $routes = $routes & format_text("\n* {$n} ({$s_cost} {$units} travel): {$s_route}")

if $routes != ""
  $location_report &= "\n\nRoutes from here, on this map:"
  $location_report &= $routes
  $location_report &= "\nEach route is the single steps to take, in order, one square each: N is north, S is south, E is east, W is west. So E-E-N means move east, then east again, then north."

3. Create the instruction block. Add an instruction block called Location instructions and paste this in as its content:

<<$location_report>>

Whenever I attempt to move, determine if I am attempting to move North, South, East or West. If I attempt to move somewhere I cannot move to, then I stay where I am.
If the report says this square leads into another map, I may go there, and I arrive at the position it gives.
Use any routes listed above when I ask the way somewhere. Those routes only cover the map I am on; to reach somewhere on another map I must first walk to the way in.
Do not invent a route, a place, or a way into another map that is not listed.

An example of a location report generated by this model is:

I am currently at a road: A dirt road that makes for easy travel.

My immediate surroundings are:
* NORTH: grassland. I can go this way.
* SOUTH: grassland. I can go this way.
* EAST: a road. I can go this way.
* WEST: the gates of Saltfield (leads to Saltfield). I can go this way.

Routes from here, on this map:
* Cairnmouth (26 hours travel): E-E-E-E-S-E-E-S-E-E-E-E-E-N-E-E-E-N-E-E-N-E-N-E-E-E
Each route is the single steps to take, in order, one square each: N is north, S is south, E is east, W is west. So E-E-N means move east, then east again, then north.

-- then I walk west onto the gate, and at the start of the next turn --

I am currently at The Gate Yard: Mud and cartwheel ruts, just inside the east gate.

My immediate surroundings are:
* NORTH: a street. I can go this way.
* SOUTH: a street. I can go this way.
* EAST: the east gate (leads to The Island). I can go this way.
* WEST: a street. I can go this way.

Routes from here, on this map:
* The Quayside (36 minutes travel): N-N-N-W-W-W-W-W-W-W-W-W-S-S-S-W-W-W
* The Market Square (25 minutes travel): N-N-N-W-W-W-W-W-W-W-S-S-S
Each route is the single steps to take, in order, one square each: N is north, S is south, E is east, W is west. So E-E-N means move east, then east again, then north.

An interactive example of this model can be found here.

Graph[edit]

The previous model drew the whole world as a grid, which can help for human visualization but ultimately it isn't relevant in a lot of situations. When playing a world, it's important to know how to get from Saltfield to Cairnmouth, but keeping track of every turn in the road or the exact shape of the forest usually isn't important. Navigating on a grid can also be slow - nobody wants to spend twenty turns following a road to get to their destination if nothing exciting is going to happen on that road.

This model does away with the grid and map, and instead has the world designer provide the map in the form of a graph - a series of nodes (locations) and edges (transitions between locations). As the player navigates, they will transition from location to location. As a map grows and the grid approach becomes unwieldy, this becomes more and more of a necessity. If you are not used to developing a graph-based map it can be unintuitive, so there is a Python script given below that can generate a visualization of the graph.

Note that it may be useful to classify certain transitory locations as nodes instead of edges, even if they would instinctively feel like transitions instead of locations proper. This is because at any given time, the player will be at exactly one location. If the world designer wants events to be able to happen while traveling, they need to set up the travel method as a location where the event can occur. For example, the world designer may want the player to be ambushed while taking the road - this means the road needs to be defined as a location, so the player is located there while the ambush happens.

Edges can also optionally have directions. These correspond to the eight compass directions, e.g. NE, S, SW. These aren't necessary, but they can help the storyteller AI avoid giving unintuitive outputs, or allow for better visualizations in the Python tool below.

The maximum distance of a landmark is compared to the total cost of reaching it, not the number of hops needed to reach it.

Installation — click to show the steps

1. Create the tracked items. Add these six. The two values given as below are long, and follow the table.

Name Type Visible to Auto-update Starting value
Locations YAML the player — below
Links YAML the player — below
Travel units Text nobody — miles
Current location Text the player and AI yes Saltfield
Location update instructions Text nobody — leave it empty
Location Report Text the player — leave it empty

Locations.

Saltfield:
  description: A small fishing community on the western shore.
  landmark: true
  max_distance: 40
Gull Point:
  description: A headland of bare rock and seabirds, with the whole coast laid out
    below.
The Strand:
  description: The coast road, running along the top of a long gray beach.
Millford:
  description: A watermill, a bridge over the ford, and a dozen cottages.
The Old Path:
  description: A rutted path inland through waist-high gorse, little used.
The Standing Stones:
  description: Nine leaning stones of ancient origin in a rough circle.
  landmark: true
  max_distance: 8
The Long Rise:
  description: The coast road again, climbing steadily east into the foothills.
The Forest Track:
  description: Close-grown pines on both sides, and the road narrowed to a rut between
    them. Nothing good happens here after dark.
Cairnmouth:
  description: A large town built in the foothills of the mountains.
  landmark: true
  max_distance: 40

Every place the player can be needs an entry, keyed by its name and holding a description, with landmark and max_distance optional.

Links.

- between:
  - Saltfield
  - Gull Point
  direction: N
  travel_time: 3
  description: A cliff path climbs north out of Saltfield onto the headland at Gull
    Point.
- between:
  - Saltfield
  - The Strand
  direction: E
  travel_time: 2
  description: Saltfield's east gate opens onto the Strand, where the road drops to
    the sand.
- between:
  - Saltfield
  - The Forest Track
  direction: E
  travel_time: 3
  description: A track head behind Saltfield's smokehouses runs east into the wood.
- between:
  - The Strand
  - Millford
  direction: E
  travel_time: 4
  description: The Strand ends at the mill bridge, which carries the road into Millford.
- between:
  - Millford
  - The Old Path
  direction: SE
  travel_time: 2
  description: A gate in Millford's east wall lets onto the Old Path, running inland.
- between:
  - The Old Path
  - The Standing Stones
  direction: SE
  travel_time: 2
  description: The Old Path peters out at the foot of the Standing Stones.
- between:
  - Millford
  - The Long Rise
  direction: NE
  travel_time: 3
  description: The coast road leaves Millford by the toll post and begins the Long
    Rise.
- between:
  - The Long Rise
  - Cairnmouth
  direction: E
  travel_time: 7
  description: The Long Rise tops out at Cairnmouth's west gate.
- between:
  - The Forest Track
  - Cairnmouth
  direction: E
  travel_time: 15
  description: The Eagle Gate lets the Forest Track in at Cairnmouth's western edge.
- between:
  - The Standing Stones
  - The Forest Track
  travel_time: 9
  description: A deer track doubles back on itself so often that nobody keeps their
    bearings.

direction reads from the first place in between to the second. So a link between Saltfield and The Strand with a direction of E means the Strand is east of Saltfield — and, walked the other way, Saltfield is west of the Strand. You only need to specify the link and direction one way, the inverse is also computed. The eight values are N, NE, E, SE, S, SW, W and NW.

Direction is optional, and you can have some links with direction and some without. It is also possible to have multiple links leave one place in the same direction.

Current location. In the grid model, we keep track of the player's location by their X and Y coordinate. In this model, we keep track by the name of the player's location. Paste this, and nothing else, into its update instructions:

<<$location_update_instructions>>

What that substitutes in is written by the same script that writes the location report, so it names the places the player can actually reach this turn. An example would be:

These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:
* `The Strand`
* `The Old Path`
* `The Long Rise`
If I did not move to one of them, leave this exactly as it is.

2. Create the trigger. Add a trigger called Evaluate location, set as in the grid model: fire at the start of the game, also fire mid-game, trigger more than once, and a condition of 'Pawscript expression is true' with the expression 'true'.

Then add a single Run a script effect and paste this in:

# Where the player is.
set $here = $current_location
set $here_loc = $locations.item($here)
$location_report = format_text("I am currently at {$here}: {$here_loc.description}")

# Every link touching this place, named from the player's side. A link stores only one
# direction, so walking it backwards reports the opposite -- that is the second choose().
# $reach collects the same places bare, for the update instructions.
set $exits = ""
set $reach = ""
for each $l in $links
  set $a = $l.between.item(1)
  set $b = $l.between.item(2)
  if $a = $here
    if $l.direction.exists()
      set $word = choose($l.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
      set $exits = $exits & format_text("\n* {$b}: {$l.travel_time} {$travel_units} {$word}. {$l.description}")
    else
      set $exits = $exits & format_text("\n* {$b}: {$l.travel_time} {$travel_units}. {$l.description}")
    set $reach = $reach & format_text("\n* `{$b}`")
  if $b = $here
    if $l.direction.exists()
      set $word = choose($l.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
      set $exits = $exits & format_text("\n* {$a}: {$l.travel_time} {$travel_units} {$word}. {$l.description}")
    else
      set $exits = $exits & format_text("\n* {$a}: {$l.travel_time} {$travel_units}. {$l.description}")
    set $reach = $reach & format_text("\n* `{$a}`")

if $exits != ""
  $location_report &= "\n\nMy immediate surroundings are:"
  $location_report &= $exits

# What Current location's update instructions substitute in: the places reachable this turn,
# and nothing else, so the AI never has to work out where it may legally move to.
if $reach = ""
  $location_update_instructions = "There is nowhere I can travel to from here. Leave this exactly as it is."
else
  $location_update_instructions = "These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:" & $reach & "\nIf I did not move to one of them, leave this exactly as it is."

# Bounded Bellman-Ford, as in the grid model but hopping between places rather than squares.
# $trail records the legs taken, so a route can be read back as prose.
set $cost = record()
set $trail = record()
$cost.item($here) = 0
$trail.item($here) = ""
set $frontier = list($here)

for each $pass in range($locations.count())
  set $next = list()
  for each $k in $frontier
    set $k_cost = $cost.item($k)
    set $k_trail = $trail.item($k)
    for each $l in $links
      set $a = $l.between.item(1)
      set $b = $l.between.item(2)
      if $a = $k
        set $n_cost = $k_cost + $l.travel_time
        if $n_cost < $cost.item($b, 999999)
          $cost.item($b) = $n_cost
          if $l.direction.exists()
            set $word = choose($l.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
            set $leg = $l.travel_time & " " & $travel_units & " " & $word & " to " & $b
          else
            set $leg = $l.travel_time & " " & $travel_units & " to " & $b
          if $k_trail = ""
            $trail.item($b) = $leg
          else
            $trail.item($b) = $k_trail & ", then " & $leg
          $next.append($b)
      if $b = $k
        set $n_cost = $k_cost + $l.travel_time
        if $n_cost < $cost.item($a, 999999)
          $cost.item($a) = $n_cost
          if $l.direction.exists()
            set $word = choose($l.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
            set $leg = $l.travel_time & " " & $travel_units & " " & $word & " to " & $a
          else
            set $leg = $l.travel_time & " " & $travel_units & " to " & $a
          if $k_trail = ""
            $trail.item($a) = $leg
          else
            $trail.item($a) = $k_trail & ", then " & $leg
          $next.append($a)
  set $frontier = $next

# Landmarks reachable inside their own max_distance, with the legs to get there.
set $routes = ""
for each $n in $locations.keys()
  set $m = $locations.item($n)
  if $m.landmark.exists() and $m.landmark and $n != $here
    if $cost.item($n, 999999) <= $m.max_distance
      set $m_cost = $cost.item($n)
      set $m_trail = $trail.item($n)
      set $routes = $routes & format_text("\n* {$n} ({$m_cost} {$travel_units}): {$m_trail}")

if $routes != ""
  $location_report &= "\n\nRoutes from here:"
  $location_report &= $routes
  $location_report &= "\nFollow each leg in the order given."

3. Create the instruction block. Add an instruction block called Location instructions and paste this in as its content:

<<$location_report>>

I can only travel along the ways listed above, and only to the place each one leads to. A direction given for a way is the way I set out in, and traveling it covers the distance given before I arrive at the far end.
Use any routes listed above when I ask the way somewhere, and do not invent a route, a place, a way between places, or a direction that is not listed.

An interactive version of this model can be found here

Although the above is very useful for the AI, it is not as useful for humans - having just a list of locations and transitions is very hard to visualize. The below Python script can be used to generate a simple map that displays the locations and transitions.

Drawing the map — click to show the script

Paste your locations into the first block and your transitions into the second, then run it. It writes map.svg, which any browser will open, and lays the places out to respect whatever bearings the links carry.

"""Draw a map of the locations and transitions, as an SVG you can open in a browser.

Paste the same YAML you gave the world into the two blocks below, then run this file.
It needs PyYAML: pip install pyyaml
"""

import math
import pathlib

import yaml

LOCATIONS = """
INSERT LOCATIONS YAML HERE
"""

TRANSITIONS = """
INSERT TRANSITIONS YAML HERE
"""

# Which way each bearing points on the page. Y grows downwards in an image, so north is negative.
DIAGONAL = math.sqrt(0.5)
COMPASS = {"N": (0, -1), "NE": (DIAGONAL, -DIAGONAL), "E": (1, 0), "SE": (DIAGONAL, DIAGONAL),
           "S": (0, 1), "SW": (-DIAGONAL, DIAGONAL), "W": (-1, 0), "NW": (-DIAGONAL, -DIAGONAL)}
OPPOSITE = {"N": "S", "NE": "SW", "E": "W", "SE": "NW",
            "S": "N", "SW": "NE", "W": "E", "NW": "SE"}

FAN = math.radians(16)     # how far apart to open links that leave one place on the same bearing
TOO_CLOSE = 0.5            # places nearer than this shove each other apart
HALF_POINT = 22.5          # half a compass point: past this, a line reads as the wrong direction
CLEARANCE = 46             # pixels a line is bowed to keep clear of a place it does not touch

locations = yaml.safe_load(LOCATIONS)
transitions = yaml.safe_load(TRANSITIONS)
names = list(locations)
longest = max(t["travel_time"] for t in transitions)

def span(link):
    return link["travel_time"] / longest * 2

def unit(link, aim):
    """The direction a link should point, first place to second, or None if it was given no
    bearing. `aim` holds any links fanned aside to stop them lying on top of each other."""
    if "direction" not in link:
        return None
    return aim.get(id(link), COMPASS[link["direction"]])

def nearest_point(point, a, b):
    """The spot on the line a-b closest to `point`, and how far along the line that is."""
    dx, dy = b[0] - a[0], b[1] - a[1]
    if dx == 0 and dy == 0:
        return a, 0.0
    along = max(0.0, min(1.0, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy) / (dx * dx + dy * dy)))
    return (a[0] + along * dx, a[1] + along * dy), along

# Place the first location, then walk outward along the links, putting each new one exactly where
# its bearing and distance say it goes. That is already right wherever the map is a tree; only links
# that close a loop are left disagreeing, and the settling below shares out the difference.
start = {names[0]: [0.0, 0.0]}
frontier = [names[0]]
while frontier:
    node = frontier.pop(0)
    for link in transitions:
        a, b = link["between"]
        for near, far in ((a, b), (b, a)):
            if near != node or far in start:
                continue
            step = unit(link, {}) or (1.0, 0.0)
            if near == b:
                step = (-step[0], -step[1])
            start[far] = [start[node][0] + step[0] * span(link),
                          start[node][1] + step[1] * span(link)]
            frontier.append(far)
for i, name in enumerate(names):          # anything no link ever reached
    start.setdefault(name, [math.cos(i), math.sin(i)])

def settle(aim):
    """Pull every link towards the bearing and distance it asks for, and shove apart any two places
    that have ended up on top of each other. Lines running too close to a place are left alone here
    and bowed aside when they are drawn -- trying to fix that by moving places instead only bends
    the bearings, which are the thing this map exists to show."""
    position = {name: list(point) for name, point in start.items()}
    for _step in range(500):
        push = {name: [0.0, 0.0] for name in names}
        for i, a in enumerate(names):
            for b in names[i + 1:]:
                dx, dy = position[a][0] - position[b][0], position[a][1] - position[b][1]
                gap = max(math.hypot(dx, dy), 0.01)
                if gap < TOO_CLOSE:
                    shove = (TOO_CLOSE - gap) * 0.2
                    push[a][0] += dx / gap * shove
                    push[a][1] += dy / gap * shove
                    push[b][0] -= dx / gap * shove
                    push[b][1] -= dy / gap * shove
        for link in transitions:
            a, b = link["between"]
            heading, want = unit(link, aim), span(link)
            dx, dy = position[b][0] - position[a][0], position[b][1] - position[a][1]
            if heading is None:                       # only a distance was given
                gap = max(math.hypot(dx, dy), 0.01)
                pull = (gap - want) * 0.05
                errx, erry = dx / gap * pull, dy / gap * pull
            else:
                errx = (dx - heading[0] * want) * 0.3
                erry = (dy - heading[1] * want) * 0.3
            push[a][0] += errx
            push[a][1] += erry
            push[b][0] -= errx
            push[b][1] -= erry
        for name in names:
            position[name][0] += push[name][0]
            position[name][1] += push[name][1]
    return position

def fan_apart(position, flip):
    """Several links can leave one place on the same bearing, and drawn literally they would lie on
    top of one another. Open them out to either side instead, putting the one whose far end already
    lies to the north on the northern side. Which way round that should go is a guess, so it is
    tried both ways and judged below."""
    groups = {}
    for link in transitions:
        if "direction" not in link:
            continue
        a, b = link["between"]
        for near, far, backwards in ((a, b, False), (b, a, True)):
            here = OPPOSITE[link["direction"]] if backwards else link["direction"]
            groups.setdefault((near, here), []).append((link, far))
    aim = {}
    for (near, here), members in groups.items():
        if len(members) < 2:
            continue
        ux, uy = COMPASS[here]
        across = (-uy, ux)                       # ninety degrees off the shared bearing
        members.sort(key=lambda m: (position[m[1]][0] - position[near][0]) * across[0]
                                 + (position[m[1]][1] - position[near][1]) * across[1],
                     reverse=flip)
        for i, (link, _far) in enumerate(members):
            turn = (i - (len(members) - 1) / 2) * FAN
            x, y = COMPASS[link["direction"]]
            aim[id(link)] = (x * math.cos(turn) - y * math.sin(turn),
                             x * math.sin(turn) + y * math.cos(turn))
    return aim

def misread(position):
    """How many links come out pointing more than half a compass point away from the bearing they
    were given. Those are the ones a reader would take for the wrong direction entirely."""
    wrong = 0
    for link in transitions:
        if "direction" not in link:
            continue
        a, b = link["between"]
        drawn = math.degrees(math.atan2(-(position[b][1] - position[a][1]),
                                        position[b][0] - position[a][0]))
        meant = math.degrees(math.atan2(-COMPASS[link["direction"]][1],
                                        COMPASS[link["direction"]][0]))
        if abs((drawn - meant + 180) % 360 - 180) > HALF_POINT:
            wrong += 1
    return wrong

def crossings(position):
    """How many pairs of links cross. Links sharing a place are ignored -- they meet, not cross."""
    def side(p, q, r):
        return (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1])
    total = 0
    for i, one in enumerate(transitions):
        for other in transitions[i + 1:]:
            if set(one["between"]) & set(other["between"]):
                continue
            a, b = (position[end] for end in one["between"])
            c, d = (position[end] for end in other["between"])
            if side(a, b, c) * side(a, b, d) < 0 and side(c, d, a) * side(c, d, b) < 0:
                total += 1
    return total

# Settle once to see roughly where everything wants to be, and use that to decide which way to open
# the crowded bearings. Then settle again from the original starting point rather than from the
# rough answer -- carrying it over would leave the map stuck in the shape it already had.
#
# A link drawn pointing the wrong way is worse than two lines crossing, so that is judged first.
rough = settle({})
position = min((settle(fan_apart(rough, flip)) for flip in (False, True)),
               key=lambda p: (misread(p), crossings(p)))

# Fit whatever shape came out onto the canvas, centered. The margin has to leave room for the
# labels, not just the dots -- a name is drawn centered on its dot and sticks out either side.
WIDTH, HEIGHT, MARGIN = 900, 650, 120
xs = [p[0] for p in position.values()]
ys = [p[1] for p in position.values()]
span_x, span_y = max(max(xs) - min(xs), 0.01), max(max(ys) - min(ys), 0.01)
scale = min((WIDTH - 2 * MARGIN) / span_x, (HEIGHT - 2 * MARGIN) / span_y)
left, top = (WIDTH - span_x * scale) / 2, (HEIGHT - span_y * scale) / 2
screen = {name: (left + (p[0] - min(xs)) * scale, top + (p[1] - min(ys)) * scale)
          for name, p in position.items()}

escape = lambda text: str(text).replace("&", "&").replace("<", "<").replace(">", ">")

svg = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" '
       f'font-family="sans-serif">',
       f'<rect width="{WIDTH}" height="{HEIGHT}" fill="white"/>']
for link in transitions:
    a, b = (screen[end] for end in link["between"])
    # Bow the line away from any place it would otherwise run through. Bending the picture is much
    # cheaper than moving the places, which would throw the bearings out to fix a drawing problem.
    worst, bow = 0.0, (0.0, 0.0)
    for name, point in screen.items():
        if name in link["between"]:
            continue
        foot, along = nearest_point(point, a, b)
        room = math.dist(point, foot)
        if 0.05 < along < 0.95 and room < CLEARANCE and CLEARANCE - room > worst:
            worst = CLEARANCE - room
            away = math.hypot(foot[0] - point[0], foot[1] - point[1]) or 1
            bow = ((foot[0] - point[0]) / away * worst, (foot[1] - point[1]) / away * worst)
    middle = ((a[0] + b[0]) / 2 + bow[0], (a[1] + b[1]) / 2 + bow[1])
    if worst:
        # A quadratic curve sits halfway to its control point, so aim twice as far off.
        control = ((a[0] + b[0]) / 2 + bow[0] * 2, (a[1] + b[1]) / 2 + bow[1] * 2)
        svg.append(f'<path d="M {a[0]:.0f} {a[1]:.0f} Q {control[0]:.0f} {control[1]:.0f} '
                   f'{b[0]:.0f} {b[1]:.0f}" fill="none" stroke="#888" stroke-width="2"/>')
    else:
        svg.append(f'<line x1="{a[0]:.0f}" y1="{a[1]:.0f}" x2="{b[0]:.0f}" y2="{b[1]:.0f}" '
                   f'stroke="#888" stroke-width="2"/>')
    # A link is walked both ways, so the label names both -- "3 N/S", not "3 N".
    both = (f'{link["direction"]}/{OPPOSITE[link["direction"]]}' if "direction" in link else "")
    svg.append(f'<text x="{middle[0]:.0f}" y="{middle[1] - 4:.0f}" font-size="12" fill="#666" '
               f'text-anchor="middle">{link["travel_time"]} {both}</text>')
for name, (x, y) in screen.items():
    svg.append(f'<circle cx="{x:.0f}" cy="{y:.0f}" r="7" fill="#333"/>')
    svg.append(f'<text x="{x:.0f}" y="{y - 14:.0f}" font-size="14" text-anchor="middle">'
               f'{escape(name)}</text>')
svg.append("</svg>")

pathlib.Path("map.svg").write_text("\n".join(svg))
print("wrote map.svg")

An example of a location report generated by this model is:

I am currently at Millford: A watermill, a bridge over the ford, and a dozen cottages.

My immediate surroundings are:
* The Strand: 4 miles West. The Strand ends at the mill bridge, which carries the road into Millford.
* The Old Path: 2 miles Southeast. A gate in Millford's east wall lets onto the Old Path, running inland.
* The Long Rise: 3 miles Northeast. The coast road leaves Millford by the toll post and begins the Long Rise.

Routes from here:
* Saltfield (6 miles): 4 miles West to The Strand, then 2 miles West to Saltfield
* The Standing Stones (4 miles): 2 miles Southeast to The Old Path, then 2 miles Southeast to The Standing Stones
* Cairnmouth (10 miles): 3 miles Northeast to The Long Rise, then 7 miles East to Cairnmouth
Follow each leg in the order given.

Hybrid[edit]

Just as the single-map hybrid model calculates a graph from its map grid, each of the map grids in a multi-map hybrid model is used to calculate a separate graph.

Installation — click to show the steps

1. Create the tracked items. Add these five. Maps is long, and follows the table.

Name Type Visible to Auto-update Starting value
Maps YAML the player — below
Current map Text the player and AI — The Island
Current location Text the player and AI yes The Strand
Location update instructions Text nobody — leave it empty
Location Report Text the player — leave it empty

It takes both Current map and Current location to say where the player is, since the same name may exist on more than one map. Current map has auto-update off, because the storyteller AI never sets it — the trigger in step 2 does.

Maps.

The Island:
  grid: |-
    ~^^^^^^^^^^^^^^^^^^^^^^^^^~~
    ~^^^^^........^^^^^^^^^^^^~~
    ~^^......TTTTTTT...^^^^^^^~~
    ~^.....TTTTTTTTTTTT....^^^~~
    ~.....TTTTTTTTTTTTTT===O.^~~
    ~.....TTTTTTTTTTTTT==.....~~
    ~~S=====TTTTTTTTT===......~~
    ~~.....===TTTT====......~~~~
    ~~~......======......~~~~~~~
    ~~~~.......~~....~~~~~~~~~~~
    ~~~~~.....~~~~~~~~~~~~~~~~~~
    ~~~~~~~....~~~~~~~~~~~~~~~~~
    ~~~~~~~~..~~~~~~~~~~~~~~~~~~
    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  travel_units: hours
  legend:
    ^:
      name: mountain
      movement: false
      movement_cost: 0
    '~':
      name: water
      movement: false
      movement_cost: 0
    .:
      name: grassland
      movement: true
      movement_cost: 2
    T:
      name: forest
      movement: true
      movement_cost: 5
    '=':
      name: a road
      movement: true
      movement_cost: 1
    O:
      name: a town
      movement: true
      movement_cost: 1
    S:
      name: the gates of Saltfield
      movement: true
      movement_cost: 1
      description: The road ends at the east gate, and the town begins.
      to_map: Saltfield
      to_x: 15
      to_y: 5
  locations:
    Cairnmouth:
      x: 24
      y: 5
      landmark: true
      max_distance: 60
      description: A large town built in the foothills of the mountains.
    The Strand:
      x: 7
      y: 7
      description: The coast road where it runs along the top of the beach.
    Millford:
      x: 12
      y: 9
      description: A watermill, a bridge over the ford, and a dozen cottages.
    The Long Rise:
      x: 21
      y: 6
      description: The coast road, climbing east into the foothills.
Saltfield:
  grid: |-
    ~~#######N######
    ~~H............#
    ~~H...#.##.....#
    ~~H...#..#.....#
    ~~H...#MM#.....E
    ~~H...#MM#.....#
    ~~H...####.....#
    ~~H............#
    ~~H....####....#
    ~~H....####....#
    ~~###U##########
  travel_units: minutes
  legend:
    '#':
      name: a building
      movement: false
      movement_cost: 0
    '~':
      name: the harbor water
      movement: false
      movement_cost: 0
    .:
      name: a street
      movement: true
      movement_cost: 2
    M:
      name: the market square
      movement: true
      movement_cost: 1
    H:
      name: the quayside
      movement: true
      movement_cost: 2
    N:
      name: the north gate
      movement: true
      movement_cost: 2
      description: A narrow arch in the north wall, opening onto the dunes.
      to_map: The Island
      to_x: 5
      to_y: 6
    E:
      name: the east gate
      movement: true
      movement_cost: 2
      description: The main gate, and the road inland beyond it.
      to_map: The Island
      to_x: 5
      to_y: 7
    U:
      name: the south gate
      movement: true
      movement_cost: 2
      description: A low gate onto the salt flats south of the town.
      to_map: The Island
      to_x: 5
      to_y: 8
  locations:
    The Quayside:
      x: 3
      y: 5
      landmark: true
      max_distance: 60
      description: Tar, rope, and a dozen boats tied along the water.
    The Market Square:
      x: 8
      y: 5
      landmark: true
      max_distance: 60
      description: Stalls under awnings, and more noise than anywhere else in the town.
    The Gate Yard:
      x: 15
      y: 5
      description: Mud and cartwheel ruts, just inside the east gate.
    The Shambles:
      x: 13
      y: 9
      description: A lane of butchers behind the eastern houses.

Each map has a separately defined legend, travel_units and locations. Names and symbols can be reused on different maps, but not within the same map.

Three legend fields make a square lead somewhere else. to_map names the map, and to_x and to_y are the square on it that the player arrives at. A legend entry with those becomes a place in its own right, taking its name and description from the entry — so a map wanting two ways out wants two legend entries. Only the first square drawn with that character becomes the place; the same character drawn elsewhere on the map is ordinary ground, belonging to whichever place is nearest.

Current location. Paste this, and nothing else, into its update instructions, as in the earlier graph models:

<<$location_update_instructions>>
These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:
* `the gates of Saltfield`
* `Millford`
If I did not move to one of them, leave this exactly as it is.

2. Create the trigger. Add a trigger called Evaluate location, set as in the earlier models: fire at the start of the game, also fire mid-game, trigger more than once, and a condition of 'Pawscript expression is true' with the expression 'true'.

Then add a single Run a script effect and paste this in. It does all three jobs in order — build every map's graph, carry the player across if they are standing on a way out, then write the report — so there is one trigger and no ordering to get wrong:

# --- Build one graph per map, from the grids. -----------------------------------
# Rebuilt every turn, so an edit to a map takes effect immediately. Nothing here has to
# survive the turn, which is why none of it is a tracked item.
set $places = record()
set $built = list()

for each $map in $maps.keys()
  set $legend = $maps.item($map).legend
  # A blank line is not a row. One left at the top or bottom of the item -- easily picked up
  # when copying -- would otherwise shift every square by a row and leave a map of width 0,
  # which fails silently: no territories, no links, and a report with no exits.
  set $rows = list()
  for each $line in $maps.item($map).grid.lines()
    if $line != ""
      $rows.append($line)
  set $height = $rows.count()
  set $width = $rows.item(1).characters().count()

  for each $n in $maps.item($map).locations.keys()
    set $spot = $maps.item($map).locations.item($n)
    $places.item($map & "|" & $n) = $spot

  for each $y in range($height)
    for each $x in range($width)
      set $ch = $rows.item($y).characters().item($x)
      set $t = $legend.item($ch)
      if $t.to_map.exists()
        if $places.item($map & "|" & $t.name, "") = ""
          set $door = record()
          $door.x = $x
          $door.y = $y
          $door.description = $t.description
          $door.to_map = $t.to_map
          $door.to_x = $t.to_x
          $door.to_y = $t.to_y
          $places.item($map & "|" & $t.name) = $door

# A search outward from every place at once, so each square learns which place is nearest.
# That carves each map into territories, one per place. A border square is reached but
# never stepped off -- that is what keeps the search inside its own map.
set $owner = record()
set $dist = record()
set $fx = record()
set $fy = record()

for each $map in $maps.keys()
  set $legend = $maps.item($map).legend
  # A blank line is not a row. One left at the top or bottom of the item -- easily picked up
  # when copying -- would otherwise shift every square by a row and leave a map of width 0,
  # which fails silently: no territories, no links, and a report with no exits.
  set $rows = list()
  for each $line in $maps.item($map).grid.lines()
    if $line != ""
      $rows.append($line)
  set $height = $rows.count()
  set $width = $rows.item(1).characters().count()

  set $frontier = list()
  set $here_names = list()
  for each $p in $places.keys()
    set $ends = $p.split("|")
    if $ends.item(1) = $map
      set $spot = $places.item($p)
      set $seed = $map & "|" & ($spot.x) & "," & ($spot.y)
      $owner.item($seed) = $ends.item(2)
      $dist.item($seed) = 0
      $fx.item($seed) = $spot.x
      $fy.item($seed) = $spot.y
      $frontier.append($seed)
      $here_names.append($ends.item(2))

  for each $pass in range($width * $height)
    set $next = list()
    for each $k in $frontier
      set $kx = $fx.item($k)
      set $ky = $fy.item($k)
      set $kd = $dist.item($k)
      set $ko = $owner.item($k)
      if $kx > 1 and $kx < $width and $ky > 1 and $ky < $height
        set $t = $legend.item($rows.item($ky - 1).characters().item($kx))
        if $t.movement
          set $nk = $map & "|" & ($kx) & "," & ($ky - 1)
          set $nd = $kd + $t.movement_cost
          if $nd < $dist.item($nk, 999999)
            $dist.item($nk) = $nd
            $owner.item($nk) = $ko
            $fx.item($nk) = $kx
            $fy.item($nk) = $ky - 1
            $next.append($nk)
        set $t = $legend.item($rows.item($ky + 1).characters().item($kx))
        if $t.movement
          set $nk = $map & "|" & ($kx) & "," & ($ky + 1)
          set $nd = $kd + $t.movement_cost
          if $nd < $dist.item($nk, 999999)
            $dist.item($nk) = $nd
            $owner.item($nk) = $ko
            $fx.item($nk) = $kx
            $fy.item($nk) = $ky + 1
            $next.append($nk)
        set $t = $legend.item($rows.item($ky).characters().item($kx + 1))
        if $t.movement
          set $nk = $map & "|" & ($kx + 1) & "," & ($ky)
          set $nd = $kd + $t.movement_cost
          if $nd < $dist.item($nk, 999999)
            $dist.item($nk) = $nd
            $owner.item($nk) = $ko
            $fx.item($nk) = $kx + 1
            $fy.item($nk) = $ky
            $next.append($nk)
        set $t = $legend.item($rows.item($ky).characters().item($kx - 1))
        if $t.movement
          set $nk = $map & "|" & ($kx - 1) & "," & ($ky)
          set $nd = $kd + $t.movement_cost
          if $nd < $dist.item($nk, 999999)
            $dist.item($nk) = $nd
            $owner.item($nk) = $ko
            $fx.item($nk) = $kx - 1
            $fy.item($nk) = $ky
            $next.append($nk)
    set $frontier = $next

  set $edge = record()
  for each $y in range($height)
    for each $x in range($width)
      set $sq = $map & "|" & ($x) & "," & ($y)
      set $ko = $owner.item($sq, "")
      if $ko != ""
        set $nk = $map & "|" & ($x + 1) & "," & ($y)
        set $no = $owner.item($nk, "")
        if $no != "" and $no != $ko
          set $cross = $dist.item($sq) + $legend.item($rows.item($y).characters().item($x + 1)).movement_cost + $dist.item($nk)
          if $cross < $edge.item($ko & "~" & $no, 999999)
            $edge.item($ko & "~" & $no) = $cross
            $edge.item($no & "~" & $ko) = $cross
        set $nk = $map & "|" & ($x) & "," & ($y + 1)
        set $no = $owner.item($nk, "")
        if $no != "" and $no != $ko
          set $cross = $dist.item($sq) + $legend.item($rows.item($y + 1).characters().item($x)).movement_cost + $dist.item($nk)
          if $cross < $edge.item($ko & "~" & $no, 999999)
            $edge.item($ko & "~" & $no) = $cross
            $edge.item($no & "~" & $ko) = $cross

  set $far = record()
  for each $p in $edge.keys()
    $far.item($p) = $edge.item($p)
  for each $via in $here_names
    for each $from in $here_names
      for each $to in $here_names
        set $round = $far.item($from & "~" & $via, 999999) + $far.item($via & "~" & $to, 999999)
        if $round < $far.item($from & "~" & $to, 999999)
          $far.item($from & "~" & $to) = $round

  set $done = record()
  for each $p in $edge.keys()
    if $done.item($p, "") = ""
      set $ends = $p.split("~")
      set $a = $ends.item(1)
      set $b = $ends.item(2)
      $done.item($p) = "y"
      $done.item($b & "~" & $a) = "y"
      set $direct = $edge.item($p)
      set $spare = "n"
      for each $c in $here_names
        if $c != $a and $c != $b
          if $far.item($a & "~" & $c, 999999) + $far.item($c & "~" & $b, 999999) <= $direct
            set $spare = "y"
      if $spare = "n"
        set $ma = $places.item($map & "|" & $a)
        set $mb = $places.item($map & "|" & $b)
        set $east = $mb.x - $ma.x
        set $north = $ma.y - $mb.y
        set $new_link = record()
        $new_link.map = $map
        $new_link.between = list($a, $b)
        $new_link.travel_time = $direct
        if $north.abs() * 2 < $east.abs()
          $new_link.direction = if($east > 0, "E", "W")
        else if $east.abs() * 2 < $north.abs()
          $new_link.direction = if($north > 0, "N", "S")
        else
          $new_link.direction = if($north > 0, if($east > 0, "NE", "NW"), if($east > 0, "SE", "SW"))
        $built.append($new_link)

# A way out names a square, not a place, so the player arrives as whoever owns that square.
# This is the whole reason the search above records an owner for every square.
set $arrive = record()
for each $p in $places.keys()
  set $spot = $places.item($p)
  if $spot.to_map.exists()
    $arrive.item($p) = $owner.item($spot.to_map & "|" & ($spot.to_x) & "," & ($spot.to_y), "")

set $graph = $built
set $gazetteer = $places
set $arrivals = $arrive

# --- Cross between maps, if the player is standing on a way out. ----------------
# The AI travels to the gate like any other place and never sets the map. This turns
# arriving there into a crossing on the turn after.
set $key = $current_map & "|" & $current_location
set $spot = $gazetteer.item($key)

if $spot.to_map.exists()
  $current_location = $arrivals.item($key, "")
  $current_map = $spot.to_map

# --- Write the report for whichever map the player is now on. -------------------
set $here_map = $current_map
set $here = $current_location
set $units = $maps.item($here_map).travel_units
set $spot = $gazetteer.item($here_map & "|" & $here)
$location_report = format_text("I am currently at {$here}: {$spot.description}")

# Only this map's links. A place that leads elsewhere is labelled where it stands.
set $exits = ""
set $reach = ""
for each $link in $graph
  if $link.map = $here_map
    set $place_a = $link.between.item(1)
    set $place_b = $link.between.item(2)
    set $place_a_label = $place_a
    if $gazetteer.item($here_map & "|" & $place_a).to_map.exists()
      set $place_a_label = $place_a & " (leads to " & $gazetteer.item($here_map & "|" & $place_a).to_map & ")"
    set $place_b_label = $place_b
    if $gazetteer.item($here_map & "|" & $place_b).to_map.exists()
      set $place_b_label = $place_b & " (leads to " & $gazetteer.item($here_map & "|" & $place_b).to_map & ")"
    if $place_a = $here
      set $word = choose($link.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
      set $exits = $exits & format_text("\n* {$place_b_label}: {$link.travel_time} {$units} {$word}.")
      set $reach = $reach & format_text("\n* `{$place_b}`")
    if $place_b = $here
      set $word = choose($link.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
      set $exits = $exits & format_text("\n* {$place_a_label}: {$link.travel_time} {$units} {$word}.")
      set $reach = $reach & format_text("\n* `{$place_a}`")

if $exits != ""
  $location_report &= "\n\nMy immediate surroundings are:"
  $location_report &= $exits

# The reachable places, bare, for Current location's update instructions.
if $reach = ""
  $location_update_instructions = "There is nowhere I can travel to from here. Leave this exactly as it is."
else
  $location_update_instructions = "These are the locations that I can reach from my current position. If I move to one of these, then set the value of this tracked item to the name of that location, rendered exactly how it appears between the backticks below:" & $reach & "\nIf I did not move to one of them, leave this exactly as it is."

# Bounded Bellman-Ford over this map's links only. Routes stop at the edge of the map,
# because costs on two maps are in different units and adding them means nothing.
set $cost = record()
set $trail = record()
$cost.item($here) = 0
$trail.item($here) = ""
set $frontier = list($here)

for each $pass in range($gazetteer.count())
  set $next = list()
  for each $node in $frontier
    set $node_cost = $cost.item($node)
    set $node_trail = $trail.item($node)
    for each $link in $graph
      if $link.map = $here_map
        set $place_a = $link.between.item(1)
        set $place_b = $link.between.item(2)
        if $place_a = $node
          set $step_cost = $node_cost + $link.travel_time
          if $step_cost < $cost.item($place_b, 999999)
            $cost.item($place_b) = $step_cost
            set $word = choose($link.direction, "N", "North", "NE", "Northeast", "E", "East", "SE", "Southeast", "S", "South", "SW", "Southwest", "W", "West", "NW", "Northwest", "")
            set $leg = $link.travel_time & " " & $units & " " & $word & " to " & $place_b
            if $node_trail = ""
              $trail.item($place_b) = $leg
            else
              $trail.item($place_b) = $node_trail & ", then " & $leg
            $next.append($place_b)
        if $place_b = $node
          set $step_cost = $node_cost + $link.travel_time
          if $step_cost < $cost.item($place_a, 999999)
            $cost.item($place_a) = $step_cost
            set $word = choose($link.direction, "N", "South", "NE", "Southwest", "E", "West", "SE", "Northwest", "S", "North", "SW", "Northeast", "W", "East", "NW", "Southeast", "")
            set $leg = $link.travel_time & " " & $units & " " & $word & " to " & $place_a
            if $node_trail = ""
              $trail.item($place_a) = $leg
            else
              $trail.item($place_a) = $node_trail & ", then " & $leg
            $next.append($place_a)
  set $frontier = $next

# Landmarks on this map, reachable inside their own max_distance.
set $routes = ""
for each $entry in $gazetteer.keys()
  set $ends = $entry.split("|")
  if $ends.item(1) = $here_map
    set $landmark_name = $ends.item(2)
    set $landmark = $gazetteer.item($entry)
    if $landmark.landmark.exists() and $landmark.landmark and $landmark_name != $here
      if $cost.item($landmark_name, 999999) <= $landmark.max_distance
        set $landmark_cost = $cost.item($landmark_name)
        set $landmark_trail = $trail.item($landmark_name)
        set $routes = $routes & format_text("\n* {$landmark_name} ({$landmark_cost} {$units}): {$landmark_trail}")

if $routes != ""
  $location_report &= "\n\nRoutes from here, on this map:"
  $location_report &= $routes
  $location_report &= "\nFollow each leg in the order given."

3. Create the instruction block. Add an instruction block called Location instructions and paste this in as its content:

<<$location_report>>

I can only travel to the places listed above, and traveling to one of them covers the distance given before I arrive.
If the report says I can go into another map, I may do that, and I arrive at whatever place it names.
Use any routes listed above when I ask the way somewhere. Those routes only cover the map I am on; to reach somewhere on another map I must first travel to the way in.
Do not invent a route, a place, a way between places, or a way into another map that is not listed.

An example of a location report generated by this model is:

I am currently at The Strand: The coast road where it runs along the top of the beach.

My immediate surroundings are:
* the gates of Saltfield (leads to Saltfield): 4 hours West.
* Millford: 7 hours East.

Routes from here, on this map:
* Cairnmouth (23 hours): 7 hours East to Millford, then 12 hours East to The Long Rise, then 4 hours East to Cairnmouth
Follow each leg in the order given.

-- then I travel to the gates of Saltfield, and at the start of the next turn --

I am currently at The Gate Yard: Mud and cartwheel ruts, just inside the east gate.

My immediate surroundings are:
* The Market Square: 26 minutes West.
* the east gate (leads to The Island): 2 minutes East.
* The Shambles: 12 minutes Southwest.

Routes from here, on this map:
* The Quayside (40 minutes): 12 minutes Southwest to The Shambles, then 28 minutes West to The Quayside
* The Market Square (26 minutes): 26 minutes West to The Market Square
Follow each leg in the order given.

An interactive example of this model can be found here.