Bobby Loot Tables

This link from the old web has a code example for loot generation, though the method could also be used for vaults, monsters, or other dungeon features. Particular objects are constrained to a minimum and maximum level, so that your trusty (or rusty) starting dagger will no longer spawn below dungeon level 5, and the Sword of Ur-Slayåge only starts to spawn somewhere below D10, probably for game balance reasons. An advantage of this method is that the objects can be placed in a table. Adding new objects, what levels they spawn on, and their weights can be done quite easily. The tables can be stored in a database or text file which will avoid the risk of a loot table change also causing a random code change. Converted to Common LISP, a weighted generator might look something like the following.

    (block nil (setq *random-state* (make-random-state t)) (return))

    (deftype level-t  () `(integer 1 26))
    (deftype weight-t () `(integer 1 10000))

    (defstruct looty
      object
      (min 1 :type level-t)
      (max 1 :type level-t)
      (weight 1 :type weight-t))

    (defun lootgen (&key level lootylist &aux possible (max 0))
      (declare (level-t level) (fixnum max))
      (loop for loo in lootylist
            when (and (>= level (looty-min loo))
                      (<= level (looty-max loo))) do
            (push loo possible)
            (incf max (looty-weight loo)))
      (when possible
        (let ((roll (random max)) loot)
          (loop for loo in possible do
                (let ((w (looty-weight loo)))
                  (when (< roll w)
                    (setf loot loo)
                    (loop-finish))
                  (decf roll w)))
          (when loot (looty-object loot)))))

    (defparameter *bobby-loot-tables*
      (list
        (make-looty :object 'dagger :min 1 :max 5 :weight 9)
        (make-looty :object 'rock   :min 3 :max 4 :weight 2)
        (make-looty :object 'sling  :min 2 :max 7 :weight 1)))

    (lootgen :level 3 :lootylist *bobby-loot-tables*)

This code is simple enough that one should expect only 'DAGGER to appear on dungeon level one, nothing to spawn below dungeon level seven, bummer, and levels three and four to be the most complicated where three! three items may spawn. Ah-ah-ah. Those good at doing math (and handling any errors in the code or compiler or CPU) in their heads should be able to give you object spawn percentages on dungeon level four, but for the rest of us there are

Tests

One will need some means to test the loot tables, so that questions such as "what can spawn on dungeon level 42 and with what odds for each item?" can be answered. The tests can also confirm that the code is not too buggy, e.g. a 90 weight dagger and a 10 weight sling should, with enough runs, result in somewhere around 90% daggers and 10% slings. If not, you have a problem, and how would you know without tests? That is, besides leaning on your players as hopefully observant beta testers, a fairly common practice in the gaming industry.

    (load "lootgen")

    (defun plusplus (key hash)
      (multiple-value-bind (count setp) (gethash key hash)
        (if setp
          (incf (gethash key hash))
          (setf (gethash key hash) 0))))

    (defconstant +trials+ 10000)

    (let ((freq (make-hash-table)))
      (loop repeat +trials+ do
            (plusplus (lootgen :level 2 :lootylist *bobby-loot-tables*) freq))
      (maphash
        #'(lambda (k v)
            (format t "~&~a~c~5d ~1,,5$%~&" k #\tab v
                    (* 100 (/ v +trials+)))) freq))
    $ sbcl --script testloot.lisp 
    DAGGER   8988  89.9%
    SLING    1010  10.1%

An automated test would round the results and check that the results are close enough to some expected value, assuming enough iterations are done (but not too many so as to waste not too many CPU hours) so that it is unlikely that a test will stray from the expected value. This will catch such bugs as the RNG being broken, or maybe someone refactored the code and broke loot generation along the way, or most likely someone fiddled with the loot tables and forgot to update the corresponding tests. This last issue could be avoided by having a relatively static loot generation library with extensive tests. The more fiddled with game code would use the well-tested loot library as a hopefully solid foundation.

Soft Edges

This may not be a problem, though the hard edges on the loot levels (or again vault types or monsters or whatever) means that players will pretty quickly figure out and publish that fire axen start to spawn on dungeon level 42 and therefore if they always play with the fire axe—it's "the meta" or they're role-playing a flaming lumberjack, leaping from tree to tree!—they will know to make a beeline for level 42 and grind there until event of fire axe. With softer edges one can reduce the odds of fireaxeage so that it is possible to spawn on 42, if rare, or more likely on some deeper and more dangerous level. A downside here is that terminally addicted players might grind for even longer (or write a bot to do this) on D42 waiting for that rare drop, so there may need to be some other means to move the player along. Dungeon Crawl Stone Soup once had (doubtless automated) mummies getting to the maximum experience level on D1 by beating up rats. This is also the story hook of any number of isekai. Another downside is that the code is more complicated and takes up more CPU, another common practice in the gaming industry.

    (block nil (setq *random-state* (make-random-state t)) (return))

    (deftype level-t  () `(integer 1 26))
    (deftype weight-t () `(integer 1 10000))

    (defstruct sloot
      object
      (levelfn nil :type function)
      (weight 1 :type weight-t))

    (defun softloot (&key level slool &aux possible (max 0))
      (declare (level-t level) (fixnum max))
      (loop for loo in slool
            when (< (funcall (sloot-levelfn loo) level) (random 1.0)) do
            (push loo possible)
            (incf max (sloot-weight loo)))
      (when possible
        (let ((roll (random max)) loot)
          (loop for loo in possible do
                (let ((w (sloot-weight loo)))
                  (when (< roll w)
                    (setf loot loo)
                    (loop-finish))
                  (decf roll w)))
          (when loot (sloot-object loot)))))

    (defun f1 (level) (declare (ignore level)) 0.95)
    (defun f2 (level) (declare (ignore level)) (+ 0.5 (random 1.0)))
    (defun f3 (level) (+ -0.4 (* 1.5 (sin (/ level 5)))))

    (defparameter *bobby-loot-tables*
      (list
        (make-sloot :object 'dagger :levelfn #'f1 :weight 9)
        (make-sloot :object 'rock   :levelfn #'f2 :weight 2)
        (make-sloot :object 'sling  :levelfn #'f3 :weight 1)))

    (softloot :level 3 :slool *bobby-loot-tables*)

Here a function determines the odds of an item appearing on a level, which means there may be no items generated, or anyways that feeble humans will be less able to look at the code and know what the odds are of something generating somewhere. A graphing calculator may help confirm that (graphable) functions look about right. Should the number of levels change one may need to go back and rejigger all the equations. This could be a bummer if there are a variety of equations for a variety of loots. If the dungeon does not have many levels then one could instead use level slots with odds for the item on each level, though if the dungeon is expanded the tables would also need reworking. Sub-dividing the dungeon into smaller and distinct regions may help avoid refactoring in area A from impacting the code for area B.

Edge Fadeoff

A simpler system would take the current level and use the delta from the min or max level the item can appear on to calculate a falloff percentage. In this case all items would use the same falloff function, unless there's an optional "falloff fiddle" value associated with each item to change the odds at both or either end.

Another way to have edge fadeoff is to summon out-of-depth items (but instead usually monsters) at very low odds. A different section of the code would handle this, so the regular loot selection would not need to be complicated with fadeoffs.

Hard loot edges might be better indicated by having different "regions" in a dungeon. New area? New vaults, loots, monsters, and then not having to wonder if it was L9 or L10 where Rust Monsters start to appear, or there might be other indications of what monsters live where that experienced players could learn to look for, rather than having to rely on external information.

Per-Level Loot Odds

This method will suit dungeons with not many levels, as the loot odds table has a percentage for each dungeon level. A perhaps interesting idea is to generate a partial normal distribution, though there are many other ways to fill in the slots: linear change, exponential, noise, combinations of these and others. A partial normal distribution here means to be lazy about generating the distribution: we give up after only some number of attempts.

    (block nil (setq *random-state* (make-random-state t)) (return))

    (defun roll (times sides)
      (declare (fixnum times sides))
      (loop repeat times summing (random sides)))

    (defun increase (index array)
      (when (>= index (first (array-dimensions array)))
        (setf array (adjust-array array (1+ index))))
      (incf (aref array index)))

    (defun partial-normal
           (&aux (bucket (make-array 1 :element-type 'fixnum
                                     :initial-element 0
                                     :adjustable t)))
      (loop repeat 100 do
            (increase (roll 10 4) bucket))
      bucket)

    (loop repeat 10 do
          (format t "~&~a~&" (partial-normal)))

There are several ways to roll against the per-level odds. One would be to track the maximum value, so that a level counter of 4 on dungeon level 12 would result in 4 in 100 or pretty low odds of loot generating. Another would be to set a ceiling value. Any value above, say, 10 would a 100% chance of spawning the item, and the previous 4 would instead be 40% odds.

    $ sbcl --script normal.lisp 
    #(0 0 0 0 0 0 0 0 0 1 3 4 11 12 19 12 6 8 10 2 5 3 1 2 0 1)
    #(0 0 0 0 0 3 0 1 1 4 7 9 4 5 9 6 10 5 15 9 5 5 1 1)
    #(0 0 0 0 0 0 1 3 1 3 4 10 5 4 16 10 10 13 6 4 4 1 3 2)
    #(0 0 0 0 0 0 0 2 4 2 0 5 6 10 13 12 6 11 13 6 5 1 4)
    #(0 0 0 0 0 2 1 1 2 4 4 5 5 14 3 11 9 8 8 6 8 6 1 2)
    #(0 0 0 0 0 0 0 1 2 4 6 8 6 7 12 12 6 12 10 8 3 1 1 0 1)
    #(0 0 0 0 0 0 0 1 2 2 4 6 10 10 10 7 10 9 12 6 5 4 2)
    #(0 0 0 0 0 0 0 1 1 2 6 3 10 10 9 14 12 10 10 3 4 1 1 1 0 2)
    #(0 0 0 0 0 0 0 1 1 3 2 6 8 11 13 15 13 4 10 6 2 3 0 1 1)
    #(0 0 0 0 0 0 0 3 3 1 3 4 6 11 9 22 12 4 6 6 2 3 2 2 1)

These odds might suit survival games where there can be quite a lot of variance, and the stress of not finding an expected item on the expected level. If we look at dungeon level 12, item spawn odds vary from zero percent to over 100% if we set a ceiling at 10 for 100%. Usually it is about 60%.

    $ tail -3 normal2.lisp 
    (loop repeat 10 do
          (format t "~a " (aref (partial-normal) 11)))
    (fresh-line)
    $ sbcl --script normal2.lisp 
    5 7 0 5 5 10 5 7 2 9 
    $ sbcl --script normal2.lisp
    5 8 5 5 8 6 5 3 6 8 
    $ sbcl --script normal2.lisp
    5 3 7 6 7 13 6 7 7 6 
    $ sbcl --script normal2.lisp
    6 4 7 7 5 6 7 5 9 6 
    $ mathu stats
    5 7 0 5 5 10 5 7 2 9
    5 8 5 5 8 6 5 3 6 8
    5 3 7 6 7 13 6 7 7 6
    6 4 7 7 5 6 7 5 9 6
    #

Box plot diagrams or other such statistical tools become increasingly relevant with this sort of variance.

Out Of Depth

Out of depth (OOD) placement generates an object, typically a monster, well above where it normally appears so that a player will face a challenge they are likely not be ready for, or, less likely, will be rewarded with a rare item (except for those game balance issues). Nothing fancy need be done here. Simply use the loot table and ask for something from, say, 2d4 levels deeper than the current level, or as appropriate for how many levels there are and how much the difficulty changes between levels. Out of depth rolls are usually rare. If they were common that would be normal.

Some games limit what monsters can be picked for OOD. In this case there would be an additional flag that the object picking code would need to honor (more branching), or distinct tables for OOD objects (more memory, possible duplication and desync of the same data). Distinct tables would offer the possibility of unique monsters or items or vaults in the special OOD table, as opposed to "Troll, only sooner than expected".

Metatables

Here one roll for a table to roll on, recursing until (hopefully) an item is found. These could be arranged in various ways, for example by item type,

    potion 40
    scroll 30
    weapon 10
    staff   1

where Mages of course get the short end of the stick. When the RNG turns up, say, a scroll another loot table would be consulted to pick a particular scroll which in turn have their various level limits and weighting.

Another way to arrange the tables would be by rarity, so

    common    100
    rare       10
    legendary   1

would most likely pick "common" items (90.09% of the time). What is in the "common" table could vary; it might be a list of objects, or another metatable to roll on. Too many lookups will slow the code down, and too many tables may be a headache to maintain, but it may make sense for your game design to know the odds of a legendary item are (which could increase with depth as common items fall off the level limit) and to be able to easily tune the value. Legendary items could also be implemented by setting the weight really low compared to other items, but then you would need to run a monte carlo simulation (or to calculate the odds by other, more efficient means) to see what the odds are of "Axe d'Flambe, weight 1" spawning given all the other items, level limits, OOD rolls, boss drops, etc., involved.

    (load "lootgen")

    (defparameter *common*
      (list
        (make-looty :object 'rock  :min 1 :max 30 :weight 10)
        (make-looty :object 'stick :min 1 :max 30 :weight 5)))

    (defparameter *rare*
      (list
        (make-looty :object 'string :min 1 :max 30 :weight 9)
        (make-looty :object 'teacup :min 1 :max 30 :weight 3)))

    (defparameter *legendary*
      (list
        (make-looty :object 'signed-copy-geb :min 1 :max 30 :weight 100)
        (make-looty :object 'flaming-axe     :min 1 :max 30 :weight 1)))

    (defparameter *by-rarity*
      (list
        (make-looty :object *common*    :min  1 :max 30 :weight 100)
        (make-looty :object *rare*      :min  5 :max 30 :weight 10)
        (make-looty :object *legendary* :min 10 :max 30 :weight 1)))

    (defun reclootgen (&key level lootylist)
      (let ((loot (lootgen :level level :lootylist lootylist)))
        (typecase loot
          (list (reclootgen :level level :lootylist loot))
          (t loot))))

    (reclootgen :level 11 :lootylist *by-rarity*)

Note that there are duplicated level checks here. There may be better designs that avoid the duplicate "okay for this level?" checks, but as usual I'm making all this up as I go along.

Metatables would also be an easy way to apply seasonal adjustments, so around a solstice the *by-rarity* table might have some odds of a solstice table to roll on.

The "if list, pick randomly from it; if atom, return it" pattern can also be found in such texts as "Paradigms of Artificial Intelligence Programming" (PAIP) or what passed for AI in those days. Graph search, baby!