45. How to handle RPG Stats system and equipping items in Java

In the previous posts, we created and modified our inventory system in Java Micronaut to be reactive and use websockets.

The basic inventory demo was shown here.

Inventory system was integrated with Unreal Engine in this post.

Now, we want to be able to equip the items that are in our inventory and integrate it with our stats system.

There are many ways to implement this and I wanted to find something that was simple and fairly extensible.

The server code that is referenced in this post can be found here.

Model RPG stats in backend

Stats attached to character

Stats systems are essential for an RPG game. There are countless ways of implementing them and each approach will have their own pros and cons.

The approach I took is a balance between simplicity and extensibility. i.e. I don’t want an overcomplicated stats system as I want to build something that works soon. However I do want to create a system which can be extended to include more complex features.

Stats will be split into two categories

  • base stats
  • derived stats

Base stats will include things like:

  • Strength
  • Dexterity
  • Intelligence
  • Stamina

These stats will be used to control whether the player can equip certain items. They will also have direct impact on the derived stats.

Derived stats will include things like:

  • Current / Max HP
  • Current / Max MP
  • Attack speed / Cast speed
  • Physical / Magical amplification
  • Critical chances
  • Armor / Defense bonuses

So for example, strength can directly increase physical damage amplification, dexterity could increase your chance to critically hit and stamina could increase your max HP.

This way, I am able to add additional parameters as my system expands.

Model for Stats

Don’t forget, the code is available in the repository! It will change with time and adapt to requirements.

@Data
@Builder
@Introspected
public class Stats {
    private String actorId; // player name or mob id

    private Map<String, Integer> baseStats;
    private Map<String, Double> derivedStats;
    private Map<String, Double> itemEffects;
    private Map<String, Double> statusEffects;

    private Integer attributePoints;

    @BsonCreator
    @JsonCreator
    public Stats(
            @JsonProperty("actorId") @BsonProperty("actorId") String actorId,
            @JsonProperty("baseStats") @BsonProperty("baseStats") Map<String, Integer> baseStats,
            @JsonProperty("derivedStats") @BsonProperty("derivedStats")
                    Map<String, Double> derivedStats,
            @JsonProperty("itemEffects") @BsonProperty("itemEffects")
                    Map<String, Double> itemEffects,
            @JsonProperty("statusEffects") @BsonProperty("statusEffects")
                    Map<String, Double> statusEffects,
            @JsonProperty("attributePoints") @BsonProperty("attributePoints")
                    Integer attributePoints) {
        this.actorId = actorId;
        this.baseStats = baseStats == null ? new HashMap<>() : baseStats;
        this.derivedStats = derivedStats == null ? new HashMap<>() : derivedStats;
        this.itemEffects = itemEffects == null ? new HashMap<>() : itemEffects;
        this.statusEffects = statusEffects == null ? new HashMap<>() : statusEffects;
        this.attributePoints = attributePoints == null ? 0 : attributePoints;
    }

    public Stats() {
        baseStats = new HashMap<>();
        derivedStats = new HashMap<>();
        itemEffects = new HashMap<>();
        statusEffects = new HashMap<>();
    }

    public int getBaseStat(StatsTypes stat) {
        return baseStats.getOrDefault(stat, 0);
    }

    public Double getDerived(StatsTypes type) {
        return derivedStats.getOrDefault(type.getType(), 0.0);
    }

    public Map<String, Double> recalculateDerivedStats() {
        Map<String, Double> updatedDerived = new HashMap<>();
        int strength = getBaseStat(StatsTypes.STR);
        int dexterity = getBaseStat(StatsTypes.DEX);
        int stamina = getBaseStat(StatsTypes.STA);
        int intelligence = getBaseStat(StatsTypes.INT);

        updatedDerived.put(StatsTypes.MAX_HP.getType(), 100.0 + stamina * 10);
        updatedDerived.put(StatsTypes.MAX_MP.getType(), 50.0 + intelligence * 5);

        updatedDerived.put(
                StatsTypes.CURRENT_HP.getType(),
                getDerived(StatsTypes.CURRENT_HP)
                                > updatedDerived.get(StatsTypes.MAX_HP.getType())
                        ? updatedDerived.get(StatsTypes.MAX_HP.getType())
                        : getDerived(StatsTypes.CURRENT_HP));

        updatedDerived.put(
                StatsTypes.CURRENT_MP.getType(),
                getDerived(StatsTypes.CURRENT_MP)
                                > updatedDerived.get(StatsTypes.MAX_MP.getType())
                        ? updatedDerived.get(StatsTypes.MAX_MP.getType())
                        : getDerived(StatsTypes.CURRENT_MP));

        updatedDerived.put(StatsTypes.ATTACK_SPEED.getType(), 50.0 + dexterity);
        updatedDerived.put(StatsTypes.CAST_SPEED.getType(), 50.0 + intelligence);
        updatedDerived.put(StatsTypes.PHY_AMP.getType(), 1 + strength * 0.01);
        updatedDerived.put(StatsTypes.MAG_AMP.getType(), 1 + intelligence * 0.01);
        updatedDerived.put(StatsTypes.PHY_CRIT.getType(), 5 + dexterity * 0.1);

        // add other effects, such as item and statuses (buffs etc)
        Map<String, Double> otherEffects = mergeStats(itemEffects, statusEffects);
        updatedDerived = mergeStats(updatedDerived, otherEffects);

        // evaluate if new entries are different to old ones
        MapDifference<String, Double> diff = Maps.difference(derivedStats, updatedDerived);
        Map<String, Double> changedStats = new HashMap<>(diff.entriesOnlyOnRight());
        diff.entriesOnlyOnLeft()
                .forEach(
                        (key, val) ->
                                changedStats.put(key, 0.0)); // these values have been 'removed'
        diff.entriesDiffering().forEach((key, val) -> changedStats.put(key, val.rightValue()));

        derivedStats = updatedDerived;

        return changedStats;
    }

    public static Map<String, Double> mergeStats(
            Map<String, Double> left, Map<String, Double> right) {
        Map<String, Double> copy = new HashMap<>(left);
        right.forEach(
                (k, v) -> {
                    if (copy.containsKey(k)) {
                        copy.put(k, copy.get(k) + v);
                    } else {
                        copy.put(k, v);
                    }
                });

        return copy;
    }

    public static Map<String, Double> mergeLeft(
            Map<String, Double> left, Map<String, Double> right) {
        right.forEach(
                (k, v) -> {
                    if (left.containsKey(k)) {
                        left.put(k, left.get(k) + v);
                    } else {
                        left.put(k, v);
                    }
                });

        return left;
    }
}

What are the key parts?

Let’s discuss what we have in this model.

First of all, you can see the JSON/BSON properties – this is just for storing it in our database.

We have the private String actorId – this is used to join against our player or mob.

Next, we have the key parts:

    private Map<String, Integer> baseStats;
    private Map<String, Double> derivedStats;
    private Map<String, Double> itemEffects;
    private Map<String, Double> statusEffects;

These will be used to hold our actual stats. You can see the base stats are a map of Integers where String will refer to the stat type.

The rest of the stats, such as derived stats and item effects are Doubles. They will undergo certain calculations and can be decimals.

Currently I have implemented the basic derived stats and item effects.

There is also a variable Integer attributePoints which will be used to increase the base stats whenever the player levels up.

Bear in mind, the Level attributes will be stored through different place, because Mobs and Players will have different representations of levels, but these stats can be the same.

One of the key functionalities in this class is: Map<String, Double> recalculateDerivedStats().

This function will recalculate what should be the derived stats, including things like MAX HP, for the given actor.

Here’s a very simple example:

updatedDerived.put(StatsTypes.MAX_HP.getType(), 100.0 + stamina * 10);
updatedDerived.put(StatsTypes.MAX_MP.getType(), 50.0 + intelligence * 5);

....

Map<String, Double> otherEffects = mergeStats(itemEffects, statusEffects);

So we can see that our max HP can be equal to:

  • 50 + (stamina * 10))+ item effect modifiers

Of-course you can make it as simple or as complex as you wish and that will be one of the key features of this approach.

Stats service layer

The service layer will be handling the following things for now:

  • Initializing player stats – when you create a character, it will create the base stats too
  • Stats getter – allows you to fetch stats from DB
  • Delete stats – when you delete player or kill mob, clear that data.
  • Update item stats – whenever you equip/unequip items, you want to update players stats
  • Handle stats difference – this is communication part of service layer

The last part is quite crucial – whenever there’s an observed difference in the derived stats (e.g. equipped item or stat point added or damage taken) we will notify players via Kafka/Websocket about this.

Items model

Our Items have been extended to include a property of ItemEffects.

Here’s the full model defining an Item.

public abstract class Item {

    @BsonCreator
    @JsonCreator
    public Item(
            @JsonProperty("itemId") @BsonProperty("itemId") String itemId,
            @JsonProperty("itemName") @BsonProperty("itemName") String itemName,
            @JsonProperty("category") @BsonProperty("category") String category,
            @JsonProperty("itemEffects") @BsonProperty("itemEffects")
                    Map<String, Double> itemEffects,
            @JsonProperty("stacking") @BsonProperty("stacking") Stacking stacking,
            @JsonProperty("value") @BsonProperty("value") Integer value,
            @JsonProperty("config") @BsonProperty("config") ItemConfig itemConfig) {

        this.itemId = itemId;
        this.itemName = itemName;
        this.category = category;
        this.itemEffects = itemEffects;
        this.stacking = stacking;
        this.value = value;
        this.itemConfig = itemConfig;
    }

    String itemId;
    String itemName;
    String category;
    Map<String, Double> itemEffects;
    Stacking stacking;
    Integer value;
    ItemConfig itemConfig;

    public abstract EquippedItems createEquippedItem(
            String characterName, ItemInstance itemInstance);
}

The key addition to the Item model is Map<String, Double> itemEffects.

This in essence will take eligible ‘derived stats’ and add that to item properties.

When a player equips items, these item effects will be merged along with all equipped items and summed to existing derived effects.

Item Instance model

So the Item is the base of the item, then each instance of the item has a ItemInstance model.

public class ItemInstance {

    @BsonCreator
    @JsonCreator
    public ItemInstance(
            @JsonProperty("itemId")
            @BsonProperty("itemId") String itemId,
            @JsonProperty("itemInstanceId")
            @BsonProperty("itemInstanceId") String itemInstanceId,
            @JsonProperty("item")
            @BsonProperty("item") Item item) {

        this.itemId = itemId;
        this.itemInstanceId = itemInstanceId;
        this.item = item;
    }

    String itemId;
    String itemInstanceId;
    Item item;
}

Equipped Items model

And finally, the EquippedItems will have their own model too.

public abstract class EquippedItems {

    @BsonCreator
    @JsonCreator
    public EquippedItems(
            @JsonProperty("characterName") @BsonProperty("characterName") String characterName,
            @JsonProperty("itemInstance") @BsonProperty("itemInstance") ItemInstance itemInstance,
            @JsonProperty("category") @BsonProperty("category") String category) {
        this.characterName = characterName;
        this.itemInstance = itemInstance;
        this.category = category;
    }

    String characterName;
    ItemInstance itemInstance;
    String category;
}

The category is important to identifying what slot the item fits into (such as weapon, helm, boots, etc) and to avoid items being equipped into same slot.

In terms of implementations, check the repository for more details.

For example:

What’s next

For this post, I will demonstrate this Stats / Equipping items working with Websockets and Postman (video will be available on top when complete). After this, I will create a step-by-step post on how to integrate the client on Unreal Engine.

Once the inventory/stats system is ready, we can begin a basic combat system which integrates with the Stats system – specifically modifying actors health.