@reldens/skills Level Progression

Guide to levels, experience and class paths in @reldens/skills: Level, LevelsSet, ClassPath and SkillsServer, auto-filled levels, level modifiers, skill unlocks, labels and client synchronization.

Architecture

  • Level - a single level with its required experience and modifiers.
  • LevelsSet - a collection of levels with experience tracking and level up / down.
  • ClassPath - extends LevelsSet with skills by level and labels by level.
  • SkillsServer - wraps a ClassPath and synchronizes it with the client.

Level

constructor(props)
{
    if(!sc.hasOwn(props, 'key') || isNaN(props.key)){
        Logger.critical('Invalid Level key.');
        return false;
    }
    this.key = parseInt(props.key);
    this.modifiers = props.modifiers;
    this.label = sc.get(props, 'label', props.key);
    this.requiredExperience = sc.get(props, 'requiredExperience', sc.get(props, 'required_experience', 0));
}
  • key - the level number (numeric).
  • label - defaults to the key; useful for messages like "you reached level X".
  • requiredExperience - the total experience needed to reach the level (cumulative, not the delta from the previous level).
  • modifiers - array of @reldens/modifiers Modifier instances applied to the owner when the level is reached; pass an empty array when the level has none.
const { Level } = require('@reldens/skills');
const { Modifier, ModifierConst } = require('@reldens/modifiers');

let level5 = new Level({
    key: 5,
    label: 'Expert Warrior',
    requiredExperience: 1000,
    modifiers: [
        new Modifier({key: 'level-5-hp', propertyKey: 'stats/maxHp', operation: ModifierConst.OPS.INC, value: 50}),
        new Modifier({key: 'level-5-atk', propertyKey: 'stats/atk', operation: ModifierConst.OPS.INC, value: 10})
    ]
});

LevelsSet

Manages the levels, the experience, the automatic level up and the level modifiers.

Initialization

const { LevelsSet, Level } = require('@reldens/skills');

let levelsSet = new LevelsSet({owner: player});
await levelsSet.init({
    levels: {
        1: new Level({key: 1, requiredExperience: 0, modifiers: []}),
        2: new Level({key: 2, requiredExperience: 100, modifiers: []}),
        3: new Level({key: 3, requiredExperience: 300, modifiers: []})
    },
    currentLevel: 1,
    currentExp: 0
});

Init props:

  • levels (required) - Level instances indexed by key.
  • currentLevel (default 0) - must be one of the level keys for the automatic level up to work.
  • currentExp (default 0).
  • autoFillRanges (default false), autoFillExperienceMultiplier (default 1.5).
  • increaseLevelsWithExperience (default true) - level up automatically when the experience reaches the next level.
  • levelsByExperience - ordered level keys (default: keys sorted by level key).
  • setRequiredExperienceLimit (default false) - cap the experience at the max level requirement.

Initialization flow:

  1. Validates the owner (with getPosition()) and the levels; logs a critical error and returns false if they are missing.
  2. Fires INIT_LEVEL_SET_START.
  3. Reads the configuration props.
  4. Sets the levels (auto-filling the ranges when enabled) and fires SET_LEVELS.
  5. Sets increaseLevelsWithExperience and levelsByExperience.
  6. Fires INIT_LEVEL_SET_END.

The initialization does not apply the current level modifiers; level modifiers are applied when a level is reached through levelUp().

Auto-fill level ranges

With autoFillRanges, the missing levels between two defined levels are generated:

  • Each generated level requires round(previousLevel.requiredExperience * autoFillExperienceMultiplier).
  • Each generated level reuses the modifiers of the lower defined level, so they are applied again on every generated level up.
  • The label defaults to the level number.
  • GENERATED_LEVELS is fired for each generated level.
  • Defined levels are never modified.
// defined: 1 (100 exp) and 5 (1000 exp), multiplier 1.5
// generated: 2 (150), 3 (225), 4 (338)
await levelsSet.init({
    levels: {
        1: new Level({key: 1, requiredExperience: 100, modifiers: []}),
        5: new Level({key: 5, requiredExperience: 1000, modifiers: []})
    },
    currentLevel: 1,
    autoFillRanges: true
});

Since the generated values multiply the previous requirement, a defined level with 0 required experience generates levels that also require 0 and are reached immediately.

Adding experience

await levelsSet.addExperience(500);
console.log(levelsSet.currentLevel, levelsSet.currentExp);
  1. Calculates the new total: currentExp + number.
  2. When increaseLevelsWithExperience is enabled and the total reaches the next level, walks the levels in levelsByExperience order and calls levelUp() for each level reached, stopping at the first level not reached (its requirement becomes the next level experience).
  3. With setRequiredExperienceLimit, at the max level the total is capped to the current level requirement.
  4. Sets currentExp and fires LEVEL_EXPERIENCE_ADDED with (levelsSet, number, newTotalExp, currentLevelIndex, nextLevelIndex, nextLevelKey, nextLevel, nextLevelExp, isLevelUp).

The method does not return the total; read currentExp.

// levels: 1 (0), 2 (100), 3 (300); current level 1
await levelsSet.addExperience(250);
// currentLevel = 2, currentExp = 250 (level 3 needs 300)
await levelsSet.addExperience(100);
// currentLevel = 3, currentExp = 350

Next level experience

getNextLevelExperience() returns the requirement of the first level not reached yet. At the max level it returns the current level requirement.

let remaining = levelsSet.getNextLevelExperience() - levelsSet.currentExp;

Level up and level down

await levelsSet.levelUp():

  1. Returns false when the current level is already the last level.
  2. Increments currentLevel by one.
  3. Applies the modifiers of the new level.
  4. Fires LEVEL_UP.

await levelsSet.levelDown():

  1. Returns false at level 1 or lower.
  2. Reverts the modifiers of the current level.
  3. Decrements currentLevel by one.
  4. Fires LEVEL_DOWN.

Use cases for level down: death penalties, level drain effects, prestige systems.

Level modifiers

applyLevelModifiers(revert) fires LEVEL_APPLY_MODIFIERS with (levelsSet, levelInstance) and then applies (or reverts) every modifier of the current level on the owner with modifier.apply(owner) / modifier.revert(owner).

The modifiers of the previous level are not reverted on level up, so level bonuses accumulate:

// level 2: maxHp +10, level 3: maxHp +15, base maxHp 100
await levelsSet.levelUp(); // level 2: maxHp = 110
await levelsSet.levelUp(); // level 3: maxHp = 125
await levelsSet.levelDown(); // reverts level 3: maxHp = 110

Design the level modifiers as increments over the previous level. Every property used by the modifiers must exist on the owner.

ClassPath

Extends LevelsSet with skill trees and labels by level.

const { ClassPath, Level } = require('@reldens/skills');

let classPath = new ClassPath({owner: player});
await classPath.init({
    key: 'warrior',
    label: 'Warrior',
    labelsByLevel: {1: 'Novice Warrior', 5: 'Veteran Warrior'},
    levels: levels,
    skillsByLevel: {
        1: [swordSkill],
        5: [shieldSkill, bashSkill]
    },
    currentLevel: 1,
    currentExp: 0
});

Additional properties:

  • key (required) and label (defaults to the key).
  • labelsByLevel and currentLabel.
  • skillsByLevel - Skill instances by level; skillsByLevelKeys - the same structure with skill keys.
  • currentSkills - the skills available now, indexed by skill key.
  • affectedProperty - the property affected by the class skills (for example stats/hp).

Initialization flow:

  1. Runs the LevelsSet initialization.
  2. Validates the key (logs a critical error and returns false without it).
  3. Sets the label, the labels by level and the current label (preset, or the label of the highest labeled level reached, or the base label).
  4. Sets the skills by level and their keys.
  5. Sets the current skills (see below).
  6. Sets the affected property and fires INIT_CLASS_PATH_END.

Owner skills

setOwnerSkills(skills):

  • When currentSkills is passed to init(), it is used as is.
  • Otherwise, for every defined level up to the current level that has skills in skillsByLevel, the skills are added with addSkills(), then SET_SKILLS is fired.
// skillsByLevel: {1: [skill1, skill2], 5: [skill3], 10: [skill4]}, currentLevel: 5
// currentSkills contains skill1, skill2 and skill3; skill4 requires level 10

Adding and removing skills

  • await classPath.addSkills([skill]) - fires ADD_SKILLS_BEFORE, adds each skill to currentSkills by key, fires ADD_SKILLS_AFTER. Use cases: level unlocks, skills learned from a trainer, skills granted by equipment.
  • await classPath.removeSkills([skillOrKey]) - accepts instances or keys; fires REMOVE_SKILLS_BEFORE, deletes them, fires REMOVE_SKILLS_AFTER. Use cases: forgotten skills, expired temporary skills, unequipped items.

Level up and down with skills and labels

ClassPath overrides both methods:

  • levelUp() - adds the skills of the next level (when defined), sets the label of the next level (when defined) and then runs the LevelsSet level up (level, modifiers, event).
  • levelDown() - removes the skills of the current level, sets the label of the previous level (when defined) and then runs the LevelsSet level down.
// labelsByLevel: {1: 'Novice Warrior', 5: 'Veteran Warrior'}, currentLevel: 1
console.log(classPath.currentLabel); // 'Novice Warrior'
await classPath.levelUp(); // level 2, no label defined: 'Novice Warrior'
// after reaching level 5:
console.log(classPath.currentLabel); // 'Veteran Warrior'

Class path events

All LevelsSet events plus:

  • INIT_CLASS_PATH_END - params (classPath).
  • SET_SKILLS - params (classPath).
  • ADD_SKILLS_BEFORE / ADD_SKILLS_AFTER - params (classPath, skills).
  • REMOVE_SKILLS_BEFORE / REMOVE_SKILLS_AFTER - params (classPath, skills).
classPath.listenEvent(
    SkillsEvents.ADD_SKILLS_AFTER,
    async (classPath, skills) => {
        for(let skill of skills){
            console.log('New skill unlocked:', skill.key);
        }
    },
    classPath.getOwnerUniqueEventKey('skillUnlock'),
    classPath.getOwnerEventKey()
);

SkillsServer

Server-side wrapper for a ClassPath with automatic client synchronization.

  • Properties: classPath (the ClassPath) and client (the Sender).
  • The client must implement send() and broadcast().

Initialization flow:

  1. Validates the owner and its getPosition().
  2. Uses the classPath prop or creates a new ClassPath, and sets its owner.
  3. When a client is provided, validates send() and broadcast(), creates the Sender and registers its listeners.
  4. Starts classPath.init(props) without awaiting it.

Validation problems are logged as critical errors and stop the setup (an invalid client also skips the class path initialization).

Messages sent by the Sender

  • INIT_CLASS_PATH_END - action rski.ICpe, send - lvl, lab, exp, skl (current skill keys), ne (next level experience), nl (label of the current level, when defined).
  • LEVEL_UP - action rski.Lu, send - lvl, lab, skl (skill keys of the new level, when any), ne.
  • LEVEL_EXPERIENCE_ADDED - action rski.Ea, send - exp.
  • SKILL_BEFORE_CAST - action rski.Bc, broadcast - skillKey and the owner x / y.
  • SKILL_ATTACK_APPLY_DAMAGE - action rski.Ad, broadcast - d (damage).
{
    act: 'rski.Lu',
    owner: 'player-123',
    data: {
        lvl: 5,
        lab: 'Veteran Warrior',
        ne: 2000,
        skl: ['shield-block', 'bash']
    }
}

Complete Progression Example

const SkillsServer = require('@reldens/skills/lib/server');
const { Level } = require('@reldens/skills');
const { Modifier, ModifierConst } = require('@reldens/modifiers');

let maxHpBonus = (key, value) => new Modifier({
    key: key,
    propertyKey: 'stats/maxHp',
    operation: ModifierConst.OPS.INC,
    value: value
});

let levels = {
    1: new Level({key: 1, requiredExperience: 0, modifiers: []}),
    2: new Level({key: 2, requiredExperience: 100, modifiers: [maxHpBonus('hp-2', 10)]}),
    3: new Level({key: 3, requiredExperience: 250, modifiers: [maxHpBonus('hp-3', 15)]}),
    4: new Level({key: 4, requiredExperience: 500, modifiers: [maxHpBonus('hp-4', 20)]}),
    5: new Level({key: 5, requiredExperience: 1000, modifiers: [maxHpBonus('hp-5', 25)]})
};

let skillsServer = new SkillsServer({
    owner: player,
    client: roomClient,
    key: 'warrior',
    label: 'Warrior',
    labelsByLevel: {1: 'Novice Warrior', 5: 'Veteran Warrior'},
    levels: levels,
    skillsByLevel: {
        1: [slashSkill],
        5: [powerAttackSkill, shieldBlockSkill]
    },
    currentLevel: 1,
    currentExp: 0
});
let classPath = skillsServer.classPath;
// wait for the class path initialization (INIT_CLASS_PATH_END) before adding experience

await classPath.addExperience(250);
// levels 2 and 3 reached: maxHp +10 +15
// the client receives rski.Lu twice, then rski.Ea with exp 250

await classPath.addExperience(750);
// total 1000: levels 4 and 5 reached, label 'Veteran Warrior',
// powerAttackSkill and shieldBlockSkill added to currentSkills

await classPath.addExperience(500);
// total 1500: still level 5 (max level), the client receives rski.Ea with exp 1500

Common Patterns

1. Multiple classes per player

let classes = {
    warrior: new SkillsServer({owner: player, client: client, key: 'warrior', levels: warriorLevels, currentLevel: 1, skillsByLevel: warriorSkills}),
    mage: new SkillsServer({owner: player, client: client, key: 'mage', levels: mageLevels, currentLevel: 1, skillsByLevel: mageSkills})
};
await classes.warrior.classPath.addExperience(500);
await classes.mage.classPath.addExperience(300);

2. Skill requirements by level

function canLearnSkill(skillKey, classPath)
{
    for(let level of Object.keys(classPath.skillsByLevelKeys)){
        if(-1 !== classPath.skillsByLevelKeys[level].indexOf(skillKey)){
            return classPath.currentLevel >= Number(level);
        }
    }
    return false;
}

3. Prestige / rebirth

async function prestigeClass(classPath)
{
    let prestigeLevel = classPath.currentLevel;
    while(classPath.currentLevel > 1){
        await classPath.levelDown(); // reverts each level modifiers and removes its skills
    }
    classPath.currentExp = 0;
    let prestigeBonus = new Modifier({
        key: 'prestige-'+prestigeLevel,
        propertyKey: 'stats/atk',
        operation: ModifierConst.OPS.INC,
        value: prestigeLevel
    });
    prestigeBonus.apply(classPath.owner);
    return prestigeLevel;
}

4. Experience bonuses

function calculateExperienceGain(baseExperience, player, enemy)
{
    let multiplier = 1;
    if(player.hasExperienceBoost){
        multiplier += 0.5;
    }
    if(player.inParty){
        multiplier += 0.2;
    }
    if(player.level - enemy.level > 5){
        multiplier *= 0.5;
    }
    return Math.floor(baseExperience * multiplier);
}
await classPath.addExperience(calculateExperienceGain(100, player, enemy));

5. Skill points

class SkillPointsClassPath extends ClassPath
{

    async init(props)
    {
        this.skillPoints = 0;
        await super.init(props);
    }

    async levelUp()
    {
        await super.levelUp();
        this.skillPoints++;
    }

    async learnSkill(skill, cost = 1)
    {
        if(this.skillPoints < cost){
            return false;
        }
        this.skillPoints -= cost;
        await this.addSkills([skill]);
        return true;
    }

}

Debugging

console.log('Key:', classPath.key);
console.log('Level:', classPath.currentLevel, classPath.currentLabel);
console.log('Experience:', classPath.currentExp, '/', classPath.getNextLevelExperience());
console.log('Skills:', Object.keys(classPath.currentSkills));
console.log('Levels order:', classPath.levelsByExperience);

classPath.listenEvent(
    SkillsEvents.LEVEL_APPLY_MODIFIERS,
    (levelsSet, levelInstance) => {
        for(let modifier of levelInstance.modifiers){
            console.log('[MODIFIER]', modifier.propertyKey, modifier.operation, modifier.value);
        }
    },
    classPath.getOwnerUniqueEventKey('debugModifiers')
);

Common Pitfalls

1. Not awaiting level operations

levelUp(), levelDown() and addExperience() are async; read the level only after awaiting them.

2. Current level not defined in the levels

currentLevel defaults to 0. If there is no level 0, adding experience never levels up. Set it to an existing level key.

3. Gaps between level keys

levelUp() always moves one level. Without autoFillRanges, define every level (1, 2, 3...) so each level up finds its Level instance.

4. Unexpected automatic level up

increaseLevelsWithExperience is enabled by default. Set it to false if your game levels up manually (for example, after a quest).

5. Changing the levels after initialization

levelsByExperience is calculated during init(). Adding levels to classPath.levels later breaks the order; include every level in the init props.

6. Sharing skill instances

A skill instance keeps its own owner, target, cooldown and uses. Create separate instances for each owner and class path.

Related Documentation

Go Up