Level-Up Event Orchestrator

The level-up orchestrator is the flow that fires every time the player’s accumulated XP crosses the threshold for the next level. It is the single ceremony that turns raw XP gain into a hand-of-cards choice, pausing the simulation while the player decides which reward to apply.

Flow

The orchestrator runs as a sequenced ceremony each time LevelingSystem.checkLevelUp(game) returns true. The simulation update loop calls LevelingSystem.update(game) once per frame, which loops on checkLevelUp so multi-level jumps (e.g. boss-kill XP dumps) queue one card per level rather than collapsing into a single choice.

  1. Threshold cross. game.xp >= XP_THRESHOLDS[game.level + 1]. The XP threshold table is _BASE_THRESHOLDS (levels 0-20, hand-tuned) and extends infinitely beyond level 20 by compounding the previous level’s cost at 12% per step via _ensureThreshold.
  2. Increment level. game.level++ and game.enemyDifficultyLevel++ together — every level-up bumps enemy difficulty in lockstep with player power.
  3. Recompute xpToNext. XP_THRESHOLDS[game.level + 1] - game.xp so the HUD bar redraws against the new ceiling.
  4. Fire level_up signal. Sig.fire('level_up', 0, 0, game.level, 0, '') — the effect system hooks (artifacts that trigger on level-up) listen here.
  5. Pause sim. game.rewardQueue.push({ type: 'level_up', level: game.level }). The reward queue is consumed by the UI layer, which sets timeDilation = 0 while a reward card is being shown — the world freezes, projectiles hang in mid-air, the player picks at their leisure.
  6. Generate reward choices. generateRewardChoices(game, count, ship) builds the card pool (see below).
  7. Display reward cards. UI renders the choices as tappable cards with rarity borders, before/after stat values, and NEW badges for first-time picks.
  8. Await tap. Player makes one selection. Reroll and banish charges may consume the current pool and reroll, or remove a specific card from the run’s pool forever.
  9. Apply reward. applyReward(choice, ship, game) dispatches on choice.type — modifier picks register as permanent Modifier entries (source level_up:<modId>:<stat>), weapon upgrades bump weapon.level, merges consume the two parents and grant the legendary, artifacts route through grantArtifact.
  10. Unpause. Reward queue empty → timeDilation = 1 → simulation resumes.

Reward pool composition

generateRewardChoices builds the card pool from up to five categories, all competing for the count slots (default 3). Categories:

  1. Modifiers (ship upgrades). Split into NEW (upgradeCounts[id] === 0, not yet picked) and UPGRADE (already owned, current level below maxLevel). damage_* modifiers are filtered to the damage tags the player’s currently-owned weapons actually carry — picking up a damage_fire mod when you own no fire weapons is impossible. damage_all is the exception: offered as long as the ship has at least one weapon.
  2. Weapon upgrades. Owned weapons below MAX_WEAPON_LEVEL (20). Each card bumps weapon.level by max(1, round(rarityMult)) — legendary rolls add 2 levels in one pick.
  3. New weapons. Surfaced from weapon chests (generateWeaponChoices) rather than level-up cards. Legendaries are excluded from chest pools — they’re merge-only.
  4. Weapon merges. findMergeCandidates(ship) returns any pair of level-20 non-legendary weapons that combine into a specific legendary. Merge cards are prepended to the pool — they take priority slots up to ceil(count / 2), so a level-up that surfaces a merge will always show it as the first card.
  5. Artifacts and shooting-star cards. Surfaced via separate paths (grantArtifact, generateShootingStarChoices) and merged into the same RewardChoice[] type with their own type discriminator.

Slot allocation

After prepending merges, each remaining slot independently rolls weapon (35%) vs. modifier (65%). This produces a natural distribution around ~35% weapon upgrades per pull. If the chosen pool is exhausted, the slot falls back to the other pool. Both pools are Fisher-Yates shuffled before drawing.

Hard caps: never offer the same weapon twice in one pull, never offer the same modifier twice in one pull. A padding pass at the end fills any unfilled slots from remaining unused entries. If a pull genuinely has fewer unique candidates than count, the player gets fewer cards rather than dupes.

Per-card rarity roll

Every card rolls its own rarity independently via _rollRarity(ship):

RarityWeightMultiplier
Common501.0
Uncommon301.25
Rare151.5
Epic41.75
Legendary12.0

Luck shifts the distribution via effectiveLuck = ship.luck * (1 + ship.luckMult). Each non-common tier’s weight is multiplied by (1 + effectiveLuck * _LUCK_BIAS[tier]) with bias coefficients [0, 0.01, 0.02, 0.03, 0.04] — legendary scales fastest, common stays unboosted so it’s never starved. The roll is uniform random against the post-luck weighted total.

The multiplier feeds back into the reward effect: modifier picks scale every effect value by mult, weapon-upgrade picks gain round(mult) extra levels (1 for common-rare, 2 for epic-legendary), and the card border colour matches the rolled rarity.

Banished keys

game.banishedKeys: Set<string> holds banish-charge entries that filter the pool. Keys are built by banishKeyForChoice:

  • weapon|<id>, weapon_upgrade|<id>, weapon_merge|<legendaryId>, modifier|<id> — direct id-based banishing.
  • artifact_new|<id> vs. artifact_upgrade|<id> — separate keys so banishing a brand-new artifact doesn’t lock out future tier-ups of an already-owned one.
  • shooting_star|<category> — banishes a shooting-star category from the pool for the rest of the run.
  • grant_reroll / grant_banish / grant_refuel are not banishable — banishing the resource-grant cards would break the only refill path for the charge they grant.

The filter runs at the start of generateRewardChoices against modifier candidates, weapon upgrades, and merge candidates. Banished entries are dropped before the slot-allocation phase, so banished items never even reach the rarity roll.