Appearance
How to Build: Skill System
One of the things that makes idle RPGs satisfying is watching numbers go up. You fight a goblin, your attack skill ticks up a few points, and eventually you hear that level-up chime. The skill system is what drives that feeling -- it tracks experience points earned from combat actions, checks for level-ups, and feeds back into the damage formula so the player actually gets stronger over time.
We have four combat skills, each with its own level and experience point track, and an exponential curve that keeps early progression fast while still giving long-term players something to chase.
The four skills
Each skill directly affects combat calculations:
- Attack -- accuracy, meaning hit chance against enemies
- Strength -- damage output per hit
- Defence -- evasion and damage reduction
- Hitpoints -- maximum health pool
These map to the Skills trait on the player entity:
typescript
const Skills = trait({
attackLevel: 1, attackXp: 0,
strengthLevel: 1, strengthXp: 0,
defenceLevel: 1, defenceXp: 0,
hitpointsLevel: 10, hitpointsXp: 0,
})Notice that hitpoints starts at level 10, not 1. This gives the player a reasonable health pool from the beginning rather than dying in one hit. The other three skills start at 1, meaning the player is weak but immediately starts growing.
How experience points are earned
Experience points are earned passively from combat actions. The player doesn't choose where experience points go -- the game awards them based on what happened during each combat tick:
- Player lands a hit --> Attack experience points
- Player deals damage --> Strength experience points (scaled by damage dealt)
- Player is attacked (hit or miss) --> Defence experience points
- Any combat tick --> Hitpoints experience points
This creates a natural feedback loop. A player who fights a lot of enemies levels up all four skills. A player who fights tough enemies (taking more hits, dealing with higher defence) progresses defence faster. The exact amounts per action are tuning knobs you'll adjust during playtesting.
The experience point curve
We covered this in the shared game data layer, but it's worth revisiting in context. The curve function:
typescript
function xpForLevel(level: number): numberReturns the experience points required to reach a given level. With the exponential formula (100 * 1.1^(level-1)), level 2 takes 100 experience points, level 10 takes about 236, and level 50 takes about 11,739. Each level is roughly 10% harder than the last.
The beauty of this approach is that the interface never changes even if you swap the formula entirely. Maybe you switch to a polynomial curve, or a lookup table, or a piecewise function with breakpoints. As long as xpForLevel(n) returns a number, every system that depends on it keeps working.
Granting experience points and checking for level-ups
The core action takes an entity, a skill name, and an amount:
typescript
function grantXp(
entity: Entity,
skill: 'attack' | 'strength' | 'defence' | 'hitpoints',
amount: number,
): voidInside, the logic is a while loop. Add the experience points to the current total, then check: does the new total meet or exceed the threshold for the next level? If so, subtract the threshold, increment the level, and check again. This handles the edge case where a single large experience point grant pushes through multiple levels at once.
There's one special case: when hitpoints levels up, the player's maximum health increases and their current health gets a +1 bump. This means leveling hitpoints during combat gives a small immediate benefit, which feels great.
typescript
// Pseudocode for the level-up loop:
// 1. Add amount to current experience points for this skill
// 2. While current experience points >= xpForLevel(currentLevel + 1):
// a. Subtract the threshold from current experience points
// b. Increment the level
// c. If skill is 'hitpoints', increase max health and heal 1 point
// 3. Write updated experience points and level back to the entityDeriving effective stats
Raw skill levels aren't the whole picture. A player with Attack level 5 and a Copper Sword (+2 attack bonus) has an effective attack of 7. The combat system needs these combined values, and the equipment menu needs them for comparison previews.
Rather than storing derived stats as a separate trait that needs to be kept in sync, compute them on demand:
typescript
function getEffectiveStats(
entity: Entity,
): { attack: number; strength: number; defence: number }This function reads the entity's Skills and Equipment traits, looks up the gear definitions from shared data, and sums the bonuses. It's cheap enough to call every combat tick without worrying about caching.
The reason I prefer on-demand computation over a "derived stats" trait that syncs every frame: it eliminates an entire class of bugs where the derived trait is stale because some system forgot to trigger a recalculation. The source of truth is always Skills + Equipment, and the derived value is always fresh.
Wiring it all together
The combat tick system (built in Phase 3) is what actually calls grantXp. The flow looks like this:
- Combat tick fires
- Player attacks an enemy -- if the hit lands, call
grantXp(player, 'attack', 1) - Hit deals damage -- call
grantXp(player, 'strength', damageDealt) - Enemy attacks player (regardless of hit/miss) -- call
grantXp(player, 'defence', 1) - Any combat activity -- call
grantXp(player, 'hitpoints', 1)
The skill system doesn't know or care about combat mechanics. It just receives experience point grants and handles progression. Combat doesn't know or care about experience point curves. It just calls grantXp when things happen. Clean separation.
File organization
shared/game-data/xp.ts # xpForLevel function (shared across packages)
src/core/actions/skills.ts # grantXp actionThat's it -- two files. The Skills trait itself lives on the player entity (defined in the player traits file). The experience point curve lives in shared data because the wiki package also uses it to display progression tables. The grant action lives in core because it's called by the combat system.
We now have a progression system that rewards the player for engaging in combat, scales infinitely, and feeds directly into the damage formula. Next up: giving the player something to spend all that newfound power on by moving them through the world.