Using Probability Theory to Benefit the Company
A Probability Theory Analysis of Lethal Company’s Profit Quota System
Updated for Lethal Company version 65!
Lethal Company is a “co-op horror game about scavenging at abandoned moons to sell scrap to the Company.”1 In the game, you (and up to 3 of your friends) take an autopilot spaceship to several dangerous planets in order to collect scrap items which you can sell to your enigmatic employer, only referred to as The Company. The catch is these planets are the home of several monsters or entitites which will promptly dispatch of unsuspecting scavengers who invade their territory.
The Company’s terrible totally legal™ and ethical™ business model relies on its employees completing a sequence of profit quotas which they must fulfill in the span of 3 days. Each time you complete a quota, the next quota will be higher. That is, every 3 days there is an increasing amount of profit you must achieve if you don’t want the company to promptly dispatch of you very violently fire you with a modest compensation package. This blog post is sponsored by The Company.
In this post, I’d like to jump head-first into the inner workings of how the quota system works and see if we can derive some useful mathematical results from it.
This post delves somewhat deeply into some mathematical topics which may not be straightforward to understand. Even I am just trying to learn them! If you’d like to join me in this endeavour, go ahead and read the entire post. If not, feel free to skip to the Results section at the end!
A Quick Lesson in Probability Theory
What Even Is Randomness, Anyway?
Imagine you’re flipping a fair coin. How many things can happen?
- The coin lands on Heads;
- The coin lands on Tails.
Those are our only two possible outcomes. Now, what are the chances that each of these actually happens? Intuitively, we know that if we have a fair coin then it’s a 50/50 chance of it landing on Heads or Tails… but how do we formalise this mathematically?
Let’s try “inventing” a way to do this. Say we have a letter
Using this, we can express formulae for concepts like “The probability that the coin lands on Tails is 50%”, which becomes
Measure-Theoretic Definition of Random Variables
That’s all it takes, mostly! We’ve just defined a random variable. Formally, we’ve come up with a measurable function
If you want to be really pedantic, then the axiomatic measure-theoretic definition of random variables says we need
Going back to our coin example, we could have
, the empty set, is “Neither heads nor tails”; is “Heads”; is “Tails”; is “Either heads or tails”.
Intutively, we know there’s a 50/50 chance of landing on one of the sides, and we’ll always land on one of them (and never in neither), so we could define our proability measure as
The standard usage is when
Expected Value (or Average) and Variance
When dealing with things that are random, it’s common to say something will happen a certain way “on average”. What does this mean for all the formal definitions we’re setting up?
When we have a random variable
which is essentially a fancy way of saying we go over every possible outcome (even if there’s an infinite number of them; welcome to integral calculus) and tally up the probability of each of those outcomes occurring.
Let’s look at a simple example. Returning to our coin, remember we assigned the quantity
The natural question you could ask would be something along the lines of “1.5? What? That’s not one of the quantities we defined!” And you’d be absolutely right! The expected value doesn’t always have such a straightforward interpretation. In this case, we could say that the expected value,
One of the cool things about the expected value is that it constitutes a linear operator. That is, if we have two random variables
Of course, the expected value only gives us an average outcome, and certainly many attempts might yield values that aren’t exactly equal to that average outcome. Thus, it might be natural to inquire how we can measure the deviation of any trial from the expected value. This is where the concepts of variance and standard deviation come in! Intuitively, the variance gives us a measure of how far a set of values is spread out from their expected value. The standard deviation is a more easily-interpretable description of how much the values deviate from their expected value.
Turns out, the variance is defined in terms of the expected value,
and the standard deviation is just defined as
; ; .
Note: the quantity
Applying Probability Theory to Lethal Company
Now that we’ve established the mathematical tools we will be using, it’s time to take a look at what’s happening under the hood in Lethal Company.
The Quota System
The general idea for the profit quotas is that, each time a player completes a quota, they’re sent off to complete the next quota, which is necessarily higher than the previous one. If we look into the game’s code, we can see exactly how this happens. The following is the code for the TimeOfDay.SetNewProfitQuota
function, which handles the calculation of the next quota each time the player completes the previous one.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (!base.IsServer)
return;
this.timesFulfilledQuota++;
int num = this.quotaFulfilled - this.profitQuota;
float num3 = Random.Range(0f, 1f);
num3 *= Mathf.Abs(this.luckValue -1f);
float num2 = Mathf.Clamp(1f + (float)this.timesFulfilledQuota * ((float)this.timesFulfilledQuota / this.quotaVariables.increaseSteepness), 0f, 10000f);
num2 = this.quotaVariables.baseIncrease * num2 * (this.quotaVariables.randomizerCurve.Evaluate(num3) * this.quotaVariables.randomizerMultiplier + 1f);
this.profitQuota = (int)Mathf.Clamp((float)this.profitQuota + num2, 0f, 1E+09f);
this.quotaFulfilled = 0;
this.timeUntilDeadline = this.totalTime * 4f;
int overtimeBonus = num / 5 + 15 * this.daysUntilDeadline;
this.SyncNewProfitQuotaClientRpc(this.profitQuota, overtimeBonus, this.timesFulfilledQuota);
Let’s starting by breaking down the relevant parts. The variable num2
is the amount that gets added to the previous quota to obtain the next quota. The quota itself is, intuitively, the this.profitQuota
variable. There’s also num3
, which takes the player’s Luck into account. Luck was a mechanic, added in version 65, which is calculated based on the amount of decorative furniture the player has purchased.
These calculations further depend on some parameters contained in the this.quotaVariables
variable. If we look into the game’s code once more, we can see that these are the needed parameters:
Variable | Description | Value |
---|---|---|
startingQuota | Starting Quota | 130 |
increaseSteepness | Increase Steepness | 16 |
baseIncrease | Base Increase | 100 |
randomizerMultiplier | Randomiser Multiplier | 1 |
There’s also a randomizerCurve
variable, but we’ll look into that one more in-depth in the following section.
Let’s begin formalising the quota system mathematically. We will use
where
By analysing the code, we can see that num2
variable, which is calculated in these 3 lines of code:
1
2
3
this.timesFulfilledQuota++;
float num2 = Mathf.Clamp(1f + (float)this.timesFulfilledQuota * ((float)this.timesFulfilledQuota / this.quotaVariables.increaseSteepness), 0f, 10000f);
num2 = this.quotaVariables.baseIncrease * num2 * (this.quotaVariables.randomizerCurve.Evaluate(num3) * this.quotaVariables.randomizerMultiplier + 1f);
The timesFulfilledQuota
variable, which starts at a value of 0, is randomizerCurve
using a function
where randomizerCurve.Evaluate(num3)
. Expanding Equation
where the base case is
Before we analyse this formula further, we need to take a more in-depth look at the player’s luck
Player Luck
The player’s luck is a value determined by how much decorative furniture the player has purchased for their ship. The higher a player’s luck, the higher the chance the quota increases more slowly, providing a gameplay incentive to purchasing decorative items. The game calculates the player’s luck value at the end of each quota (to use when calculating the next quota) using the following function.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void CalculateLuckValue()
{
if (this.timesFulfilledQuota == 0)
{
AutoParentToShip[] array = Object.FindObjectsByType<AutoParentToShip>(FindObjectsSortMode.None);
for (int i = 0; i < array.Length; i++)
if (array[i].unlockableID != -1)
this.furniturePlacedAtQuotaStart.Add(array[i].unlockableID);
}
this.luckValue = 0f;
for (int j = 0; j < this.furniturePlacedAtQuotaStart.Count; j++)
{
if (this.furniturePlacedAtQuotaStart[j] > StartOfRound.Instance.unlockablesList.unlockables.Count)
Debug.LogError(string.Format("'Lucky' furniture with id {0} exceeded the unlockables list size; skipping", this.furniturePlacedAtQuotaStart[j]));
this.luckValue = Mathf.Clamp(this.luckValue + StartOfRound.Instance.unlockablesList.unlockables[this.furniturePlacedAtQuotaStart[j]].luckValue, -0.5f, 1f);
}
Debug.Log(string.Format("Luck calculated: {0}", this.luckValue));
}
That is, the game looks at the list
where
Decorative Item | Luck Value |
---|---|
Orange Suit | 0 |
Green Suit | 0 |
Hazard Suit | 0 |
Pajama Suit | 0 |
Cozy Lights | 0.005 |
Teleporter | 0 |
Television | 0.02 |
Cupboard | 0 |
File Cabinet | 0 |
Toilet | 0.01 |
Shower | 0.015 |
Light Switch | 0 |
Record Player | 0.005 |
Table | 0.004 |
Romantic Table | 0.005 |
Bunkbeds | 0 |
Terminal | 0 |
Signal Translator | -0.012 |
Loud Horn | 0.0025 |
Inverse Teleporter | 0.004 |
Jack-O-Lantern | 0.012 |
Welcome Mat | 0.003 |
Goldfish | 0.006 |
Plushie Pajama Man | 0.003 |
Purple Suit | 0 |
Bee Suit | 0 |
Bunny Suit | 0 |
Disco Ball | 0.06 |
From these, we can see that the Disco Ball contributes the most luck (0.06), and the Signal Translator the least (-0.012). Interestingly, the Signal Translator seems to be the only unlockable ship item that contributes negatively to the player’s luck4.
These values, coupled with Equation
or, in terms of
A value of
The Randomiser Curve
In the game’s code, the randomiser curve is a function5 which takes as input a random number between 0 and 1 and outputs another value, in order to “increase the randomness” of the output.
While decompiling the game’s code does not offer a way to directly visualise the shape of the curve, we can hook into the code and output a bunch of values for it so we can plot it using an external graphing tool, like Desmos. Here’s what that looks like when using around 100 points on the curve:
Lethal Company’s randomiser curve sampled at 101 points.
Approximating using Least-Squares Regression
Initially, I noticed that the curve could be approximated by a function of the form
where
Constant | Approximate Value |
---|---|
−0.326786244784 | |
0.115164070585 | |
−3.08270565794 | |
1.57079632679 | |
0.209865018049 | |
0.165967731215 |
The explicit formula given by these coefficients is approximately
The Lethal Company Wiki states that the randomiser curve can be approximated using the function
While this function does approximate the shape of the original curve, it diverges more from its shape than our initial approximation, as visible in the following comparison. Note that the original curve is coloured green, the Wiki’s approximation in blue, and the arctangent approximation in red.
Comparison between Lethal Company’s Randomiser Curve, a Logarithmic approximation, and an Arctangent approximation.
Nevertheless, the simplicity of the wiki’s formula offers a less precise but easier to calculate formula for approximating the randomiser curve.
I’ve set up a Desmos plot where you can play around with this curve. Feel free to play around with the shape of the approximation function!
Explicit Formula using Non-Linear Interpolation
We can do better!
Thanks to my sister Seukari for helping with this part of the post! 🙂 (She’s so smart and knows all about Unity.)
In short, our problem with the randomiser curve is that we have a set of points we can sample but we don’t know the exact shape of the curve. One of the ways we can try finding the shape of the original curve from a set of points is through something called interpolation. In short, interpolation is a way of finding a curve which fits (or more-or-less fits) a set of discrete data points, which is exactly what we want to do! One way to do this is using spline interpolation, where the set of points is approximated by piecewise polynomials, that is, a bunch of polynomials defined for each piece of the curve, which all fit together smoothly to form the entire function. One of the most common methods of spline interpolation is through Bézier curves, which you might’ve heard of.
However, it turns out the randomiser curve is defined using Cubic Hermite splines, and we can figure out that the exact (barring floating-point precision errors) shape of the randomiser curve is given by:
The following comparison shows that this approximation perfectly describes the shape of the randomiser curve observed from the sampled points.
Comparison between Lethal Company’s Randomiser Curve, a Logarithmic approximation, and a Hermite spline interpolation.
This is the exact curve that Unity uses (the coefficients of this formula were computed within Unity itself) to evaluate the randomiser curve at an arbitrary point.
You can play around with this function using this Desmos plot. Additionally, feel free to use the following Mathematica code, which I used to compute the function!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(* AnimationCurve Keyframe Data *)
X = {0, 0.117235, 0.8803625, 1};
Y = {-0.5030289, -0.1301773, 0.1534421, 0.5030365};
Tangents = {7.455404, 0.5548811, 0.5221589, 7.051469};
(* Auxiliary Functions *)
a[k_] := ((X[[k + 1]] - X[[k]])*(Tangents[[k + 1]] + Tangents[[k]]) - 2*(Y[[k + 1]] - Y[[k]])) /
(X[[k + 1]] - X[[k]])^3
b[k_] := (3 *(Y[[k + 1]] - Y[[k]]) - (X[[k + 1]] - X[[k]]) *
(Tangents[[k + 1]] + 2*Tangents[[k]]))/(X[[k + 1]] - X[[k]])^2
(* Coefficient of each Term in the Hermite Spline *)
CoefficientXCubed[k_] := a[k]
CoefficientXSquared[k_] := b[k] - 3*X[[k]]*a[k]
CoefficientX[k_] := Tangents[[k]] - X[[k]]*(b[k] + CoefficientXSquared[k])
FreeCoefficient[k_] := (X[[k]]^2)*(b[k] - a[k]*X[[k]]) - Tangents[[k]]*X[[k]] + Y[[k]]
(* Function Definition *)
R[k_, x_] := CoefficientXCubed[k]*(x^3) + CoefficientXSquared[k]*(x^2) +
CoefficientX[k]*x + FreeCoefficient[k]
RandomiserCurve[x_] := Piecewise[{
{R[1, x], X[[1]] <= x <= X[[2]]},
{R[2, x], X[[2]] < x <= X[[3]]},
{R[3, x], X[[3]] < x <= X[[4]]}
}]
Explicit Formula for the -th Quota
Equation
Turns out this recurrence is somewhat straightforward to solve to obtain a direct, explicit formula for the
- …
All we did was replace
For the less mathematically-inclined but tech-savvy readers out there, that large scary symbol is just a for
loop, and it’s known as the summation operator.
This general formula allows us to calculate the minimum and maximum possible values for each quota. Note that, since
where
Since each quota is an integer value, we can write
where
Probability Distribution of the -th Quota
Equation
What we can do instead is use Equation
so that Equation
where we use
where
Since
which gives
where
This finally allows us to express our recurrence relation for the PDF of
The base case is
While it would be intractable to express a general formula for
which gives
Calculating the Average Quota
Since we have obtained a general formula for
How on Earth do we calculate anything with this monstrosity? Well, remember how I said the expected value operator was linear? Recall that, if we have two random variables
where, since every
Finally, it turns out that the sum gives rise to an explicit formula9, allowing us to write
Almost there! We’re so close to an explicit formula for the average
And that’s it! We have a general formula for approximating the
or, by using the known values for the coefficients,
where
Calculating the Quota Variance
We already have an explicit formula for the average quota, but… how do we know how close the quota will actually be to this value? Can we calculate the standard deviation?
Yes! Let’s do it. We can start by using Equation
Now, while
Since
We already have a formula for
We can then solve the recurrence in Equation
or, once again using the known values for the coefficients,
where
I’ve set up a Desmos plot where you can play around with the formulae for the average quota and the variance.
Total Scrap Required and Average Per Day
There are two more formulas which we can easily obtain from the expected quota value. Let
and
Intuitively, the total scrap value needed to complete any quota is that quota along with the sum of all previous quotas, and we can divide this value by the number of days from the beginning of the game (3 days per quota) to get the daily scrap value needed.
The expected value for these quantities is given directly by the expected value of
where the sum can itself be evaluated to give the general closed formula
From this, we easily obtain
The variance (and therefore the standard deviation) of these variables cannot be easily calculated as for the
Finally, using the explicit values for the coefficients we can write
and
where
General Results
General Formulae
The average value of the
The variance of the
The average total scrap value required to complete the
The average daily scrap value required to complete all quotas up to the
For all these formulae,
and
where
The minimum and maximum values of the
and
where
Quota Tables
The following tables, much like the one in the Lethal Company Wiki, present the average quota values, along with the total scrap value required to collect and how that translates to the average scrap value collected per day. The average scrap per day is how much scrap value you need to collect per day, on average, to meet that quota, taken over the duration of the run! That is, if you wished to meet quota 10, you should collect an average of 369 scrap value per day since the beginning of your run.10
We now present these tables with values related to the first
Neutral Luck ( )
If the player’s luck remains at
First 25 Lethal Company quotas, for a luck value of 0.
Worst Luck ( )
I actually don’t know why these values actually ended up being slightly lower than those for
First 25 Lethal Company quotas, for the lowest possible luck value.
Best Luck ( )
First 25 Lethal Company quotas, for the highest possible luck value.
If you’re curious, I generated the values to fill these tables using the following Mathematica code. Feel free to use it if you want!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
m = 1; (* Randomiser Multiplier *)
s = 1/16; (* Increase Steepness *)
B = 100; (* Base Increase*)
Q1 = 130; (* Starting Quota *)
PlayerLuck = 0.1545; (* Luck is between -0.012 and 0.1545 *)
LuckMultiplier = Abs[PlayerLuck - 1];
(* Probability Distribution of the n-th Quota *)
Quota[1] = ProbabilityDistribution[DiracDelta[x - Q1], {x, -Infinity, +Infinity}];
Quota[n_] := Quota[n] =
TransformedDistribution[
q + B*(1 + s*(n - 1)^2)*(1 + m*RandomiserCurve[LuckMultiplier*u]),
{q \[Distributed] Quota[n - 1], u \[Distributed] UniformDistribution[]}
];
AvgQuota[n_] := AvgQuota[n] = NExpectation[x, x \[Distributed] Quota[n]];
VarQuota[n_] := VarQuota[n] = Variance[Quota[n]];
StdevQuota[n_] := StdevQuota[n] = StandardDeviation[Quota[n]];
AvgTotalRequired[n_] := AvgTotalRequired[n] = Sum[AvgQuota[k], {k, 1, n}];
AvgDailyRequired[n_] := AvgDailyRequired[n] = AvgTotalRequired[n]/(3 n);
(* Minimum value (u = 0) and maximum value (u = 1) for the n-th Quota *)
QuotaBound[n_, u_] := Q1 + B*(1 + m*RandomiserCurve[LuckMultiplier*u])*(n - 1)*(2*s*n^2 - s*n + 6)/6;
QuotaLowerBound[n_] := Ceiling[QuotaBound[n, 0]];
QuotaUpperBound[n_] := Floor[QuotaBound[n, 1]];
NumQuotas = 25;
AveragesTable =
Table[Round /@ {i, AvgQuota[i], QuotaLowerBound[i],
QuotaUpperBound[i], AvgTotalRequired[i],
AvgDailyRequired[i]}, {i, NumQuotas}
];
Grid[
Prepend[
AveragesTable, {"Quota", "Average", "Minimum Possible Value",
"Maximum Possible Value", "Average Total Required",
"Average Daily Required"}],
Frame -> All
];
Insert[%, {Background -> {None, {GrayLevel[0.7], {White}}},
Dividers -> {Black, {2 -> Black}}, Frame -> True,
Spacings -> {2, {2, {0.7}, 2}}}, 2
]
Export["QuotaTable_Luck"<>ToString[PlayerLuck]<>".png", %]
Luck Values
The following table presents the luck value the player gains by acquiring each unlockable item for their ship.
Decorative Item | Luck Value |
---|---|
Orange Suit | 0 |
Green Suit | 0 |
Hazard Suit | 0 |
Pajama Suit | 0 |
Cozy Lights | 0.005 |
Teleporter | 0 |
Television | 0.02 |
Cupboard | 0 |
File Cabinet | 0 |
Toilet | 0.01 |
Shower | 0.015 |
Light Switch | 0 |
Record Player | 0.005 |
Table | 0.004 |
Romantic Table | 0.005 |
Bunkbeds | 0 |
Terminal | 0 |
Signal Translator | -0.012 |
Loud Horn | 0.0025 |
Inverse Teleporter | 0.004 |
Jack-O-Lantern | 0.012 |
Welcome Mat | 0.003 |
Goldfish | 0.006 |
Plushie Pajama Man | 0.003 |
Purple Suit | 0 |
Bee Suit | 0 |
Bunny Suit | 0 |
Disco Ball | 0.06 |
Consequences for Moon-Exclusive High Quota Runs
Using the above results, we can establish some limits on how long a moon-exclusive high quota speedrun can be viable!
Thanks to Sikorski, a fellow member of the Sheff’s Kitchen server on Discord, for helping with these sections! 🙂
The game’s code that needed to be analysed for the following analysis mainly consists of the RoundManager.SpawnScrapInLevel
, RoundManager.GetRandomWeightedIndex
, and TimeOfDay.SetNewProfitQuota
methods. The relevant logic was re-implemented in Python to enable the required custom analysis. The used code spans several files and several hundred lines, and is thus not presented in this article.
Daily Scrap Value per Moon
In the following sections, we cover the amount of scrap value spawned per day on each moon. We additionally argue for a computational approach, as opposed to the analytical approach we have taken so far, due to the prohibitive difficulty of mathematically modelling Lethal Company’s scrap spawning behaviour.
In short, instead of analytically calculating the PDFs related to scrap spawn, we run a large number of simulations so as to sample a large number of values from the probability distribution we wish to describe. Then, we apply Kernel Density Estimation (KDE), which approximates the “true” PDF using other functions, known in this context as kernels. Mathematically, the PDF
where
In our calculations, we use the gaussian_kde method provided by the SciPy Python library, which provides an easy-to-use API for calculating the KDE given a set of samples. Specifically, it uses Gaussian kernels, i.e.
Interior Scrap
In order to properly simulate scrap spawns, we have to take a few things into account. Firstly, each moon has a uniformly random varying total amount of scrap that it spawns between predefined minimum and maximum bounds. In addition to this, each moon has a set of possible items that can spawn with their respective probabilities. Finally, each item itself has a uniformly random value generated between its minimum and maximum scrap values.
Mathematically, the total scrap value
where
- The depedency of
on . We can think of as being a uniform pdf, yet it changes depending on what type of scrap is selected. This, by itself, is not completely impossible to work around, as in principle one could consider a huge piece-wise function where is simply a random number whose value determines from which uniform PDF we are drawing from. However… - The dependency of the upper-bound on the sum is what makes us an analytic approach markedly intractable. Unfortunately, we cannot even expand the sum simply because we do not know if the terms will exist or not.
Furthermore, the following needs to be considered:
- The Factory interior type always guarantees an Apparatus spawn, which is worth a fixed 80 scrap value;
- The Mineshaft interior type guarantees the spawn of an extra 6 scrap items.
Finally, we need to consider the possibility of single scrap events, in which all generated scrap on a moon for the day is replaced with a single type of scrap. The probability that this happens is based on the chosen item’s rarity, and individual scrap value is adjusted based on the total generated scrap value. This further serves to support the argument of a computational (as opposed to an analytical) approach, as the individual scrap value is now dependent upon total scrap value. The following, taken directly from the game’s code, handles the logic for determining whether a single scrap event will occur. In particular, if num3 != -1
, then num3
represents the scrap item which will be used for the event.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int num3 = -1;
if (this.AnomalyRandom.Next(0, 500) <= 25)
{
num3 = this.AnomalyRandom.Next(0, this.currentLevel.spawnableScrap.Count);
bool flag = false;
for (int n = 0; n < 2; n++)
{
if (this.currentLevel.spawnableScrap[num3].rarity >= 5 && !this.currentLevel.spawnableScrap[num3].spawnableItem.twoHanded)
{
flag = true;
break;
}
num3 = this.AnomalyRandom.Next(0, this.currentLevel.spawnableScrap.Count);
}
if (!flag && this.AnomalyRandom.Next(0, 100) < 60)
num3 = -1;
}
The following flowchart illustrates this logic in a more readable format. Note that this logic is only executed with a
Logic for determining if a single scrap event executes.
Beehives and Entities
We now address the possible scrap value that can be gained from entity spawns, e.g. Butler knives and Nutcracker shotguns/shells, and why we consider hives and not these other sources of scrap. The simple answer is just that taking into account the enemy spawns is just too complicated. In this project, we assume that player skill is not a factor; the employees will play with 100% efficiency and their skills will not play a role in the amount of scrap that can be gained.
The way in which entities are determined to spawn is a complicated process that is neatly detailed on this LC wiki page. The main point is that whether or not an entity spawns depends on:
- The number of entities of that type has already spawned (either through the maximum number spawned or the fact that a type of entity is more likely to spawn if other entities of that type have already spawned);
- The number of total entities that have spawned (through the indoor/outdoor/daytime power levels).
The point is that entity spawns are strictly not independent events. This makes including the Butler knives, shotguns, and shells into the scrap value PDF prohibitively difficult.
This is all opposed to beehives which, in the case of both conditions, only ever spawn at the beginning of the day, and are therefore not subjected to the dependence on other entity spawns. Up to 6 hives can spawn with a fixed probability on every moon. For example, on Experimentation, since there is a
In terms of value, beehives can be worth as anywhere from 40 to 150 scrap value (compared with a fixed 35, 60, and 30 for knife, shotgun, and shotgun shells, respectively). Thus, we consider beehives capable of influencing the pace of a run, and therefore count them towards the total scrap value on a moon in our calculations.
Total Scrap Value
Using the previous logic, we re-implement the game’s RoundManager.SpawnScrapInLevel
function, which handles (as the name implies) spawning interior scrap when the player enters a moon. Then, coupled with the logic for simulating beehive spawns, we run
In order to perform these calculations, the game’s code had to be analysed to obtain the spawn chances of beehives and each scrap item on each moon, along with the chance of each interior being used. Curiously, these values are presented in the game’s code for the moon 44 Liquidation, even though it is not currently implemented. For curiosity’s sake, we thus include it in our analysis.
These distributions are illustrated in the following figure.
Distributions of daily scrap value spawned on each moon.
Limits on High-Quota Runs
The following sections assume the following: the player is using a mod which removes the cost of travelling to paid moons; gift boxes are sold as-is, without being opened. These assumptions are required to enable calculations to be performed.
In Lethal Company, you only meet a profit quota if you manage to sell a total scrap value greater than or equal to that quota. This doesn’t necessarily mean you need to find that amount of scrap value on moons in the 3 days of a quota’s duration, as you can accumulate scrap from previous runs on the ship if you don’t oversell.
Let
where
where
To successfuly meet the
where we take the ordering
We thus have the condition
which can be simplified to yield
Intuitively, the total generated scrap value throughout the entirety of a run must be enough to meet the sum total of each quota in the run. A run thus becomes impossible if
Thus, there exists a hard limit for single-moon high quota runs, with the highest achievable quota being the
or, equivalently, using the definition of deterministic dominance,
An explicit definition of this value of
which can be expanded to
We thus obtain the PDFs
Neutral Luck ( )
Probability of not enough scrap spawning to complete the quota, for a luck value of 0.
Worst Luck ( )
Probability of not enough scrap spawning to complete the quota, for the lowest possible luck value.
Best Luck ( )
Probability of not enough scrap spawning to complete the quota, for the highest possible luck value.
Special Case - Theoretical Best Luck ( )
As a special case, we analyse what a run could look like if the player’s luck is
where
This reduces the probability
Performing the same analysis as before but using the new definition for the probability of a quota not being viable gives rise to the results presented in the following figure.
Probability of not enough scrap spawning to complete the quota, for the theoretical highest possible luck value. These values are not obtainable in the vanilla game as of version 65.
Results
The following table presents the average scrap value generated per day, including beehives, on each moon.
Moon | Average Scrap Value Spawned Per Day ( |
---|---|
41 Experimentation | 414 |
220 Assurance | 684 |
56 Vow | 727 |
21 Offense | 789 |
61 March | 711 |
20 Adamance | 843 |
85 Rend | 1251 |
7 Dine | 1292 |
8 Titan | 1609 |
68 Artifice | 1843 |
44 Liquidation | 1864 |
5 Embrion | 718 |
Artifice, boasting the higher average scrap value, is undoubtedly the most profitable moon currently in the game. Interestingly, if Liquidation were to be implemented with its current loot table, it’d be more profitable than Artifice by an average of 21 scrap value.
The following table presents the last viable quota for each moon, that is, the last quota that you’re guaranteed to complete if you play a perfect run, for three different player luck values which can be obtained in the vanilla game, along with the theoretical upper bound for player luck. After this quota, it is not guaranteed that enough scrap spawns to successfully complete the quota.
Moon | Last Quota (-0.012) | Last Quota (0) | Last Quota (0.1545) | Last Quota (1) |
---|---|---|---|---|
41 Experimentation | 9 | 9 | 10 | 13 |
220 Assurance | 12 | 12 | 13 | 17 |
56 Vow | 13 | 13 | 13 | 17 |
21 Offense | 13 | 13 | 14 | 18 |
61 March | 13 | 13 | 13 | 17 |
20 Adamance | 14 | 14 | 14 | 19 |
85 Rend | 16 | 16 | 17 | 22 |
7 Dine | 17 | 17 | 17 | 22 |
8 Titan | 18 | 18 | 19 | 24 |
68 Artifice | 19 | 19 | 20 | 25 |
44 Liquidation | 19 | 19 | 20 | 25 |
5 Embrion | 13 | 13 | 13 | 17 |
From these values, we conclude that luck, as currently implemented in the game, might aid you in reaching a higher quota on some moons. However, in practice, the extra scrap that has to be sold to acquire all the necessary items, along with the randomness of when they would be available in the store, does not make this, in our opinion, a viable strategy for vanilla v65 high-quota runs.
Final Thoughts
In this article (which ended up being much longer than I expected), we derived some mathematical results from Lethal Company’s profit quota system. We found formulae for the average quota and the corresponding variance, along with the average total and daily scrap value required to meet any quota. We also derived some results related to the average hard limit on moon-exclusive high-quota runs.
I hope these results can be of some use to Lethal Company speedrunners, or casual users interested in the game’s inner workings.
And that’s enough Math for today! Thank you for reading this post. ❤️
Footnotes
From Lethal Company on Steam, as of August 15th, 2024. ↩︎
denotes the power set of a set , which is the set of all possible subsets (combinations of elements) of . A set is just a collection of elements. ↩︎The symbol
denotes the set of real numbers, which is basically whatever number you can think of: is a real number, so is , so are and , etc. ↩︎Maybe as a way to balance its usefulness? ↩︎
If you want to look into the technical aspect of it, the randomiser curve uses Unity’s AnimationCurve system. ↩︎
The
value, called the coefficient of determination, gives a measure of how well a function approximates a given set of points. Values closer to 1 mean better approximations. ↩︎Although we could probably prove it using a technique known as mathematical induction. ↩︎
Even Wolfram Mathematica (the paid version!) couldn’t compute anything for
. ↩︎By way of some simple algebra along with what’s known as the formula for the square pyramidal numbers. ↩︎
Keep in mind the average is taken over every day of your run, so you don’t need to collect this amount of scrap value every day. Say, if you collected 100 on Experimentation one day, and 900 on Titan the next day, your average for those two days would be 500. ↩︎
A higher number of simulations would take too long to execute in our systems, as even
took approximately a day to execute in total for the three player luck values. While an effort could be made to optimise our methods and algorithms, we argue that this amount is sufficient for our analysis, as more precision in the calculated values would lead to negligible differences in our overall results. ↩︎