A basic RPG-style levelling system

Tested On

Many games from all genres take inspiration from RPGs in the form of a "level-up" system. This system typically rewards the player "experience points" for tasks, and gives the player a "level" when a milestone is hit. Each level will generally make the player stronger in some way.

For this guide, we'll be introducing such a system to Wolf3D. By the end you will have a functional but basic system for levelling, where kills will reward experience, and levels will give the player a higher maximum health.

You will:

  • Add variables the Gamestate Structure, which holds the majority of data pertaining to the player (Like health and ammo)
  • Create a system where the player levels up when they reach a certain amount of score.
  • Change weapon damage behaviour based on this new system.
  • Create a display of the player's current level and amount of experience.

Depending on the particular engine you're working with (DOS Wolf3D, Wolf4SDL) you may find minor differences between the code snippets and your own source code. Do not just copy and paste the entire snippets!

When creating something with multiple moving parts, it helps to plan out what we are going to do.

First, what will we need in an experience and levelling system? We can look to other games that include their own versions:

  • "Experience points" and "player levels"
  • Players earn experience points for performing actions in the game.
  • The player gains a "level" when they obtain enough experience points.
  • A reward for reaching a new level. For this guide, we'll be settling on it increasing a player's maximum health.

These are the basic elements of our system. We can then expand on those points, thinking about them in terms of the engine and source code:

  • We need variables to track points and levels.
  • We will need functions to increase the player's points total and level when applicable.
  • We will need to change the code so that the player's level has an effect on health.

Once you have a general idea of what you will need to do, you'll have an idea of where you need to look in the code to bring your ideas and concepts to life.

The first thing we need to do is create new variables to store the information about the player's level and experience. We'll look in WL_DEF, locating the "Gamestate" structure within it.

Depending on what version of Wolf3D you're working with (DOS, or a fork of Wolf4SDL), it will look similar to:

//---------------
//
// gamestate structure
//
//---------------

typedef struct
{
    short       difficulty;
    short       mapon;
    int32_t     oldscore,score,nextextra;
    short       lives;
    short       health;
    short       ammo;
    short       keys;
    weapontype  bestweapon,weapon,chosenweapon;

    short       faceframe;
    short       attackframe,attackcount,weaponframe;

    short       episode,secretcount,treasurecount,killcount,
                secrettotal,treasuretotal,killtotal;
    int32_t     TimeCount;
    int32_t     killx,killy;
    boolean     victoryflag;            // set during victory animations
} gametype;

This structure creates most of the elements that will hold information about the player. Things like health, score, and weapons are all stored in variables within.

For our system, we're going to add two new variables for player levels. We won't need to worry about adding one for experience yet.

//---------------
//
// gamestate structure
//
//---------------

typedef struct
{
    short       difficulty;
    short       mapon;
    int32_t     oldscore,score,nextextra;
    short       lives;
    short       health;
    short       ammo;
    short       keys;
    weapontype  bestweapon,weapon,chosenweapon;

    byte        lvl, oldlvl;       // The Player Level variables

By placing our variables in the Gamestate structure, we ensure that it will be correctly saved and loaded (as well as reset when starting a new game!) properly. This is because the engine is already programmed to do so.

Now that we have the variables, we need to tell the game how to track it.

As mentioned in the previous step, the game already handles many of the things needed to happen with variables in the Gamestate Structure. Saving, loading, resetting on death, are all done automatically.

However, we will need to tweak some of these things slightly to have the player's level tracked properly. We'll do this before we get into any of the juicier stuff.

First, we'll look at the NewGame function, in WL_MAIN, which should look something like:

/*
=====================
=
= NewGame
=
= Set up new game to start from the beginning
=
=====================
*/

void NewGame (int difficulty,int episode)
{
    memset (&gamestate,0,sizeof(gamestate));
    gamestate.difficulty = difficulty;
    gamestate.weapon = gamestate.bestweapon
            = gamestate.chosenweapon = wp_pistol;
    gamestate.health = 100;
    gamestate.ammo = STARTAMMO;
    gamestate.lives = 3;
    gamestate.nextextra = EXTRAPOINTS;
    gamestate.episode=episode;

    startgame = true;
}

This function is a relatively simple one. The first line is the most powerful - it takes all the variables stored in the Gamestate Structure, and sets them to 0, to start fresh.
Once that has executed, the NewGame() function then goes through and gives values to any variables that need them. After all, the player will have a harder time if they start with 0 ammo, 0 lives and 0 health!

Because of how we will factor rewards and display things later, we will actually want our level values being set to 0. So, since that first line (memset) makes every variable in the Gamestate structure equal to 0, we won't need to change anything here!

If you find it strange that we have two variables for the player's level, don't worry; the next step explains why.

Now, depending on how your levelling system will work, we'll want to look at what happens when the player dies. In WL_GAME, you'll find the function Died() towards the bottom. We'll want to look at the end of this chunk of code.

    gamestate.lives--;

    if (gamestate.lives > -1)
    {
        gamestate.health = 100;
        gamestate.weapon = gamestate.bestweapon
            = gamestate.chosenweapon = wp_pistol;
        gamestate.ammo = STARTAMMO;
        gamestate.keys = 0;
        pwallstate = pwallpos = 0;
        gamestate.attackframe = gamestate.attackcount =
            gamestate.weaponframe = 0;

        if(viewsize != 21)
        {
            DrawKeys ();
            DrawWeapon ();
            DrawAmmo ();
            DrawHealth ();
            DrawFace ();
            DrawLives ();
        }
    }

This snippet of Died() removes a life from the player, and if the player hasn't run out (gamestate.lives > -1), then it will start the player at the beginning of the map with default stats. The player's health is reset at 100, they lose all their weapons except the pistol, and so on.

Since our level variable is a new addition, the function doesn't know to deal with it yet. As such at the moment if the player dies, they will stay at the level they were when they died.
For our system, we'll be resetting the player's level to what it was at the end of the last level. This is where our "oldlvl" variable comes in.

We'll insert a line that will set the current player level to the value of "oldlvl", upon death.

if (gamestate.lives > -1)
    {
        gamestate.health = 100;
        gamestate.weapon = gamestate.bestweapon
            = gamestate.chosenweapon = wp_pistol;
        gamestate.ammo = STARTAMMO;
        gamestate.keys = 0;

        gamestate.lvl = gamestate.oldlvl; // Our new line of code

        pwallstate = pwallpos = 0;

The keen-eyed modder may note that currently gamestate.oldlvl would reset the player level to "1" ('0' in the source code) at the start of a new life, which isn't quite what we have in mind. If a player were on one of the last levels of a game/episode, it would be quite a shock to drop all the way back to how the character began!

We'll want to go a little further down WL_GAME to the GameLoop() function. Within this crucial function we'll want to locate the code that deals with finishing a floor.

        switch (playstate)
        {
            case ex_completed:
            case ex_secretlevel:
                if(viewsize == 21) DrawPlayScreen();
                gamestate.keys = 0;
                DrawKeys ();
                VW_FadeOut ();

                ClearMemory ();

                LevelCompleted ();              // do the intermission
                if(viewsize == 21) DrawPlayScreen();

ex_completed and ex_secretlevel happen when an elevator at the end of a floor is triggered. In these lines, floor-specific variables (specifically the keys the player collected) are reset and the screen that displays stats (Par time, Kills/Treasure/Secrets) is shown.

This is a perfect spot for the game to check the player's level, and store it.

        switch (playstate)
        {
            case ex_completed:
            case ex_secretlevel:
                if(viewsize == 21) DrawPlayScreen();
                gamestate.keys = 0;

                gamestate.oldlvl = gamestate.lvl;

                DrawKeys ();

Now, the game will remember what level the player was on at the end of a floor, and as such, the beginning of the next.

What does this mean? Well, let's say a player starts a floor while at level 4. During the course of the floor they level twice to 6, but die. When the level restarts, the game will backtrack the player's level to what it was at the start of the floor (gamestate.oldlvl), which would be 4.

So far we've created the foundations for the player's level progression system, but currently it will stay at '0'. How does the player increase their level?

To solve that question, we're going to adapt the pre-existing highscore system!

First, let's have a look at score. The functions for it exist in WL_AGENT:

/*
===============
=
= DrawScore
=
===============
*/

void DrawScore(void)
{
    if (viewsize == 21 && ingame)
        return;
    LatchNumber(6, 16, 6, gamestate.score);
}

/*
===============
=
= GivePoints
=
===============
*/

void GivePoints(int32_t points)
{
    gamestate.score += points;
    while (gamestate.score >= gamestate.nextextra)
    {
        gamestate.nextextra += EXTRAPOINTS;
        GiveExtraMan();
    }
    DrawScore();
}

DrawScore() will draw the value of the player's current score on the statusbar, and GivePoints() is the function that rewards the player with score.

An example of the use of GivePoints() can be found in the KillActor() function inside WL_STATE, which triggers when an enemy dies:

    case guardobj:
        GivePoints(100);
        NewState(ob, &s_grddie1);
        PlaceItemType(bo_clip2, tilex, tiley);
        break;

The above snippet is the code that initiates when a Brown Guard dies - The player is rewarded with 100 points, the enemy enters a fully dead state, and drops a small ammo clip.

Returning ourselves to the GivePoints() function, the game adds the amount specified (In the KillActor() snippet, this specified value is '100') to the player's score variable (gamestate.score). It then goes on to check the player's current score and if it has reached a milestone (stored in the 'gamestate.nextextra' variable), then it will reward a life and increase the value of gamestate.nextextra to the next milestone.

This behaviour mimicks everything we need for a level/experience system! Score can easily be treated as experience, and the 'gamestate.nextextra' milestone can be the requirement to level up!

All we need to do to get this working is change a single line in the function:

/*
===============
=
= GivePoints
=
===============
*/

void GivePoints(int32_t points)
{
    gamestate.score += points;
    while (gamestate.score >= gamestate.nextextra)
    {
        gamestate.nextextra += EXTRAPOINTS;
        gamestate.lvl++;                        // Our changed line
    }
    DrawScore();
}

Typically, the game awards an extra life when the player hits the milestone contained in 'gamestate.nextextra'. Now instead, the player's level will increase by one on that milestone.

How does the milestone work? Well, in the NewGame() function we visited earlier, we can see the line that sets the initial value:

gamestate.nextextra = EXTRAPOINTS;

But what is EXTRAPOINTS? We can find it in WL_DEF, under 'GLOBAL CONSTANTS':

#define EXTRAPOINTS 40000

This is a "define preprocessor", and basically works as a text-substitution for the value. This line makes it so if you reference 'EXTRAPOINTS' elsewhere in the source code, the game will replace that with a value of 40,000..

So the way we have set it up so far, the player will level up for every 40,000 points they score. However, that might be a bit much as it could take a while for the player to level up. For this guide, we'll make it 10,000.

#define EXTRAPOINTS 10000

For your personal game, you'll want to finetune this amount to match what you need in your game.

Right now the player gains score for their actions in the game and when they get 10,000 worth their level goes up. But right now, the player's level really means nothing in terms of the game besides a counter for the number of times they hit that 10,000 mark.

The rewards that are given to your player will depend on the game you want to create, but could include things like weapon upgrades or character stats.

For this guide, levelling will result in the player's maximum health capacity increasing by 10.

To do this, we have to locate each place in the code that the game checks the player's health, and alter it so that the game doesn't simply assume that the maximum is 100.

First and most importantly, we look at the HealSelf() function in WL_AGENT:

/*
===============
=
= HealSelf
=
===============
*/

void    HealSelf (int points)
{
    gamestate.health += points;
    if (gamestate.health>100)
        gamestate.health = 100;

    DrawHealth ();
    gotgatgun = 0;    // JR
    DrawFace ();
}

This is the function called in all instances that the player is healed. It heals the player according to the specified amount of "points", then checks if that puts the player above 100 health. If it does, it sets the player's health to that 100. Then, it updates the health displays on the statusbar.

To do that, we'll make two changes to HealSelf():

/*
===============
=
= HealSelf
=
===============
*/

void    HealSelf (int points)
{
    gamestate.health += points;
    if (gamestate.health > 100 + (gamestate.lvl*10))
        gamestate.health = 100 + (gamestate.lvl*10);

    DrawHealth ();
    gotgatgun = 0;    // JR
    DrawFace ();
}

With the above change, the function now takes into account the player level in it's check. It will check the level of the player, and multiply it by 10 then add it onto the 100. So when the player is level 5, they will get an extra 50 health!

Now you'll want to go to the GetBonus() function further down inside WL_AGENT, and change all the health items to account for this too. By default, they cannot be picked up if the player is already at 100 health. For example, this is the snippet for the first aid kit:

    case    bo_firstaid:
        if (gamestate.health == 100)
            return;

        SD_PlaySound (HEALTH2SND);
        HealSelf (25);
        break;

We'll simply want to follow suit with our changes in HealSelf(), and make it add the same math.

    case    bo_firstaid:
        if (gamestate.health == 100 + (gamestate.lvl*10))
            return;

        SD_PlaySound (HEALTH2SND);
        HealSelf (25);
        break;

You'd want to do that with the other health-related items too (Like the dog food item).

Depending on your game, the player may be able to get quite a high score and as such obtain levels higher than what you intend them to reach.

To fix this we're going to add a level cap, so once the player hits level 10, they can't go higher.

First, we're going to follow the original game's example with EXTRAPOINTS (Detailed above), and add our own "preprocessor variable" to WL_DEF. This can go anywhere in the file, but for organization's sake we'll put it in 'GLOBAL CONSTANTS' alongside EXTRAPOINTS:

#define NUMBERCHARS 9

//----------------

#define EXTRAPOINTS 40000

#define LEVELCAP 10 // Our added line.

#define RUNSPEED 6000

Now, we want to tell the game that once level 10 (Reminder: In the code, gamestate.lvl would actually be equal to '9' but show to the player as 10) is reached, it is not to go any higher even if the player hits a milestone.

Back in the GivePoints() function of WL_AGENT, we can see that the game checks for whether the player's score has hit a milestone, and if it does, the level-up occurs. We'll want to extend this to include checking for the level cap.

/*
===============
=
= GivePoints
=
===============
*/

void GivePoints(int32_t points)
{
    gamestate.score += points;
    while (gamestate.score >= gamestate.nextextra && gamestate.lvl < LEVELCAP)
    {
        gamestate.nextextra += EXTRAPOINTS;
        gamestate.lvl++;
    }
    DrawScore();
}

In a conditional statement like a 'while' or 'if' statement, the use of '&&' is a way to say "AND", so it checks for both conditions. When GivePoints() checks to see if a player has hit a levelling milestone, it will also check that gamestate.lvl is less than our set LEVELCAP!

We now have almost everything we need for our system. Once the player earns enough points they will level up, and their weapon will fire faster.

However, the player has no way of knowing what level they are, unless they look at their score and calculate accordingly (Score divided by 10,000). To fix this, we're going to change the statusbar to display the player's level, instead of what floor they are on.

You can learn more about altering and rearranging the statusbar in it's associated guide.

In WL_AGENT, we'll want to go to the conveniently named DrawLevel() function:

/*
===============
=
= DrawLevel
=
===============
*/

void DrawLevel(void)
{
    if (viewsize == 21 && ingame)
        return;
#ifdef SPEAR
    if (gamestate.mapon == 20)
        LatchNumber(2, 16, 2, 18);
    else
#endif
        LatchNumber(2, 16, 2, gamestate.mapon + 1);
}

This function simply tells the game to display the number of the current map (The '+ 1' is because in the code, the first entry is considered '0'). We can ignore or even remove the information within the #ifdef SPEAR and #endif, because the enclosed code only applies when compiling the code for Spear of Destiny.

DrawLevel() utilizes LatchNumber() to display this information, and all we have to do is tell it to display our level variable:

/*
===============
=
= DrawLevel
=
===============
*/

void DrawLevel(void)
{
    if (viewsize == 21 && ingame)
        return;
#ifdef SPEAR
    if (gamestate.mapon == 20)
        LatchNumber(2, 16, 2, 18);
    else
#endif
    LatchNumber(2, 16, 2, gamestate.lvl + 1);
}

It's that simple. We keep the '+ 1' since gamestate.lvl starts at 0, but we want the player to see it as "level 1".

Now, the functions like DrawLevel() that draw elements on the statusbar are called at the times those displays would need to be updated.
For example, you can see in the GivePoints() function:

/*
===============
=
= GivePoints
=
===============
*/

void GivePoints(int32_t points)
{
    gamestate.score += points;
    while (gamestate.score >= gamestate.nextextra && gamestate.lvl < LEVELCAP)
    {
        gamestate.nextextra += EXTRAPOINTS;
        gamestate.lvl++;
    }
    DrawScore();
}

At the end of the function, there's a call for DrawScore(). That's because after increasing the player's score, the display will still show the score before the points were given. Calling the function causes it to refresh and update.

Because we're reusing DrawLevel(), many of those calls have been made for us such as at the start of a new level. However, we'll need to update the display when the player actually levels up.
For that, it's back to GivePoints() for one final change:

/*
===============
=
= GivePoints
=
===============
*/

void GivePoints(int32_t points)
{
    gamestate.score += points;
    while (gamestate.score >= gamestate.nextextra && gamestate.lvl < LEVELCAP)
    {
        gamestate.nextextra += EXTRAPOINTS;
        gamestate.lvl++;
        DrawLevel();
    }
    DrawScore();
}

With this simple change, the player's level will display correctly after it increases!

If you've followed everything correctly your source should compile, and you will have a functioning experience and level system, complete with a reward for levelling up!
Below you can see a screenshot of it in action; on the original game's Episode 1 Map 1, the player reached over 10,000 points, their level went up to 2, and they were able to have up to 110% health!

The compiled code

Some considerations:

  • The statusbar artwork in the original game uses the word "FLOOR" where the level is displayed. You might need to make a new statusbar image to help the player make sense of what the numbers represent.
  • Currently there is no sound triggered when the player achieves a new level. It might be worth adding a satisfying sound for when it happens to alert the player.
  • Each level takes 10,000 points to reach, but a static level system might not be what you want. Could you make it that the transition to Level 2 only takes 5,000 points, but further levels will take 10,000?
  • The reward of a maximum health increase isn't the only thing you could give the player! You could try adapting it to work with ammo, or make the chaingun unlock after the player reaches level 10.
    • As a further note, if ammo were increased to go over 99, it will display weirdly on the statusbar when trying to display any number with 3 digits. To fix this, you will want to make the following change to the DrawAmmo() function in WL_AGENT:
       
      void DrawAmmo(void)
      {
          if (viewsize == 21 && ingame)
              return;
          LatchNumber(27, 16, 3, gamestate.ammo);
      }

      This small change tells the game to display up to 3 digits (Numbers up to 999) for ammo. Changing the statusbar is covered more thoroughly in it's associated guide.