This fall I started building a new game: a bullet-hell roguelike called DET-33, built on Phaser 3 and Vue. It’s the kind of game where the whole appeal is hundreds of projectiles on screen doing wildly different things, which means the weapon code becomes the heart of the project very quickly. Mine started as one weapon.js file, and it did not stay manageable for long.

The problem is the obvious one. A bullet that travels in a straight line, a bullet that spirals, a bullet that boomerangs back, a bullet that splits on impact, and a bullet that drops an acid pool are all “a bullet,” but the branching to support them in one file gets ugly fast. So I tore weapon.js apart and rebuilt the projectiles as composable behaviors using the strategy pattern.

One behavior, one job

Every behavior extends a small BulletBehavior base class with two methods that matter: a shoot that fires from a point toward a target, and an update that moves the bullet each frame. The weapon doesn’t know or care how the bullet moves. It just holds a behavior instance and delegates to it.

// the weapon just owns a behavior and fires it
this.bulletBehavior = BehaviorRegistry.get(weaponData.behavior);
this.bulletBehavior.shoot(fromX, fromY, targetX, targetY, now);

New movement patterns become new files instead of new branches. A BehaviorRegistry maps a name to a behavior class and falls back to a default bullet if it can’t find one, so adding a pattern is “write the class, register the name.” Over a couple of weeks that registry grew to around 37 behaviors: circle, pulsing circle, cardioid, spiral, helical, boomerang, ricochet, zigzag, figure eight, rose curve, lissajous curve, seeking, proximity split, contact split, gravity well, chain lightning, and a pile of zone effects like freezing, inferno, vampiric, and acid pool.

Effects route through the same idea

The interesting wrinkle was status effects. A freezing bullet, a poison bullet, a confusion bullet: those don’t just move, they do something to whatever they hit. Rather than let each behavior reach in and tween an enemy directly, a bullet that wants to apply something temporary builds an effect and hands it off to be managed centrally. The confusion bullet is a nice example, it doesn’t damage the target, it emits an effect that makes the mob orbit in a confused circle for a duration. Keeping that as its own concern means stacking and expiry are solved in one place instead of 37.

It’s the same lesson as the async chaining and pipeline posts from a few years ago. The win isn’t any single clever bullet, it’s that the shape of the system lets me keep adding clever bullets without the cost going up each time. When the marginal effort of behavior number 38 is the same as number 3, you end up with a much weirder and more interesting arsenal than you would have planned up front.