Randomness (enemies, drops, items, etc) getting messy

Started by
4 comments, last by LorenzoGatti 8 years, 5 months ago

Suppose I'm making a top-down 2D shooter kind of game, with a pretty big explorable world. If you think of the Diablo games that would be close enough for this discussion.

I'm very big on randomness. The world is randomly generated, and now I'm working on randomly placing enemies and items. Its quickly turning into a huge mess and I'm looking for advice on how to design the world/enemy/item generators better.

Suppose I have 26 different enemies: enemyA to enemyZ. They are in increasing order of difficulty. For example maybe enemyA is a stupid little rat and enemyZ is a badass dragon. I more or less want to have the easier enemies close to the player start point, and the harder ones further away, obviously so that the player slowly encounters harder enemies as he explores further away. Additionally I plan to tweak specific instances of the enemies for additional randomness. For example, when I spawn a rat, he'll have a random speed between 4 and 7, and random maxHP between 80 and 100, etc etc.

For any given point (tile) in the world, I want a range of possible enemies to be able to spawn. For example, near the player start point, I want enemyA to enemyE to be possible, with most enemies being A, and fewer being B, fewer still being C, and E being quite rare (since E is pretty tough for a player just starting).

Another example: a bit further away from the player start point, I want the possible enemies to be H to Q, with the middle one ( L ) being most frequent, while H and Q would be rare at this location (because H is very easy and Q is very hard for the player at this point in the game).

I hope this makes sense so far.

Basically I'd like some tips on how to structure all this in the code. With a focus on having everything easy to change, because I'm sure I will need to be tweaking things A LOT when I get to the game balancing stage.

What I have now is becoming a maintenance nightmare. For example see the code below.

Note that _difficulty01 is a helper variable that I have pre-calculated for every single tile in the game. Its a double between 0.0 and 1.0 and basically indicates how far the tile is from the player's start location. I figured I could use this to decide what kind of an enemy to create (0 = easy, 1 = hard).


// For each tile in the game,
if (GameMath._random.Next(0, 1000) > 900) // 10% of the time, we should maybe spawn an enemy on this tile
{
   if (GameMath._random.NextDouble() < theTile.__difficulty01 ) // only proceed if difficutly test passes (affects frequency / number of baddies - more difficult = more baddies).                             {
                                // weve decided to add a baddie. what type? difficulty factors in to the type selection 


                                Baddie.TYPE typeToSpawn = Factory.GetRandomBaddieType(theTile.__difficulty01);
                                Baddie theBaddie = GameObjectManager.MakeBaddie(theTile.__Xpixel, theTile.__Ypixel, typeToSpawn, theTile.__difficulty01);
                                GameObjectManager.SpawnBaddie(theTile, theBaddie);
                                
                            }
                        }
                            
                        else if (GameMath._random.Next(0, 10000) > 9550) // if there is no enemy (ie, 90 % of the time) then 4.5% of the time, spawn an item
                        {

                            // add some kind of item. what kind?
                            int randItem = Factory.GetRandomItemType();                            

                            Item theItem = GameObjectManager.MakeItem(theTile.__Xpixel, theTile.__Ypixel, 0, randItem);
                            GameObjectManager.SpawnItem(theTile, theItem);

                        }
                                               
                        
Advertisement

Sounds like you have three problems here, and you've already half-solved one of them:

* Given a location, what approximate difficulty of mob should be spawned?

* Given a difficulty, what specific mob should be spawned?

* Given a mob, and maybe given a difficulty, how many units should be spawned and what should their stats be?

The first you've assigned as the responsibility of some kind of map search, a "minimum distance to home" metric apparently.

The second, you say you'd like to obey a Normal aka Gaussian Distibution curve centered on the difficulty level. The most correct ways to do this are quite complex, but there is a shortcut that's often "good enough". Generate a random number in [0,1) then square it, and then on a coin-flip negate it. This will give you a number in [-1,+1] with a 50% chance of being in the +/-0.25 range, a 70% chance of being in the +/-0.5 range, and an 86% chance of being in the +/-0.75 range. Then simply multiply by the half-width of the difficulty range you want (i.e. on a 26-point scale where you want +/- 4 enemies from the "target" difficulty, 4/26=0.15), and then add the desired target difficulty metric.

That's all just the number crunching that you need to put into your Factory.GetRandomBaddieType(Number) method, though. Your third problem lies inside the GameObjectManager.MakeBaddie(...) method, and I'd need to know more about the way you've created your enemy representations to suggest design patterns for creating them. But the code you've shown us thus far looks like it's well-enough designed... so where the the maintenance nightmare that you're having?

RIP GameDev.net: launched 2 unusably-broken forum engines in as many years, and now has ceased operating as a forum at all, happy to remain naught but an advertising platform with an attached social media presense, headed by a staff who by their own admission have no idea what their userbase wants or expects.Here's to the good times; shame they exist in the past.
Here's how I would do the key part of picking a random monster of approximately the right level.

float random_number_concentrated_around_zero(int k) {
  float result = 999999999.9f;
  
  for (int i = 0; i < k; ++i) {
    float u = -1.0f + 2.0f * uniform_random_distribution();
    if (std::abs(u) < std::abs(result))
      result = u;
  }
  
  return result;
}

int random_monster_level(float target_level, float spread, int num_levels, int k) {
  while (1) {
    float level = target_level + spread * random_number_concentrated_around_zero(k);
    if (level >= 0 && level < num_levels)
      return int(level);
  }
}

The number `k' indicates how concentrated the distribution is towards the target level. I would try k=2 or k=3 as a start. Perhaps `concentration' is a better name for the variable.

Thanks for the feedback guys!

Wyrframe, that was a very small snippet of code that I posted, as an example. The maintenance nightmare is that the real code is 20 times larger than that :)

It feels wrong to be messing directly with the RNG to come up with a probability, for eg GameMath._random.Next(0, 10000) > 9550 to get a 4.5 percent chance of something happening. Not to mention when I decide that it should actually be 5.6 percent chance, I have to muck around with that ugly math. And the worst part is, I have lots of nesting. For example in the snippet I posted there is a 10% chance of an enemy on a tile, otherwise a 4.5% chance of an item. Those 2 probabilities are therefore linked, and that makes it difficult to think about. For example, if I were to reduce the chance of enemy from 10% to 8%, that has a side-effect of increasing the chance of an item. Thats un-intuitive and those 2 things should really not be linked, I'm thinking. I have other parts of the code where the nesting goes 3, 4 even 5 levels deep. So tweaking one small percentage chance can have a huge side-effect on all sorts of other chances.

Honestly, I was half expecting someone to recommend a table-like structure to organize everything, because I've been thinking that might be better. Something like:

ENEMY DIFFICULTY FIRST_APPEARS_AT_DIFFICULTY LAST_APPEARS_AT_DIFFICULTY LOWEST_STARTING_HP HIGHEST_STARTING_HP

enemyA 1 0.0 0.15 1 4

enemyB 2 0.06 0.2 4 10

enemyC 3 0.13 0.34 7 12

I definitely like the shortcut for approximating a normal distribution, I'll definitely be using that where I can.

If you can express your needs in a table, that would be the best option. The more stuff you can move from code logic to data, the better off you'll be in terms of testing/code coverage and maintainability. So try to find the right abstractions so that you can put all this into a table.

Sounds like you have three problems here, and you've already half-solved one of them:

* Given a location, what approximate difficulty of mob should be spawned?

* Given a difficulty, what specific mob should be spawned?

* Given a mob, and maybe given a difficulty, how many units should be spawned and what should their stats be?

All three subproblems beg for explicit scripting, in order to keep the code of the game engine as simple and general as possible.
The same monster creation subsystem should be able to cope with different monster creation policies: player-selected difficulty settings with qualitatively and quantitatively different threats, different levels with specific monster types (e.g. coypu in the city level, squirrels in the forest level), special level layouts with special monster distributions (e.g. a linear level with evenly spaced groups of monsters of appropriately increasing strength, a very open level with a rather uniform distribution of monsters starting far enough from the player to not see him at the beginning).
Level creations should be scripted too (for different level types), and integrated with monster creation and other content types (objectives, exits, etc.). For the sake of argument, let's assume a setup like Doom: the player starts in a fixed place and wins by reaching the exit (which can consist of multiple alternative locations or a large boundary) without being killed.
Monster generation needs to ensure that there are no easy paths (without enough monsters) or unfair setups (e.g. ambushes where many monsters come and retreating is impossible). To help with this objective, every monster should have a maximum distance it can roam away from its spawning point.

As a simple and general technique, you can repeatedly compute shortest paths from the starting point to the exit, with movement costs increased according to the strength of monsters that need to be passed because they are spawned close to the path (within their respective roaming range). If the shortest path costs too little, spawn a monster in a random place along the shortest path, distributed according to that place's designated difficulty level. The shortest/safest path becomes increasingly winding, until at some point eluding all monsters becomes impossible and at a later point the best path is challenging enough to stop adding monsters.

Monster types should be scripted (without separate classes or enumerations in your code, only a general Monster class). For example:


[enemy]
level=6 #how dangerous it is, for evaluating threats
name=rat #likely to be displayed to the player 
spritesheet=rat.png
animation=rat.json
#name, skill, damage, attack delay, weight for random selection purposes
attack=bite;    2+1d2;    1+4d4; 2+1d3;16  
attack=claw;    4+1d4;      1d2;     1d2;5  
hp=79+1d21 #uniform distribution; 60+20d2 would be still 80-100 but with a different average
speed=3+1d4 #4-7
range=5 #for roaming, in "tiles" 
[enemy]
level=30
name=vorpal bunny
spritesheet=vbunny.png
animation=vbunny.json
attack=bite;60+4d20; 20d6+2d120; 3d3;1
#...


A simple parser could load trivially easy data structures from a file containing any number of these definitions.
Then a script for monster distribution at interesting difficulty levels (let's treat difficulties as arbitrary positive values) could reference monster types:


[difficulty]
level=3.2
#weight for random selection purposes, name, number (assuming multiple monsters can be stacked)
5;      rat;    1
4;    mouse;    1d4
#average 5/9 rats and 10/9 mice
[difficulty]
level=3.8
7;    rat;    2d2
5;    rat;    1
2;hamster;    2d2
1   mouse;    1d4
#average 26/15 rats, 2/5 hamsters and 1/6 mice

Linear interpolation can provide a distribution for any unspecified difficulty level; for example, at difficulty 3.6 the distribution can be a mixture of 1/3 the one for difficulty 3.2 and 2/3 the one for difficulty 3.8, like this:

  • 5*1/3 rat; 1
  • 4*1/3 mouse; 1d4
  • 7*2/3 rat; 2d2
  • 5*2/3 rat; 1
  • 2*2/3 hamster; 2d2
  • 1*2/3 mouse; 1d4

No need to consolidate similar entries.




...

Omae Wa Mou Shindeiru

This topic is closed to new replies.

Advertisement