Maps and Navigation
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.
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.
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.
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.
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.
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.
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.
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.