This is the eighteenth post in the Building a Roguelike in Javascript series. I recommend you start at the beginning unless you've been following along. This part corresponds to part 14 in Trystan's series. All the code for this part can be found at the part 14 tag of the jsrogue repository. At the time of writing, I am still using the d4ea2ab commit for rot.js.

In this post, we're going to make it so that our entities can level up! Currently the only real differentiating factor between our entities is that they have a strength and defense value. We want our entities to be able to grow over time! We'll start out with a basic system in which an entity will gain experience when they kill another entity. Once enough experience is accumulated the entity will level up. In our game, we'll heal an entity when they level up and give them the choice of increasing one of their stats (max health, attack value, defense value, or sight radius). Note that entities other than our hero will also be able to level up!

Demo Link

The results after this post can be seen here.

assets/entitymixins.js

Before we can make our entities level up, we need some helper functions which will allow us to increase our stats such as our health, attack value, defense value, and sight radius. We're going to add these functions to their respective mixins. We're also going to add in a setHp method to the Destructible mixin as it seems I had forgotten to do so.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
Game.EntityMixins.Attacker = {
    // ...
    getAttackValue: function() {
        // ...
    },
    increaseAttackValue: function(value) {
        // If no value was passed, default to 2.
        value = value || 2;
        // Add to the attack value.
        this._attackValue += value;
        Game.sendMessage(this, "You look stronger!");
    },
    // ...
};

// ...
Game.EntityMixins.Destructible = {
    // ...
    setHp: function(hp) {
        this._hp = hp;
    },
    increaseDefenseValue: function(value) {
        // If no value was passed, default to 2.
        value = value || 2;
        // Add to the defense value.
        this._defenseValue += value;
        Game.sendMessage(this, "You look tougher!");
    },
    increaseMaxHp: function(value) {
        // If no value was passed, default to 10.
        value = value || 10;
        // Add to both max HP and HP.
        this._maxHp += value;
        this._hp += value;
        Game.sendMessage(this, "You look healthier!");
    },
    // ...
};

// ...

Game.EntityMixins.Sight = {
    // ...
    increaseSightRadius: function(value) {
        // If no value was passed, default to 1.
        value = value || 1;
        // Add to sight radius.
        this._sightRadius += value;
        Game.sendMessage(this, "You are more aware of your surroundings!");
    },
    // ...
};

The first thing we want to do is create a mixin which will encompass an entity's leveling ability. We will provide a function to give an entity experience points. When an entity's experience points reach a given level threshold, they'll be healed and given a number of stat points which can be allocated in various areas. For now we will describe our leveling thresholds with a simple relationship of (level * level) * 10. There are tons of more complicated leveling schemes which I encourage you to check out online. As the entity gains stat points, they will be able to increase various stats using the helper methods we just defined. We will keep track of which stats can actually be increased based on the mixins that the entity has.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Game.EntityMixins.ExperienceGainer = {
    name: 'ExperienceGainer',
    init: function(template) {
        this._level = template['level'] || 1;
        this._experience = template['experience'] || 0;
        this._statPointsPerLevel = template['statPointsPerLevel'] || 1;
        this._statPoints = 0;
        // Determine what stats can be levelled up.
        this._statOptions = [];
        if (this.hasMixin('Attacker')) {
            this._statOptions.push(['Increase attack value', this.increaseAttackValue]);
        }
        if (this.hasMixin('Destructible')) {
            this._statOptions.push(['Increase defense value', this.increaseDefenseValue]);   
            this._statOptions.push(['Increase max health', this.increaseMaxHp]);
        }
        if (this.hasMixin('Sight')) {
            this._statOptions.push(['Increase sight range', this.increaseSightRadius]);
        }
    },
    getLevel: function() {
        return this._level;
    },
    getExperience: function() {
        return this._experience;
    },
    getNextLevelExperience: function() {
        return (this._level * this._level) * 10;
    },
    getStatPoints: function() {
        return this._statPoints;
    },
    setStatPoints: function(statPoints) {
        this._statPoints = statPoints;
    },
    getStatOptions: function() {
        return this._statOptions;
    },
    giveExperience: function(points) {
        var statPointsGained = 0;
        var levelsGained = 0;
        // Loop until we've allocated all points.
        while (points > 0) {
            // Check if adding in the points will surpass the level threshold.
            if (this._experience + points >= this.getNextLevelExperience()) {
                // Fill our experience till the next threshold.
                var usedPoints = this.getNextLevelExperience() - this._experience;
                points -= usedPoints;
                this._experience += usedPoints;
                // Level up our entity!
                this._level++;
                levelsGained++;
                this._statPoints += this._statPointsPerLevel;
                statPointsGained += this._statPointsPerLevel;
            } else {
                // Simple case - just give the experience.
                this._experience += points;
                points = 0;
            }
        }
        // Check if we gained at least one level.
        if (levelsGained > 0) {
            Game.sendMessage(this, "You advance to level %d.", [this._level]);
            // Heal the entity if possible.
            if (this.hasMixin('Destructible')) {
                this.setHp(this.getMaxHp());
            }
            // TODO: Actually increase stats.
        }
    }
};

There's a couple of things to notice from our code. First we can now define in an entity's template their starting level and experience points as well as th enumber of stat points to gain per level! Secondly, we've left a TODO for where we actually increase the stats. The reason for this is that, for now, we want our non-player entities to randomly increase stats while we want the player to increase his stats via a screen. We'll need to create some mixins for this, but we have to create our screen before we can do that!

assets/screens.js

We want to create a screen that will render all of an entity's stat increasing options and allow the player to increase any of the options until there are no stat points left to allocate.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Game.Screen.gainStatScreen = {
    setup: function(entity) {
        // Must be called before rendering.
        this._entity = entity;
        this._options = entity.getStatOptions();
    },
    render: function(display) {
        var letters = 'abcdefghijklmnopqrstuvwxyz';
        display.drawText(0, 0, 'Choose a stat to increase: ');

        // Iterate through each of our options
        for (var i = 0; i < this._options.length; i++) {
            display.drawText(0, 2 + i, 
                letters.substring(i, i + 1) + ' - ' + this._options[i][0]);
        }

        // Render remaining stat points
        display.drawText(0, 4 + this._options.length,
            "Remaining points: " + this._entity.getStatPoints());   
    },
    handleInput: function(inputType, inputData) {
        if (inputType === 'keydown') {
            // If a letter was pressed, check if it matches to a valid option.
            if (inputData.keyCode >= ROT.VK_A && inputData.keyCode <= ROT.VK_Z) {
                // Check if it maps to a valid item by subtracting 'a' from the character
                // to know what letter of the alphabet we used.
                var index = inputData.keyCode - ROT.VK_A;
                if (this._options[index]) {
                    // Call the stat increasing function
                    this._options[index][1].call(this._entity);
                    // Decrease stat points
                    this._entity.setStatPoints(this._entity.getStatPoints() - 1);
                    // If we have no stat points left, exit the screen, else refresh
                    if (this._entity.getStatPoints() == 0) {
                        Game.Screen.playScreen.setSubScreen(undefined);
                    } else {
                        Game.refresh();
                    }
                }
            }
        }
    }
};

assets/entitymixins.js

Now that our screen has been created, let's create mixins for both of these which will have a onGainLevel function that will handle what to do when a level is gained. These will both have the group name StatGainer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ...
Game.EntityMixins.RandomStatGainer = {
    name: 'RandomStatGainer',
    groupName: 'StatGainer',
    onGainLevel: function() {
        var statOptions = this.getStatOptions();
        // Randomly select a stat option and execute the callback for each
        // stat point.
        while (this.getStatPoints() > 0) {
            // Call the stat increasing function with this as the context.
            statOptions.random()[1].call(this);
            this.setStatPoints(this.getStatPoints() - 1);
        }
    }
};

Game.EntityMixins.PlayerStatGainer = {
    name: 'PlayerStatGainer',
    groupName: 'StatGainer',
    onGainLevel: function() {
        // Setup the gain stat screen and show it.
        Game.Screen.gainStatScreen.setup(this);
        Game.Screen.playScreen.setSubScreen(Game.Screen.gainStatScreen);
    }
};

Now that this mixin is created, let's modify our ExperienceGainer mixin to make use of it. You'll want to replace the TODO with the following bit of code:

1
2
3
if (this.hasMixin('StatGainer')) {
    this.onGainLevel();
}

Finally, we need to actually give our entities experience points! We're going to use a simple formula which takes into consideration all of the victim's stats. This formula will also take into consideration level differences, such as if the killer is a much higher level (few experience points) or much lower level (many experience points). Let's modify our Destructible mixin to give the points.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Game.EntityMixins.Destructible = {
    // ...
    takeDamage: function(attacker, damage) {
        this._hp -= damage;
        // If have 0 or less HP, then remove ourseles from the map
        if (this._hp <= 0) {
            Game.sendMessage(attacker, 'You kill the %s!', [this.getName()]);
            // If the entity is a corpse dropper, try to add a corpse
            if (this.hasMixin(Game.EntityMixins.CorpseDropper)) {
                this.tryDropCorpse();
            }
            this.kill();
            // Give the attacker experience points.
            if (attacker.hasMixin('ExperienceGainer')) {
                var exp = this.getMaxHp() + this.getDefenseValue();
                if (this.hasMixin('Attacker')) {
                    exp += this.getAttackValue();
                }
                // Account for level differences
                if (this.hasMixin('ExperienceGainer')) {
                    exp -= (attacker.getLevel() - this.getLevel()) * 3;
                }
                // Only give experience if more than 0.
                if (exp > 0) {
                    attacker.giveExperience(exp);
                }
            }
        }
    }
};

assets/entities.js

Now that we've created our mixins, let's add them to the appropriate entities.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Game.PlayerTemplate = {
    // ...
    mixins: [Game.EntityMixins.PlayerActor,
             Game.EntityMixins.Attacker, Game.EntityMixins.Destructible,
             Game.EntityMixins.InventoryHolder, Game.EntityMixins.FoodConsumer,
             Game.EntityMixins.Sight, Game.EntityMixins.MessageRecipient,
             Game.EntityMixins.Equipper,
             Game.EntityMixins.ExperienceGainer, Game.EntityMixins.PlayerStatGainer]
};

// ...

Game.EntityRepository.define('fungus', {
    // ...
    mixins: [Game.EntityMixins.FungusActor, Game.EntityMixins.Destructible,
             Game.EntityMixins.ExperienceGainer, Game.EntityMixins.RandomStatGainer]
});

Game.EntityRepository.define('bat', {
    // ...
    mixins: [Game.EntityMixins.TaskActor, 
             Game.EntityMixins.Attacker, Game.EntityMixins.Destructible,
             Game.EntityMixins.CorpseDropper,
             Game.EntityMixins.ExperienceGainer, Game.EntityMixins.RandomStatGainer]
});

Game.EntityRepository.define('newt', {
    // ...
    mixins: [Game.EntityMixins.TaskActor,
             Game.EntityMixins.Attacker, Game.EntityMixins.Destructible,
             Game.EntityMixins.CorpseDropper,
             Game.EntityMixins.ExperienceGainer, Game.EntityMixins.RandomStatGainer]
});

Game.EntityRepository.define('kobold', {
    // ...
    mixins: [Game.EntityMixins.TaskActor, Game.EntityMixins.Sight,
             Game.EntityMixins.Attacker, Game.EntityMixins.Destructible,
             Game.EntityMixins.CorpseDropper,
             Game.EntityMixins.ExperienceGainer, Game.EntityMixins.RandomStatGainer]
});

assets/screens.js

We have some last few tweaks to do to make our leveling system more obvious. First we're going to render our level and experience points to the play screen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Game.Screen.playScreen = {
    // ...
    render: function(display) {
        // ...
        // Render player stats
        var stats = '%c{white}%b{black}';
        stats += vsprintf('HP: %d/%d L: %d XP: %d', 
            [this._player.getHp(), this._player.getMaxHp(),
             this._player.getLevel(), this._player.getExperience()]);
        display.drawText(0, screenHeight, stats);
        // Render hunger state
        var hungerState = this._player.getHungerState();
        display.drawText(screenWidth - hungerState.length, screenHeight, hungerState);
    },
    // ...

assets/map.js

Now our game's experience system is fully functional! But it's a bit boring having every single entity be level 1. Let's make it so that our entities get progressively more experienced as we go down into the cave!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
Game.Map = function(tiles, player) {
    // ...
    // Add random entities and items to each floor.
    for (var z = 0; z < this._depth; z++) {
        // 15 entities per floor
        for (var i = 0; i < 15; i++) {
            var entity = Game.EntityRepository.createRandom();
            // Add a random entity
            this.addEntityAtRandomPosition(entity, z);
            // Level up the entity based on the floor
            if (entity.hasMixin('ExperienceGainer')) {
                for (var level = 0; level < z; level++) {
                    entity.giveExperience(entity.getNextLevelExperience() -
                        entity.getExperience());
                }
            }
        }
        // 15 items per floor
        // ...
}

Conclusion

In this post we introduced an experience and leveling system! Enemies now get tougher as we go down into the cave and it's added a nice difficulty factor to the game. The next post will be a quick sidetrack from the game where we'll introduce a final level with a boss. This will allow us to define an actual winning condition! After that we'll get back on track with Trystan's tutorial and introduce a help, examine, and a look screen!

I hope you enjoyed this post! Remember that all the code for this part can be found at the part 14 tag of the jsrogue repository. As always please feel free to post any comments whether questions, clarifications or criticism!

Thanks for reading,

Dominic

Next Part

Part 15 - Event-Based Entities and a Final Boss