Biblyon the Great

This zine is dedicated to articles about the fantasy role-playing game Gods & Monsters, and other random musings.

Gods & Monsters Fantasy Role-Playing

Beyond here lie dragons

Rolling random levels across a range of experience points in AD&D

Jerry Stratton, November 4, 2017

Substandard ability rolls

I’m prepping for a North Texas RPG Convention session, an Advanced Dungeons & Dragons adventure I’m going to run in June, in which the characters should be from levels four through six. I wanted to create these characters completely randomly; the adventure is an old-school adventure designed for old-school characters. This means a random variety of abilities and levels.

Initially, I eyeballed the level range, going from class to class in the Players Handbook knowing that I didn’t want the lowest experience point total in the range to produce a level lower than four, and that I didn’t want the highest experience point total in the range to produce a level higher than six. I correctly calculated that I needed to roll d25,000 experience and add 10,000 to it.1 The easiest way to handle this was to roll d100,000, divide by 4, round up, and add to 10,000. So, on creating each character I randomly rolled a d100,000 and performed the calculation, possibly adding 10% if the character had an earned experience bonus.2

However, reading over the adventure I wasn’t sure if perhaps I might want to make the characters level 5 to 7 instead. This would make the calculation 22,500 plus d100,000 times .375. Since I still had all of the d100,000 rolls, I first just pulled out my trusty Tandy PC-73 and wrote a quick BASIC program to handle the calculations.

  • 10 INPUT A
  • 20 B=A*.375+22500
  • 30 B=- INT(-B)
  • 40 PRINT B

Since 10% is easy to add mentally, it was faster to handle that calculation on my own instead of adding another line of code to ask whether the character merited the bonus.

Nowadays I would usually use Pythonista on the iPhone or iPad rather than the PC-7, but I was in an old-school mode, and I’ve had this BASIC-programmable pocket calculator since I first bought in in 1986, often using it for repetitive game calculations.4

It worked well enough that I decided to keep track of the d100,000 rolls in a text file.

  • Saurian Thief: 9,977
  • Elven Fighter/Magic-User: 14,408 -/*
  • Magic-User: 18,300
  • Dwarf Fighter: 28,310
  • Fighter: 45,240
  • Paladin: 57,950 *

The stars mean that the character merits an experience-point bonus. For the Elven Fighter/Magic-User, that character merits an experience-point bonus as a Magic-User but not as a Fighter.

Once I realized that this would make it easy to parse the text file programmatically, I wrote a command-line program to do the same thing in the future that I’d done individually with the programmable calculator, without having to enter the rolls each time.

I used Perl because I find it easier to parse text files in Perl.5

[toggle code]

  • #!/usr/bin/perl
  • use POSIX;
  • # calculate experience from d100,000 rolls
  • while (@ARGV) {
    • $option = shift;
    • if ($option =~ /^--base$/) {
      • $base = shift;
    • } elsif ($option =~ /^--factor$/) {
      • $factor = shift;
    • } elsif ($option =~ /^--help$/) {
      • print "$0 --base ### --factor ###\n";
      • exit();
    • } else {
      • print "Unknown option: $option.\n";
      • exit();
    • }
  • }
  • while (<>) {
    • /^([^:]+): ([0-9,]+)( .*)?/;
    • ($character, $roll, $bonus) = ($1, $2, $3);
    • $roll =~ s/,//g;
    • $experience = $base + $roll*$factor;
    • if ($bonus eq ' *') {
      • $experience *= 1.1;
    • } elsif ($bonus ~~ [' -/*', ' */-', ' */*', ' -/-']) {
      • $experience1 = $experience/2;
      • $experience2 = $experience1 * 1.1 if $bonus ne ' -/-';
      • if ($bonus eq ' */*') {
        • $experience1 = $experience2;
      • } elsif ($bonus eq ' */-') {
        • ($experience1, $experience2) = ($experience2, $experience1);
      • } elsif ($bonus eq ' -/-') {
        • $experience2 = $experience1;
      • }
      • $experience1 = formatExperience($experience1);
      • $experience2 = formatExperience($experience2);
      • print "$character: $experience1/$experience2\n";
      • next;
    • } elsif ($bonus) {
      • print "UNKNOWN BONUS FOR $character\n";
      • exit;
    • }
    • $experience = formatExperience($experience);
    • print "$character: $experience\n";
  • }
  • sub commish {
    • my($number) = shift;
    • my(@digits);
    • $number = reverse $number;
    • @digits = unpack("(A3)*", $number);
    • $number = reverse join ',', @digits;
    • return $number;
  • }
  • sub formatExperience {
    • my($experience) = shift;
    • $experience = ceil($experience);
    • $experience = commish($experience);
    • return $experience;
  • }

With this, I can easily run the code on the above character file to get the experience point totals corresponding to each character’s d100,000 roll:

  • Saurian Thief: 27,495
  • Elven Fighter/Magic-User: 14,301/15,732
  • Magic-User: 29,575
  • Dwarf Fighter: 32,078
  • Fighter: 36,310
  • Paladin: 43,437

Checking the program’s results against my hand-calculated results, I realized that I’d done the calculations wrong on one of the characters for the initial level 4-6 range. Frankly, I find it amazing I only made one mistake across twelve characters. It was a simple one: I’d given the Dwarf an experience-point bonus that he shouldn’t have received.6

As usually happens when I write a program to make a particular task easier, I start thinking of other ways to make the task easier; so I added a function to calculate the experience point range as well.

This meant making an array of the experience-point thresholds for each class in the AD&D Players Handbook:

[toggle code]

  • # experience thresholds
  • # format is 0, each level's maximum, and then the increase per level at the end of the table
  • # if the class is level-limited, the final number should be -1
  • %levels = (
    • 'Cleric' => [0, 1500, 3000, 6000, 13000, 27500, 55000, 110000, 225000, 450000, 675000, 900000, 225000],
    • 'Druid' => [0, 2000, 4000, 7500, 12500, 20000, 35000, 60000, 90000, 125000, 200000, 300000, 750000, 1500000, -1],
    • 'Fighter' => [0, 2000, 4000, 8000, 18000, 35000, 70000, 125000, 250000, 500000, 750000, 1000000, 250000],
    • 'Paladin' => [0, 2750, 5500, 12000, 24000, 45000, 95000, 175000, 350000, 700000, 1050000, 1400000, 350000],
    • 'Ranger' => [0, 2250, 4500, 10000, 20000, 40000, 90000, 150000, 225000, 325000, 650000, 975000, 1300000, 325000],
    • 'Magic-User' => [0, 2500, 5000, 10000, 20500, 40000, 60000, 90000, 135000, 250000, 375000, 750000, 1125000, 1500000, 1875000, 2250000, 2625000, 3000000, 3375000, 375000],
    • 'Illusionist' => [0, 2250, 4500, 9000, 18000, 35000, 60000, 95000, 145000, 220000, 440000, 660000, 880000, 220000],
    • 'Thief' => [0, 1250, 2500, 5000, 10000, 20000, 42500, 70000, 110000, 160000, 220000, 440000, 660000, 220000],
    • 'Assassin' => [0, 1500, 3000, 6000, 12000, 25000, 50000, 100000, 200000, 300000, 425000, 575000, 750000, 1000000, 1500000, -1],
    • 'Monk' => [0, 2250, 4750, 10000, 22500, 47500, 98000, 200000, 350000, 500000, 700000, 950000, 1250000, 1750000, 2250000, 2750000, 3250000, -1],
  • );

It’s a fairly basic calculation except for three classes: the Monk, Assassin, and Druid are all level-limited classes. They can’t go up in level once they hit the top of their “profession”. They required a special case in the code.

This change means I can create new ranges just by running the program instead of eyeballing throughout the Players Handbook. Use --explain to get just the ranges instead of running the calculations on a list of characters.

  • bin/experience --explain --levels 7-10
  • Levels 7-10 go from 98,001 to 200,000 experience points using classes
  • Fighter, Monk, Ranger, Druid, Assassin, Cleric, Magic-User, Thief, Illusionist, Paladin.
  • Random experience will be 98,000 + d100,000 times 1.02.

You may find it useful, when you’re limiting the classes, to remember the base and factor, and use those on the command-line, instead of always remembering to exclude/include the correct classes.

Without --explain, you can pipe a text file to the code, or just paste it in:

  • $ bin/experience --levels 7-10 < characters.txt
  • Using base 98,000 and factor 1.02.
  • Saurian Thief: 108,177
  • Elven Fighter/Magic-User: 56,349/61,983
  • Magic-User: 116,666
  • Dwarf Fighter: 126,877
  • Fighter: 144,145
  • Paladin: 172,820

The larger code also supports any number of multi-classes instead of just two.

One customization you might wish to make is to adjust the die roll for experience. My choice was a d100,000, that I roll using five ten-siders.7 You can modify the line “$dieRoll = 100000;” to anything you want; if you’re a fan of 30-siders, make it “$dieRoll = 30;” or if you’re a fan of six-siders, make it “$dieRoll = 6;”. Just remember that that’s the number of discrete experience-point totals the characters will have. If you roll a d6, for example, there will be six discrete experience-point totals spread evenly across the range of experience chosen, with a potential ten percent variation for high stats.

Download Zip file (2.8 KB)

[toggle code]

  • #!/usr/bin/perl
  • use POSIX;
  • use List::Util qw[min max];
  • # calculate experience from d100,000 rolls
  • # cat Experience\ Rolls.txt | ~/bin/experience --levels 4-6 --excludeClass Paladin --excludeClass Assassin --levels 5-7
  • # ~/bin/experience --showLevels --levels 7-10
  • # experience thresholds
  • # format is 0, each level's maximum, and then the increase per level at the end of the table
  • # if the class is level-limited, the final number should be -1
  • %levels = (
    • 'Cleric' => [0, 1500, 3000, 6000, 13000, 27500, 55000, 110000, 225000, 450000, 675000, 900000, 225000],
    • 'Druid' => [0, 2000, 4000, 7500, 12500, 20000, 35000, 60000, 90000, 125000, 200000, 300000, 750000, 1500000, -1],
    • 'Fighter' => [0, 2000, 4000, 8000, 18000, 35000, 70000, 125000, 250000, 500000, 750000, 1000000, 250000],
    • 'Paladin' => [0, 2750, 5500, 12000, 24000, 45000, 95000, 175000, 350000, 700000, 1050000, 1400000, 350000],
    • 'Ranger' => [0, 2250, 4500, 10000, 20000, 40000, 90000, 150000, 225000, 325000, 650000, 975000, 1300000, 325000],
    • 'Magic-User' => [0, 2500, 5000, 10000, 20500, 40000, 60000, 90000, 135000, 250000, 375000, 750000, 1125000, 1500000, 1875000, 2250000, 2625000, 3000000, 3375000, 375000],
    • 'Illusionist' => [0, 2250, 4500, 9000, 18000, 35000, 60000, 95000, 145000, 220000, 440000, 660000, 880000, 220000],
    • 'Thief' => [0, 1250, 2500, 5000, 10000, 20000, 42500, 70000, 110000, 160000, 220000, 440000, 660000, 220000],
    • 'Assassin' => [0, 1500, 3000, 6000, 12000, 25000, 50000, 100000, 200000, 300000, 425000, 575000, 750000, 1000000, 1500000, -1],
    • 'Monk' => [0, 2250, 4750, 10000, 22500, 47500, 98000, 200000, 350000, 500000, 700000, 950000, 1250000, 1750000, 2250000, 2750000, 3250000, -1],
  • );
  • # this needs to be higher than any level you’re ever likely to use
  • # a 99th level Magic-User only has up to 33,750,000 XP, so a billion should be fine
  • $infinity = 1000000000;
  • # this is the maximum number on the die roll for experience
  • # if you prefer something like a d6 instead of a d100,000, change this to 6
  • $dieRoll = 100000;
  • # parse command-line options
  • while (@ARGV) {
    • $option = shift;
    • if ($option =~ /^--base$/) {
      • $base = shift;
    • } elsif ($option =~ /^--factor$/) {
      • $factor = shift;
    • } elsif ($option =~ /^--levels$/) {
      • $levelRange = shift;
      • if ($levelRange =~ /^([1-9][0-9]?)-([1-9][0-9]?)$/) {
        • ($minLevel, $maxLevel) = ($1, $2);
      • } else {
        • print "Levels must be specified as range #-# where # is from 1 to 10.\n";
        • exit;
      • }
    • } elsif ($option =~ /^--excludeClass$/) {
      • $class = shift;
      • if ($levels{$class}) {
        • $excludeClasses[$#excludeClasses+1] = $class;
      • } else {
        • print "Unknown class $class.\n";
        • exit;
      • }
    • } elsif ($option =~ /^--includeClass$/) {
      • $class = shift;
      • if ($levels{$class}) {
        • $includeClasses[$#includeClasses+1] = $class;
      • } else {
        • print "Unknown class $class.\n";
        • exit;
      • }
    • } elsif ($option =~ /^--explain$/) {
      • $showLevels = 1;
    • } elsif ($option =~ /^--help$/) {
      • print "$0 [--base ### --factor ###] [--levels #-#] [--includeClass <class>] [--excludeClass <class>] [--explain]\n";
      • exit();
    • } else {
      • print "Unknown option: $option.\n";
      • exit();
    • }
  • }
  • # calculate base and factor from level range
  • if ($levelRange) {
    • $minimumXP = -1;
    • $maximumXP = $infinity;
    • foreach my $class (keys %levels) {
      • next if @includeClasses && not grep(/^$class$/, @includeClasses);
      • next if grep(/^$class$/, @excludeClasses);
      • $classesUsed[$#classesUsed+1] = $class;
      • $lowEnd = levelExperience($class, $minLevel-1);
      • $minimumXP = max($minimumXP, $lowEnd) if $lowEnd != $infinity;
      • $highEnd = levelExperience($class, $maxLevel);
      • $maximumXP = min($maximumXP, $highEnd);
    • }
    • if ($minimumXP < 0) {
      • print "This level range is not possible for this class or classes.\n";
      • exit;
    • }
    • if ($maximumXP < $minimumXP) {
      • print "This level range for those classes produces an impossible range: $minimumXP to $maximumXP. You will need to increase the range of levels.\n";
      • exit;
    • }
    • $base = $minimumXP;
    • $range = $maximumXP-$minimumXP;
    • $factor = $range/$dieRoll;
    • if ($showLevels) {
      • $minimumXP++;
      • print "Levels $levelRange go from ${\commish($minimumXP)} to ${\commish($maximumXP)} experience points using classes\n";
      • print join(', ', @classesUsed), ".\n\n";
      • print "Random experience will be ${\commish($base)} + d100,000 times $factor.\n";
      • exit;
    • } else {
      • print "Using base ${\commish($base)} and factor $factor.\n\n";
    • }
  • }
  • # read character file and calculate experience based on d100,000 roll in the file
  • while (<>) {
    • /^([^:]+): ([0-9,]+)( .*)?/;
    • ($character, $roll, $bonus) = ($1, $2, $3);
    • $roll =~ s/,//g;
    • $experience = $base + $roll*$factor;
    • $bonus =~ s/^ +//g;
    • if (!$bonus || $bonus eq '*' || $bonus eq '-') {
      • $experience *= 1.1 if $bonus eq '*';
      • $experience = formatExperience($experience);
    • } elsif ($bonus eq '-') {
      • $experience = formatExperience($experience);
    • } elsif ($bonus =~ /[*-][\/*-]+/) {
      • @bonuses = split('/', $bonus);
      • $experience /= $#bonuses+1;
      • @experiences = ();
      • foreach my $bonus (@bonuses) {
        • if ($bonus eq '-') {
          • $experiences[$#experiences+1] = formatExperience($experience);
        • } elsif ($bonus eq '*') {
          • $experiences[$#experiences+1] = formatExperience($experience * 1.1);
        • } else {
          • print "UNKNOWN BONUS CODE FOR $character\n";
          • exit;
        • }
      • }
      • $experience = join('/', @experiences);
    • } elsif ($bonus) {
      • print "UNKNOWN BONUS SEQUENCE FOR $character\n";
      • exit;
    • }
    • print "$character: $experience\n";
  • }
  • # get experience for level and class
  • sub levelExperience {
    • my($class, $level) = @_;
    • my(@levels, $experience);
    • @levels = @{$levels{$class}};
    • if ($level < $#levels) {
      • $experience = $levels[$level];
    • } else {
      • if ($levels[$#levels] == -1) {
        • #this class is level-limited
        • $experience = $infinity;
      • } else {
        • $experience = $levels[$#levels-1] + ($level-$#levels+1)*$levels[$#levels];
      • }
    • }
    • return $experience;
  • }
  • # put commas into numbers
  • sub commish {
    • my($number) = shift;
    • my(@digits);
    • return 'infinity' if $number == $infinity;
    • $number = reverse $number;
    • @digits = unpack("(A3)*", $number);
    • $number = reverse join ',', @digits;
    • return $number;
  • }
  • # round experience up and put commas in them
  • sub formatExperience {
    • my($experience) = shift;
    • $experience = ceil($experience);
    • $experience = commish($experience);
    • return $experience;
  • }

It occurs to me that one thing the code does not do is roll the dice. I always prefer to roll dice by hand and record the results. It just doesn’t seem right to do otherwise.

  1. This ignores Paladins, since in my original list of characters, I wanted a Ranger instead of a Paladin. But when I rolled a perfect Paladin, I changed my mind.

  2. This technically meant that some characters could exceed the stated level limit. Merit has its advantages!

  3. The PC-7 was a rebadged Casio FX-5200P from 1986.

  4. The PC-7 is mostly useful only for simple, on-the-fly repetitive calculations. Because it has no means to save programs outside of itself, and it loses its memory every time you replace the batteries, you would never want to write complex programs using it.

  5. Perl is available by default on Mac OS X, and on most flavors of Linux.

  6. This explained an anomaly in the calculations: under the new experience-point range, the Dwarf did not go up in level; that’s because I gave him a bonus in the initial 4-6 calculations but (correctly) did not in the later 5-7 calculations.

    Always pay attention to anomalies when programming!

  7. Well, one ten-sider five times in a row.

  1. <- Divine guidance