Jeremy Friesen: Mythic Bastionland Map Play Aid Emacs Package
In the Forge from the Worst series, I’ve been writing about my solo play in Mythic Bastionland 📖 . I had went into the game hoping to engage the map without referee knowledge of the map; no insight into where all the features are, but instead allowing the procedures of the game to reveal the locations of various features.
Midway through my 3rd session, I found myself needing to reveal information of the map that was going to be more challenging to derive. I chose that moment—a just in time moment—to set about loading the map into Emacs 📖 and then write commands to interrogate the map.
I reviewed the questions I had already asked of the map, and wrote them down:
- What is the direction to a named Myth?
- What is the nearest Myth?
- What is a random Myth that is not the nearest?
- Is there a barrier when moving between these two hexes?
- Is there a Myth on this Hex?
- What is feature is there on this hex?
I suspect I may also need to ask: “What is the direction to the nearest Myth?”
I wasn’t going to type up the map and store it, but instead randomly generate the map. Also, I wanted to make sure that I could generate a map that would conform to the player-discovered information from the sessions thus far session.
Initial Research
Given that I was going to be working out distances, I read through the amazing and helpful Hexagonal Grids.
I needed to settle on an appropriate coordinates systems. With some reading, I chose to adopt the double-height coordinates. Which was not the notation I had already written down in blog posts, but would be easy to map.
Initial Work
I knew that once I created a map that conformed to Sir Weydlyn’s observations, I wouldn’t want to dig into the data nor debug functions using that map. So, my approach was to work from a disposable map and test the functions.
I used the Read-eval-print loop (REPL 📖) to test these functions. One emergent foible is that I kept using the single-height coordinates; introducing a translating function for internal work. As of this foible persists. And creates a bit more chatter, which we’ll see in a bit.
Encoding the Known World
Once I had a solid set of functions that could answer questions, I set about encoding the Known World. Let’s walk through this.
First we have this mbc function. It converts single-height coordinates to
double-height such that (mbc 8 1) will be (8 . 2) and (mbc 7 1) will be (7 . 3).
(defalias 'mbc 'mythic-bastionland--random-coord)
Next let’s read over the code and comments. I’ll meet you after the code block.
(defalias 'mbc 'mythic-bastionland--random-coord)
(mythic-bastion-land-map-write
(mythic-bastionland-map-generate
`((constraints .
((nearest . ((label . "The Mountain")
(feature . myths)
(coord . ,(mbc 9 1))))
(nearest . ((label . "The Judge")
(feature . myths)
(coord . ,(mbc 7 2))))))
;; This is where Sir Weydlyn encountered Seer Tompot.
(sanctums . (("Tompot (Tangled Seer)" . ,(mbc 8 1))))
;; With the chosen random scenario, we assign the Moutain, then
;; pick a random one for the Beast
(myths . (("The Mountain" .
(,(mbc 10 4) ,(mbc 8 4) ,(mbc 9 2)
,(mbc 9 3) ,(mbc 9 4) ,(mbc 9 5)))
("The Beast" .
(,(mbc 8 3) ,(mbc 9 3) ,(mbc 8 4)
,(mbc 9 4) ,(mbc 8 5)))
("The Judge" .
,(mythic-bastionland-hexes-within-range
(mbc 7 2) 3))))
;; These have been converted to double height coordinates.
(holdings . (("Tower" . (9 . 3)) ("Castle" . (5 . 7))
("Fortress" . (1 . 19)) ("Town" . (8 . 16))))
(omens-revealed . (("The Mountain" . 1)))
(omit (
;; Sir Wedylyn crossed between these two potential
;; barriers.
(barriers .
((,(mbc 8 1) . ,(mbc 9 1))
(,(mbc 9 1) . ,(mbc 8 2))
(,(mbc 8 2) . ,(mbc 7 2)))))))))
The mythic-bastionland-map-generate function takes an alist with keys: holdings,
myths, landmarks, dwellings, sanctums, monuments, hazards, curses, ruins,
barriers, constraints, omens-revealed, and omit.
This allows me to specify where to place specific already known landmarks as well as to omit placing landmarks at a given coordinate.
Of those: holdings, myths, landmarks, dwellings, sanctums, monuments, hazards,
curses, and ruins are nameable feature types; a function I wanted as I managed
the map.
The omit option allows me to specify coordinates that I will not place the
named feature.
The constraints option are tests that must be true with the completed map. When
all of them are not true, I discard that generated map and create another one.
By default this will be attempted 10 times; but you can also pass max-retries to
the generation to modify that amount.
And omens-revealed allows for tracking of each omen’s present state.
Generating the World from that Which is Known
With the mythic-bastionland-features, I define the feature types (that are
renamable), how many there should be, and optionally a minimum distance.
(defvar mythic-bastionland-features
'((myths . ((how-many . (6))))
(holdings . ((how-many . (4)) (min-distance . 5)))
(sanctums . ((how-many . (3 4))))
(monuments . ((how-many . (3 4))))
(dwellings . ((how-many . (3 4))))
(hazards . ((how-many . (3 4))))
(curses . ((how-many . (3 4))))
(ruins . ((how-many . (3 4)))))
"Feature types that are labeled, and thus renameable. Also we want
to consider how many of these we might place as well as the minimum
distance (if any).")
For holdings, there are 4 at a minimum distance of 5. For myths there are
always 6. And for the others, there is either 3 or 4.
And now we have the code that generates the map based on the given configuration.
In short:
- We place the given features; when a feature has more than one coordinate, we randomly pick a coordinate that does not have something in it.
- Build a list of the remaining features to place.
- Looping through the locations to place, attempt to place them on the map; honoring minimum distance, omitted coordinates, as well as only allowing one feature per hex.
- Randomly place known barriers, skipping over omitted ones. (As of , I don’t have a means of placing known barriers, but that feature is trivial to add).
- Validate that all constraints are true; if not, try again.
- The record any
omens-revealed.
(defun mythic-bastionland-map-generate (config)
"Generate and store `mythic-bastionland-map' via CONFIG.
See `mythic-bastionland-features' for some of the `car' values of
CONFIG. Another is `barriers' (which are unamed). Another is `omit',
itself an alist, with the same `car' values as those in CONFIG (except
`omit').
When providing existing locations to place, you may provide either a
single coordinate or a list of coordinates (from which the function will
randomly pick a candidate of coordinates not already placed). The logic
enforces that only one feature may be placed in each hex.
Given this placement logic, ensure that the config places features with
less candidate spaces earlier."
(let ((max-retries
(or (alist-get 'max-retries config) 10))
(keep-mapping t)
(the-map nil))
(while (and keep-mapping (> max-retries 0))
;; Assume that we don't need to keep trying to build the map
(setq keep-mapping nil)
(setq the-map nil)
(setq max-retries (- max-retries 1))
;; Now, let's see if our assumption is correct.
(let ((barriers nil)
(locations nil)
(locations-to-place nil))
;; First put the locations on the map...no effort is taken to
;; avoid location collisions. Also, queue up further locations
;; to place.
(cl-loop for (feature . fconfig) in mythic-bastionland-features do
(let* ((feat-locations
(alist-get feature config))
(how-many
(alist-get 'how-many fconfig))
;; TODO: allow for multiple feature entries.
(placed-features '()))
;; When we are given location qs for this feature type, add
;; it to the placed list.
(when feat-locations
(cl-loop for (label . list-or-one-coord) in feat-locations do
(let ((placed-coordinates
(mapcar #'car locations)))
(if (consp (car list-or-one-coord))
;; We have a list of coordinates
(let ((coord
(seq-random-elt
(seq-filter
(lambda (c)
(not (member c placed-coordinates)))
list-or-one-coord))))
(unless coord
(user-error "Location %s with coordinate options %s cannot be placed due to collisoin with all other placed locations."
(label list-or-one-coord)))
(cl-pushnew (cons coord label) locations)
(cl-pushnew (cons label coord) placed-features))
;; We have one coordinate
(let ((coord list-or-one-coord))
(when (member coord placed-coordinates)
(user-error "Location %s with coord %s cannot be placed due to existing placed location"
label coord))
(cl-pushnew (cons coord label) locations)
(cl-pushnew (cons label coord) placed-features))))))
(cl-pushnew (cons feature placed-features) the-map)
;; Next queue up placing the remainder of locations for the
;; feature type (accounting for what was already given).
(dotimes (i (- (seq-random-elt how-many)
(length feat-locations)))
(cl-pushnew (cons feature
(format "%s %s" feature (+ i 1)))
locations-to-place))))
;; Now that we have our task list of what all needs adding.
;;
;; This involves avoiding collisions with other placed features
;; as well as heading the guidance of an omit coordinates for
;; the given feature.
(cl-loop for (feature . label) in locations-to-place do
(let ((keep-trying t)
(min-distance
(alist-get 'min-distance
(alist-get feature mythic-bastionland-features)))
(omitted-feature-coordinates
(alist-get feature (alist-get 'omit the-map))))
(while keep-trying
(let* ((coord
(mythic-bastionland--random-coord)))
(when (and
;; Verify that what we're placing is place at
;; the minimum distance.
(if min-distance
(<= min-distance
(min
(mapcar
(lambda (label-coord)
(mythic-bastionland--hex-distance
coord (cdr label-coord)))
(alist-get feature the-map))))
t)
(not (or
(assoc coord locations)
(member coord
omitted-feature-coordinates))))
(progn
(setq keep-trying nil)
;; For locations we favor storing the (coord . label)
;; This makes later comparisons easier.n
(cl-pushnew (cons coord label) locations)
;; For a named feature favor storing (label . coord)
;; as this makes prompts easier.
(cl-pushnew (cons label coord)
(alist-get feature the-map))))))))
;; Nex, we handle the barriers as they are a bit of a different
;; creature. We generate them by placing them between two
;; neighboring hexes.
;;
;; I have given special consideration for hexes on the edge of
;; the map; Namely don't create barriers on the edges. And
;; proportionally reduce the chance of adding a barrier on those
;; edges proportional to the number sides that the hex has
;; on-map neighbors."
(let ((omitted-barriers
(mapcar
(lambda (b)
(mythic-bastionland--make-ordered-pair
(car b) (cdr b)))
(alist-get 'barriers (alist-get 'omit config)))))
(dotimes (i (+ 23 (random 3)))
(let ((keep-trying t))
(while keep-trying
(let* ((coord
(mythic-bastionland--random-coord))
(in-6-chance
(cond
((member coord '((0 . 0) (11 . 22)))
;; top-left, bottom-right
2)
((member coord '((11 . 0) (0 . 22)))
;; top-right, bottom-right
3)
((member (car coord) '(0 11))
;; from or to
4)
((member (cdr coord) '(0 23))
;; top of col that is taller; bottom of
;; col that is shorter
3)
((member (cdr coord) '(1 22))
;; top of col that is shorter; bottom of
;; col that is taller
5)
(t 6))))
(when (<= (+ 1 (random 6)) in-6-chance)
(progn
(let* ((neighbor
(seq-random-elt
(mythic-bastionland--neighbors coord)))
(pair
(mythic-bastionland--make-ordered-pair
coord neighbor)))
;; Don't repeat barriers
(when
(not (or (member pair barriers)
(member pair omitted-barriers)))
(progn
(cl-pushnew pair barriers)
(setq keep-trying nil)))))))))))
;; PS...make sure we add the locations and barriers to the map.
(cl-pushnew `(locations . ,locations) the-map)
(cl-pushnew `(barriers . ,barriers) the-map)
;; Next, see if we have a conformant map
(cl-loop
for (constraint . info)
in (alist-get 'constraints config) do
(pcase constraint
('nearest
(unless (mythic-bastionland--test-constraint-nearest info the-map)
(setq keep-mapping t)))
(_ (user-error "Unknow constraint %s" constraint))))))
;; TODO: Consider generalizations but for now this is adequate.
(when-let ((omens-revealed (assoc 'omens-revealed config)))
(push omens-revealed the-map))
the-map))
It’s All a Mapping Problem
I once read that all computer science problems are mapping problems. There were two that I needed to consider.
First, when rolling up a barrier, it is placed along the shared side of two
adjacent hexes. This meant creating a “unique key” for those pairs, so that I
don’t accidentally pix Hex 0,0 then its neighbor Hex 0,1 to place a barrier, and
then pick Hex 0,1 and its neighbor Hex 0,0 to place a hex.
Enter the mythic-bastionland--make-ordered-pair function:
(defun mythic-bastionland--make-ordered-pair (from to)
"Provide a consistent sort order FROM and TO coordinates."
(let ((from
(or from
(mythic-bastionland--text-to-coord nil "Left ")))
(to
(or to
(mythic-bastionland--text-to-coord nil "Right "))))
(if (> (car from) (car to))
`(,from . ,to)
(if (> (cdr from) (cdr to))
`(,from . ,to)
`(,to . ,from)))))
It normalizes a pair of coordinates so that we can have consistent interaction when referencing those two coordinates.
Second, one of the questions was direction from one coordinate to another.
And here we have mythic-bastionland--direction:
(defun mythic-bastionland--direction (&optional from to)
"Get human-readable direction FROM TO."
(let ((from
(or from
(mythic-bastionland--text-to-coord nil "From ")))
(to
(or to
(mythic-bastionland--text-to-coord nil "To "))))
(cond
((equal to from)
"Under your nose")
((= (car from) (car to))
(if (> (cdr from) (cdr to))
"North" "South"))
(t (let ((slope (/
(float (- (cdr to) (cdr from)))
(float (- (car to) (car from))))))
(cond
;; After compass, protractor, marker, and spreadsheet
;; work, I'm happy with the direction calculations.
;; Remember, hex maps starting from top-left instead
;; of bottom right like Geometry means things get a
;; mind bending (at least for this old guy).
((or (> slope 4) (< slope -4))
(if (> (cdr from) (cdr to))
"North" "South"))
((<= 0.8 slope) (<= slope 4)
(if (> (cdr from) (cdr to))
"Northwest" "Southeast"))
((< -0.8 slope 0.8)
(if (> (car from) (car to))
"West" "East"))
((<= -4 slope -0.8)
(if (> (cdr from) (cdr to))
"Northeast" "Southwest"))))))))
Using geometry of Rise over Run to determine slope, I can enter the from and to coordinate to get the named direction. As the comments indicate, this involved some protractor work to make sure I got the algorithm correct.
When I had set out, I had first thought of saying the two adjacent hexes to the right of a given hex were to the given hex’s east. But the geometry suggested breaking this apart.
So for a given Hex, and looking at Hexes one space away and starting at the Hex directly above the given Hex and working clockwise we have: north, north east, south east, south, south west, and north west. Stepping to the next ring out, we have: north, north east, north east, west, south east, south east, south, south west, south west, west, north west, north west.
I felt that having the algorithm well understood by me would make for consistent solo play.
Testing This Thing
When you look at the code, you might notice that the interactive functions will
take a coordinate or prompt you for one. You might also note that some
non-interactive functions take optional coordinates, and prompt if none are
given.
All of this was in service of attempting to test functions. Verifying the correctness of distance and direction required no knowledge of the map, but instead relied on two coordinates. So I could bombard these functions in the REPL and prompt for the inputs.
I also made a decision not to codify Sir Weydlyn’s map until I’d test driven things a few times. Hence I write the map to disk and then read it back when I want to use it. This also serves to clobber the variable’s value, preventing accidental peaking. I am considering further measures, but am holding off.
I found that once it was all tested, and I started playing, I realized I wanted to adjust some functionality. Namely, determining the nearest myth. I refactored that section. And to test, used a bit of dependency injection, to pass in the map I wanted to test (so as to not peak).
Once I verified behavior, I loaded the game map and made sure the question I had
previously asked of the map returned the same result. And it didn’t so I set
about further refactoring (which added the constraints option). I adjusted the
initial config, adding constraints and allowing for features to be placed from a
subset of coordinates.
Conclusion
I’ve had two significant refactors of the base functionality.
From the initial state to the next state, I needed to consider that I was changing the logic for what was nearest, going from randomly picking hexes that had the same distance to now consistently picking hexes.
I performed the refactor then tested my map. The answer I got conflicted with
established facts (e.g. “The Judge” was the closest myth to 9,1). So I needed
to refactor again. This is when I introduced constraints. Which was relatively
easy to introduce.
I did the second refactor in two parts. Part 1 was wrapping the existing body in the following then re-indenting:
(let ((max-retries
(or (alist-get 'max-retries config) 10))
(keep-mapping t)
(the-map nil))
(while (and keep-mapping (> max-retries 0))
;; Assume that we don't need to keep trying to build the map
(setq keep-mapping nil)
(setq the-map nil)
(setq max-retries (- max-retries 1))
…EXISTING BODY…
))
This change was a noop change, that I committed. Then I set about implementing
the constraints logic. This way I would have smaller second commit that didn’t
interweave with indentation changes.
You can find the “mythic-bastionland” package on Sourcehut.
-1:-- Mythic Bastionland Map Play Aid Emacs Package (Post Jeremy Friesen)--L0--C0--2025-12-17T01:11:00.000Z


For