Just for fun, I thought I’d talk about my die-rolling algorithm.
One recurring piece of feedback that I got prior to release (and I agreed) was that attacks seemed to miss more than expected, and that streaks of misses were too common. This is of course (as is well-documented) partly down to the way we perceive unfavorable events, but games use a variety of techniques to mitigate that and improve the experience.
I was originally just using Godot’s built-in random number generator, and my tests produced distributions with something like a 5-10% variance from the mean. I’m not sure if that’s considered unusually high, but I did some research and found a design pattern for finite distributions called the shuffle bag pattern. This works a bit like a deck of cards: you shuffle all numbers in the distribution into a list, then pull one at a time until you’ve used them all. This produces a perfectly uniform distribution.
So, I took this pattern as a starting point and wrote a class that I call DiceBag. The bag contains sets of integers corresponding to die sizes (currently just d20 and d100), and each set contains one of each possible roll. However, I can also partition them further. Let’s look at the d100 set, which is used for attack rolls. I split the d100 rolls into five sets, incremented by fives. So, before being shuffled, the d100 set looks like:
Partition 1: 1, 6, 11, 16…
Partition 2: 2, 7, 12, 17…
etc.
Players and enemies also have separate roll sets, so that a lucky or unlucky roll from one side doesn’t influence the other side.
On application startup, I populate and shuffle all partitions and randomly pick one as the starting point. Whenever a die roll is requested, I issue the next available number. When I reach the end of one partition, I reshuffle it and move on to the next one.
I partition the shuffle bag for two reasons:
- To limit the period over which a full spread of values is guaranteed.
- To reduce streaking by ensuring that no* two rolls are closer than 5.
Some games go further in artificially manipulating outcomes to remain within certain boundaries - e.g. if you miss twice in a row, your next hit is a guaranteed critical - but I didn’t want to do that. I see this strictly as improving the uniformity of roll distribution, which doesn’t technically favor the player.
I did make two other small adjustments to the rules in the player’s favor, though. First, I gave the player front row a small (5%) accuracy bonus, which serves both to reduce the physical fighters’ miss rate a bit and add a little more incentive to place someone in the front row. I also removed the 95% hit chance cap for players (I kept it in place for enemies, since we love it when the enemies miss). The reason was that this caused situations where a character could artificially miss an otherwise guaranteed hit. When you’ve got a +20 accuracy buff and spend MP on an attack skill that adds another +20, and you still miss against the slowest enemies in the game, it feels like something is wrong. This was a D&D holdover that didn’t actually improve the game. It’s probably more valid for tabletop, where rolling a natural 1 is a tactile event that clearly communicates what just happened.
This all ended up working quite well. Combat felt smoother, more fair, and less streaky. I can’t quantify any statistical differences between the basic shuffle bag pattern and the modified version that I’m using, but the modifications at least narrow its behavior down to something I can better understand and control.
* When switching to a new partition, there’s a small chance of rolling an adjacent number, but this is rare enough that I’m not worried about it.