Generating scrolls – redux

In previous post I indulged myself with some wishful coding and outlined some ideas how to procedurally generate scrolls that have name and description and that can be regenerated again and again based on id number. For a change, I actually went and implemented system like that and will detail some points about that in this post.

I wanted the system for scroll (and artefacts in general) be open for extension, but without too much of ceremony or jumping through hoops.Some time ago I wrote about multiple dispatch or multimethods for short and this looked like a prime candidate for them. Game engine would have one multimethod generate-artefact that would by default throw an exception. New cases could then registered on it, one for each type of artefact being created. For the engine this would look like there just one function that can create all kinds of artefacts, but programmer still would be able to split definitions based on type of artefact and arrange them as they wished to.

(defmulti generate-artefact [artefact-type &optional [seed nil]]
  "select method based on artefact-type parameter"

API is small, just specify type of artefact to be created and an optional seed (esentially an id for that specific item) used for procedural generation.

Since there’s good chance that I would be needing similar generators for different purposes (think of generating just a scroll or a magical shelf full of scrolls), I split actual generation to two parts: first a blueprint is assembled in one or more stages. After a suitable blueprint has been generated, it’s used to create an actual instance of an artefact. This allows breaking the problem into small, manageable chunks and possibly reusing them at a later stage:

(default-method generate-artefact [artefact-type &optional [seed nil]]
  "create blueprint of specific type and instantiate it"
  (-> (create-blueprint artefact-type seed)

Both create-blueprint and instantiate-blueprints are multimethods that are parametrized by type of data (essentially artefact type) they’re handling. They form sort of a assembly line where input is optional seed for random number generator and output is fully instantiated artefact (or part of an artefact, more about this a bit later).


Assembling blueprints and turning them into objects

Blueprint is just simple dictionary with predetermined keys. For example, for the scrolls I came up with following code to create it:

(defmethod create-blueprint 'scroll [artefact-type &optional [seed nil]]
  "create blueprint for a scroll"  
  (setv rng (if seed
              (Random seed)
  {:type 'scroll
   :name (create-blueprint 'scroll-name :seed (new-seed rng))
   :tube (create-blueprint 'scroll-tube :seed (new-seed rng))
   :paper (create-blueprint 'scroll-paper :seed (new-seed rng))
   :writing (create-blueprint 'scroll-writing :seed (new-seed rng))})

First step is to create random number generator based either on seed or system time, depending if the seed has been given. Since routine for generating random numbers is deterministic, this ensures that we can create same item over and over again. Name of the scroll, descriptions of the tube holding it, paper and writing are then generated by their respective routines. Each returns a piece of blueprint that is attached to correct spot in the parent blueprint. :type is special key as it defines type of the blueprint and is used to dispatch to correct implementation of multimethod.

new-seed is helper function that generates a new seed using the supplied random number generator. This chaining is important as it in turn ensures repeatability of the process:

(defn new-seed [rng]
  "create new random seed"
  (.randint rng 0 9223372036854775807))

All the way to the bottom, we finally find something that doesn’t delegate to next level below:

(def *tube-materials*
  {'wood   #t("wooden" 0.8)
   'iron   #t("iron"   0.9)
   'brass  #t("brass"  1.0)
   'silver #t("silver" 1.1)
   'onyx   #t("onyx"   1.2)
   'ivory  #t("ivory"  1.3)
   'gold   #t("golden" 1.4)})

(def *tube-qualities*
  {'plan          #t("plain"              0.9)
   'ornate        #t("ornate"             1.0)
   'decorated     #t("decorated"          1.1)
   'carved        #t("beautifully carved" 1.2)
   'very-ornate   #t("very ornate"        1.3)
   'mastercrafted #t("mastercrafted"      1.4)})

(defmethod create-blueprint 'scroll-tube [artefact-type &optional [seed nil]]
  "create blueprint for scroll tube"
  (setv rng (if seed
              (Random seed)
  {:type 'scroll-tube
   :quality (random-key *tube-qualities* rng)
   :material (random-key *tube-materials* rng)})

This creates a blueprint that defines a random scroll tube (namely material and how it has been decorated). *tube-materials* and *tube-qualities* define possible values for these, along with string representation and price multiplier that we’ll later use to calculate how expensive the scroll is.

So with this setup, we now have a hierarchy of blueprints, all neatly lined up and ready to be turned into a real object. This is done by calling instantiate-blueprints multimethod that automatically dispatches execution to the correct implementation based on blueprint type:

(defmethod instantiate-blueprints 'scroll-tube [blueprint]
  (.join " " [(first (get *tube-qualities* (:quality blueprint)))
              (first (get *tube-materials* (:material blueprint)))

‘scroll-tube doesn’t create a whole new object, instead it creates a string representation for part of the scroll. For now that’s enough, but if in the future I want to model them as a separate object, it’s relatively easy to add instantiation of real item here. Currently we just do a lookup to *tube-qualities* and *tube-materials* dictionaries and fetch correct values from there. Result will be a string like beautifully carved iron tube or plan wooden tube.

The reason why this method is called to instantiate-blueprints and not instantiate-blueprint is that sometimes different parts of the parent blueprint affect to each other and thus have to handled together. For example paper and the writing on it affect how they’re described:

(defmethod instantiate-blueprints #t('scroll-paper 'scroll-writing)
           [paper-blueprint writing-blueprint]
  "get description for paper and the writing on it"
  (.join " " [(first (get *paper-materials* (:material paper-blueprint)))
              (first (get *paper-conditions* (:condition paper-blueprint)))
              (get-conjuction-for-paper-and-writing paper-blueprint writing-blueprint)
              (first (get *writing-qualities* (:quality writing-blueprint)))
              "writing is"
              (first (get *writing-details* (:detail writing-blueprint)))]))

It’s still not a perfect solution, but it manages a little bit better than if paper and writing were handled separately. Instead of generating Vellum has torn a bit and good quality writing is decorative it will generate Vellum has torn a bit but good quality writing is decorative. Little details like this might take a bit of work to code, but skipping them is easy way to ruin the immersion.

(defmulti instantiate-blueprints [&rest blueprints]
  "select method based on type in blueprint"
  (if (= (len blueprints) 1)
    (:type (first blueprints))
    (tuple (list-comp (:type blueprint) [blueprint blueprints]))))

Dispatching is done based on one of two possible situations: there’s one blueprint or there’s multiple blueprints. In case of single blueprint, value corresponding to key :type is fetched and used to select which function to call. In case of multiple blueprints, values of :type key of each blueprint are collated to a single tuple and that is used to select final function to call. In the far future, I might even change syntax of defmethod to highlight dispatch values more, like in:

(defmethod '(instantiate-blueprints :: scroll-paper -> scroll-writing)
           [paper-blueprint writing-blueprint]

Final step is to actually create an instance of Item and fill in the boring details (name, description, weight, price and so on). And price is simple calculation of just going through all the attributes in blueprint, finding corresponding entry in data table and multiplying all of them with base price. Better quality scroll attributes now yield more expensive scrolls, while bad attributes lower the value.

While this works well (at least for now), I already came up with an alternative idea of how to handle generation of descriptions: markov chains (which already are used to generate names of scrolls). Given big enough sample data or hand crafted lookup tables, one could easily generate believable descriptions for scrolls. This system would run into trouble if there were a need to analyze actual composition of the scroll (for example, in order to calculate value of the scroll). Building lookup table by hand would also be pretty tedious, but with enough clever engineering should be doable.


Markov chain (artist’s impression)

Nothing of course prevents mixing these two different approaches where it makes sense. Things like names are easy enough to generate with markov chains, while other attributes are better suited for different algorithms.

One thought on “Generating scrolls – redux

  1. Pingback: Tinkering with society generation | Engineer's Journey

Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s