Moonlight Pixels

Novice After-Hours Game Development.

JRPG-Engine Stat System

2019-01-10 Jay
At the time of this blog post jrpg-engine is still in development (pre-1.0). APIs referneced in this post may change before 1.0 release.

Stats in a RPG are how we model the differences between entities in the game simulation. The warrior with Strength of 25 is likely to deal more damage with a standard physical attack than the mage with Strength of 8. As far as jrpg-engine is concerned, stats come into play with the simulation of combat. While it is possible some RPGs will use stats in calcualtion for game mechanics outside of combat, the near term forcus of stats in jrpg-engine will be to serve comabt.

Examples of stats may include:

Strength

Used in calculating physical attack damage.

Attack Power

Comination of player’s Strength and modifiers assigned by equiped weapon.

Speed

Used in calculating how fast the player’s combat turn meter fills up.

When desiging how I wanted stats to work in jrpg-engine, I had a few requirements in mind.

  1. All combatants (players and enemies) should have the same set of stats, simplifying calculation reuse.

  2. Stats need to have non-permanent modfiers applied to them from sources such as equipment and status effects.

  3. Some stats need to be defined in terms of other stats, for example a Dodge stat that combines Speed and Luck.

  4. Stats may have limits on their maximum value, that doesn’t need to be aplied equally to players and enemies. For example MaxHP might be capped at 9999 for playes and uncapped for enemies.

  5. Stats may have a minimum value, such as enforcing that MaxHP can never be less that 1, regardless of what modifiers are applicable.

JRPG-Engine’s Stats Design

Lets first take a look at how jrpg-engine models the definition of Stats and their values are are assigned to players and enemies.

stats class diagram
Stat

The defintion of how a specific stat funtions.

Stat.Key

A key interface used in regestering game defintion content with jrpg-engine. Keys being interfaces allow them to be implemented as enums. The topic of managing game definitiion content could be a complete future blog post.

StatHolder

Interface implemented by both players and enemies that contains the raw integr base values for each stat.

The Stat class above is abstract. When defining stats in jrpg-engine, they are defined as one of two concrete types.

stats class types diagram
BaseStat

These are simple stats correspond to an assigned base value on a StatHolder

CompositeStat

These stats do not correspond to an assigned base value son a StatHolder, but instead are calculated from one of more other Stats.

Stat Meters

Stats also support StatMeters. StatMeters are a special data type that holds an integer value that ranges between zero and the value of a Stat assigned to represent it’s max value. Representing values capped by a Stat allow for the following scenario to be handled correctly.

A player has a MaxHP of 200.

The player equips an accessory that raises MaxHP by 10%. Max HP is now 220 and HP is still at 200.

The player heals to 100%, bringing HP to 220.

The player unequips the accessory

You can imagine that this may lead to a scenario where the player has 220 HP with a MaxHP of 200. If we model HP as a StatMeter, the current value of the max value stat is checked every time request the StatMeter’s value. With a StatMeter, HP will never exceed MaxHP.

Required Stats

One of my goals with jrpg-engine is to not force too many constraints on game implementatons based on what I think makes a reasonable game setup. That said, there is a minimal set of requires stats all games must include to support core aspects of the engine.

At the time of this blog post, there are 3 planned required stats.

Stat Why its required

MaxHP

Players and enemies have an HP Meter, that when it it reached zero, signifies they are dead/KO’d.

Level

Experience is awarded after victories and players may level up based on an assigned level function.

CombatTurnInterval

The number of 'ticks' it takes for the player/enemy’s combat turn meter to fill up.

An Example Implementation

In a small demo game I’m putting together to test jrpg-engine throughout development, I currently have the following stats. Many of my BaseStats have a corresponding 'wrapper' CompositeStat, whose base value is simply the calculated value of its single input. This is done to allow Equipment to target the wrapper stat with modifiers, while status effects can target the raw base stats.

Base Stat Wrapper Description

Strength

Attack Power

Attack Power is used in calculating damage dealt by physical attacks.

Stamina

Defense

Defense is used in calculating the damage suffered by physical attacks.

Magic

Magic Power

Magic Power is used in calculating the strenght of effect of magic spells.

Resistence

Magic Defense

Magic Defense is used in calculating the damage suffered by magic attacks.

Agility

Evasion

Evasion is used in calculating whether or not an attack is eveaded.

Speed

Combat Turn Interval

Combat Turn Interval is required by the engine to determine time between turns in combat.

MaxHP

N/A

MaxHP is required by the engine to constrain the HP meter.

MaxMP

N/A

MaxMP constrains the MP meter used, which i sconsumed by casting spells.

Level

N/A

Level is required by the engine and additioanlly used in some skill checks, like Steal.

In code, these stats look like this

import com.moonlightpixels.jrpg.combat.stats.BaseStat;
import com.moonlightpixels.jrpg.combat.stats.CompositeStat;
import com.moonlightpixels.jrpg.combat.stats.RequiredStats;
import com.moonlightpixels.jrpg.combat.stats.Stat;
import com.moonlightpixels.jrpg.combat.stats.StatCap;
import com.moonlightpixels.jrpg.combat.stats.StatHolder;

public final class StatDefinitions {
    private StatDefinitions() { }

    public static final Stat STRENGTH = BaseStat.builder()
        .key(Stats.Strength)
        .name("Strength")
        .shortName("STR")
        .cap(255)
        .minValue(1)
        .build();

    public static final Stat STAMINA = BaseStat.builder()
        .key(Stats.Stamina)
        .name("Stamina")
        .shortName("STA")
        .cap(255)
        .minValue(1)
        .build();

    public static final Stat MAGIC = BaseStat.builder()
        .key(Stats.Magic)
        .name("Magic")
        .shortName("MAG")
        .cap(255)
        .minValue(1)
        .build();

    public static final Stat RESISTANCE = BaseStat.builder()
        .key(Stats.Resistance)
        .name("Resistance")
        .shortName("RES")
        .cap(255)
        .minValue(1)
        .build();

    public static final Stat AGILITY = BaseStat.builder()
        .key(Stats.Agility)
        .name("Agility")
        .shortName("AGI")
        .cap(255)
        .minValue(1)
        .build();

    public static final Stat SPEED = BaseStat.builder()
        .key(Stats.Speed)
        .name("Speed")
        .shortName("SPD")
        .cap(255)
        .minValue(1)
        .build();

    public static final Stat ATTACK_POWER = CompositeStat.builder()
        .key(Stats.AttackPower)
        .name("Attack Power")
        .shortName("ATP")
        .input(STRENGTH)
        .statFunction(inputs -> inputs.get(STRENGTH.getKey()))
        .minValue(0)
        .build();

    public static final Stat DEFENSE = CompositeStat.builder()
        .key(Stats.Defense)
        .name("Defense")
        .shortName("DEF")
        .input(STAMINA)
        .statFunction(inputs -> inputs.get(STAMINA.getKey()))
        .minValue(0)
        .build();

    public static final Stat MAGIC_POWER = CompositeStat.builder()
        .key(Stats.MagicPower)
        .name("Magic Power")
        .shortName("MGP")
        .input(MAGIC)
        .statFunction(inputs -> inputs.get(MAGIC.getKey()))
        .minValue(0)
        .build();

    public static final Stat MAGIC_DEFENSE = CompositeStat.builder()
        .key(Stats.MagicDefense)
        .name("Magic Defense")
        .shortName("MGD")
        .input(RESISTANCE)
        .statFunction(inputs -> inputs.get(RESISTANCE.getKey()))
        .minValue(0)
        .build();

    public static final Stat EVASION = CompositeStat.builder()
        .key(Stats.Evasion)
        .name("Evasion")
        .shortName("EVA")
        .input(AGILITY)
        .statFunction(inputs -> inputs.get(AGILITY.getKey()))
        .minValue(0)
        .build();

    public static final Stat COMBAT_TURN_INTERVAL = CompositeStat.builder()
        .key(RequiredStats.CombatTurnInterval)
        .name("Combat Turn Interval") // Note this likely will never be in the
        .shortName("CTI")             // UI, as this is a 'hidden' stat
        .input(SPEED)
        .statFunction(inputs -> inputs.get(SPEED.getKey()))
        .minValue(0)
        .build();

    public static final Stat MAXHP = BaseStat.builder()
        .key(RequiredStats.MaxHP)
        .name("Max HP")
        .shortName("HP")
        .cap(new StatCap(9999, StatHolder.Type.Player))
        .minValue(1)
        .build();

    public static final Stat MAXMP = BaseStat.builder()
        .key(Stats.MaxMP)
        .name("Max MP")
        .shortName("MP")
        .cap(new StatCap(999, StatHolder.Type.Player))
        .minValue(1)
        .build();

    public static final Stat LEVEL = BaseStat.builder()
        .key(RequiredStats.Level)
        .name("Level")
        .shortName("LVL")
        .cap(99)
        .minValue(1)
        .build();
}
comments powered by Disqus