← DEVLOG
Interactive2026.04.266 min read

Galaxy Wing — Weapon System + Module Split Retrospective

From single weapon with 5 power levels to 5×7 weapons + 5 subweapons, plus splitting a 1035-line logic.ts into 8 modules

canvasgameweapon-systemsubweaponrefactoringmodularization

Where We Started — 5-Way Was the Cap

Galaxy Wing originally had a single weapon with 5 power-up tiers. The most you could see was a 5-way fan, and there wasn't much progression to discover. I decided to expand to 7-tier deeper evolution + weapon type switching + subweapons all in one pass.


5 type × 7 level — Branching + Depth

I defined five types — spread / laser / wave / plasma / charge — and packed all 7-level evolution data into a single object.

export const WEAPON_DEFS: Record<WeaponType, readonly WeaponLevelDef[]> = {
  spread: [
    /* 7 levels */
  ],
  laser: [
    /* 7 levels */
  ],
  wave: [
    /* 7 levels */
  ],
  plasma: [
    /* 7 levels */
  ],
  charge: [
    /* 7 levels */
  ],
};

Each level defines angles / offsets / color / radius / damage / speed / fireInterval, plus optional type-specific fields (piercing / waveAmplitude / plasmaSplash / chargeTime). With this data-driven approach, firePlayer collapsed into a single spawn loop.

Drop kinds split into 'P' (level up within type) and 'W' (cycle to next type, restart at level 1).


Charge — Hold-Release Mechanism

The charge type has a separate input flow: hold Z to charge, release to fire.

function handleChargeWeapon(state, input) {
  if (input.fire) {
    if (p.chargeStartFrame === -1 && state.frame >= p.nextFireFrame) {
      p.chargeStartFrame = state.frame;
    }
  } else if (p.chargeStartFrame !== -1) {
    const fullyCharged = state.frame - p.chargeStartFrame >= def.chargeTime;
    if (fullyCharged) spawnPlayerBullets(state, def, p.x, p.y, 1.5);
    else {
      /* weak single shot */
    }
    p.chargeStartFrame = -1;
  }
}

The tricky part was resetting charge state when EMP stun hit. Visual feedback is a purple ring around the player, pulsing when fully charged.


Special Bullets — The Pool-Reuse Stale Trap

The bullet pool shares a single interface across all kinds — spread / laser / wave / plasma / missile / mine all acquire the same Bullet object. This caused a bug: if the previous shot from a slot was plasma, plasmaSplash stayed positive on the recycled object, and the next spread shot would explode in splash.

Fix: spawnPlayerBullets explicitly resets every special field.

b.piercing = def.piercing ?? false;
b.waveAmplitude = def.waveAmplitude; // undefined → undefined
b.plasmaSplash = def.plasmaSplash;
b.lastHitIdx = undefined;
b.homingTurnRate = undefined;
b.mineExplosionRadius = undefined;
b.mineExplodeFrame = undefined;

Explicit undefined assignment is the key to preventing pool-reuse stale state.


5 Subweapons — Single Slot + Cycle

I added a single-slot subweapon system separate from the main weapon.

TypeEffectCooldown
missileHoming + plasma splash1.5s
empBurstClear enemy bullets + 60f stun12s
lightningInstant chain (1 hop)4s
drone10s side-slot auto-fire12s
spreadMineProximity/lifetime → 8-way burst6s

'S' drop cycles types (empty slot → first, otherwise → next). C key fires. On mobile, a SUB button sits next to the bomb button, with cooldown shown as a conic-gradient mask.

.subBtnCooldown {
  background: conic-gradient(rgba(0, 0, 0, 0.55) calc(var(--sub-cd) * 360deg), transparent 0);
}

--sub-cd slides 0→1 to sweep the mask clockwise.


logic.ts 1035 → 8 Modules

Adding all this swelled galaxyWingLogic.ts to 1035 lines. Input / player / charge / subweapons / wave dispatch / spawn patterns / enemy AI / bullets / collision / power-ups / combo were all in one file. I drew the dependency graph to make sure splits were one-directional.

score (root)
  ↓
explode → subweaponLogic, player → collision, update → logic
              ↘ spawn (independent)
              ↘ types (subweapons type only)

damagePlayer is only called by collision but lives in player module (semantic fit), and findNearestActiveEnemy is shared by lightning and missile homing — exported from subweaponLogic and imported by update.

After split, each file landed at 100~250 lines, and the 1:1 mapping of all 45 functions was preserved. I verified by comparing function inventories before/after and running tsc --noEmit.

Renderer got similar treatment — 816 → 98 lines, with drawPlayer / drawEnemies / drawBullets / drawHUD / drawPowerUps / drawParticles split into 6 files.


Wrap-Up

5×7 weapons + 5 subweapons + 16-module split, all in one cycle. Thanks to the data-driven weapon definition, adding a new weapon type means just one row in WEAPON_DEFS. Subweapons are similar — SUBWEAPON_DEFS plus one branch.

The biggest takeaway: don't miss the split moment. Should have split around 500 lines instead of letting it ride to 1035.

Content related to this post

Try it yourself

Guestbook

Leave a short note about this post

0 / 140

Loading...