Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Inventory and Equipment Management

Hello! Welcome back to our JRPG development course.

In the last lesson, we built the character status screen. More importantly, we engineered the underlying system for character stats, separating base stats from final stats and creating equip() and unequip() methods on our Actor class. This was a crucial step in creating a data-driven architecture.

Today, we'll make that system interactive by implementing the UI that calls those methods. Our learning outcome is to implement the inventory and equipment screens, allowing for item usage and management. We'll build the screens where the player can see their items, equip weapons and armor, and use consumables like potions. This will involve both UI implementation and further refinement of our game's data structures.

1. From Item Database to Player Inventory

So far, we have a master list of all possible items in the game (equipmentData.js from the last lesson). Now, we need a data structure to represent the items the player actually possesses. A simple list of item objects isn't enough, as we need to track quantities (e.g., "99 Potions").

A clean way to model this is with a class that manages the inventory data. For the data itself, a JavaScript Map is an excellent choice, where the key is the itemId and the value is the quantity. This provides fast lookups and updates.

Let's define an Inventory class that encapsulates this logic. This will be our central API for all inventory operations.

// /systems/Inventory.js

export class Inventory {
    constructor(maxSlots = 99) {
        // A Map of itemId -> quantity
        this.items = new Map();
        this.maxSlots = maxSlots; // Max number of unique item stacks
    }

    /**
     * Adds a specific quantity of an item to the inventory.
     * @param {string} itemId - The ID of the item to add.
     * @param {number} quantity - The number of items to add.
     * @returns {boolean} - True if the item was added, false otherwise (e.g., inventory full).
     */
    addItem(itemId, quantity = 1) {
        const currentQuantity = this.items.get(itemId) || 0;
        
        // If the item isn't in the inventory yet, check if there's space for a new stack.
        if (currentQuantity === 0 && this.items.size >= this.maxSlots) {
            console.log("Inventory is full. Cannot add new item stack.");
            return false;
        }

        this.items.set(itemId, currentQuantity + quantity);
        return true;
    }

    /**
     * Removes a specific quantity of an item from the inventory.
     * @param {string} itemId - The ID of the item to remove.
     * @param {number} quantity - The number of items to remove.
     * @returns {boolean} - True if the item was successfully removed.
     */
    removeItem(itemId, quantity = 1) {
        const currentQuantity = this.items.get(itemId) || 0;

        if (currentQuantity < quantity) {
            console.warn(`Cannot remove ${quantity} of ${itemId}, only ${currentQuantity} available.`);
            return false;
        }

        const newQuantity = currentQuantity - quantity;
        if (newQuantity > 0) {
            this.items.set(itemId, newQuantity);
        } else {
            this.items.delete(itemId); // Remove the item stack if quantity is zero
        }
        return true;
    }

    /**
     * Gets the quantity of a specific item.
     * @param {string} itemId 
     * @returns {number}
     */
    getQuantity(itemId) {
        return this.items.get(itemId) || 0;
    }

    /**
     * Returns an array of all items in the inventory.
     * @returns {Array<{itemId: string, quantity: number}>}
     */
    getAllItems() {
        return Array.from(this.items.entries()).map(([itemId, quantity]) => ({ itemId, quantity }));
    }
}

We can now add an instance of this to our global game state.

// /state/gameState.js (Updated)
import { Actor } from '../entities/Actor.js';
import { Inventory } from '../systems/Inventory.js';

// ... playerPartyData ...

export const gameState = {
    party: playerPartyData.map(data => new Actor(data)),
    inventory: new Inventory(),
    gold: 100
};

// Add some starting items for testing
gameState.inventory.addItem('wpn001', 1);
gameState.inventory.addItem('arm001', 1);
gameState.inventory.addItem('potion01', 5); // We'll define 'potion01' soon

2. Designing an Effective Inventory UI

Before coding the UI, it's worth considering the user experience (UX). A game's inventory is one of its most frequently used menus, and a clunky design can frustrate players. For classic JRPGs, the key tasks are equipping gear, using healing items, and checking item descriptions.

This video provides an excellent overview of inventory UX, exploring how different games have approached the challenge.

Inventory UX Design - How Zelda, Resident Evil, and Doom Make Great Game Menu UX

The video 'Inventory UX Design' from Design Doc provides a fascinating look at how inventory systems have evolved and the design principles that make them effective. It will help us think about how to make our own inventory screen intuitive and functional.

Please watch the following segments: Evolution of Inventory Design (1:50 - 3:34): This part traces the history from text adventures to the list-based inventories of games like Final Fantasy, which is the style we are emulating. Optimizing for Common Tasks (7:40 - 12:49): This is the most relevant part for us. It discusses how making common tasks quick and easy is vital. Pay attention to the examples from the Zelda series about accessing items and how better UI choices can dramatically improve gameplay flow.

Drawing from the video and classic JRPG conventions, our screen will have a few key components:

  • An Equipment window showing the character's currently equipped gear in selectable slots.
  • An Inventory window listing all items the player possesses.
  • A Stats window to show the character's attributes, so the player can see the effect of equipping an item.
  • A Description window that provides details about the currently highlighted item.

3. Implementing the Equipment Screen

Let's create an EquipmentScene. This scene will be more complex than our previous ones, as it needs to manage interaction between multiple windows. We'll use a simple state machine (with a this.mode variable) to control which window is currently active.

The two primary modes will be:

  1. 'EQUIP_SLOT_SELECT': The player is selecting an equipment slot (e.g., "Right Hand").
  2. 'ITEM_SELECT': The player has selected a slot and is now choosing an item from the inventory to equip.

First, let's expand our item database to include consumables and more equipment. We'll rename equipmentData.js to itemData.js to reflect its broader scope.

// /data/itemData.js
export const itemData = {
    // Equipment
    'wpn001': {
        id: 'wpn001',
        name: 'Bronze Sword',
        type: 'equipment',
        slot: 'rightHand',
        description: 'A basic sword made of bronze.',
        modifier: { add: { str: 5 } }
    },
    'arm001': {
        id: 'arm001',
        name: 'Leather Armor',
        type: 'equipment',
        slot: 'body',
        description: 'Simple armor made from hardened leather.',
        modifier: { add: { def: 4 } }
    },
    // Consumables
    'potion01': {
        id: 'potion01',
        name: 'Potion',
        type: 'consumable',
        description: 'Restores 50 HP.',
        // Effect function will be added in the next section
    }
};

Now for the EquipmentScene itself. This is a significant piece of code, but it brings together many of the components and concepts we've developed so far.

// /scenes/EquipmentScene.js
import { UIComponent } from '../components/UIComponent.js';
import { Window } from '../components/Window.js';
import { SelectableList } from '../components/SelectableList.js';
import { gameState } from '../state/gameState.js';
import { itemData } from '../data/itemData.js';

export class EquipmentScene extends UIComponent {
    constructor({ sceneManager }) {
        super({ x: 0, y: 0, width: 816, height: 624 });
        this.sceneManager = sceneManager;
        this.actor = gameState.party[0]; // For now, just the first character
        this.mode = 'EQUIP_SLOT_SELECT'; // Initial mode

        // --- Create UI Windows ---
        this.statsWindow = new Window({ x: 20, y: 20, width: 380, height: 180 });
        this.equipWindow = new Window({ x: 20, y: 210, width: 380, height: 280 });
        this.itemWindow = new Window({ x: 415, y: 20, width: 380, height: 500 });
        this.helpWindow = new Window({ x: 20, y: 500, width: 776, height: 104 });

        this.addChild(this.statsWindow);
        this.addChild(this.equipWindow);
        this.addChild(this.itemWindow);
        this.addChild(this.helpWindow);

        // --- Create Selectable Lists ---
        this.equipmentSlots = ['rightHand', 'leftHand', 'head', 'body', 'accessory'];
        this.equipList = new SelectableList({
            x: 20, y: 30, items: this.equipmentSlots.map(slot => ({
                text: `${slot}: ${this.actor.equipment[slot]?.name || '-------'}`,
                value: slot
            }))
        });
        this.equipWindow.addChild(this.equipList);
        this.equipList.activate();

        this.itemList = new SelectableList({ x: 20, y: 30, items: [] });
        this.itemWindow.addChild(this.itemList);
        this.itemList.deactivate();
        
        this.updateStatsDisplay();
        this.updateHelpText();
    }

    updateStatsDisplay() {
        const statsToDisplay = ['str', 'def', 'agi', 'int'];
        
        this.statsWindow.drawContent = (context) => {
            // Similar to StatusScene, draw actor name, level, HP, MP
            context.font = '24px Arial';
            context.fillStyle = 'white';
            context.fillText(`${this.actor.name} - Lv ${this.actor.level}`, 20, 40);
            context.fillText(`HP: ${this.actor.hp} / ${this.actor.stats.getFinal('maxHp')}`, 20, 80);
            context.fillText(`MP: ${this.actor.mp} / ${this.actor.stats.getFinal('maxMp')}`, 200, 80);
            
            // Draw stats
            let yOffset = 120;
            statsToDisplay.forEach((stat, i) => {
                const xOffset = i % 2 === 0 ? 20 : 200;
                if (i % 2 === 0 && i > 0) yOffset += 30;
                context.fillText(`${stat.toUpperCase()}: ${this.actor.stats.getFinal(stat)}`, xOffset, yOffset);
            });
        };
    }

    updateHelpText() {
        let text = '';
        let activeList = this.equipList.active ? this.equipList : this.itemList;
        const selectedValue = activeList.getSelectedValue();

        if (selectedValue) {
            if (this.mode === 'EQUIP_SLOT_SELECT') {
                const item = this.actor.equipment[selectedValue];
                text = item ? itemData[item.id].description : `Select a slot to equip an item.`;
            } else {
                const item = itemData[selectedValue];
                text = item ? item.description : '';
            }
        }
        
        this.helpWindow.drawContent = (context) => {
            context.font = '20px Arial';
            context.fillStyle = 'white';
            context.fillText(text, 20, 40);
        };
    }
    
    handleConfirm() {
        if (this.mode === 'EQUIP_SLOT_SELECT') {
            const selectedSlot = this.equipList.getSelectedValue();
            
            // Filter inventory for items equippable in this slot
            const equippableItems = gameState.inventory.getAllItems()
                .map(i => itemData[i.itemId])
                .filter(item => item.type === 'equipment' && item.slot === selectedSlot);

            this.itemList.setItems(equippableItems.map(item => ({
                text: `${item.name} (x${gameState.inventory.getQuantity(item.id)})`,
                value: item.id
            })));

            // Switch mode
            this.mode = 'ITEM_SELECT';
            this.equipList.deactivate();
            this.itemList.activate();
            
        } else if (this.mode === 'ITEM_SELECT') {
            const selectedItemId = this.itemList.getSelectedValue();
            if (selectedItemId) {
                const item = itemData[selectedItemId];
                this.actor.equip(item);
                
                // Refresh UI and switch back
                this.handleCancel();
                this.updateEquipList();
                this.updateStatsDisplay();
            }
        }
    }

    handleCancel() {
        if (this.mode === 'ITEM_SELECT') {
            this.mode = 'EQUIP_SLOT_SELECT';
            this.itemList.deactivate();
            this.equipList.activate();
        } else {
            // Exit the scene
            this.sceneManager.pop();
        }
    }

    updateEquipList() {
        this.equipList.setItems(this.equipmentSlots.map(slot => ({
            text: `${slot}: ${this.actor.equipment[slot]?.name || '-------'}`,
            value: slot
        })));
    }
    
    update(input) {
        if (input.isPressed('ArrowDown') || input.isPressed('ArrowUp')) {
            const activeList = this.equipList.active ? this.equipList : this.itemList;
            activeList.update(input);
            this.updateHelpText();
        }
        if (input.isPressed('Enter')) {
            this.handleConfirm();
        }
        if (input.isPressed('Escape')) {
            this.handleCancel();
        }
    }
    
    draw(context) {
        if (!this.visible) return;
        super.draw(context); // Draws the windows
        
        this.children.forEach(child => {
            if (child.drawContent) { // Manually draw window contents
                context.save();
                context.translate(child.x, child.y);
                child.drawContent(context);
                context.restore();
            }
        });
    }
}

Don't forget to wire this new scene into your MainMenuScene to replace the placeholder!

4. Data-Driven Item Usage

Our learning outcome includes "item usage." This is where the power of a data-driven approach truly shines. Instead of hard-coding what a Potion does, we can store its effect directly in our item data as a function. When the player uses the item, we just execute that function.

This approach is incredibly flexible. The effect function can modify actor stats, change game state variables, or even trigger complex events, all without changing the core inventory code. The following video demonstrates this exact concept masterfully using GameMaker Language, but the principles are universal and translate perfectly to JavaScript.

Full Item and Item Inventory System in GMS2!

The video 'Full Item and Item Inventory System in GMS2!' by Peyton Burnham provides an incredibly detailed guide to building a robust inventory. We're going to focus on a specific, powerful technique it introduces.

Please watch the section on Implementing Item Usage with Custom Functions (44:16 - 51:40). The key takeaway is how an 'effect' property is added to the item's data structure. This property holds a function that gets executed when the item is used. Notice how this allows for completely different outcomes (healing vs. deploying a bomb) using the same core 'use item' logic.

Let's apply this to our itemData.js. We'll add an effect function to our Potion.

// /data/itemData.js (Updated)

// ... equipment ...

'potion01': {
    id: 'potion01',
    name: 'Potion',
    type: 'consumable',
    target: 'single_ally', // Useful for battle, for now just affects the user
    description: 'Restores 50 HP.',
    effect: (user) => {
        // Calculate the amount to heal
        const healAmount = 50;
        const newHp = Math.min(user.stats.getFinal('maxHp'), user.hp + healAmount);
        
        if (newHp === user.hp) {
            console.log(`${user.name} is already at full health.`);
            return false; // The item was not consumed
        }
        
        user.hp = newHp;
        console.log(`${user.name} restores ${healAmount} HP.`);
        return true; // The item was successfully consumed
    }
}

Finally, we need to update our EquipmentScene to handle this. We can add logic so that if the player is in the item list (ITEM_SELECT mode) and presses a different key (e.g., 'Shift'), it triggers the "use" action instead of "equip."

// In EquipmentScene's update method:

update(input) {
    // ... existing up/down navigation ...
    
    if (input.isPressed('Enter')) {
        this.handleConfirm();
    }
    if (input.isPressed('Escape')) {
        this.handleCancel();
    }
    // Add new logic for using an item
    if (input.isPressed('ShiftLeft') && this.mode === 'ITEM_SELECT') {
        this.handleUseItem();
    }
}

// Add the new handler method to EquipmentScene:

handleUseItem() {
    const selectedItemId = this.itemList.getSelectedValue();
    const item = itemData[selectedItemId];

    if (item && item.type === 'consumable' && item.effect) {
        const consumed = item.effect(this.actor); // Execute the effect
        if (consumed) {
            gameState.inventory.removeItem(selectedItemId, 1);
            
            // Refresh the item list to show the new quantity
            this.handleConfirm(); // A bit of a hack, re-enters ITEM_SELECT mode to refresh the list
            this.handleCancel();
            this.handleConfirm();
            
            this.updateStatsDisplay(); // Update HP display
        }
    }
}
Test your understanding!

You want to create a "Strength Tonic" that permanently increases a character's base str by 1. How would you define the effect function for this item in itemData.js? Remember that our Stats class from the last lesson stores baseStats.

Show answer

The effect function would directly modify the actor.stats.baseStats object.

'str_tonic': {
    id: 'str_tonic',
    name: 'Strength Tonic',
    type: 'consumable',
    description: 'Permanently increases base Strength by 1.',
    effect: (user) => {
        user.stats.baseStats.str += 1;
        console.log(`${user.name}'s base Strength increased!`);
        return true; // The tonic is consumed.
    }
}

This demonstrates the power of this approach. The UI doesn't need to know anything about what a "Strength Tonic" is; it just calls the effect function and removes the item if the function returns true.

Conclusion

This was a dense but incredibly productive lesson! We have successfully created a fully functional inventory and equipment management system, a cornerstone of any JRPG.

Key Takeaways:

  • Inventory API: A dedicated Inventory class provides a clean, centralized API for adding, removing, and querying items, abstracting the underlying data structure.
  • Complex UI with State: We managed a multi-window UI by using a simple state machine (this.mode) to control user focus and interaction logic. This is a common pattern in both game and web development.
  • Data-Driven Effects: Storing behavior (like an item's effect) as functions within our data files is a powerful, scalable technique. It allows us to add new and varied item functionalities without ever touching the core engine code.

Preview of the next lesson:
Now that our party can manage their items, they'll need a place to stock up and sell unwanted loot. In the next lesson, "Implement the UI for a shop, including buy/sell logic," we will build a shop screen. This will leverage the Inventory API and UI components we created today to handle transactions with the player's gold and inventory.

Can't find a good explanation? Sign up and we'll make it for you

Sign up