Halve XP curve base from 100 to 50 for faster early leveling

- Level 1 now costs 5 kills of the base craft instead of 10
- Total XP to reach level 80 drops from ~17.4M to ~8.7M
- Update XpManager default, progression data, tests, and README to match
This commit is contained in:
Brian Fertig 2026-09-19 12:07:18 -06:00
parent 8ceb21808f
commit 0cf859363c
5 changed files with 40 additions and 40 deletions

View File

@ -93,8 +93,8 @@ respawns, the bar — lives in **`data/combat.json`**:
(the roster sets XP-per-threat: the base craft 10, the bomber 25 —
`data/combat.json → enemy.xp`), banked against a curve that's easy at
first and hard later, never a grind (`data/progression.json` —
`base × n^power`, shipped 100 × n², capped at level 80; ten base kills
buy level 1, the whole run to the cap is 17.4M XP). A crossed level
`base × n^power`, shipped 50 × n², capped at level 80; five base kills
buy level 1, the whole run to the cap is 8.7M XP). A crossed level
fires a `levelup` EVENT (one per level — the armor bump
(`progression.reward`: +3 hull / +2 shields, full heal), the burst +
voice, the feed's line all ride it; the skill layer — active and

View File

@ -45,7 +45,7 @@
},
"enemy": {
"_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). xp = the EXP a kill of one is worth (the XP bank's currency — js/combat/XpManager.js; the curve is data/progression.json, and the base value is 10, so ten kills of the base craft = level 1). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.",
"_comment": "The hostile craft. texture = spritesheet of frameWidth×frameHeight frames (frame picks which one — frame 0 = the red triple-boost fighter, art facing north, filling its frame; the sheet is 10×10 of 128px frames). size = hull length in world px (render = size × the art's footprint), hitRadius = the collision circle in world px (bullets + rams). count = hostiles in the arena at once. speed px/s + turn (1/s velocity-approach rate) = the chase feel. orbitRadius px = the standoff ring they keep around the player; orbitSpeed rad/s = how fast they circle it (direction alternates per craft). hull/shields = their armor (shields absorb first — js/combat/Combat.js). xp = the EXP a kill of one is worth (the XP bank's currency — js/combat/XpManager.js; the curve is data/progression.json, and the base value is 10, so five kills of the base craft = level 1). weapon.* = their lance: range px, cooldown s, bulletSpeed px/s, damage, spread rad, bulletLife s; boltLength/boltWidth px + muzzle px are the lance's VISUALS (the bolt's length × thickness and the muzzle flash's size — js/combat/Bullet.js + Fx.muzzle). ramDamage = hull damage a COLLISION deals you; ramDamageSelf = what the collision deals THEM (per hit, ramGrace s apart). respawn = the pack reforms (respawnDelay s) after a kill, so the range stays stocked while you tune.",
"texture": "assets/images/ships-enemy.png",
"frameWidth": 128,
"frameHeight": 128,

View File

@ -1,7 +1,7 @@
{
"_comment": "COMBAT PROGRESSION — the combat level + experience bank (js/combat/XpManager.js, wired into js/combat/CombatSystem.js's kill bookkeeping). maxLevel = the level CAP (the player starts at level 0 — the barometer: level 1 costs 10 base enemies). The curve: xpForLevel(n) = base × n^power, where n = the level being ADVANCED TO (1..maxLevel) — a quadratic: level 1 = 100 XP (10 × the raider's 10 XP, data/combat.json → enemy.xp), and each level's cost is n² — the relative step SHRINKS (4×, 2.25×, 1.78× … 1.03×), so early levels fly by and the late ones never become a wall (the 'easier than WoW' pitch: with an honest roster, kills-per-level grow LINEARLY, 10n). power = the GRIND INTENSITY knob (lower = gentler back half — 1.8; higher = steeper — 2.2); base scales the WHOLE curve (lower base = faster early levels). Enemy XP is PER-TYPE (each combat.enemyTypes entry's own `xp` — the rule of thumb is (hull+shields)/5, rounded): the curve is fixed in XP, the roster sets XP-per-threat, so pacing tunes two independent dials. CAP BEHAVIOR: the level holds at maxLevel and the XP is BANKED (xp keeps counting — harmless; the post-cap currency will be the skill layer, not XP). reward = the per-level ARMOR BUMP the level-up grants (hullPerLevel/shieldPerLevel added to combat.playerShip's max armor; fullHealOnLevelUp refills both) — the placeholder toughness until the skill layer lands (a passive can later override or stack these).",
"_comment": "COMBAT PROGRESSION — the combat level + experience bank (js/combat/XpManager.js, wired into js/combat/CombatSystem.js's kill bookkeeping). maxLevel = the level CAP (the player starts at level 0 — the barometer: level 1 costs 5 base enemies). The curve: xpForLevel(n) = base × n^power, where n = the level being ADVANCED TO (1..maxLevel) — a quadratic: level 1 = 50 XP (5 × the raider's 10 XP, data/combat.json → enemy.xp), and each level's cost is n² — the relative step SHRINKS (4×, 2.25×, 1.78× … 1.03×), so early levels fly by and the late ones never become a wall (the 'easier than WoW' pitch: with an honest roster, kills-per-level grow LINEARLY, 5n). power = the GRIND INTENSITY knob (lower = gentler back half — 1.8; higher = steeper — 2.2); base scales the WHOLE curve (lower base = faster early levels). Enemy XP is PER-TYPE (each combat.enemyTypes entry's own `xp` — the rule of thumb is (hull+shields)/5, rounded): the curve is fixed in XP, the roster sets XP-per-threat, so pacing tunes two independent dials. CAP BEHAVIOR: the level holds at maxLevel and the XP is BANKED (xp keeps counting — harmless; the post-cap currency will be the skill layer, not XP). reward = the per-level ARMOR BUMP the level-up grants (hullPerLevel/shieldPerLevel added to combat.playerShip's max armor; fullHealOnLevelUp refills both) — the placeholder toughness until the skill layer lands (a passive can later override or stack these).",
"maxLevel": 80,
"base": 100,
"base": 50,
"power": 2,
"reward": {
"hullPerLevel": 3,

View File

@ -55,19 +55,19 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const m = new XpManager();
const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10;
// The curve (data/progression.json — base × n^power, shipped: 100 × n²):
check('curve: level 1 = 100 XP (the data, not a private copy)', m.xpForLevel(1) === 100);
check('curve: level 1 = ten base kills (the barometer)', m.xpForLevel(1) === 10 * baseXp);
check('curve: level 2 = 400 XP', m.xpForLevel(2) === 400);
check('curve: level 3 = 900 XP', m.xpForLevel(3) === 900);
check('curve: level 80 = 640,000 XP', m.xpForLevel(80) === 640000);
// The curve (data/progression.json — base × n^power, shipped: 50 × n²):
check('curve: level 1 = 50 XP (the data, not a private copy)', m.xpForLevel(1) === 50);
check('curve: level 1 = five base kills (the barometer)', m.xpForLevel(1) === 5 * baseXp);
check('curve: level 2 = 200 XP', m.xpForLevel(2) === 200);
check('curve: level 3 = 450 XP', m.xpForLevel(3) === 450);
check('curve: level 80 = 320,000 XP', m.xpForLevel(80) === 320000);
check('curve: quadratic (n²) — level n = n² × level 1', m.xpForLevel(5) === 25 * m.xpForLevel(1));
check('curve: the relative step shrinks (2→3 is a smaller × than 1→2)',
m.xpForLevel(3) / m.xpForLevel(2) < m.xpForLevel(2) / m.xpForLevel(1));
check('curve: the cap is the level (80), not the formula', m.maxLevel === 80);
check('curve: xpForLevel(<1) = 0', m.xpForLevel(0) === 0 && m.xpForLevel(-4) === 0);
check('curve: fresh ledger (level 0, 0 xp, not max)',
m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 100);
m.level === 0 && m.xp === 0 && m.isMax === false && m.xpForNext() === 50);
}
// ------------------------------------------------------------- the grants
@ -76,12 +76,12 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const m = new XpManager();
const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10;
// The barometer, as a player would see it: ten kills of the base craft.
// The barometer, as a player would see it: five kills of the base craft.
let levelUps = 0;
const sub = m.on('levelup', () => levelUps++);
for (let i = 0; i < 10; i++) m.grant(baseXp, 'kill');
check('grant: ten base kills → level 1 (xp 0, the bank is spent)',
m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 400);
for (let i = 0; i < 5; i++) m.grant(baseXp, 'kill');
check('grant: five base kills → level 1 (xp 0, the bank is spent)',
m.level === 1 && m.xp === 0 && levelUps === 1 && m.xpForNext() === 200);
sub();
m.grant(999);
check('grant: the unsubscribe stops the listener (no more levelup events)',
@ -89,15 +89,15 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// A BIG grant crosses several levels — one 'levelup' event EACH (an
// "unlocks exactly at level N" rule can't miss its level). 10,000 buys
// 100+400+900+1600+2500+3600 = 9,100 → level 6, 900 banked toward 7.
// 50+200+450+800+1250+1800+2450 = 7,000 → level 7, 3,000 banked toward 8.
const m2 = new XpManager();
const crossed = [];
m2.on('levelup', (ev) => crossed.push(ev.to));
const r = m2.grant(10000, 'dev'); // → level 6
check("grant: a big grant crosses to level 6 (the curve's math)",
r.level === 6 && r.levelsGained === 6 && r.leveledUp === true && m2.xp === 900);
check('grant: one levelup event PER level crossed (1,2,3,4,5,6)',
JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6]));
const r = m2.grant(10000, 'dev'); // → level 7
check("grant: a big grant crosses to level 7 (the curve's math)",
r.level === 7 && r.levelsGained === 7 && r.leveledUp === true && m2.xp === 3000);
check('grant: one levelup event PER level crossed (1,2,3,4,5,6,7)',
JSON.stringify(crossed) === JSON.stringify([1, 2, 3, 4, 5, 6, 7]));
// The source tag rides the events (the ledger's audit trail).
const m3 = new XpManager();
@ -141,9 +141,9 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
m.on('levelup', (ev) => levelEvents.push(ev.to));
// A grant bigger than the WHOLE curve (the sum of levels 1..80 =
// 17,388,000): the level caps, the overflow is BANKED (harmless — the
// 8,694,000): the level caps, the overflow is BANKED (harmless — the
// post-cap currency will be skills, not XP).
const totalToCap = (100 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 100
const totalToCap = (50 * (max * (max + 1) * (2 * max + 1))) / 6; // Σ n², base 50
const huge = totalToCap + 12345;
const r = m.grant(huge, 'dev');
check('cap: the level holds at the cap (80)', r.level === max && m.isMax === true);
@ -177,14 +177,14 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
{
const m = new XpManager();
m.grant(550, 'kill'); // 550 = 100 + 400 + 50 → level 2, 50 banked
check('state: level 2 with 50 banked toward level 3', m.level === 2 && m.xp === 50);
m.grant(550, 'kill'); // 550 = 50 + 200 + 300 → level 2, 300 banked
check('state: level 2 with 300 banked toward level 3', m.level === 2 && m.xp === 300);
const json = m.toJSON();
check('save: the record is {level, xp} — the curve stays in data',
json.level === 2 && json.xp === 50 && Object.keys(json).length === 2);
json.level === 2 && json.xp === 300 && Object.keys(json).length === 2);
const m2 = XpManager.fromJSON(json);
check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 50);
check('restore: level + xp land, no events', m2.level === 2 && m2.xp === 300);
// A restore clamps junk (a hand-edited record can't break the run).
const m3 = XpManager.fromJSON({ level: 999, xp: -50 });
@ -199,14 +199,14 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// stats(): the bar's snapshot (progress 0..1, the next level's cost).
const m5 = new XpManager();
m5.grant(50, 'kill');
m5.grant(25, 'kill');
const s = m5.stats();
check('stats: level 0, 50/100 toward level 1',
s.level === 0 && s.xp === 50 && s.xpForNext === 100 && approx(s.progress, 0.5) && s.isMax === false);
check('stats: level 0, 25/50 toward level 1',
s.level === 0 && s.xp === 25 && s.xpForNext === 50 && approx(s.progress, 0.5) && s.isMax === false);
m5.grant(60, 'kill');
const s2 = m5.stats();
check('stats: after crossing, 10/400 toward level 2',
s2.level === 1 && s2.xp === 10 && s2.xpForNext === 400 && approx(s2.progress, 0.025));
check('stats: after crossing, 35/200 toward level 2',
s2.level === 1 && s2.xp === 35 && s2.xpForNext === 200 && approx(s2.progress, 0.175));
}
// --------------------------------------------------- the system's wiring
@ -312,15 +312,15 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
check("system: a kill grants the raider's xp (10, level still 0)",
combat.xp.xp === 10 && combat.xp.level === 0 && levels.length === 0);
// Nine more base kills → the level lands: the armor bump + the beat.
for (let i = 0; i < 9; i++) {
// Four more base kills → the level lands: the armor bump + the beat.
for (let i = 0; i < 4; i++) {
const e = combat.spawnEnemy(200 + i * 40, 0, 10 + i);
e.damage(999);
combat.killEnemy(e, 2000 + i);
}
const hullPerLevel = Number(config.get('progression.reward.hullPerLevel', 3)) || 0;
const shieldPerLevel = Number(config.get('progression.reward.shieldPerLevel', 2)) || 0;
check('system: ten base kills → level 1 (the barometer, through the system)',
check('system: five base kills → level 1 (the barometer, through the system)',
combat.xp.level === 1 && levels.length === 1 && levels[0] === 1);
check("system: the level's armor bump stacks on the base (max armor up)",
combat.maxHull === 100 + hullPerLevel && combat.maxShields === 40 + shieldPerLevel);
@ -332,7 +332,7 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// The bomber's kill banks ITS value (the roster sets XP-per-threat).
bomber.damage(999);
combat.killEnemy(bomber, 3000);
check("system: the bomber's kill banks 25 (toward level 2's 400)",
check("system: the bomber's kill banks 25 (toward level 2's 200)",
combat.xp.xp === 25 && combat.xp.level === 1 && levels.length === 1);
// A RESTORED ledger: the level's armor applies on build — with no

View File

@ -8,8 +8,8 @@
*
* xpForLevel(n) = base × n^power (n = the level ADVANCED TO, 1..maxLevel)
*
* with the shipped values (maxLevel 80, base 100, power 2) that makes
* level 1 cost 100 XP — ten kills of the base hostile (its 10 XP,
* with the shipped values (maxLevel 80, base 50, power 2) that makes
* level 1 cost 50 XP — five kills of the base hostile (its 10 XP,
* data/combat.json → enemy.xp) — the barometer. The quadratic means each
* level's cost is n²: the relative step SHRINKS as you climb (4×, 2.25×,
* 1.78× … 1.03×), so early levels fly by and the late ones never become
@ -45,7 +45,7 @@ import { config } from '../config/Config.js';
export class XpManager {
constructor() {
this.maxLevel = Math.max(1, Math.floor(Number(config.get('progression.maxLevel', 80)) || 80));
this.base = Math.max(0, Number(config.get('progression.base', 100)) || 0);
this.base = Math.max(0, Number(config.get('progression.base', 50)) || 0);
this.power = Math.max(0.05, Number(config.get('progression.power', 2)) || 2);
this.level = 0;
this.xp = 0; // progress toward the next level (BANKED at the cap)