GameMaker: Custom pseudorandom number generators!

A handwritten text reads: "Custom pseudorandom number generators!"

I wrote a bunch of PRNGs for GameMaker!

This is a large-ish post about what these are, how they work, when you might want to use them, and performance comparisons.

What's a PRNG

Most random number generators that you see on computers are pseudorandom number generators.

These are algorithms that output random-looking numbers based on one or other form of initial seed or state. Same input means same outputs!

Typically each iteration the generator will modify its internal state and return a value.

For example, a tiny, pitiful implementation of Lehmer random number generator might look something like this:

newState = (oldState * 7) % 11;

Given an initial state/seed of 3, this would output

( 3 * 7) % 11 = 21 % 11 = 10
(10 * 7) % 11 = 70 % 11 = 4
( 4 * 7) % 11 = 28 % 11 = 6
( 6 * 7) % 11 = 42 % 11 = 9
( 9 * 7) % 11 = 63 % 11 = 8
( 8 * 7) % 11 = 56 % 11 = 1
( 1 * 7) % 11 =  7 % 11 = 7
( 7 * 7) % 11 = 49 % 11 = 5
( 5 * 7) % 11 = 35 % 11 = 2
( 2 * 7) % 11 = 14 % 11 = 3
( 3 * 7) % 11 = 21 % 11 = 10
...

As you can see, the algorithm traverses all of its 10 possible states in a shuffled order before it will start repeating the values.

A "real" generator can be not unlike this one, but with larger parameters like 2 147 483 647 for modulo and 48 271 for multiplier (more on this later).

Uses

These are some of the things that a custom generator can be good for.

Separating generators

Suppose you have your big dragon boss or anything.

A person with a polearm stands before an angry dragon.

GameMaker's built-in random number generator has reasonably uniform distribution, so if your boss has 4 attacks, picking a random one with irandom or choose will have each occur just about 1/4 of the time, on average.

But, if you also call the random number functions between these, such as to give the player/boss attacks a little spread or spawn particles, you are using up the generator outputs and the results might not no longer be so uniform.

By giving the boss a "personal" random number generator, you can ensure that things stay fairer on average.

Another layer of defense with or without a "personal" RNG is to check that an action has already happened too many times in a row and re-roll if it's so. The game has to feel fair, not be fair.

A different approach to this specific scenario is to make a shuffled list of possible actions and re-shuffle it whenever you reach the end of it. This, however, can look a little too uniform as actions will always happen 0 ... 2N-2 steps apart and will never happen more than twice in a row.

Bonus: Nuclear Throne trivia

When I first started working on widescreen support (now in open beta!), I have noticed something peculiar: depending on your aspect ratio, the levels would generate differently, but consistently for each ratio.

Levels in Nuclear Throne generate over multiple frames and the player gets to see this kind of screen while the generator is busy placing everything:

A screenshot of Nuclear Throne's "generating" screen

Here you can see a HUD, a bunch of stats, and a Cool Swirling Portal in the background. And that portal was the reason why the level generation would vary!

See, the portal's curvature is represented by series of instances with position/rotation/radius that are destroyed when they're outside the screen. And the larger the screen, the later these get destroyed!

By giving the portal effects a separate RNG the level generation was no longer reliant on aspect ratio.

"Save scumming"

A person with a sword and a shield stands on an isometric tile and cluelessly looks at a "17%" label above them

If your game involves chance actions and allows to save/load the game before performing such actions, players will inevitably figure out that they can save and re-load the game to re-try the roll until they get what they want.

GameMaker does not currently allow to save/load state of the built-in random generator (and likely won't in the near future), but with a custom generator, you could save its state along with the game and load it back so that performing the same action after loading the game will always yield the same result.

Rollback

If you are doing rollback and using RNG functions, it is nice to be able to save/load RNG state so that you can replay the frame(s) with the same RNG outputs.

Force-setting the seed every frame is an option, but that leads us to...

What's wrong with random_set_seed

People regularly try to solve any of above problems by calling random_set_seed before each PRNG call.

That is not a good idea, but you probably wouldn't believe the extent of it. Let me show you:

I made this little app (called prng_distro_test in the GitHub repository) that picks an n based on window size, does n * 1000 calls to irandom(n - 1), and displays the results as a grid of squares where the brightness indicates how often each number was returned relative to how often it should (ideally) be returned.

If we set the PRNG seed once or let GM set it, value distribution looks like this:

A screenshot of PRNG distribution test app.
The title bar reads:
Buckets: 9216, expected avg: 1000, min: 885 (88%), max: 1121 (112%)

Pretty smooth, right? Just about 12% variation.

And here's what happens if you call random_set_seed before each irandom:

A screenshot of PRNG distribution test app.
The title bar reads:
Buckets: 9216, expected avg: 1000, min: 0 (0%), max: 2821 (282%)

The results are visibly less distributed - the picture looks much noisier overall, some numbers were returned 3x more often than they should have been, and there are even a few numbers that weren't returned once across ten million calls!

There are two parts to why this happens:

  1. PRNGs offer adequate distribution over a number of calls on any valid seed, but they cannot promise anything for doing a couple calls on arbitrary seeds.
  2. GameMaker's random_set_seed uses a hash function.
    Its job is to produce sufficiently different generator states for any X and X+1, not to produce seeds that create any specific distribution on first PRNG use.

The situation can be improved by generating a good bunch (at least 100, preferably 1000 or more) values from the same seed at a time and using these up until you run out of them.

However, if you need a separate generator or to save/load generator state, you should probably be doing that instead of trying to stick adhesive tape on the built-in generator.

... and random_get_seed

You've probably seen people do something like this:

var prevSeed = random_get_seed();
random_set_seed(...);
...

random_set_seed(prevSeed);

So we store the current seed, do some stuff, and then restore it, right?

But the function doesn't work like that - it returns the generator's seed from the last random_set_seed or randomize call, not the generator's state. And to "roll back" the generator, you need the state.

If you were to do something like this:

random_set_seed(1234);
repeat (3) show_debug_message("pre " + string(irandom(1000)));
//
var prevSeed = random_get_seed();
random_set_seed(5678);
show_debug_message("change " + string(irandom(1000)));
random_set_seed(prevSeed);
//
repeat (3) show_debug_message("post " + string(irandom(1000)));

The output would be

pre 320
pre 806
pre 377
change 267
post 320
post 806
post 377

because you're not restoring the generator's state, you're restarting the generator.


With that out of the way, the algorithms!

Algorithms

I have implemented a few of these in my library for a good sample.

Here, result is a value between 0 (inclusive) and 1 (exclusive).
Some generators might never return an actual 0, but you probably shouldn't rely on a 1:231 (or rarer) event anyway.

MINSTD

This is a so-called Lehmer random number generator with modulo of 2147483647 and multiplier of 48271. The code's just this:

state = (state * 48271) % 2147483647;
result = state / 2147483647;

It's a dead-simple generator that's commonly used for actions that happen a lot, though I think its most important property is that it's hard to mess this up: you can do this with either integers and floats, signed or unsigned - so long as it's a 64-bit number, it'll work fine.

The state should be initialized with a value between 1 (inc.) and 2147483646 (inc.).

random_r

I'm not sure if there's a "proper name" for this one, but it's a linear congruential generator that's primarily known for being the TYPE_0 generator in GLIBC/GCC. The code goes like this:

state = (state * 1103515245 + 12345) & 0x7fff_ffff;
result = state / 2147483648; // <- that's 0x8000_0000

You might think that it's hard to mess this one up, but that's wrong! The number is just about big enough that if state is a real (rather than an integer), you'll lose precision bits on some multiplications and your generator will loop weirdly.

Also I found a YYC/VM inconsistency while implementing this one.

Xorshift (64-bit)

This one's all bit operations, no multiplications!

There are over two thousand possible set of parameters/order of operations for the 64-bit version, but I wasn't not feeling adventurous so I used the one that was tested the most.

Adapting the code straight from the Wikipedia article, you might think that it should look something like this:

state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
result = state / 18446744073709551616.0; // <- that's 2^64

And that should work, right?

But no. GameMaker's 64-bit integers are signed, so your output is in -0.5 .. 0.5 range.

It might be tempting to add 0.5 to result and call it a day, but that too would be wrong.

The reason for that is that when you bit-shift a negative number to the right (in GM or in general), the sign bit pads out. So the following

state = 0x8000_0000_0000_0000;
show_debug_message(ptr(state >> 7));

would show not 0100000000000000 as one might expect, but FF00000000000000.
And that's pretty bad for the generator!

The best thing I could up quickly come with is this:

state ^= ((state & $7fff_ffff_ffff_ffff) >> 7) | ((state < 0) * $0100_0000_0000_0000);

So we're cutting off the sign bit and we're putting back what would be the sign bit shifted by 7 to the right (if it was on).

And if we'll have to remove the sign anyway, might as well take the portion that actually fits in a real number:

result = (state & 0x1F_FFFF_FFFF_FFFF) / 9007199254740992.0; // <- that's 0x20_0000_0000_0000

WELL512

This is the PRNG that GameMaker itself uses!

Standing for Well Equidistributed Long-period Linear, it's a RNG with state consisting of an index and 16 unsigned 32-bit numbers. Allegedly it's all-around pretty good.

This is another one where the code almost works if you copy-pasted it:

var a, b, c, d; // originally `unsigned long`
a = state[index];
c = state[(index + 13) & 15];
b = a ^ c ^ (a << 16) ^ (c << 15);
c = state[(index + 9) & 15];
c ^= (c >> 11);
a = b ^ c;
state[index] = a;
d = a ^ ((a << 5) & 0xDA442D24);
index = (index + 15) & 15;
a = state[index];
a = a ^ b ^ d ^ (a << 2) ^ (b << 18) ^ (c << 28);
state[index] = a;
result = a;

but you have to add a & 0xFFFF_FFFF to each array write to keep numbers within the unsigned 32-bit integer range. And then random_set_seed works very much like

var s = seed;
for (var i = 0; i < 16; i++) {
    s = ((s * 214013) + 2531011) >> 16;
    state[i] = s;
}
index = 0;

but you'll need to apply & 0xFFFF_FFFF after multiplication.

Tests

Starting with some notes:

I have implemented each of these generators in Haxe➜GML and as a C++ DLL.

A DLL is advantageous for some of these, but more importantly offers a way to verify that the GML version works correctly in arbitrary scenarios by calling the same thing from GML/C++ versions.
For your uses you would be picking one or the other, depending on which platform(s) you target.

The test cases found below are the following:

  • Built-in: GameMaker's built-in WELL512 generator.
  • GML, struct: RNG state is stored in a constructor with methods, like so:

    var rng = new WELL512();
    rng.setSeed(100);
    var num = rng.float(4);
    

    This is what you would usually want for multiple generators.

  • GML, array: RNG state is stored inside an array, like so:

    var rng = well512_create();
    well512_set_seed(rng, 100);
    var num = well512_float(rng, 4);
    

    Honestly I was expecting a larger boost given that looking up things in an array is a much simpler operation than pulling out items from a struct's hashtable.

  • GML, global: RNG state is stored in global variables, so you get

    well512_set_seed(100);
    var num = well512_float(4);
    

    This is always faster and especially faster on YYC.

  • C++, array: RNG references are tiny arrays.
    These consist of a "what is this" tag and a pointer to a C++ structure.
    The functions verify that the structure exist and call the underlying C++ function.
    Same APIs as "GML, array"!
  • C++, struct: RNG references are structs with methods.
    Had to write a new code generator just for this!
    Good results though! Same APIs as "GML, struct".
  • C++, raw pointer: you can grab a C++ pointer from a struct/array and call the underlying function yourself. This is as fast as it gets for these.
    But be careful! If you use a pointer a wrong/non-existing thing, your game will crash.

Benchmarking is done using Dragonite's GMBenchmark.
Graphs show relative time so lower is better.

WELL512

Let's start with the interesting question: how close can you get to the built-in RNG?

On VM, pretty close!

GMBenchmark results for "WELL512" on VM read:
Built-in: 0.109 (100.0%)
C++ & raw pointers: 0.123 (88.6%)
C++ & GM structs: 0.306 (35.6%)
C++ & GM arrays: 0.337 (32.3%)
GML, global: 1.540 (7.1%)
GML, array: 1.798 (6.1%)
GML, struct: 1.877 (5.8%)

On YYC, not very close...

GMBenchmark results for "WELL512" on YYC read:
Built-in: 0.006 (100.0%)
C++ & raw pointers: 0.038 (15.8%)
C++ & GM arrays: 0.101 (5.9%)
C++ & GM structs: 0.102 (5.8%)
GML, global: 0.105 (5.6%)
GML, struct: 0.157 (3.8%)
GML, array: 0.194 (3.1%)

But that's because the built-in function call is very fast! It gets special treatment so random(1234) generates the same code as if you called the underlying function yourself in C++.

MINSTD

This generator is much simpler so it manages to beat some of the extension calls even on VM:

GMBenchmark results for "MINSTD" on VM read:
C++ & raw pointers: 0.118 (100.0%)
GML, global: 0.181 (65.2%)
GML, flat: 0.264 (44.8%)
C++ & GM structs: 0.297 (39.8%)
GML, struct: 0.315 (37.6%)
C++ & GM arrays: 0.328 (36.1%)

And on YYC, a global version almost beats the C++ raw pointer version!

GMBenchmark results for "MINSTD" on YYC read:
C++ & raw pointers: 0.044 (100.0%)
GML, global: 0.044 (99.6%)
GML, struct: 0.074 (59.3%)
C++ & GM structs: 0.104 (41.9%)
C++ & GM arrays: 0.107 (41.1%)
GML, flat: 0.120 (36.5%)

You can squeeze a little more performance out of it by turning the generator into a set of macros:

GMBenchmark results for "MINSTD tricks" on YYC read:
MINSTD, macro: 0.029 (100.0%)
MINSTD, C++ & raw p.: 0.043 (67.5%)
MINSTD, forceinline: 0.046 (62.2%)
MINSTD, normal: 0.049 (58.9%)

Here I'm also testing a script that modifies a self.state variable with a forceinline tag, but that is not as efficient as doing this during a GML build with a macro.

random_r

Largely similar - after all, it's only one more operation per call than MINSTD.

GMBenchmark results for "Rand0" on VM read:
C++ & raw pointers: 0.112 (100.0%)
GML, global: 0.197 (57.0%)
GML, flat: 0.277 (40.5%)
C++ & GM structs: 0.295 (38.0%)
C++ & GM arrays: 0.316 (35.5%)
GML, struct: 0.320 (35.1%)

GMBenchmark results for "Rand0" on YYC read:
C++ & raw pointers: 0.039 (100.0%)
GML, global: 0.046 (83.4%)
GML, struct: 0.060 (64.5%)
C++ & GM structs: 0.098 (39.3%)
C++ & GM arrays: 0.100 (38.6%)
GML, flat: 0.122 (31.6%)

For lack of a better name, I'm calling it Rand0 here.

Xorshift

This one's slightly more expensive for VM, but not as expensive as WELL512!

GMBenchmark results for "Xorshift64" on VM read:
C++ & raw pointers: 0.121 (100.0%)
C++ & GM structs: 0.292 (41.3%)
C++ & GM arrays: 0.336 (35.9%)
GML, global: 0.551 (21.9%)
GML, flat: 0.752 (16.1%)
GML, struct: 0.797 (15.2%)

But YYC performs much better!

GMBenchmark results for "Xorshift64" on YYC read:
GML, global: 0.031 (100.0%)
C++ & raw pointers: 0.038 (81.6%)
GML, struct: 0.064 (48.1%)
C++ & GM structs: 0.099 (31.0%)
C++ & GM arrays: 0.100 (30.8%)
GML, flat: 0.114 (27.0%)

The generated code doesn't seem to include any "special sauce" so without delving into disassembly I take it that the C++ compiler figures out that the function is just wiggling an int64 and skips the type checks.

Turning this into a set of macros makes it 3 times faster than calling the same code in a DLL:

GMBenchmark results for "Xorshift64 tricks" on YYC read:
Xorshift64, macro: 0.013 (100.0%)
Xorshift64, C++ & raw p.: 0.037 (35.8%)
Xorshift64, forceinline: 0.042 (31.8%)
Xorshift64, normal: 0.043 (30.8%)

Says something about the cost of external calls, doesn't it?

Oh, and also I found another runtime inconsistency while doing this! I checked and apparently it has been around since GM:S, so this feels like taking a stroll through a forest and stepping on a rake that someone forgot there years ago.

Tests, part 2

And here are different algorithms side-by-side!

C++

Per above, if you're doing small things like RNGs in C++, most of the time is spent on moving values between GM/C++, so pick what you like the most, really.

GMBenchmark results for "C++ & raw pointers" on VM read:
XorShift64: 0.112 (100.0%)
MINSTD: 0.118 (94.7%)
Rand0: 0.121 (92.1%)
WELL512: 0.129 (86.4%)

GMBenchmark results for "C++ & raw pointers" on YYC read:
Rand0: 0.037 (100.0%)
XorShift64: 0.037 (99.9%)
WELL512: 0.038 (95.9%)
MINSTD: 0.043 (84.5%)

Perhaps a "give me N random numbers" function would be more effective for series of values?

GML, structs

MINSTD and random_r perform reasonably well on both VM and YYC; Xorshift isn't far behind, but only on YYC.

GMBenchmark results for "GML, structs" on VM read:
MINSTD: 0.318 (100.0%)
Rand0: 0.338 (94.2%)
XorShift64: 0.804 (39.6%)
WELL512: 1.768 (18.0%)

GMBenchmark results for "GML, structs" on YYC read:
Rand0: 0.059 (100.0%)
XorShift64: 0.066 (89.8%)
MINSTD: 0.074 (80.6%)
WELL512: 0.160 (37.2%)

GML, global

MINSTD and random_r once again perform reasonably well on both VM and YYC; Xorshift is better, but only on YYC.

GMBenchmark results for "GML, global" on VM read:
MINSTD: 0.195 (100.0%)
Rand0: 0.201 (97.4%)
XorShift64: 0.549 (35.6%)
WELL512: 1.489 (13.1%)

GMBenchmark results for "GML, global" on YYC read:
XorShift64: 0.031 (100.0%)
Rand0: 0.046 (67.4%)
MINSTD: 0.046 (67.1%)
WELL512: 0.105 (29.3%)

Conclusions

This post came out a little long because of all the graphs, hasn't it

  • For GML implementations, global/single-instance versions are always faster than multi-instance struct/array ones and should be preferred if you need a fixed number of additional PRNGs.
  • If you are slightly concerned about performance,

    • Xorshift is the best choice on YYC.
      If you test in VM on Windows during development, you can make the game instantiate the GML Xorshift constructor on YYC and the C++ Xorshift constructor on VM.
    • MINSTD and random_r are good choices for VM-only games.
      If you only target Windows, you can use the C++ extension instead.
  • If you need to squeeze every last bit of performance out of a PRNG,

    • Macro-based Xorshift is the absolute winner for YYC.
    • Macro-based MINSTD is good for games that are tested on both YYC and VM.
      (when ran in VM, it's only ~15% slower than a C++ extension call)
  • WELL512 is slightly slower when running in GML, but has a much longer period for use cases where that matters.

Downloads

And that's about it!

You can grab the extension on itch.io or build it yourself from the source code.

See you next time

Related posts:

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.