Building a Config-Driven Browser RPG in React
The rpg repo is my attempt to build an RPG sandbox without hardcoding every rule into the UI. Instead of treating item upgrades, class growth, battle skills, and story progression as separate one-off features, I built the app around a central configuration model and a small set of stateful engines that interpret that model at runtime.
The game itself is a browser RPG with several layers: story mode, resource management, item operations, entity management, and a turn-based battle simulator. Underneath that, though, the interesting part is not the tabs. The interesting part is that most of the game rules are data.
What I Built
At the top level, the application does a few distinct jobs:
- Generates characters and items from an RPG schema.
- Manages currencies, rarity tiers, prefixes, affinities, and equipment slots.
- Runs item operations like rerolls, salvage, transmog, and upgrades.
- Presents a story mode with gated chapters and battles.
- Simulates turn-based combat with buffs, debuffs, status effects, cooldowns, and skill scaling.
The code is React-heavy, but I did not want React components to become the rules engine. Components should render state and trigger actions. They should not be the place where business logic goes to die.
Architecture
The center of the repo is gameConfig. That object defines archetypes, rarity limits, stat systems, affinities, resource types, equipment slots, progression curves, and operation definitions. Once that was in place, most of the rest of the app became an interpreter.
This is the shape I was after:
transmog: {
name: "Transmog",
resourceType: "transmogDust",
allowedTypes: [
"gloves", "legs", "feet", "weaponMain", "weaponOff",
"belt", "chest", "head", "accessory", "gem", "artifact", "pet"
],
cost: {
resources: [{ type: "transmogDust", base: 50, amount: 50 }]
},
successChance: { base: 1.0 },
modifications: [
{
target: "otherProperty",
action: "modify",
parameters: {
propertyKey: "subType",
value: {
dynamic: `
(item) => {
const arch = gameConfig.tuiRpgConfig.technicalRequirements
.baseEntityClass.entityArchetypes[item.type];
if (!arch || !arch.properties?.subTypes) return item.subType;
const subTypes = arch.properties.subTypes;
const randomIndex = Math.floor(Math.random() * subTypes.length);
return subTypes[randomIndex];
}
`
}
}
}
]
}
That pattern let me define operations as data instead of writing a custom handler for every button in the UI. The operation engine only needs to understand targets, actions, constraints, costs, and dynamic values. The content team in this repo is basically me, but the architecture still benefits from the separation.
State Management
The runtime state sits in GameStateContext. That context exposes resources, entities, config reads, equipment operations, and story progress. It also boots the initial player character and resource pool from config instead of duplicating those defaults in the UI.
One detail I cared about was entity updates. Operations like rerolling stats do not behave like a normal shallow merge. They may need to remove old stat keys, add new ones, and keep the rest of the entity intact. So the update path supports updates, toDelete, and toAdd explicitly.
const updateEntity = useCallback((entityId, update) => {
setEntities((prev) => {
const index = prev.findIndex((entity) => entity.id === entityId);
if (index === -1) return prev;
const oldEntity = prev[index];
const updates = update.updates ? update.updates : update;
let newStats = { ...oldEntity.stats };
if (updates.stats) newStats = { ...newStats, ...updates.stats };
if (update.toDelete && Array.isArray(update.toDelete)) {
update.toDelete.forEach((key) => delete newStats[key]);
}
if (update.toAdd) {
newStats = { ...newStats, ...update.toAdd };
}
const newEntity = {
...oldEntity,
...updates,
stats: newStats,
prefixes: updates.prefixes || oldEntity.prefixes,
modifications: updates.modifications || oldEntity.modifications,
};
const newEntities = [...prev];
newEntities[index] = newEntity;
return newEntities;
});
}, []);
That gave me a much cleaner boundary between state mutation and operation interpretation. The operation engine can say what changed; the context can decide how to persist it.
Entity Generation
The EntityGenerator class is where characters and items are born. It reads archetypes from config, rolls subtype and rarity, initializes stats, applies class growth, calculates proficiencies, clamps values, and builds names.
For items, it leans into randomness: weighted rarity rolls, prefix pools, affinity pools, and stat caps based on rarity tier. For characters, it does the opposite. Characters get a more deterministic stat initialization path, then class-specific growth is applied on top:
- Weighted rarity for loot.
- Config-driven base stats and caps.
- Class growth formulas for player characters.
- Derived stats and proficiencies calculated after creation.
That split turned out to be important. Items should surprise the player. Core characters should not feel arbitrary.
The Operation Engine
The real core of the project is useOperationEngine. That hook loads operation definitions from config, checks affordability, evaluates constraints, runs success rolls, applies modifications, clamps stats, and persists the result.
The reason I like this architecture is that operations are composable. A reroll is not a special-case screen. It is just a recipe:
- Remove old prefixes.
- Pick new ones from a pool.
- Charge the correct dust.
- Respect the current rarity cap.
- Update the entity in one state transition.
That same machinery also handles salvage, upgrade, transmog, and other operations. Once the interpreter existed, adding new content became much cheaper than adding new systems.
UI Composition
HomeScreen is the shell that makes the whole thing usable. It pulls config and state from context and renders a tabbed experience: story mode, operations, entities, and battle simulation. I kept the router surface area small and let game state drive the internal experience instead of turning every view into a route.
The start screen is intentionally tiny, but there is one practical detail in it that matters: it unlocks the audio context before the game starts. That prevents the app from tripping over browser autoplay restrictions later.
Battle Simulation
The battle simulator is a separate subsystem, but it still follows the same design principle: data first, engine second. Skills come from config, teams are represented as CombatantState, and damage is derived from base values plus scaling, synergy, resistances, and statuses.
function computeSkillDamage(
skill: SkillInfo,
actor: CombatantState,
defender: CombatantState,
usageCount: number
): number {
const aStats = getFinalCombatantStats(actor);
let dmg = skill.damage_target || 0;
if (skill.baseScaling?.strength?.multiplier) {
dmg += (aStats.strength || 0) * skill.baseScaling.strength.multiplier;
}
if (skill.baseScaling?.dexterity?.multiplier) {
dmg += (aStats.dexterity || 0) * skill.baseScaling.dexterity.multiplier;
}
if (skill.baseScaling?.energy?.multiplier) {
dmg += (aStats.energy || 0) * skill.baseScaling.energy.multiplier;
}
const dStats = getFinalCombatantStats(defender);
if (skill.damage_type?.attackType?.includes("melee")) {
const dr = dStats.damageReduction || 0;
dmg -= (dr / 100) * dmg;
}
return Math.max(0, Math.floor(dmg));
}
That code is not especially glamorous, but it is exactly the kind of mechanical layer I wanted: one place that turns skill definitions and combatant state into a result the UI can explain.
Why I Built It This Way
The main reason I like this repo is that it scales in the right direction. Adding a new rarity tier, resource type, operation, or skill does not force a component rewrite. The UI mostly stays the same because the engine is interpreting data, not baking in assumptions.
If I kept pushing this project, I would do three things next:
- Split the giant config into domain-specific files with schema validation at load time.
- Add stronger TypeScript types to the operation and entity layers instead of tolerating
@ts-nocheckin core files. - Pull the battle rules into a pure simulation module so it can be tested outside React.
Even in its current form, though, the repo captures the design goal I cared about most: build the RPG as a rules engine with a UI, not a UI with some RPG logic jammed inside it.
check it out here
https://scriptinghobby.com/rpg/index.html