Advancing the RPG system

This guide follows on from the basic RPG levelling system guide, exploring some ideas for expansion of the system.

In this guide, we will look at making levelling up a curved experience like in other games with an RPG system - in that it will start off relatively easy to level, but gradually get harder and harder. We will also look at making changes to increase the difficulty and create mechanic requirements dependant on the player level!

This guide assumes you've implemented levelling and experience as shown in the guide for A basic RPG-style levelling system, or something similar.

Depending on your source port, there may be minor differences in the code.

We touched on altering the EXTRAPOINTS value from it's initial 40,000 to make it easier or harder to level up. For example, changing the value to 10,000 would make it that the player would level up much more frequently.

However, a static number like that may not be what you are after. At current, we have it designed so that when the player hits a level milestone in gamestate.nextextra, it increases the next goal by the value of EXTRAPOINTS, which is static and never changes.

So going by the default values, our experience required to gain levels looks as follows for the first 10 levels:

Level Experience To Next Level Total Experience
1 10,000 10,000
2 10,000 20,000
3 10,000 30,000
4 10,000 40,000
5 10,000 50,000
6 10,000 60,000
7 10,000 70,000
8 10,000 80,000
9 10,000 90,000

Instead, we want it to be easier for the player to level initially, with higher levels taking more points to obtain. So, the gap from levels 8 to 9 would take longer than the gain from say, level 2 to level 3.

To see a visual example of this growth, here is a graph mapping out the experience required per level in the MMORPG game Guild Wars 2:

Guild Wars 2 Level Curve

This is the curve we seek to recreate, somewhat loosely. To do that, we'll start the player requiring as little as 2,000 points to progress to level 2. To do that, we'll simply change the value of EXTRAPOINTS in WL_DEF.H to reflect that.

#define EXTRAPOINTS 2000

If you compile and play now, it will be much easier to level

Level Experience To Next Level Total Experience
1 2,000 2,000
2 2,000 4,000
3 2,000 6,000
4 2,000 8,000
5 2,000 10,000
6 2,000 12,000
7 2,000 14,000
8 2,000 16,000
9 2,000 18,000

It is still consistently linear, always requiring 2,000 points to level up. We need to look at how the engine will calculate the amount of experience needed. We'll want to have it calculate a constantly increasing amount.

We are going to change the level up section of the GivePoints() function in WL_AGENT to be as follows:

    while (gamestate.score >= gamestate.nextextra)
    {
        gamestate.nextextra += EXTRAPOINTS + (100*gamestate.lvl*gamestate.lvl);
        gamestate.lvl++;
    }

To break down what we're doing here, the equation we're using to calculate the experience needed for the next level is:

2000 + (100 × level × level)

So to get to the first level, the game calculates EXTRAPOINTS + (100*0*0). This becomes 2000+(0), so the requirement to reach level 2 is 2000 experience/score. However, to get from level 8 to level 9, the game would calculate EXTRAPOINTS + (100*8*8), which calculates to 8400.

When mapped out on a graph, this equation looks similar to the following:

EXP Graph

The x-axis represents the level you currently are (While we start at level 1 in-game, this is cosmetic and in the code we start at 0), and the y-axis is the experience needed for the next level. As we can see, to level up the first time will require 2,000 experience, with it going up faster and faster the more levels earned.

Level Experience To Next Level Total Experience
1 2,000 2,000
2 2,100 4,100
3 2,400 6,500
4 2,900 9,400
5 3,600 13,000
6 4,500 17,500
7 5,600 23,100
8 6,900 30,000
9 8,400 38,400
10 We've hit level cap!  

Compared to our other table above, it will now require more experience to reach maximum level, but it starts off easier and gets harder.

We're only looking at the first ten levels a player could reach, of course (applying the LEVELCAP from the earlier guide). If you were to increase the level cap, you'd see that graph curve further and further upwards, making leveling a more difficult achievement to balance against the power the player achieves.

Here is the graph for the same equation, with LEVELCAP set to 40:

Level Cap 40 Graph

As you can see it keeps spiking up so by the time you reach level 39, you'd need 162,000 experience to reach the next level! You would naturally need to design your game to compensate for this (balancing the "grind" of the game by giving the player more or better chances to earn experience).

Adjusting the numbers and calculations to achieve the right balance in your game is something outside of the scope of this guide. However, you have the basics and can use this graphic calculator website to generate graphs and test your level curve calculations.

As your player levels up and gets stronger and stronger, enemies will naturally get easier and easier to beat.

Increasing the enemy difficulty dynamically based on the player's level can be a delicate thing to balance, but if pulled off can be used to help maintain the pace of the game.

There's a lot that can be done to increase difficulty this way. We'll be doing three different changes:

  1. Make it so that enemies will deal slightly increased damage depending on the player's current level.
  2. Enemies will be more accurate as the player's level increases.
  3. If the player enters a map when level 5 or higher, all Dog enemies will be replaced with Guard enemies.

Enemy accuracy and damage

Two of our changes will occur in WL_ACT2, in the T_Shoot() function. This function activates when the enemy attempts to shoot a player. It calculates distance from the player and then uses that to work out if the player is hit and for how much. We can see a reference to accuracy in:

if (ob->obclass == ssobj || ob->obclass == bossobj)
            dist = dist*2/3;                                        // ss are better shots

This small snippet of code is used to modify the value determined when calculating distance, to make the SS Enemy and Hans Grosse boss more accurate when shooting (Gretel isn't as good at shooting as her brother!)

We can use this as a base for calculating our own modification. We want all shooting enemies to be slightly more accurate with each level the player attains. So, under the above snippet, we'll add a further modifier:

dist = dist*2/gamestate.lvl+1;

You might want to tweak the values to balance the game (You wouldn't want it to get too hard!), but this is an example of a way to directly affect the accuracy of enemies.

Down further in the same function, we can see where damage is being calculated:

        // see if the shot was a hit

        if (US_RndT()<hitchance)
        {
            if (dist<2)
                damage = US_RndT()>>2;
            else if (dist<4)
                damage = US_RndT()>>3;
            else
                damage = US_RndT()>>4;

            TakeDamage (damage,ob);
        }

A random number is calculated based on how far away the enemy is and assigned to the damage variable, and then that number is being sent to the TakeDamage() function. We'll modify that value before it gets sent:

        // see if the shot was a hit

        if (US_RndT()<hitchance)
        {
            if (dist<2)
                damage = US_RndT()>>2;
            else if (dist<4)
                damage = US_RndT()>>3;
            else
                damage = US_RndT()>>4;

            damage += gamestate.lvl+1;

            TakeDamage (damage,ob);
        }

In that line, we're adding the player's level as a straight damage modifier. So at level 1, we're seeing an extra damage. At level 10, the player will take an extra 10! When taking multiple shots from an SS enemy this could be deadly!

Enemy mutation

Our last change will be enemy mutation - at higher levels, we want to replace the "easy" enemies with harder ones. In our case, the plan is:

  1. While the player is under level 5, enemies will spawn normally.
  2. When the player reaches level 5, Guard enemies will be replaced with Officers.
  3. At max level (Currently 10), Guard enemies will instead all be replaced with Mutants.

To create this change, we'll want to go to the ScanInfoPlane() function in WL_GAME.

When the game loads a level, ScanInfoPlane() runs through all the tiles on the map and depending on the value of that tile, will place an object or enemy.

For example, we can see that tiles of value 19 through 22 will spawn the Player:

                case 19:
                case 20:
                case 21:
                case 22:
                    SpawnPlayer(x,y,NORTH+tile-19);
                    break;

Down further, we can see the groups of values that make up the Guard enemy spawns:

//
// guard
//
                case 180:
                case 181:
                case 182:
                case 183:
                    if (gamestate.difficulty<gd_hard)
                        break;
                    tile -= 36;
                case 144:
                case 145:
                case 146:
                case 147:
                    if (gamestate.difficulty<gd_medium)
                        break;
                    tile -= 36;
                case 108:
                case 109:
                case 110:
                case 111:
                    SpawnStand(en_guard,x,y,tile-108);
                    break;


                case 184:
                case 185:
                case 186:
                case 187:
                    if (gamestate.difficulty<gd_hard)
                        break;
                    tile -= 36;
                case 148:
                case 149:
                case 150:
                case 151:
                    if (gamestate.difficulty<gd_medium)
                        break;
                    tile -= 36;
                case 112:
                case 113:
                case 114:
                case 115:
                    SpawnPatrol(en_guard,x,y,tile-112);
                    break;

These are the tile values for both the standing guard and patroling guard for each difficulty and direction they could face.

We want the game to take into account the level of the player before choosing which enemy to spawn. So, we will make it do a check:

                case 108:
                case 109:
                case 110:
                case 111:
                    if (gamestate.lvl >= 4 && gamestate.lvl < 9) 
                        SpawnStand(en_officer,x,y,tile-108);
                    else if (gamestate.lvl == 9)
                        SpawnStand(en_mutant,x,y,tile-108);
                    else
                        SpawnStand(en_guard,x,y,tile-108);
                    break;

Remember, the in-game level displayed is one greater than what it is in the code (Level 1 is '0', Level 8 is '7'). So what we're doing here is saying:

  1. If the player is a level between 5 and 9, spawn an Officer instead of a guard.
  2. If instead the player's level is equal to 10 (Which in the code is '9', since the first level is '0'), spawn mutants.
  3. If neither of those are true (So, our player is a level below 5), spawn a regular Guard.

You'll want to make similar changes to the Patrol spawns for consistency.

There are many more ways you could scale difficulty - maybe you could have enemy health increase too (Though, you'd want to balance it to ensure things don't get too hard), or with some more changes you could have enemies start firing projectiles!

One element that is often seen in games with experience and player levels, is elements being blocked off unless the player has grown strong enough.

In this part of the guide, we'll look at creating two instances of level requirements:

  1. Creating a map tile that is solid if encountered by the player when they are not at least level 2
  2. Changing the code so that the chaingun can only be picked up if the player is at least level 5

Blocking Tile

For this feature, we'll be utilizing code from this DHW thread by Chris Chokan, but adapting it for our purposes.

We'll be creating a new invisible object that cannot be passed unless the player is at least level 2. The idea is that they will be made to go and seek out more enemies or treasure to level up and be able to pass.

First, we'll create a new #define preparameter in WL_DEF:

//
// tile constants
//

#define ICONARROWS      90
#define PUSHABLETILE    98
#define EXITTILE        99          // at end of castle
#define AREATILE        107         // first of NUMAREAS floor tiles
#define NUMAREAS        37
#define ELEVATORTILE    21
#define AMBUSHTILE      106
#define ALTELEVATORTILE 107

#define BLOCKLEVEL2     1001 // High number to avoid potential conflicts

We create this instead of having to remember a number. BLOCKLEVEL2 represents the tile number that will be placed on the map.

Now, we need to tell the game that the player cannot pass the tile if the object is on it. To do that, we'll go to the TryMove() boolean in WL_AGENT:

/*
===================
=
= TryMove
=
= returns true if move ok
= debug: use pointers to optimize
===================
*/

boolean TryMove (objtype *ob)
{
    int         xl,yl,xh,yh,x,y;
    objtype    *check;
    int32_t     deltax,deltay;

    xl = (ob->x-PLAYERSIZE) >>TILESHIFT;
    yl = (ob->y-PLAYERSIZE) >>TILESHIFT;

    xh = (ob->x+PLAYERSIZE) >>TILESHIFT;
    yh = (ob->y+PLAYERSIZE) >>TILESHIFT;

#define PUSHWALLMINDIST PLAYERSIZE

This is a check the game makes when the player tries to move - it checks that the tile being moved to is unobstructed by walls and objects, and returns true if the space is free.

We want to add an additional check for if a tile contains the BLOCKLEVEL2 value.

boolean TryMove (objtype *ob)
{
    int         xl,yl,xh,yh,x,y;
    objtype    *check;
    int32_t     deltax,deltay;
    
    if (MAPSPOT(ob->x>>TILESHIFT, ob->y>>TILESHIFT, 1) == BLOCKLEVEL2 && gamestate.lvl < 1)
        return false;

    xl = (ob->x-PLAYERSIZE) >>TILESHIFT;
    yl = (ob->y-PLAYERSIZE) >>TILESHIFT;

    xh = (ob->x+PLAYERSIZE) >>TILESHIFT;
    yh = (ob->y+PLAYERSIZE) >>TILESHIFT;

#define PUSHWALLMINDIST PLAYERSIZE

MAPSPOT(x,y,plane) is a very useful tool for modding - it can be used to check or change the values contained in any tile on the map. The and y are calls for the tile co-ordinates, and plane is the map plane on which the value is being checked (By default they are 0 for walls and floors, 1 for objects and enemies).

Our inserted code is checking the tile the player is going to, and if it is our BLOCKLEVEL2 object, it will check the player level and act appropriately.

If you compile and add the tile to your map, it should now work! Of course, you might want to find a way to let the player know that's why they're blocked. If you were to utilize In-Game Messages, you could change our inserted code to read as follows:

    if (MAPSPOT(ob->x>>TILESHIFT, ob->y>>TILESHIFT, 1) == BLOCKLEVEL2 && gamestate.lvl < 1)
    {
        GetMessage("You must be level 2 to pass");
        return false;
    }

Level-locked Chaingun

This is a very simple change, all we need to do is go to the GetBonus() function in WL_AGENT, and locate the section specific to the chaingun:

        case    bo_chaingun:
            SD_PlaySound (GETGATLINGSND);
            facetimes = 38;
            GiveWeapon (wp_chaingun);

            if(viewsize != 21)
                StatusDrawFace (GOTGATLINGPIC);
            facecount = 0;
            break;

Similar to how the First Aid Kit checks for the player's maximum health, we're going to check for the player's level:

        case    bo_chaingun:
            if (gamestate.lvl < 4)               // Remember, '4' in the code is 'Level 5' in-game!
                break;
            SD_PlaySound (GETGATLINGSND);
            facetimes = 38;
            GiveWeapon (wp_chaingun);

            if(viewsize != 21)
                StatusDrawFace (GOTGATLINGPIC);
            facecount = 0;
            break;

So now if the player tries to collect the chaingun when below Level 5, they will be unable to pick it up. Of course, the player might need an indication as to why they can't pick it up. For that, you might want to implement In-Game Messages, then you would change your code thusly:

        case    bo_chaingun:
            if (gamestate.lvl < 4) // Remember, '4' in the code is 'Level 5' in-game!
            {
                GetMessage("You must be level 5 to collect the chaingun!");
                break;
            }
            SD_PlaySound (GETGATLINGSND);
            facetimes = 38;
            GiveWeapon (wp_chaingun);

            if(viewsize != 21)
                StatusDrawFace (GOTGATLINGPIC);
            facecount = 0;
            break;

These are two very minor examples of changes you can make to increase the depth of what your levelling system can affect.