System Balancing - Basic Equations

Table of Contents

Introduction

  1. General Formula for Cost Curves
  2. Linear sequence formulas
  3. Exponential sequence formulas
  4. Flat cost sequence formula
  5. Practical Example - Sinks
  6. Practical Example - Leaderboards
  7. Test equations
  8. Summary

1. Introduction

This series is intended to help you think through how to set up and balance game mechanics and progression. To give you a bit of context, I typically support games (and do not make them) focusing on balance, equation structure, and the implementation of mechanics. So the goal output of my scripts are typically csv files or INSERT commands for SQL tables.

Most of the code examples be presented in hacky Python, although I may reference Google Sheets or Javascript.

If you'd like to try and run elements of the Python code the main packages that I rely on are Pandas, Numpy, and Matplotlib. I'm running Python 3.6ish at the moment.

2. General Formula for Cost Curves

I recommend linear sequences or exponential sequences. They resolve clearly across the three equations that we will typically rely upon to calculate costs for players.

  1. What is the cost of a specific level (100) for a player
  2. What is the total cost of progressing to level 100 for a player (typically more useful for modeling and leaderboards)
  3. What is the cost for advancing from level 100 to level 125. This allows you to save compute and lets players use a slider or "+5"/"max" buttons without loops

That generally means relying on linear and exponential sequences. Those resolve really nicely on all three formula sets.

3. Linear: Cost = a*x + b

#Define Linear Summation Equations
#Linear Summations are where you have Lvl 1 = 1, Lvl 2 = 2, etc.
def sequenceLinearCost(a,b,x):
    return a*x+b
def sequenceLinearSum(a,b,x):
    return ((x)/2)*(a*(x+1)+2*b)
def sequenceLinearDifference(a,b,x,r):
    #x=currentlvl,r=resources,a&b constants
    #Example: Given you have upgraded to lvl 5, how many more upgrades can you buy with 500 gold and the cost curve a*x+b
    xs = sequenceLinearSum(a,b,x);
    qa = a /2
    qb = (2*b+a)/2
    qc = -(xs + r)
    return (-1*qb+(qb**2-4*qa*qc)**0.5)/(2*qa)

Note that the sequenceLinearDifference(a,b,x,r) function is effectively using the Quadratic Formula to come to a solution.


One nice thing to note is that as that as the curve progresses you can roughly assume that "Every time a player increases their sum investment by 300% they will double (see +100%) to the bonus they are receiving. As it is a sequence and not a curve the ratios start below 4.0 and approach it with time. Examples below:

Cost[19]/Cost[9] = 3.66
Cost[39]/Cost[19] = 3.81
Cost[59]/Cost[29] = 3.88
Cost[999]/Cost[499] = 3.99

Part of the reason for the error rate is that a sequence does not have the same cost progression rate as a curve so they don't match 1:1 across all ranges.

4. Exponential: Cost = a*b^x

#Exponential Equations
def sequenceExponentialCost(a,b,x):
    return a*(b**x)
def sequenceExponentialSum(a,b,x):
    return a*(1-b**(x+1))/(1-b)-a
def sequenceExponentialDifference(a,b,x,r):
  return math.log(b**(x+1)-(r*(1-b)/a))/math.log(b)-1

5. Flat: Cost = a
You may never use this one, but just know it's there. I think a use case for this  would be "Players get 1 Soul Coin per day, they can spend it on 1 of 10 options. Each level in that option gives diminishing benefit gains"

# Flat Costs - every level costs the same
# If you have flat cost the bonus will generally be sublinear
def FlatCost(a,x):
    return a
def FlatSum(a,x):
    return a*x
def FlatDifference(a,x,r):
    return a+r/x

Of the above I tend to prefer linear progression. It is easier to manage mentally in that cost is linear and sum cost is quadratic.

6. Practical Example - Resource Sinks

A common use case for the Difference equations will be the above example. A player wants to invest some amount of money in a sink and you want to be able to define how much they can spend without loops.

First let's define the cost curve...

#Define Current Player Variables
BaseCost = 5 #Gold
LevelCost = 10 #Gold
CurrentLevel = 3
CurrentGold  = 45+55+65+75+85

For lazy testing I find it's easiest to design the gold total as a sum of each level cost. In this case the equation is "Cost = 10(Level) + 5" and the starting level is 3 so the next five levels cost 45,55,65,75,85 each.

Now let's define our buttons...

#Define Max Level and then Floor it - this impacts the top selectable value on the bar
MaxLevel = math.floor(sequenceLinearDifference(LevelCost,BaseCost,CurrentLevel,CurrentGold))
#+5 Button: Active/Inactive and cost
Plus5ButtonActive  = (MaxLevel>=5)
#Cost is the difference between the sum cost of the new level and old level
Plus5ButtonCost = sequenceLinearSum(LevelCost,BaseCost,CurrentLevel+5)-sequenceLinearSum(LevelCost,BaseCost,CurrentLevel)
#+10 Button: Active/Inactive and cost
Plus10ButtonActive = (MaxLevel>=10)
Plus10ButtonCost = sequenceLinearSum(LevelCost,BaseCost,CurrentLevel+10)-sequenceLinearSum(LevelCost,BaseCost,CurrentLevel)
#+Max Button: Active/Inactive and cost
MaxLevelButtonActive = (MaxLevel>=1)
MaxLevelButtonCost = sequenceLinearSum(LevelCost,BaseCost,MaxLevel)-sequenceLinearSum(LevelCost,BaseCost,CurrentLevel)
``

MaxLevel will return the max level achievable given the player's current achievement and gold reserves, in this case 8. Note that we floor the function.

The ButtonXActive functions define whether or not the buttons are selectable. The PlusXButtonCost functions define the sum cost to the player should they select that button.

With these equations you can provide a nice UI for players without sacrificing too much compute.

6. Practical Example - Leaderboards

Let's see who has spent the most gold on sinks. For a single sink you can compare using something like current level. However, once you start to compare against multiple sinks it gets a bit tricker. That said, if you apply the sum equations you get totals to rank against quite easily.

PlayerSink1Level = 10
PlayerSink2Level = 20
PlayerSink3Level = 30

#Define Cost Functions
a1 = 10
b1 = 5
a2 = 13
b2 = 15
a3 = 7
b3 = 13

#Linear Totals
TotalSpendLinear = sequenceLinearSum(a1,b1,PlayerSink1Level)+sequenceLinearSum(a2,b2,PlayerSink2Level)+sequenceLinearSum(a3,b3,PlayerSink3Level)

With this method we can look across multiple categories to see who has invested the most. Any situation where you have multiple categories of play where level progression is inconsistent are good use cases for this approach.

7. Test Equations

I typically stress about whether I've written equations right, so in an ideal world I'll have test equations to test with. Below are a sampling of the tests I've written.

#Test Cases
print("Linear Sequence Sum")
print(sequenceLinearSum(2,1,4)) #24
print(sequenceLinearSum(1,2,5)) #25
print(sequenceLinearSum(2,1,5)) #35
print(sequenceLinearSum(1,2,6)) #33

print("Exponential Sequence Sum")
print(sequenceExponentialSum(1,2,1))  #2
print(sequenceExponentialSum(2,2,1))  #4
print(sequenceExponentialSum(1,2,2))  #6
print(sequenceExponentialSum(1,2,3))  #14
print(sequenceExponentialSum(2,2,3))  #28

print("Flat Sums")
print(FlatSum(1,2)) #2
print(FlatSum(16,16)) #256
print(FlatSum(4,4)) #16
print(FlatSum(8,4)) #32

print("Exponential Difference")
print(sequenceExponentialDifference(1,2,0,2))   # 1.0, 2**1 = 2
print(sequenceExponentialDifference(1,2,1,4))   # 2.0, 2**2 = 4
print(sequenceExponentialDifference(1,2,1,12))  # 3.0, 2**2 + 2**3 = 12
print(sequenceExponentialDifference(2,2,1,56))  # 4.0 2*(2**2+2**3+2**4)
print(sequenceExponentialDifference(6,2,1,24))  # 2.0 6*(2**2)
print(sequenceExponentialDifference(1,3,2,108)) # 4.0 3**3+3**4
print(sequenceExponentialDifference(2,3,2,54))  # 3.0 2*3**3

print("Linear Difference")
print(sequenceLinearDifference(2,0,4,10))  # 5.0
print(sequenceLinearDifference(2,0,4,22))  # 6.0
print(sequenceLinearDifference(2,2,4,12))  # 5.0
print(sequenceLinearDifference(4,3,4,23))  # 5.0
print(sequenceLinearDifference(8,0,3,120)) # 6.0

8. Edge Cases - Rounding

One edge case to think about when doing summations and differences is that you may hit some edge cases for rounding. One example of the above is that the equation for sequenceExponentialDifference does not always kick out the target integer - see this example below.

sequenceExponentialDifference(1,3,2,108) = 3.999999999999999

The hacky way to address this is just to multiply, ceiling and then divide the function before returning, perhaps something along the lines of the following:

def sequenceExponentialDifference(a,b,x,r):
  return math.ceil(10000*(math.log(b**(x+1)-(r*(1-b)/a))/math.log(b)-1))/10000

Depending on the libraries available to you there may be a better way to address.

9. Summary

Hopefully this helps serve as a bit of a starter pack on math equations. In future posts we will apply them to create cost curves and start to model out progression.

If there are specific topics you would like covered please let me know. If you have specific questions on your curves I'm happy to take a look.