Home Artists Posts Import Register

Content

Warning: This devlog contains a good chunk of math done by someone who has been out of college for 6+ years who was not particularly good or bad at math back then. If you like math, it may end up being dumb. If you don't like math, there's probably still something interesting to be found at least, or maybe you'll hate the math too much to deal. I don't know your life.

So it turns out there are a lot of numbers in this game! I suppose that's true for most video games that aren't platformers, but in this case I am making the a roguelite system from scratch. While some randomness in level generation is expected, too much variation in difficulty between runs would just make things frustrating. There needs to be some method of creating a balanced difficulty curve over the course of a run. While it could be possible to do that manually by going into a long LONG loop of playtesting and adjusting floor generation parameters until they feel correct, there are problems with that. As I've been seeing more and more, my perspective on the difficulty of the game is skewed enough by having created it that it doesn't match the average player. Instead, the much more logical option would be to create some kind of numeric basis for decision making/difficulty curve balance.

So, you know, the dreaded MATH.


The Numbers to Track

The first thing I did when sitting down to try and puzzle such a system out was to figure out what kind of numbers could be tracked to indicate the state of a player's run.

(The above is old, but is a picture of notes that I wrote down at the time. Keep these variables in mind for the rest of this devlog.)

Basically, the most important thing from my perspective is that the player have incentive to decrease R. The fewer threats are remaining on a floor at the end, the more content a player will have seen during that floor, which is ideal. A high R means that the player is prioritizing finishing the game over actually playing it. While there will always be players like that to some extent (hey, I was a speedrunner at one point too), it's less important to cater toward them when considering balance.

Still, even if R is the most important variable to track, that doesn't tell me much. Knowing that doesn't mean I can completely discount T and C, nor does it tell me anything about what I actually need to adjust numerically over the course of a run. There had to be more to consider to figure out a system.

Among the three variables, C was the most obviously easy number to track. It's simple enough to keep count when an ally takes damage, so it made sense to go at the problem from that direction.

Conceivably, a player having a higher C value would indicate that the floor is more difficult than if they have a lower C value. There's some amount of correlation there. Higher difficulty means more corruption. If higher difficulty does NOT cause higher corruption, then it means that either the floor isn't difficult enough still, OR that the player is skipping content (thus increasing R, which is bad).

Basically, the conclusion I cam to was that both variables R and C were related through the idea of threat difficulty. If that difficulty had some way of being represented numerically, then the three of them together should be able to tell what part of the game needs adjusting mid-run. So then, how do I represent difficulty?


Doing Math

Above: (Incorrect) math being done.

My decision was to create a difficulty rating for every threat (monster or trap) that is generated based on their stats. This number should in theory give a general idea of how much corruption the threat is expected to cause (C) while also giving me something to tally at the end of a floor to see how much difficulty was not cleared by the player (R).

So then what should this difficulty rating actually mean, and how do I get it? I wanted it to be based on the threat's stats and indicate if a threat would be expected to cause damage or not, which eventually led me to this:

The lowest integer X such that ceil(H / X) = ceil(X / A)
Where H is the threat's max health and A is the threat's attack 
(also for context, ceil(y) is the ceiling function, or = y rounded upward to the next integer if it isn't already an integer, just in case you don't know)

In this case X is the numeric difficulty rating. It is (assuming a character has equal work stats) the lowest possible work stat required to defeat the threat WITHOUT being hit. 

H/X is the number of turns it takes an ally to decrease the health of the monster to 0. 

X/A is the number of turns until the monster has done more damage than the ally's defensive working stat. 

So looking at an example with various X values where a monster has 60 health and 12 attack:

X     60/X     X/12

30:    2.0     2.5 => Ally defeats monster in 2 turns

25:    2.4     2.1 => Ally defeats monster in 3 turns

24:    2.5     2.0 => Ally is hit on turn 2, defeats on turn 3

19:    3.2    1.6 => Ally is hit on turn 2, defeats on turn 4

You can see where the ceiling function comes into play, as partial turns don't matter. If a monster lands a hit in 2.1 turns, then it really scores a hit in 3 turns. Likewise with ally time to kill. From this, we can see that a threat with 60 Health and 12 Attack would require an ally to have an average working stat of 25 to avoid getting hit.

But how do we change the equation to solve for X? Removing the ceil functions (at least as far as I can tell) makes it messy, and this is where I messed things up in the above picture.

The ceil of some number x can be expressed as: x + θ where 0 ≤ θ < 1

Removing both ceils and solving for x means an equation with 4 variables, two of which are unknown but within ranges. There's probably more math that can be done to condense this down to a proper equation, but I don't know it now, and I certainly didn't know it at the time that I was working this out.

Instead, as you can see in the picture, I erroneously assigned values to those ranges that made some intuitive sense USING THAT TEST DATA LISTED ABOVE, which was dumb. It worked for those numbers, but in some other cases those values of θ and φ will return an incorrect result. It took me a long time to realize that, even though it really shouldn't have. It doesn't always return the correct value for X, though in a lot of cases it does. 

I have since "solved" this equation by brute forcing it instead, looping up through integers from 1 until reaching a number X that satisfies the original problem of ceil(H/X) = ceil(X/A). By virtue of the fact that it's counting up, this will always end up with the lowest X possible.

It works, though it's not ideal. I'd like to come back and figure out how to make it into a proper equation at some point. More for pride than the efficiency being a big deal... But this is a bit of a math tangent.

REGARDLESS of all of that, the end result was having a mathematical quantity to represent any monster's difficulty/the average working stat value required to avoid damage when fighting that monster. This also worked for traps, but not always. 

Because traps have more stats, a given trap may or may not be threatening due to its health and complexity like a monster is threatening due to its health and attack. It may instead be a threat due to being difficult to dodge when first uncovered, or due to having a low rearm timer. Thankfully, these other options are far easier to represent mathematically.

Avoid Difficulty: Trap Accuracy + 1

Rearm Difficulty: floor((3 * Trap Health) / Trap Rearm Time)

Complexity Difficulty: Calculated the same as monster difficulty, but using complexity instead of attack

Wanting difficulty to always mean the same thing, for traps we can take the highest of the three difficulties between these three options. As before, it is the lowest possible working stat line required to avoid damage from this threat.


So now ALL threats are modeled

How to use this information?

Because all threats now had a difficulty rating tied to ally stats, it means that a difficulty curve can now be created that can match with the expected increasing power of the players allies over a run AND relate to those previously mentioned R and C variables. Exactly what I wanted! 

From the start of the game, floor generation has a numeric difficulty rating that it aims for, trying to keep the average difficulty of all of the threats on that floor as close to that difficulty number as it can. If it rolls to generate a threat that has a base difficulty that is too low or high, it can attach modifiers to the threat to try and pull it up/down to where it wants things to go.

This means that the entire floor also has a sum of total difficulty ratings that it can check against when a player finishes the floor. The game can assess what percentage of the total difficulty was cleared, increasing the permanent corruption cost to finish the floor based on what is found. This gives me levers to pull in order to incentivize players to decrease their R value from earlier (although perhaps it isn't made obvious enough that remaining threats is the thing that increases dispel cost yet).

As well, the fact that difficulty rating is ALSO related to the C variable from earlier means it can be used to manage that part of the difficulty curve as well! Remember that the difficulty rating is based on what working stat an ally needs to avoid damage assuming all of their working stats are the same. Ally stat spreads are varied. They're good at some things and not at others. As well, their stats are also always going up and down due to negative stats, resting, and skills. An appropriately scaled threat may or may not ACTUALLY cause damage. 

That's vague, but surprisingly it's still fine.

Remember that this is about creating a difficulty curve over the course of a run. The player's actual performance may be above or below the difficulty curve at any given point, and variation is fine to some degree. It's a roguelite. Randomness is expected. 

My decision from here was to assume that a perfectly balanced threat (as in a threat whose difficulty exactly matches the average working stats of the character) would cause .5 corruption damage. This number is somewhat arbitrary and could be moved up or down in the future, but having it as a number is helpful. It means that an expected value for the C variable can be found and compared against. If a player's corruption from the floor is notably lower than the expected value, the difficulty of the next floor can be increased more in order to keep them from getting too far off the intended curve. Conversely, if they take way MORE corruption damage than expected, the difficulty for the next floor can be increased less in order to hopefully avoid making things TOO hopeless. 

Essentially, dynamic difficulty that can react to the player's performance has been achieved through math and (to varying degrees) it WORKS.

See? Randomness. Not a HUGE amount, but some. (This is debug output from generating the same floor repeatedly, by the way.)

So I guess to sum things up, my point here is that I have put a lot of thought and work into balancing the game. While it's still not perfect for a lot of reasons, there is logic behind it. As I continue to refine it more and more (and possibly even uncover more math that turns out incorrect), I hope that people will come to trust the process more and more in turn.

Of course, there's still a lot more to talk about. This devlog didn't even bring up the T variable at all! There's a LOT of stuff that could be talked about. For now though, I should probably leave it here and get back to work. Let me know if this is the kind of thing you would find interesting hearing about more, and as always thank you for your support!

Comments

No comments found for this post.