Recommended resources
The freely available resources this lesson will direct you to.
Hello! Let's continue our journey into building a JRPG.
In our last lesson, we constructed the framework for our game's UI by implementing the main menu. We created a MainMenuScene that uses a SelectableList component to navigate options and a SceneManager to transition to placeholder sub-screens. This set a strong foundation for all the UI work to come.
Today, we'll replace one of those placeholders with a fully functional screen. We're tackling this learning outcome: Implement the character status screen, displaying base stats, equipment, and final stats. This is more than just a UI task; it requires us to design and implement the core architectural system that governs how character attributes work in an RPG. We'll explore the crucial difference between a character's innate abilities and how they are augmented by their gear.
1. The Architecture of Character Stats: Base vs. Final
Before we draw a single pixel, let's consider the data model. A character in an RPG has stats like Strength, Defense, and Agility. But what happens when they equip a sword that grants +5 Strength? If we simply add 5 to the character's strength value, we create a problem. What happens when they unequip it? We'd have to remember that 5 of their Strength came from the sword. This gets complicated when multiple items and temporary spell effects are involved.
The standard solution in RPG design is to distinguish between base stats and final stats:
- Base Stats: These are the character's intrinsic values, determined by their level and class. They are the "source of truth."
- Final Stats: These are the values used in combat calculations, derived by applying all active modifiers (from equipment, spells, etc.) to the base stats.
This approach is analogous to how a front-end framework like Vue or React uses computed properties or selectors to derive display values from a core state object. The base stats are our state, and the final stats are the computed result.
The following article provides an excellent breakdown of this concept and proposes a robust architecture for managing it.
The article 'How to Make an RPG: Stats' gives a thorough conceptual and practical guide to building a flexible stat system. It starts by defining what stats are and culminates in a code implementation for handling modifiers, which is exactly what we need.
Please read through the article, focusing on these sections: What Is a Stat? and Derived Stats: To understand the basic terminology and the concept of combining stats. A General Stat Class: To see the initial data structure for storing base stats. Modifiers: This is the most important section. Pay close attention to how it categorizes modifiers into add and mult types and provides a clear algorithm for applying them in a specific order to calculate the final stat value. The code examples are in Lua but translate directly to JavaScript objects and classes.
2. Implementing the Stat and Equipment System
Inspired by the article, let's translate that architecture into JavaScript. We'll create a Stats class to manage an entity's attributes and define a data structure for equipment.
The Stats Manager Class
This class will encapsulate all the logic for storing base stats, applying modifiers, and calculating final stats. It's a self-contained system that any game entity (player character, enemy) can use.
// /systems/Stats.js
export class Stats {
/**
* @param {object} baseStats - An object of stat names to their base values, e.g., { str: 10, def: 8 }
*/
constructor(baseStats) {
this.baseStats = { ...baseStats };
this.modifiers = new Map(); // Using a Map to easily add/remove modifiers by a unique ID
}
/**
* Adds a modifier from a source (e.g., equipment, spell).
* @param {string} id - A unique ID for the modifier source (e.g., 'sword-01').
* @param {object} modifier - The modifier object, e.g., { add: { str: 5 }, mult: { hp: 0.1 } }
*/
addModifier(id, modifier) {
this.modifiers.set(id, modifier);
}
/**
* Removes a modifier by its unique ID.
* @param {string} id - The ID of the modifier to remove.
*/
removeModifier(id) {
this.modifiers.delete(id);
}
/**
* Calculates and returns the final value of a stat after all modifiers.
* @param {string} statName - The name of the stat to calculate (e.g., 'str').
* @returns {number} - The final calculated stat value.
*/
getFinal(statName) {
const baseValue = this.baseStats[statName] || 0;
let totalAdditive = 0;
let totalMultiplicative = 0;
for (const modifier of this.modifiers.values()) {
if (modifier.add && modifier.add[statName]) {
totalAdditive += modifier.add[statName];
}
if (modifier.mult && modifier.mult[statName]) {
totalMultiplicative += modifier.mult[statName];
}
}
const modifiedValue = baseValue + totalAdditive;
const finalValue = modifiedValue + (modifiedValue * totalMultiplicative);
// We use Math.floor to ensure we are always working with integers for stats.
return Math.floor(finalValue);
}
/**
* Gets the base value of a stat, ignoring modifiers.
* @param {string} statName - The name of the stat.
* @returns {number}
*/
getBase(statName) {
return this.baseStats[statName] || 0;
}
}
Data Structures for Actors and Equipment
Now, let's integrate this Stats class into our Actor data model. We'll also define what our equipment and character data looks like. This data will eventually be loaded from JSON files, following the data-driven architecture we've discussed.
// /data/equipment.js
export const equipmentData = {
'wpn001': {
id: 'wpn001',
name: 'Bronze Sword',
slot: 'rightHand',
modifier: { add: { str: 5 } }
},
'arm001': {
id: 'arm001',
name: 'Leather Armor',
slot: 'body',
modifier: { add: { def: 4 } }
}
};
// /entities/Actor.js
import { Stats } from '../systems/Stats.js';
import { equipmentData } from '../data/equipment.js';
export class Actor {
constructor(actorData) {
this.name = actorData.name;
this.level = actorData.level;
this.hp = actorData.hp;
this.maxHp = actorData.maxHp;
this.mp = actorData.mp;
this.maxMp = actorData.maxMp;
this.stats = new Stats(actorData.baseStats);
this.equipment = {
rightHand: null,
leftHand: null,
head: null,
body: null,
accessory: null
};
// Equip initial items
actorData.initialEquipment.forEach(itemId => {
const item = equipmentData[itemId];
if (item) {
this.equip(item);
}
});
}
equip(item) {
const slot = item.slot;
// If an item is already in the slot, unequip it first
if (this.equipment[slot]) {
this.unequip(slot);
}
this.equipment[slot] = item;
this.stats.addModifier(item.id, item.modifier);
console.log(`Equipped ${item.name}.`);
}
unequip(slot) {
const item = this.equipment[slot];
if (item) {
this.stats.removeModifier(item.id);
this.equipment[slot] = null;
console.log(`Unequipped ${item.name}.`);
}
}
}
For now, we can create a global player party object to hold our character instances.
// /state/gameState.js
import { Actor } from '../entities/Actor.js';
// This would eventually be loaded from a save file
const playerPartyData = [
{
name: 'Cecil',
level: 10,
hp: 180,
maxHp: 180,
mp: 25,
maxMp: 25,
baseStats: { str: 15, def: 12, agi: 8, int: 5 },
initialEquipment: ['wpn001', 'arm001']
},
// ... other party members
];
export const gameState = {
party: playerPartyData.map(data => new Actor(data))
};
Test your understanding!
An actor has a base Agility (agi) of 12. They equip a 'Swift Dagger' (id: 'dgr002') that provides +4 agi. Then they are hit by a 'Slow' spell (id: 'slow_spell') that applies a -0.5 multiplicative modifier to agi.
What is their final Agility score according to our Stats.getFinal() method?
Show answer
- Base Value:
baseValueforagiis 12. - Modifiers:
- Dagger:
{ add: { agi: 4 } } - Slow Spell:
{ mult: { agi: -0.5 } }
- Dagger:
- Calculate Additive:
totalAdditiveis 4. - Calculate Multiplicative:
totalMultiplicativeis -0.5. - Apply Additions:
modifiedValue=baseValue+totalAdditive= 12 + 4 = 16. - Apply Multiplications:
finalValue=modifiedValue+ (modifiedValue*totalMultiplicative) = 16 + (16 * -0.5) = 16 - 8 = 8. - The final Agility is 8.
3. Implementing the Status Screen UI
With the data layer in place, building the UI becomes a matter of reading from our Actor object and displaying the data. We'll create a StatusScene that composes several Window components to organize the information, just like in classic JRPGs.
Here are some examples of what we're aiming for visually.


Let's create our StatusScene. It will take a character object, read its properties, and display them.
// /scenes/StatusScene.js
import { UIComponent } from '../components/UIComponent.js';
import { Window } from '../components/Window.js';
import { gameState } from '../state/gameState.js';
export class StatusScene extends UIComponent {
constructor({ sceneManager }) {
super({ x: 0, y: 0, width: 816, height: 624 });
this.sceneManager = sceneManager;
// For now, just show the first party member
this.actor = gameState.party[0];
// Create windows for layout
const nameWindow = new Window({ x: 20, y: 20, width: 250, height: 70 });
const statsWindow = new Window({ x: 20, y: 100, width: 250, height: 300 });
const equipWindow = new Window({ x: 290, y: 100, width: 300, height: 300 });
// Add children to the scene
this.addChild(nameWindow);
this.addChild(statsWindow);
this.addChild(equipWindow);
// Store references to update them
this.statsWindow = statsWindow;
this.equipWindow = equipWindow;
this.nameWindow = nameWindow;
this.drawActorInfo();
}
// Method to populate the windows with text content
drawActorInfo() {
// Clear previous content by overriding the draw method of the window's content
this.nameWindow.drawContent = (context) => {
context.font = '24px Arial';
context.fillStyle = 'white';
context.fillText(this.actor.name, 20, 40);
context.fillText(`Lv ${this.actor.level}`, 150, 40);
};
this.statsWindow.drawContent = (context) => {
context.font = '20px Arial';
context.fillStyle = 'white';
const stats = ['hp', 'mp', 'str', 'def', 'agi', 'int'];
let yOffset = 40;
stats.forEach(stat => {
const name = stat.toUpperCase();
let value;
if (stat === 'hp') {
value = `${this.actor.hp} / ${this.actor.stats.getFinal('maxHp')}`;
} else if (stat === 'mp') {
value = `${this.actor.mp} / ${this.actor.stats.getFinal('maxMp')}`;
} else {
value = this.actor.stats.getFinal(stat);
}
context.fillText(name, 20, yOffset);
context.fillText(value, 150, yOffset);
yOffset += 30;
});
};
this.equipWindow.drawContent = (context) => {
context.font = '20px Arial';
context.fillStyle = 'white';
context.fillText('Equipment', 20, 40);
const slots = ['rightHand', 'body', 'head', 'accessory'];
let yOffset = 80;
slots.forEach(slot => {
const item = this.actor.equipment[slot];
const itemName = item ? item.name : '-------';
context.fillText(slot, 20, yOffset);
context.fillText(itemName, 120, yOffset);
yOffset += 30;
});
};
}
// We override the base UIComponent draw method to inject content drawing
draw(context) {
if (!this.visible) return;
super.draw(context); // This will draw the windows
// Now we draw the content on top
this.children.forEach(child => {
if (child.drawContent) {
context.save();
context.translate(child.x, child.y);
child.drawContent(context);
context.restore();
}
});
}
update(input) {
if (input.isPressed('Escape') || input.isPressed('Enter')) {
this.sceneManager.pop();
}
}
}
Note: I've added a drawContent property to our windows to inject the text rendering logic. This keeps the Window class generic while allowing the StatusScene to control what's drawn inside.
Finally, update MainMenuScene.js to launch our new scene:
// In /scenes/MainMenuScene.js
import { StatusScene } from './StatusScene.js'; // Import the new scene
// ... inside the handleMenuConfirm method ...
case 1:
console.log('Transitioning to Status Scene...');
// Replace the placeholder with the real scene
this.sceneManager.push(new StatusScene({ sceneManager: this.sceneManager }));
break;
Conclusion
Today we've built one of the most fundamental systems in any JRPG. We didn't just create a screen; we engineered the entire pipeline for character stats, from their base values to the final, equipment-modified numbers used in gameplay.
Key Takeaways:
- Base vs. Final Stats: Separating a character's core stats from their modified stats is essential for a scalable and bug-free equipment and status effect system.
- Modifier System: A flexible modifier system (like the one handling
addandmultproperties) can manage bonuses from equipment, spells, and other sources in a predictable order. - Data-Driven UI: Our
StatusSceneis purely presentational. It reads data from theActorobject and displays it. Any change to the actor's equipment will automatically be reflected the next time the scene is drawn, without needing to change the UI code itself.
Preview of the next lesson:
We can now see our character's equipment, but we can't change it. In the next lesson, we will implement the inventory and equipment screens. This will involve creating a navigable list of items from the player's inventory and adding the logic to call the actor.equip() and actor.unequip() methods we created today, closing the loop on our equipment management system.