In the name of the Gods of Random. Adjusting the probability of consequences in games
Unpredictability always intrigues people and is a popular tool in the hands of game developers: procedural generation, loot boxes, crit chances, and many other options that the player can only pray to like. However, sometimes such mechanics turn with the anger of ordinary people against the creators. In this article, we will consider implementations of randomness to generate consequences in a way that minimizes player dissatisfaction and keeps them interested.
I am a graduate MIP15 and in my spare time I develop a game for Telegram chats with RPG elements – Krezar Tavernthe source code of which is possible look on github.
Contents
What is the problem?
Let’s imagine a rather classic situation. Players can go on quests, the chance of success is 80%. Two outcomes of an event with a given probability, what could be simpler?
We can write something like this in any programming language: random.nextInt(1, 100) . Всё работает, можно в прод. Но спустя какоето время игроки начинают жаловаться на сломанный рандом. Почему?
After unloading from the food base, we see that 51% of players have less than 79% of successful quests, and 34% have more than 81%.
Before you start blaming the programming language for the GSPL, let’s note that the total probability is close to the target. There is a cognitive distortion here, because we want every player to have an 80% chance! And in the code, we wrote a global call to the random number generator.
We adjust the probability
Let’s analyze practically several ways with which you can implement randomness in our game.
We’ll run each implementation through a benchmark: We’ll send 1,000 players into 5,000 events with a target chance of 80% success and see how the probability for each of them changes over time.
Implementation of the benchmark in Java
public class Benchmark {
private static final Map playersStats = new HashMap();
// здесь будет менять реализация randomProvider по ходу статьи
private static final RandomProvider randomProvider;
public static void main(String... args) {
final var playerCount = 1_000;
final var eventCount = 5_000;
for (int i = 1; i ();
stats.successRates.add((double) success);
playersStats.put(playerId, stats);
}
}
}
// для записи csv используется com.opencsv:opencsv:5.9
final var csvFilePath = "output.csv";
try (final var writer = new CSVWriter(new FileWriter(csvFilePath))) {
final String[] header = {"id", "success_rates"};
writer.writeNext(header);
for (final var entry : playersStats.entrySet()) {
final String[] record = {entry.getKey().toString(), entry.getValue().successRates.toString()};
writer.writeNext(record);
}
} catch (IOException _) {
}
}
}
public class PlayerStats {
public int successCount;
public ArrayList successRates;
}
public interface RandomProvider {
boolean next(int playerId, Map playersStats);
}
public class RandomUtils {
private static final RandomGenerator random = RandomGenerator.getDefault();
public static int getInInterval(int start, int end) {
if (start >= end) {
return start;
}
return random.nextInt(start, end + 1);
}
public static boolean processChance(int percent) {
if (percent >= 100) {
return true;
}
final var result = getInInterval(1, 100);
return result
Honest random
Let’s start with our first implementation. It’s just that each subsequent event is independent and the probability of success is 80%.
Implementation of fair randomness in Java
public class HonorRandom implements RandomProvider {
private static final int targetChance = 80;
@Override
public boolean next(int playerId, Map playersStats) {
return RandomUtils.processChance(targetChance);
}
}
Here we see an illustration the law of large numbers. At the beginning, with a small number of events, there is a large spread of success probabilities among individual players due to the influence of random fluctuations. As the number of events increases, the probability of success for all participants begins to converge to the target probability (80%).
Advantages of an honest approach:

It is not necessary to save data on previous results

Simple implementation

High unpredictability for the player
Disadvantages of implementation:

A big difference between players with a small number of events

Long convergence to target probability
An honest opportunity is great if there are a lot of events in our system and the player doesn’t spend a lot of resources to trigger them. For example, cover or dodge in an action movie.
Let’s return to the original task about quests. In our game, the user has 100 energy per day, and a quest costs 10. That is, 10 quests per day. And there are other activities that use up energy. In case of failure, the player can suffer from bad random for more than 100 days. If not shaking the game up to this point.
Event pool
What does an 80% chance mean? As a rule of thumb, what we want is 80 successful events out of 100. To cut it short, that’s 8 out of 10.
Therefore, we can make a pool of 10 elements, in which 8 successful and 2 unsuccessful quests will be randomly located.
Java event pool implementation
public class RandomPool implements RandomProvider {
private static final int poolSize = 10;
private static final int successCount = 8;
private final Map playersPools = new HashMap();
@Override
public boolean next(int playerId, Map playersStats) {
var pool = playersPools.get(playerId);
if (pool == null  pool.isEmpty()) {
pool = BooleanPool.generate(successCount, poolSize);
playersPools.put(playerId, pool);
}
return pool.next();
}
record BooleanPool(
Queue queue
) {
public static BooleanPool generate(int successCount, int poolSize) {
final var list = new ArrayList(poolSize);
for (int i = 0; i (list));
}
public boolean next() {
return queue.poll();
}
public boolean isEmpty() {
return queue.isEmpty();
}
}
}
Here it is clearly seen that every 10 events all lines cross 80%. And as the number of events increases, the swings become smaller and smaller, and much faster than in fair probability.
Of course, the pool has its drawbacks:

It must be stored somewhere on our server.

With a fixed size, players can easily track its boundaries.
Advantages of this approach:

It is easy to control the number of events

Easily expands to more variations of results
How to improve the pool:

Make a spread based on pool size to make it harder for players to track when it ends and what events are left. There can be 8 successful and 2 failed in one pool. In another – 12 by 3.

Position adjustment: No more than N identical events in a row, or events X and Y must not be next to each other.
An event pool is suitable in a situation where we want to control that the player has received all available options. For example, equipment for all available slots.
Dynamic probability
The approach is quite simple in its idea. We have a target probability A
and the player’s current success percentage B
. If B > A
means the final probability C
will be less A
. If B , значит
C > A
. A = B => C = A
.
In general, the formula can be described as:
Function F
returns the deviation from the target probability based on the rules described above. Consider the linear implementation:
It’s easy to see what if deviationCoefficient = 0
then we’ll go back to honest randomness. And the more deviationCoefficient
the faster our schedule will converge.
Java implementation of dynamic probability
public class DynamicRandom implements RandomProvider {
// Корректировать deviationCoefficient можно в зависимости от потребностей
private static final int deviationCoefficient = 50;
private final double targetRate = 0.8;
@Override
public boolean next(int playerId, Map playersStats) {
final var stats = playersStats.get(playerId);
final double currentRate;
if (stats == null) {
currentRate = targetRate;
} else {
currentRate = stats.successRates.getLast();
}
final var playerChance = (int) Math.round(
(targetRate + (targetRate  currentRate) * deviationCoefficient) * 100
);
return RandomUtils.processChance(playerChance);
}
}
Graph with deviationCoefficient = 2
Advantages of this approach:
But there are also disadvantages:

Difficult to scale if there are more than two results.

You need to store the current percentage for each player somewhere and modify it after each event.

It is difficult to build in additional generation rules.

If the deviationCoefficient is too large, you can get into a simple sequence of events
Applying such a dynamic possibility is possible if we want to keep more unpredictability for the player than in the pool. For example, precisely in the mechanics of quests from the introductory one.
Guarantor
A guarantee means that the expected result will 100% occur after a certain number of events. This system is popular in games with gacha mechanics, such as Genshin Impact. There, a 5star quality item drops every 90 prayers.
We see that until the 74th prayer, the probability is constant and approximately equal to 0.8%, and then + increases linearly to 100% on the 90th attempt.
In our case, we can invert the problem and say that we are guaranteed to fail every 5th attempt. However, it is immediately obvious that in this case we will get an overly predictable system, so we will try to adjust the parameters.
Parameters that will be needed for the guarantor:

The maximum number of events by default to the desired (
A
). 
Default target event capability (
C
). 
The number of events from which the probability begins to increase (
B
). 
The formula by which the probability increases between
B
andA
(linear, static function or something else).
While playing with the options, you can look at the graphs and select the appropriate ones.
Implementation in Java
public class GuaranteedRandom implements RandomProvider {
private final HashMap playersSuccessRows = new HashMap();
private final int expectFailEvent = 6;
private final int defaultChanceCount = 3;
private final int defaultChance = 5;
@Override
public boolean next(int playerId, Map playersStats) {
int successRow = playersSuccessRows.getOrDefault(playerId, 0);
final int currentChance;
if (successRow
Again, we see that the convergence of the probabilities to the target is better here than in the honest implementation (if you go through the numbers, you can do even better). However, even on such a simple example, I had to waste time adjusting the parameters to the desired picture. It is probably possible to derive formulas for such a system or even find readymade ones.
Advantages of the guarantor:

Transparency for the player

As for just scaling to more results
Cons of the solution:

Complex setting of expected probability

Data on the default number of consecutive events should be stored.
This implementation is pretty well known to players when it comes to all kinds of gacha mechanics. If you want to give a guarantee to the player (or you are forced by law), then that is your choice.
Conclusion
We have considered various options for implementing the mechanics of randomness in games. This list is most likely not exhaustive, you can add the missing implementations in the comments, but it is enough to create many game situations.
The main thing, when you connect randomness to your game, remember the law of large numbers. For this, we follow just a few simple rules:

We look at the probability y dynamics, rather than on a finite number of events.

We always simulate probabilities for several playersbut not one.

If you are a game designer, attach graphics to the task, with a visual example of how your randomness looks like in dynamics.

If you are a developer, be sure to ask the game designer what randomness he needs.
Feel free to experiment with formulas: select numbers, combine, invent your own. The main thing is that your players are satisfied.