Items System Implementation

The Reldens Items System manages player inventory, equipment and item modifiers, using @reldens/items-system for the core features and @reldens/modifiers for the stat modifications.

Managing items in the admin panel

Every item of the game is a record in the admin panel: what it is (type and group), how it can be used and how it changes the player stats (modifiers). The players get the items from enemy drops, rewards, NPC traders, resources like the mining rocks, trades with other players or directly from the admin panel.

Create an item

  1. Log in to the admin panel at /reldens-admin with an admin user (the path can be changed with the RELDENS_ADMIN_ROUTE_PATH environment variable).
  2. Open Items & Inventory > Items in the sidebar (/reldens-admin/items-item). The list shows every item with its key, type, group and label.
  3. Click Create New.
  4. Fill the Key (unique identifier, the other records and the custom code refer to the item by it) and the Label (the name shown to the players).
  5. Pick the Type: equipment for the items that are equipped, usable for consumables, single for stackable items, or the stackable combinations single_equipment and single_usable.
  6. For equipment pick the Group ID (the equipment slot, for example weapon or shield): only one item of each group can be equipped at the same time.
  7. Optionally fill the Description, the limits, the time outs and the CustomData (see Configuration reference below).
  8. Click Save.
Admin Panel - Items list Admin Panel - Create new item form

Add the stat modifiers

  1. Open Items & Inventory > Modifiers and click Create New.
  2. Select the Item ID, set a Key (for example atk) and the Property Key to change (for example stats/atk).
  3. Select the Operation (Increment, Decrease, Increment Percentage, Set, etc.) and set the Value.
  4. Optionally set the MaxProperty that caps the result (for example statsBase/hp for a heal potion) and click Save.

The equipment modifiers are applied when the item is equipped and reverted when it is unequipped, the usable items modifiers are applied when the item is used.

Groups and player inventories

  • Items & Inventory > Groups: the equipment slots (the sample data has weapon, shield, armor, boots, gauntlets and helmet), with their label, icon file, sort order and limits.
  • Items & Inventory > Players Inventory: the items each player owns. Create a row with the Owner ID (the player), the Item ID and the Qty to give an item to a player, Is Active marks an equipment item as equipped.
  • Items & Inventory > Types: the item types used by the Type field, installed by the basic configuration.

Restart the server after creating or changing items and modifiers: the items are loaded when the server starts, and each player inventory is loaded when the player logs in.

Configuration reference

Item fields

  • Key - unique item identifier.
  • Type - base, equipment, usable, single, single_equipment or single_usable.
  • Group ID - item group, one equipped item per group.
  • Label and Description - texts shown to the players.
  • Qty Limit - maximum quantity of the item.
  • Uses Limit - maximum number of uses.
  • UseTimeOut and ExecTimeOut - optional use delays in milliseconds.
  • CustomData - JSON with extra options, for example {"canBeDropped": true} so the item can be dropped when the player dies, and the animationData played when the item is used (see the sample potions).

Modifier fields

  • Item ID - the item the modifier belongs to.
  • Key - modifier identifier.
  • Property Key - path of the player property to change, for example stats/atk or stats/hp.
  • Operation - Increment, Decrease, Divide, Multiply, Increment Percentage, Decrease Percentage, Set, Method or Set Number (Method needs custom code, see Custom modifiers below).
  • Value - value used by the operation.
  • MaxProperty - optional property that caps the result, for example statsBase/hp.

Code integration (advanced)

The sections below describe the items system internals: how the items and their modifiers are loaded, equipped and saved, and how to extend them.

Architecture

Core components

  1. ItemsServer (@reldens/items-system) - server-side inventory manager.
  2. Inventory (@reldens/items-system) - base inventory container.
  3. ItemBase - base class for all items.
  4. ItemEquipment - specialized item type for equippable items.
  5. Modifier (@reldens/modifiers) - handles stat modifications.
  6. StorageObserver - persists inventory changes to the database.

Directory structure

lib/inventory/:

  • client/ - client-side inventory UI and rendering.
  • server/ - server-side inventory logic:
    • items-factory.js - creates item instances from database models.
    • items-modifiers-enricher.js - adds the modifiers display data to the items sent by the trader objects.
    • resolve-operation.js - resolves the display prefix and suffix for each modifier operation.
    • message-actions.js - handles equip / unequip / trade messages.
    • models-manager.js - database operations.
    • storage-observer.js - event listeners for persistence.
    • plugin.js - inventory feature plugin.
    • group-hot-plug-callbacks.js - admin hot-plug callbacks for the items groups (update and delete).
    • groups-data-remover.js - removes the items groups data from the config manager.
    • entities-config.js - admin entities overrides map (itemsInventory, itemsItem, itemsGroup).
    • entities-translations.js - admin entities labels.
    • entities/ - admin entities overrides for the items, groups and inventory.
    • exchange/ - trade processors:
      • processor.js - exchange operations (init, add, remove, confirm).
      • player-processor.js - extends the processor with the player-to-player confirm / disconfirm operations.
    • subscribers/ - event subscribers:
      • player-subscriber.js - creates the player inventory on login.
      • player-death-subscriber.js - drops the player items on death.
      • server-subscriber.js - initializes the inventory configuration on server ready.
  • constants.js

Item Creation Flow

When the player logs in

Entry point, InventoryPlugin.setup() in lib/inventory/server/plugin.js:

this.events.on('reldens.createPlayerStatsAfter', async (client, userModel, currentPlayer, room) => {
    await PlayerSubscriber.createPlayerInventory(client, currentPlayer, room, this.events, this.modelsManager);
});

Sequence:

  1. Player stats loaded (UsersPlugin.onCreatePlayerAfterAppendStats() in lib/users/server/plugin.js, listening to reldens.createPlayerAfter):
    • Stats loaded from the players_stats table.
    • Set on currentPlayer.stats and currentPlayer.statsBase.
    • Event reldens.createPlayerStatsAfter fires.
  2. Inventory creation (PlayerSubscriber.createPlayerInventory() in lib/inventory/server/subscribers/player-subscriber.js):
    let serverProps = {
        owner: currentPlayer,
        client: new ClientWrapper({client, room}),
        persistence: true,
        ownerIdProperty: 'player_id',
        eventsManager: events,
        modelsManager: modelsManager,
        itemClasses: room.config.getWithoutLogs('server/customClasses/inventory/items', {}),
        groupClasses: room.config.getWithoutLogs('server/customClasses/inventory/groups', {}),
        itemsModelData: room.config.inventory.items
    };
    let inventoryServer = new ItemsServer(serverProps);
    inventoryServer.dataServer = new StorageObserver(inventoryServer.manager, modelsManager);
    inventoryServer.dataServer.listenEvents();
  3. Items loading (StorageObserver.loadOwnerItems() in lib/inventory/server/storage-observer.js):
    async loadOwnerItems()
    {
        let itemsModels = await this.modelsManager.loadOwnerItems(this.manager.getOwnerId());
        if(0 === itemsModels.length){
            return false;
        }
        let itemsInstances = await ItemsFactory.fromModelsList(itemsModels, this.manager);
        if(false === itemsInstances){
            return false;
        }
        await this.manager.fireEvent(ItemsEvents.LOADED_OWNER_ITEMS, this, itemsInstances, itemsModels);
        await this.manager.setItems(itemsInstances);
        return true;
    }
  4. Item instance creation (ItemsFactory.fromModel() in lib/inventory/server/items-factory.js): the item class is resolved from manager.itemClasses by the item key, falling back to the class of the item type. The item props (id, key, qty, group_id, limits, customData, etc.) are built from the items_inventory row and its related_items_item relation, then:
    let itemObj = new itemClass(itemProps);
    if(itemObj.isType(ItemsConst.TYPES.EQUIPMENT)){
        itemObj.equipped = (1 === itemInventoryModel.is_active);
    }
    await this.enrichWithModifiers(itemInventoryModel, itemObj, manager);
    return itemObj;
  5. Modifier creation (ItemsFactory.enrichWithModifiers()):
    let modifiers = {};
    for(let modifierData of loadedModifiers){
        if(modifierData.operation !== ModifierConst.OPS.SET){
            modifierData.value = Number(modifierData.value);
        }
        modifierData.target = manager.owner;
        modifiers[modifierData.id] = new Modifier(modifierData);
    }
    itemObj.modifiers = modifiers;

    Each modifier target is manager.owner, the player schema (currentPlayer).

Critical timing

  • BEFORE items load: currentPlayer.stats is set (fresh object from the database).
  • DURING item creation: modifiers get target = manager.owner = currentPlayer.
  • AFTER items load: modifiers have the correct reference to currentPlayer.stats.

Equipment Flow

Manual equip (user action)

Entry point: the user clicks the equip button, the client sends a message and the server receives it.

  1. Message reception (InventoryMessageActions.executeMessageActions() in lib/inventory/server/message-actions.js):
    if(InventoryConst.ACTIONS.EQUIP === data.act){
        return await this.executeEquipAction(playerSchema, data);
    }
  2. Execute equip action (InventoryMessageActions.executeEquipAction()): if the item is not equipped, the equipped item of the same group is unequipped first (unEquipPrevious()), then the item is equipped. If it is already equipped, it is unequipped:
    let item = playerSchema.inventory.manager.items[data.idx];
    if(!item.equipped){
        this.unEquipPrevious(item.group_id, playerSchema.inventory.manager.items);
        await item.equip();
        return true;
    }
    await item.unequip();
    return true;
  3. Item equip method (Equipment.equip(), @reldens/items-system lib/item/type/equipment.js):
    async equip(applyMods)
    {
        this.equipped = true;
        await this.manager.fireEvent(ItemsEvents.EQUIP_ITEM, this);
        // apply modifiers automatically or not:
        if(applyMods === false || this.manager.applyModifiersAuto === false){
            return false;
        }
        await this.applyModifiers();
    }
  4. Apply modifiers (ItemBase.changeModifiers(), @reldens/items-system lib/item/type/item-base.js): this.target is false on the item, so each modifier uses its own target (set to currentPlayer in the factory):
    async changeModifiers(revert)
    {
        if(this.hasError){
            return false;
        }
        await this.manager.fireEvent(ItemsEvents.EQUIP_BEFORE+(revert ? 'Revert': 'Apply')+'Modifiers', this);
        let modifiersKeys = Object.keys(this.modifiers);
        if(0 >= modifiersKeys.length){
            return;
        }
        let methodName = revert ? 'revert' : 'apply';
        for(let i of modifiersKeys){
            this.modifiers[i][methodName](this.target);
        }
        return this.manager.fireEvent(ItemsEvents.EQUIP+(revert ? 'Reverted' : 'Applied')+'Modifiers', this);
    }
  5. Modifier execute (Modifier.execute(), @reldens/modifiers lib/modifier.js): after the target and conditions checks, the new value is calculated and set on the target property (for example currentPlayer.stats.atk):
    // override target if provided:
    if(target){
        this.target = target;
    }
    // calculate a new value, set on the owner and change state:
    let newValue = this.getModifiedValue(revert, useBasePropertyToGetValue);
    if(this.state === ModifierConst.MOD_MODIFIER_ERROR){
        return false;
    }
    let applyToProp = applyOnBaseProperty ? this.basePropertyKey : this.propertyKey;
    this.setOwnerProperty(applyToProp, newValue);
    this.state = revert ? ModifierConst.MOD_REVERTED : ModifierConst.MOD_APPLIED;
    return true;
  6. Property manager sets the value (PropertyManager.manageOwnerProperty(), @reldens/modifiers lib/property-manager.js): the path is split by / (for example stats/atk), the parent object (stats) is resolved and the last part (atk) is set:
    manageOwnerProperty(propertyOwner, propertyString, value)
    {
        let propertyPathParts = propertyString.split('/');
        let childPropertyOwner = this.extractChildPropertyOwner(propertyOwner, propertyPathParts);
        let propertyKey = propertyPathParts[propertyPathParts.length-1];
        if('undefined' === typeof value && !sc.hasOwn(childPropertyOwner, propertyKey)){
            ErrorManager.error('Invalid property "'+propertyKey+'" from path: "'+propertyPathParts.join('/')+'"].');
        }
        if('undefined' !== typeof value){
            childPropertyOwner[propertyKey] = value;
        }
        return childPropertyOwner[propertyKey];
    }
  7. Stats persistence (StorageObserver.listenEvents() and StorageObserver.updateAppliedModifiers()):
    this.manager.listenEvent(
        ItemsEvents.EQUIP+'AppliedModifiers',
        this.updateAppliedModifiers.bind(this),
        this.manager.getOwnerUniqueEventKey('modifiersAppliedStore'),
        masterKey
    );
    async updateAppliedModifiers(item)
    {
        return await this.modelsManager.onChangedModifiers(item, ModifierConst.MOD_APPLIED);
    }
  8. Persist data (ModelsManager.onChangedModifiers() in lib/inventory/server/models-manager.js):
    async onChangedModifiers(item, action)
    {
        // owners will persist their own data after the modifiers were applied:
        return await item.manager.owner.persistData({act: action, item: item});
    }
  9. Save player stats (RoomScene.createPlayerOnScene() in lib/rooms/server/scene.js):
    currentPlayer.persistData = async (params) => {
        await this.events.emit('reldens.playerPersistDataBefore', client, userModel, currentPlayer, params, this);
        await this.savePlayedTime(currentPlayer);
        await this.savePlayerState(currentPlayer.sessionId);
        await this.savePlayerStats(currentPlayer, client);
        await this.events.emit('reldens.playerPersistDataAfter', client, userModel, currentPlayer, params, this);
    };
  10. Client update (RoomScene.savePlayerStats()):
    await this.events.emit('reldens.savePlayerStatsUpdateClient', client, playerSchema, this);
    client.send('*', {
        act: GameConst.PLAYER_STATS,
        stats: playerSchema.stats,
        statsBase: playerSchema.statsBase
    });

Modifier Operations

From @reldens/modifiers lib/constants.js:

  1. INC - increase (flat): apply value + operand, revert value - operand.
  2. DEC - decrease: apply value - operand, revert value + operand.
  3. DIV - divide: apply value / operand, revert value * operand.
  4. MUL - multiply: apply value * operand, revert value / operand.
  5. INC_P - increase by %: apply value + Math.round(value * operand / 100), revert Math.round(value / (1 + operand / 100)).
  6. DEC_P - decrease by %: apply value - Math.round(value * operand / 100), revert Math.round(value / (1 - operand / 100)).
  7. SET - set value: apply operand, revert false.
  8. METHOD - custom method: apply and revert call a custom method on the modifier (see Custom modifiers below).
  9. SET_N - set (alternative): apply operand, revert false.

INC_P (increase percentage) calculation

From Calculator.calculateNewValue() (@reldens/modifiers lib/calculator.js).

Apply:

return originalValue + Math.round(originalValue * operationValue / 100);

Example: atk=100, value=5 results in 100 + Math.round(100 * 5 / 100) = 100 + 5 = 105.

Revert:

return Math.round(originalValue / (1 + operationValue / 100));

Example: atk=105, value=5 results in Math.round(105 / 1.05) = 100.

Database Schema

items_item (item definitions)

CREATE TABLE `items_item` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `key` varchar(255) NOT NULL,
    `type` int NOT NULL DEFAULT '0',
    `group_id` int unsigned DEFAULT NULL,
    `label` varchar(255) NOT NULL,
    `description` varchar(255) DEFAULT NULL,
    `qty_limit` int NOT NULL DEFAULT '0',
    `uses_limit` int NOT NULL DEFAULT '1',
    `useTimeOut` int DEFAULT NULL,
    `execTimeOut` int DEFAULT NULL,
    `customData` text,
    `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
    `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (`id`)
);

items_item_modifiers (item modifier definitions)

CREATE TABLE `items_item_modifiers` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `item_id` int unsigned NOT NULL,
    `key` varchar(255) NOT NULL,
    `property_key` varchar(255) NOT NULL,
    `operation` int unsigned NOT NULL,
    `value` varchar(255) NOT NULL,
    `maxProperty` varchar(255) DEFAULT NULL,
    PRIMARY KEY (`id`),
    FOREIGN KEY (`item_id`) REFERENCES `items_item` (`id`)
);
  • item_id: references the item this modifier belongs to.
  • key: modifier identifier (e.g. atk).
  • property_key: path to the property to modify (e.g. stats/atk).
  • operation: operation ID (1-9, see Modifier Operations).
  • value: value to apply, stored as string. ItemsFactory.enrichWithModifiers() converts it with Number() for every operation except SET, and the Modifier default type ModifierConst.TYPES.INT converts it with Number() in Modifier.parseValue().
  • maxProperty: optional max value property path (e.g. statsBase/hp).

items_inventory (player item instances)

CREATE TABLE `items_inventory` (
    `id` int unsigned NOT NULL AUTO_INCREMENT,
    `owner_id` int unsigned NOT NULL,
    `item_id` int unsigned NOT NULL,
    `qty` int NOT NULL DEFAULT '0',
    `remaining_uses` int DEFAULT NULL,
    `is_active` tinyint DEFAULT NULL,
    PRIMARY KEY (`id`),
    FOREIGN KEY (`owner_id`) REFERENCES `players` (`id`),
    FOREIGN KEY (`item_id`) REFERENCES `items_item` (`id`)
);
  • owner_id: player ID who owns this item instance.
  • item_id: references the item definition.
  • qty: quantity.
  • remaining_uses: uses left (if the item has a uses limit).
  • is_active: 1 if equipped, 0 if not (for equipment items only).

Event Flow

Equipment events sequence

  1. ItemsEvents.EQUIP_ITEM - fired when equip() starts. Listener: StorageObserver.saveEquippedItemAsActive() updates is_active=1 in the database.
  2. ItemsEvents.EQUIP_BEFORE+'Apply'+'Modifiers' - before the modifiers are applied. No default listeners.
  3. ItemsEvents.EQUIP+'Applied'+'Modifiers' - after the modifiers are applied. Listener: StorageObserver.updateAppliedModifiers() calls persistData() to save the stats.
  4. reldens.playerPersistDataBefore - before data persistence. Custom hooks can intercept here.
  5. reldens.savePlayerStatsUpdateClient - after the stats are saved, before the client update. Listener: UsersPlugin.updateClientsWithPlayerStats() updates the life bar UI (registered in UsersPlugin.activateLifeBar() when client/ui/lifeBar/enabled is on).
  6. The client receives the GameConst.PLAYER_STATS message with the updated stats.

Unequip events sequence

  1. ItemsEvents.UNEQUIP_ITEM - fired when unequip() starts. Listener: StorageObserver.saveUnequippedItemAsInactive() updates is_active=0 in the database.
  2. ItemsEvents.EQUIP_BEFORE+'Revert'+'Modifiers' - before the modifiers are reverted. No default listeners.
  3. ItemsEvents.EQUIP+'Reverted'+'Modifiers' - after the modifiers are reverted. Listener: StorageObserver.updateRevertedModifiers() calls persistData() to save the stats.
  4. Same persistence and client update flow as equip.

Testing Checklist

  • Equip item - stats increase correctly.
  • Unequip item - stats revert to the base value.
  • Logout with an equipped item - stats saved correctly.
  • Login with an equipped item - stats loaded with the modifiers applied.
  • Unequip after login - stats revert to the base value correctly.
  • Multiple items in the same group - only one equipped at a time.
  • Percentage modifiers - calculate correctly for different base values.
  • Flat modifiers - add / subtract exact values.
  • Max / min property limits - respect the statsBase maximums.

Performance Considerations

  • Modifiers are applied synchronously in a loop (ItemBase.changeModifiers() in @reldens/items-system).
  • For items with many modifiers, this could cause a brief delay.
  • Stats are saved to the database after every equip / unequip operation.
  • Consider batching stats updates if players frequently swap equipment.

Extension Points

Custom item types

Create a custom item class extending ItemBase or ItemEquipment:

const { ItemEquipment } = require('@reldens/items-system');

class MagicWeapon extends ItemEquipment {
    async equip(applyMods){
        // Custom equip logic
        await super.equip(applyMods);
        // Post-equip custom logic
    }
}

Register it in server/customClasses/inventory/items:

itemClasses: {
    'magic_sword': MagicWeapon
}

Custom modifiers

The METHOD operation (8) calls a method named by the modifier value, from Modifier.getModifiedValue() (@reldens/modifiers lib/modifier.js):

if(this.operation === ModifierConst.OPS.METHOD){
    // this allows you to extend a modifier and set your own calculation/application method:
    if(!sc.hasOwn(this, this.value) || 'function' !== typeof this[this.value]){
        Logger.error(['Modifier error:', this, 'Undefined method:', this.value]);
        this.state = ModifierConst.MOD_MODIFIER_ERROR;
        return false;
    }
    propertyValue = this[this.value](this, propertyValue);
}

A METHOD row in items_item_modifiers does not work with the default loader:

  • ItemsFactory.enrichWithModifiers() always builds the base Modifier class, there is no customClasses entry to replace it.
  • The method name is converted with Number() (by the factory for every operation except SET, and by the Modifier default INT type), so it becomes NaN.
  • sc.hasOwn(this, this.value) only finds own properties (or getters), a method declared on a subclass prototype is not found.

To use METHOD modifiers, custom code must replace the ItemsFactory.enrichWithModifiers() logic (used by StorageObserver.loadOwnerItems() and the trader objects) to build a Modifier subclass, keep the method name as a string (pass type: ModifierConst.TYPES.STRING) and assign the method on the instance (for example in the subclass constructor).

Event hooks

Hook into any event for custom logic:

events.on('reldens.createdPlayerSchema', async (client, userModel, currentPlayer, room) => {
    // Custom logic when player is created
});

let manager = inventoryServer.manager;
manager.listenEvent(
    ItemsEvents.EQUIP_ITEM,
    async (item) => {
        // Custom logic when an item of this inventory is equipped
    },
    manager.getOwnerUniqueEventKey('myEquipItemKey'),
    manager.getOwnerEventKey()
);

Always pass the unique key and the owner master key like lib/inventory/server/storage-observer.js does: listenEvent() uses EventsManager.onWithKey(), which skips the registration when the remove key already exists, so a listener registered without keys is only added once (for the first inventory).

References

Related Documentation

Go Up