Game Build Breakdown

Neon Siege: Arena FPS Lessons from Waves, Radar and Boss Pressure

Neon Siege teaches a classic browser-action lesson: an arena shooter is not just aim and enemies. It is information management under pressure.

IntermediateThree.jsFPS Arena11 min read

Production context

This guide studies Neon Siege, a published Supagames game. Repository source: 3dgames/fps/src/main.js.

The snippets below are intentionally smaller than the production file. They isolate transferable systems so you can reuse the idea in your own browser game without copying every detail of the original project.

1. Enemy Roles Make Waves Legible

A wave with ten identical enemies is less interesting than a wave with three roles. A chaser creates movement pressure, a shooter controls sightlines, and a heavy enemy demands target priority. The player can then read the wave as a puzzle instead of a crowd.

Represent roles as data: speed, preferred range, attack cooldown, color and reward. The spawner can mix roles by wave number without custom code for every wave.

const ENEMY_ROLES = {
  chaser: { speed: 6.4, range: 1.4, cooldown: 0.8, reward: 10 },
  shooter: { speed: 3.1, range: 18, cooldown: 1.9, reward: 16 },
  heavy: { speed: 2.2, range: 4, cooldown: 1.3, reward: 28 },
};

2. Radar Is a Survival Mechanic

A first-person arena hides enemies behind the camera. Radar fixes that, but only if it is tiny, clear and filtered. It should show immediate danger and boss direction, not every decorative object in the world.

Convert world-space offsets into 2D radar positions, clamp to the radar radius and encode enemy role with shape or color.

function projectToRadar(enemy, player) {
  const offset = enemy.position.clone().sub(player.position);
  const x = THREE.MathUtils.clamp(offset.x * 0.08, -44, 44);
  const y = THREE.MathUtils.clamp(offset.z * 0.08, -44, 44);
  return { x, y, role: enemy.role };
}

3. Boss Attacks Need Area Promises

Boss area attacks work when they make a promise: this zone will be dangerous soon. The warning color, ring size and countdown should all describe the same attack. If the VFX says one thing and the hitbox says another, players stop trusting the game.

Use the same source radius for both the visual warning mesh and the damage query. That small discipline prevents many unfair-feeling bugs.

function resolveBossBlast(blast, player) {
  blast.warning.scale.setScalar(blast.radius);
  if (blast.timer >= blast.warningTime) {
    const distance = player.position.distanceTo(blast.position);
    if (distance <= blast.radius) damagePlayer(blast.damage);
    blast.done = true;
  }
}

4. Adaptive Music Should Follow Game State

Arena shooters benefit from music that escalates with pressure. The browser-friendly way is to run a few Web Audio layers and fade their gain based on wave intensity, boss presence or low health. Avoid starting new audio files every time the wave changes.

Smooth gain changes are essential. Abrupt audio jumps feel like bugs even when the code is technically correct.

function updateMusicMix(state, audio, now) {
  const pressure = Math.min(1, state.enemyCount / 12 + (state.bossActive ? 0.35 : 0));
  audio.drums.gain.setTargetAtTime(pressure, now, 0.35);
  audio.bass.gain.setTargetAtTime(state.bossActive ? 0.9 : 0.45, now, 0.5);
}

5. Build checklist

  • Design waves from enemy roles, not raw enemy count.
  • Use radar to show danger behind the camera without overwhelming the player.
  • Drive boss warning visuals and damage checks from the same radius and timer.
  • Fade music layers from game state instead of restarting audio during combat.
Previous: Peak Hopper platformer readability Next: Three.js first-hit stutter