Build a Top-Down Tactical Shooter

Build a browser-based tactical shooter with physics, enemy AI, and health regen.

Introduction

30 Second Summary

You know that feeling in Call of Duty when you take a hit, duck behind cover, and wait for your health to tick back up before pushing forward? That tension between aggression and survival is one of the most satisfying loops in gaming.

In this project, you will build a complete top-down tactical shooter that runs entirely in the browser. You will create WASD movement with mouse aiming, physics-based bullets, wave-based enemy AI that shoots back, and a health regeneration system inspired by Modern Warfare.

What You'll Build

You will open your browser and play a fully working tactical shooter where you dodge incoming fire, line up shots on patrolling enemies, and survive escalating waves while watching your health bar drain and regenerate in real-time.

By the end of this project, you'll have:

  • A playable top-down shooter where you move with WASD, aim with your mouse, and fire bullets that travel with real physics across the arena.
  • A wave-based enemy system where enemies spawn at the edges, patrol toward you, and fire their own projectiles back at you with increasing aggression each round.
  • A Call of Duty-style health regeneration loop where taking damage triggers a cooldown and staying safe for 3 seconds gradually heals you back to full.
  • Secret Mission: Add weapon switching with an assault rifle for rapid fire and a shotgun that blasts a spread of pellets at close range.

Are there any prerequisites?

This project assumes expert-level JavaScript knowledge. You should be comfortable with classes, callbacks, and object-oriented patterns.

You will also need VS Code and Node.js already installed on your machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project and Load Phaser

Browsers block direct access to local files for security reasons. To run a game built with Phaser 3, you need the framework loaded and a local web server delivering your files over HTTP.

In this step, you will create a project folder, load Phaser from a CDN, configure the game canvas, and spin up a dev server so you can see the result in your browser.

In this step, get ready to:
  • Create the tactical-ops project folder and open it in VS Code.
  • Write the HTML file that loads Phaser 3 and configures the game canvas.
  • Serve the project locally and confirm the game canvas appears.
Create the project folder
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open the integrated terminal by clicking Terminal in the top menu bar, then New Terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir tactical-ops
cd tactical-ops
  • Confirm the folder exists by running:
ls

The output will be empty since the folder is new. That is expected.

  • Open this folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to Desktop, select the tactical-ops folder, and click Open.
  • If VS Code asks whether you trust the authors of the files in this folder, click Yes, I trust the authors.
  • Create a new file by clicking File then New File.
  • Name the file index.html and press Enter to save it.
Write the HTML shell and load Phaser

The HTML file needs a basic structure, some styling to center the canvas, and a <script> tag that pulls Phaser from a CDN.

  • Paste the following HTML into your empty index.html file:
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Tactical Ops</title>
    <style>
        body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js"></script>
</head>
<body>

</body>
</html>

What does this code do?

  • The <style> block removes default margins, sets the page background to black, and uses flexbox to center the game canvas vertically and horizontally.
  • The <script> tag loads Phaser 3.90.0 from the jsDelivr CDN. This gives your page access to the entire Phaser framework without downloading any files.
Configure the Phaser game

Phaser needs a scene class with lifecycle methods and a config object that sets the canvas size and physics engine.

  • Between the <body> and </body> tags, replace the blank line with the following script:
<script>
class GameScene extends Phaser.Scene {
    constructor() {
        super('GameScene');
    }

    preload() {}

    create() {}

    update(time) {}
}

const config = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    physics: {
        default: 'arcade',
        arcade: {
            gravity: { y: 0 },
            debug: false
        }
    },
    scene: GameScene
};

const game = new Phaser.Game(config);
</script>

What does this code do?

  • GameScene is a class that extends Phaser.Scene. It has three lifecycle hooks: preload (load assets), create (set up the world), and update (run every frame). They are empty for now.
  • The config object tells Phaser to create an 800x600 canvas using Arcade Physics with gravity set to 0. Zero gravity gives a top-down perspective where nothing falls.
  • new Phaser.Game(config) boots the engine and renders the canvas to the page.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Your index.html should contain both chunks in this order:

  • The HTML shell (<!DOCTYPE html> through </head>, then an opening <body> tag).
  • The <script> block containing GameScene, the config object, and new Phaser.Game(config).
  • A closing </script>, </body>, and </html> at the bottom.

Scroll up and confirm each chunk is present. Make sure the CDN URL is exactly https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js.

Serve the files and verify the canvas

Your HTML file references Phaser from a CDN. Browsers will only load that script over HTTP, so you need a local server.

  • Open a new terminal in VS Code by clicking Terminal then New Terminal.
  • Start the local web server by running this command:
npx http-server

What does this command do?

npx runs a package from the npm registry without installing it globally. http-server serves the current folder over HTTP on port 8080 by default.

This command takes over the terminal. You will not be able to type other commands in that terminal while the server is running. That is normal.

First time running this?

If npx has never downloaded http-server before, it will ask Ok to proceed? (y). Type y and press Enter.

Before you open the browser, what do you expect to see on the page? A blank black page, an 800x600 canvas, or an error?

  • Open your browser and navigate to http://localhost:8080.

You should see a black 800x600 rectangle centered on the page. That is your Phaser game canvas.

  • Open the browser console by pressing F12 (or Cmd+Option+J on macOS) and check for errors.

You should see Phaser's version banner logged in the console (something like Phaser v3.90.0) and zero errors.

Don't see the canvas?

  • Make sure npx http-server is still running in your terminal. If you closed it or pressed Ctrl+C, restart it.
  • Confirm you are visiting http://localhost:8080 (not https). The server does not use HTTPS.
  • Check the console for a CDN loading error. If the Phaser script failed to load, double-check the URL in your <script> tag matches exactly: https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js.

Still stuck? Help me troubleshoot why my Phaser canvas is not appearing in the browser.

Your game canvas is live. Next up, you will add a player that moves with WASD and rotates to track your mouse cursor.

Add Player Movement and Mouse Aiming

Your game canvas is live and Phaser is loaded. Now you need something on screen that responds to input.

The player character is the foundation every other system builds on. Before enemies, bullets, or scoring can exist, you need a controllable entity that moves with WASD and rotates to face the mouse cursor. This step brings the empty canvas to life.

In this step, get ready to:
  • Generate a player texture programmatically and place it on a dark arena.
  • Add WASD movement with 8-directional velocity-based controls.
  • Implement real-time mouse rotation so the player always faces the cursor.
Generate the player texture and create the arena

Instead of loading image files, you will create game art at runtime using Phaser's Graphics API. This keeps the entire project in a single HTML file with zero external assets.

  • In index.html, find the empty preload() method and add this code inside it:
// Generate textures programmatically
const playerGfx = this.make.graphics({ x: 0, y: 0, add: false });
playerGfx.fillStyle(0x00ff00);
playerGfx.fillTriangle(16, 0, 0, 32, 32, 32);
playerGfx.generateTexture('player', 32, 32);
playerGfx.destroy();

What does this code do?

this.make.graphics({ add: false }) creates an offscreen drawing canvas that never appears in the game directly.

fillTriangle(16, 0, 0, 32, 32, 32) draws a green arrow-shaped triangle pointing up. generateTexture('player', 32, 32) saves that drawing as a reusable texture key. Then destroy() cleans up the offscreen canvas since it is no longer needed.

  • Now find the empty create() method and add this code inside it to build the arena and spawn the player:
// Arena background
this.add.rectangle(400, 300, 800, 600, 0x1a1a2e);

// Player
this.player = this.physics.add.sprite(400, 300, 'player');
this.player.setCollideWorldBounds(true);
this.player.setDepth(10);

What does this code do?

this.add.rectangle() fills the canvas with a dark gray background so the player stands out visually.

this.physics.add.sprite() creates the player with a full Arcade Physics body, which lets Phaser handle velocity and collision automatically. setCollideWorldBounds(true) keeps the player inside the 800x600 canvas. setDepth(10) ensures the player renders above the background.

  • Save index.html and refresh your browser at http://localhost:8080.
  • You should see a small green triangle centered on a dark gray arena. It does not move yet.

Don't see the green triangle?

  • Open the browser console (F12). If you see a texture error, make sure the preload() code generates the texture BEFORE create() tries to use it.
  • Check that the texture key 'player' in generateTexture() matches the key in this.physics.add.sprite() exactly.
  • If the page is still a plain black rectangle, confirm your npx http-server is still running in the terminal.

Why isn't my player texture showing up on the canvas?

Register controls and add movement logic

Phaser's keyboard system requires you to register specific keys before you can poll their state each frame. Once registered, you read whether each key is held down and translate that into velocity.

  • Add this key registration code at the end of your create() method, below the player setup:
// Input
this.keys = {
    W: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.W),
    A: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.A),
    S: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.S),
    D: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.D),
    ENTER: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER),
    R: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.R)
};

What does this code do?

this.input.keyboard.addKey() registers a key so Phaser tracks its pressed/released state every frame. WASD handles movement, ENTER will start the game later, and R will restart after a game over.

  • Now find the empty update(time) method and add this movement and rotation logic inside it:
// Player movement
let vx = 0;
let vy = 0;
if (this.keys.A.isDown) vx = -200;
else if (this.keys.D.isDown) vx = 200;
if (this.keys.W.isDown) vy = -200;
else if (this.keys.S.isDown) vy = 200;
this.player.setVelocity(vx, vy);

// Player rotation toward mouse
const pointer = this.input.activePointer;
const angle = Phaser.Math.Angle.Between(this.player.x, this.player.y, pointer.worldX, pointer.worldY);
this.player.setRotation(angle + Math.PI / 2);

What does this code do?

vx and vy start at 0 each frame. If a key is held, the corresponding axis gets set to 200 or -200. When you release all keys, velocity resets to zero and the player stops instantly. This is the "velocity damping" that makes movement feel tight.

Phaser.Math.Angle.Between() calculates the angle in radians from the player to the mouse cursor. Adding Math.PI / 2 corrects the offset because the triangle texture points up (north) by default, but Phaser's rotation starts at east (right).

Why does the update function run every frame?

Phaser calls update() approximately 60 times per second. By recalculating velocity and rotation every frame, the player responds instantly to input changes. This is the standard game loop pattern for real-time input.

Before you test, what do you think will happen when you hold W and D at the same time?

  • Save index.html and refresh your browser.
  • Press W, A, S, and D. The green triangle moves in all 8 directions at 200 pixels per second and stops immediately when you release the keys.
  • Move your mouse around the canvas. The triangle smoothly rotates to always point toward your cursor, no matter where the player is on screen.

Player not moving or rotating?

  • Click inside the game canvas first. The browser only sends keyboard events to Phaser once the canvas has focus.
  • Check that you pasted the movement code inside update(time), not inside create(). Movement must run every frame to respond to input.
  • If you see a console error about this.keys being undefined, confirm the key registration block is inside create() and runs before update() tries to read it.

My Phaser player won't move with WASD or rotate toward the mouse. How do I debug this?

Double check your code

✔️ Awesome, I've got everything!

Great. Make sure you have saved index.html and that the triangle moves and rotates correctly in the browser.

ⓧ I'd like to double check the full code

Make sure your index.html contains these sections in order inside the <script> tag:

  • preload() contains the player texture generation (green triangle, 32x32, texture key 'player').
  • create() contains the dark arena rectangle, the player physics sprite at (400, 300) with world bounds and depth 10, and the WASD/ENTER/R key registration object.
  • update(time) contains the velocity logic (vx/vy default to 0, set to 200/-200 per key) and the rotation logic using Phaser.Math.Angle.Between() with the Math.PI / 2 offset.
  • The config object and new Phaser.Game(config) remain unchanged at the bottom.

Your operator can move and aim. Next up, you will give them a weapon. Clicking will fire bullets that travel with real velocity across the arena.

Implement Shooting and Bullet Physics

Your operator can move and aim, but clicking does nothing. A tactical shooter without bullets is just a walking simulator.

In this step, you will add a bullet system where clicking fires projectiles that travel with real velocity toward your cursor. You will also implement object pooling so bullets recycle instead of eating memory every time you shoot.

In this step, get ready to:
  • Generate a bullet texture and create a physics group as an object pool.
  • Fire bullets from the player toward the mouse cursor on click.
  • Recycle off-screen bullets so you can fire indefinitely.
Generate the bullet texture and create the object pool

Every bullet in your game will be a small yellow circle. Instead of creating a new object each time you click, you will create a pool of 30 bullets up front. Phaser recycles inactive bullets from the pool each time you fire, preventing garbage collection spikes during fast combat.

  • In index.html, find the preload() function. Add this code after the line playerGfx.destroy(); to generate the bullet texture:
        // Yellow circle bullet texture
        const bulletGfx = this.make.graphics({ x: 0, y: 0, add: false });
        bulletGfx.fillStyle(0xffff00);
        bulletGfx.fillCircle(4, 4, 4);
        bulletGfx.generateTexture('bullet', 8, 8);
        bulletGfx.destroy();
  • Still in index.html, scroll down to the create() function. Add this code after the closing }; of the this.keys object to set up the bullet pool and shooting input:
        // Bullet object pool - maxSize prevents memory allocation during play
        this.playerBullets = this.physics.add.group({
            classType: Phaser.Physics.Arcade.Sprite,
            maxSize: 30,
            runChildUpdate: false
        });

        // Shooting state and fire rate limiter
        this.lastFired = 0;
        this.fireRate = 150;
        this.input.on('pointerdown', () => { this.isShooting = true; });
        this.input.on('pointerup', () => { this.isShooting = false; });
        this.isShooting = false;

What does this code do?

  • generateTexture('bullet', 8, 8) creates a reusable 8x8 yellow circle texture in memory without drawing anything on screen.
  • this.physics.add.group({ maxSize: 30 }) creates a pool of up to 30 bullet sprites. When you call group.get() later, Phaser grabs an inactive bullet from this pool instead of allocating new memory.
  • The pointerdown and pointerup listeners track whether the mouse button is held down for continuous fire.
  • fireRate = 150 means the player can fire one bullet every 150 milliseconds (about 6-7 shots per second).
  • Save index.html and refresh your browser.
  • Open the browser console (press F12) and check for errors. A clean console confirms Phaser loaded your new texture and group without issues.

Seeing a console error?

  • If you see a syntax error, check that the bullet texture code sits after playerGfx.destroy(); and before the closing } of preload().
  • Make sure the this.playerBullets group code is inside create(), not accidentally placed outside the method.
  • help me debug a console error after adding my bullet texture and pool
Fire bullets toward the cursor

The pool exists but nothing fires yet. You need a method that grabs a bullet from the pool, positions it at the player, and launches it toward wherever your cursor is pointing.

  • In index.html, add a new method called fireBullet after the closing } of the update() method (but still inside the GameScene class):
    fireBullet(time) {
        // Grab an inactive bullet from the pool
        const bullet = this.playerBullets.get(this.player.x, this.player.y, 'bullet');
        if (!bullet) return;
        bullet.setActive(true).setVisible(true);
        bullet.body.enable = true;
        // Convert player rotation back to a direction angle
        const angle = this.player.rotation - Math.PI / 2;
        this.physics.velocityFromRotation(angle, 600, bullet.body.velocity);
        this.lastFired = time;
    }
  • Now scroll up to the update(time) method. Add this code at the very end of update, after the line this.player.setRotation(angle + Math.PI / 2); to trigger firing when the mouse is held down:
        // Fire bullets when mouse is held and cooldown has elapsed
        if (this.isShooting && time > this.lastFired + this.fireRate) {
            this.fireBullet(time);
        }

What does this code do?

  • this.playerBullets.get() pulls an inactive bullet from the pool. If all 30 are in use, it returns null and nothing fires.
  • this.player.rotation - Math.PI / 2 converts the player's visual rotation back to a direction angle. The player sprite has a 90-degree offset because the triangle points up.
  • this.physics.velocityFromRotation(angle, 600, ...) converts an angle and speed into x/y velocity components, launching the bullet at 600 pixels per second.
  • The time > this.lastFired + this.fireRate check prevents firing faster than once every 150ms, even if the learner spam-clicks.
  • Save index.html and refresh your browser.
  • Click anywhere in the game canvas. You should see yellow bullets firing from your green triangle toward wherever your cursor is pointing.
  • Hold the mouse button down and move the cursor around. Bullets stream out continuously, each traveling in a straight line.

Bullets not appearing?

  • Make sure fireBullet(time) is a method of GameScene (inside the class braces), not a standalone function outside the class.
  • Check that the fire-rate check in update is placed after the rotation code, not before it. The player needs a rotation value before bullets can use it.
  • Verify the texture key 'bullet' in generateTexture() matches the key in this.playerBullets.get().
  • help me debug why bullets are not firing in my Phaser shooter
Recycle bullets that leave the arena

Right now, bullets fly off-screen and stay active forever. Once all 30 pool slots are consumed, you cannot fire anymore. You need to deactivate bullets that leave the arena so the pool can recycle them.

  • In the update() method, add this code directly after the fireBullet check you just added:
        // Deactivate bullets that leave the arena, returning them to the pool
        this.playerBullets.getChildren().forEach(bullet => {
            if (bullet.active && (bullet.x < 0 || bullet.x > 800 || bullet.y < 0 || bullet.y > 600)) {
                bullet.setActive(false).setVisible(false);
                bullet.body.stop();
            }
        });

What does this code do?

  • Every frame, this loop checks each active bullet's position against the 800x600 arena bounds.
  • setActive(false).setVisible(false) marks the bullet as available in the pool. Next time you click, group.get() will grab this recycled bullet instead of allocating a new one.
  • body.stop() zeroes out the bullet's velocity so it does not keep drifting while inactive.

Before you test, think about what should happen now when you fire 30+ bullets. Will the game run out, or will it keep firing?

  • Save index.html and refresh your browser.
  • Hold the mouse button and fire at the edges of the arena for several seconds.

Bullets disappear when they leave the screen, and you can fire indefinitely. The pool recycles deactivated bullets, so you never run out of ammo.

Bullets freeze at the edges instead of disappearing?

  • Check that the bounds check uses bullet.x < 0 || bullet.x > 800 (not > 600 for x). The arena is 800 pixels wide and 600 pixels tall.
  • Make sure the cleanup loop is inside update(), not inside create(). It needs to run every frame.
  • help me figure out why my bullets are not deactivating off-screen

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Scroll through your file and confirm each section is present in this order:

  • In preload(): the player texture block (green triangle), then the bullet texture block (yellow circle).
  • In create(): arena background, player sprite, keys object, then the playerBullets group, then lastFired/fireRate/pointer listeners/isShooting.
  • In update(time): movement code, rotation code, then the fireBullet check, then the bullet cleanup forEach loop.
  • After update(): the fireBullet(time) method.

You now have a working weapon system with velocity-based projectiles and efficient memory recycling. Next up, you will add enemies that spawn in waves and die when your bullets hit them.

Spawn Enemy Waves and Handle Collisions

Your bullets fly across the arena, but they have nothing to hit. Without targets, there is no game.

In this step, you will add enemies that spawn in waves from the screen edges and drift toward you. Shooting them earns points, and clearing a wave triggers the next one with more enemies. The enemies do not fight back yet, which sets up the combat pressure you will add in the next step.

In this step, get ready to:
  • Spawn enemy waves from random screen edges with velocity aimed at the player.
  • Detect bullet-enemy collisions and update the score.
  • Automatically spawn the next wave when all enemies are eliminated.
Generate the enemy texture and create the game HUD

Before enemies can appear in the arena, you need a texture for them and a way to track game state on screen.

  • In index.html, find the preload() function. Add this code after bulletGfx.destroy(); to generate a red square texture for enemies:
        const enemyGfx = this.make.graphics({ x: 0, y: 0, add: false });
        enemyGfx.fillStyle(0xff0000);
        enemyGfx.fillRect(0, 0, 24, 24);
        enemyGfx.generateTexture('enemy', 24, 24);
        enemyGfx.destroy();
  • Next, find the create() function. Add this enemy physics group directly after the playerBullets group (after its closing });):
        // Enemy pool for wave spawning
        this.enemies = this.physics.add.group({
            classType: Phaser.Physics.Arcade.Sprite,
            maxSize: 20,
            runChildUpdate: false
        });

What does maxSize do here?

Setting maxSize: 20 caps the pool at 20 enemy sprites. Instead of creating and destroying objects every wave (which triggers garbage collection pauses), Phaser recycles inactive sprites from the pool. This keeps the frame rate smooth during intense combat.

  • Still in create(), find this.isShooting = false; and add the game state variables and HUD text below it:
        // Game state tracking
        this.score = 0;
        this.wave = 0;
        this.gameStarted = false;
        this.waveInProgress = false;

        // HUD displays
        this.scoreText = this.add.text(16, 16, 'Score: 0', { fontSize: '18px', fill: '#ffffff' }).setDepth(20);
        this.waveText = this.add.text(16, 40, 'Wave: 0', { fontSize: '18px', fill: '#ffffff' }).setDepth(20);

        this.startText = this.add.text(400, 300, 'Press ENTER to start', {
            fontSize: '24px', fill: '#ffffff'
        }).setOrigin(0.5).setDepth(20);

What does setDepth(20) do?

Depth controls draw order. The player is at depth 10, so HUD text at depth 20 always renders on top of game objects. Without this, enemies or bullets could overlap the score display.

  • Save index.html and refresh your browser.

You should see "Score: 0" in the top-left corner, "Wave: 0" just below it, and "Press ENTER to start" centered on screen. The game waits for your command.

Don't see the HUD text?

Make sure you placed the text code after this.isShooting = false; and not inside the update() function. Check the browser console for any syntax errors like unexpected tokens or missing brackets.

Help me debug my Phaser HUD text not appearing on screen.

Write the wave spawning system

Now you need the function that actually creates enemies. Each wave spawns a group of red squares from random screen edges, all drifting toward the player's current position.

  • Add the spawnWave() method to your GameScene class, after the fireBullet() method:
    spawnWave() {
        // Increment wave and scale difficulty
        this.wave++;
        this.waveText.setText('Wave: ' + this.wave);
        const count = 3 + this.wave;
        const speed = 80 * (1 + this.wave * 0.1);

        for (let i = 0; i < count; i++) {
            // Pick a random screen edge
            let x, y;
            const side = Phaser.Math.Between(0, 3);
            if (side === 0) { x = Phaser.Math.Between(0, 800); y = -20; }
            else if (side === 1) { x = Phaser.Math.Between(0, 800); y = 620; }
            else if (side === 2) { x = -20; y = Phaser.Math.Between(0, 600); }
            else { x = 820; y = Phaser.Math.Between(0, 600); }

            // Get enemy from pool and aim toward player
            const enemy = this.enemies.get(x, y, 'enemy');
            if (!enemy) continue;
            enemy.setActive(true).setVisible(true);
            enemy.body.enable = true;
            enemy.setData('lastFired', 0);

            const angle = Phaser.Math.Angle.Between(x, y, this.player.x, this.player.y);
            this.physics.velocityFromRotation(angle, speed, enemy.body.velocity);
        }
    }

What does this code do?

Each wave increases the enemy count (3 + this.wave) and speed (80 * (1 + this.wave * 0.1)). Wave 1 gets 4 enemies at 88 px/s. Wave 5 gets 8 enemies at 120 px/s.

Enemies spawn just outside the visible area (y = -20, y = 620, x = -20, x = 820) so they appear to enter from offscreen. Phaser.Math.Angle.Between() calculates the angle from the spawn point to the player, and velocityFromRotation() converts that angle into an x/y velocity vector.

  • Now add the game start logic at the very top of the update(time) function, before the movement code. This replaces the first line inside update:
        // Wait for player to press ENTER before starting
        if (!this.gameStarted) {
            if (Phaser.Input.Keyboard.JustDown(this.keys.ENTER)) {
                this.gameStarted = true;
                this.startText.setVisible(false);
                this.spawnWave();
            }
            return;
        }

Why use return here?

The return statement stops the rest of update() from running until the game has started. This prevents the player from moving or shooting before pressing ENTER.

  • Save index.html and refresh your browser.
  • Press ENTER on the game canvas.

Red squares spawn from the screen edges and drift toward your green triangle. The "Wave: 1" counter updates in the top-left. You can move and shoot, but bullets pass right through enemies because you have not added collision detection yet.

Enemies not spawning when you press ENTER?

Make sure the if (!this.gameStarted) block is the FIRST thing inside update(time). If movement code runs first, the return statement never blocks execution before the game starts. Also confirm the ENTER key is registered in your this.keys object from the earlier step.

Help me debug why enemies are not spawning when I press ENTER in my Phaser game.

Add collision detection and wave progression

Enemies drift through the arena but nothing happens when bullets hit them. You need collision detection to destroy enemies on hit and a system to spawn the next wave when the current one is cleared.

  • First, add the collision callback method to your GameScene class, after the spawnWave() method you just wrote:
    bulletHitEnemy(bullet, enemy) {
        // Deactivate both sprites and return to pool
        bullet.setActive(false).setVisible(false);
        bullet.body.stop();
        enemy.setActive(false).setVisible(false);
        enemy.body.stop();
        // Increment score
        this.score += 10;
        this.scoreText.setText('Score: ' + this.score);
    }

What does this code do?

When a bullet overlaps an enemy, both are deactivated and hidden. This does not destroy them. It returns them to their respective object pools for reuse. The score increments by 10 and the HUD updates immediately.

  • Now register the overlap check so Phaser knows to call bulletHitEnemy. In create(), find the enemies group you added earlier. Add this line directly after its closing });:
        this.physics.add.overlap(this.playerBullets, this.enemies, this.bulletHitEnemy, null, this);
  • Finally, add the wave progression check at the bottom of your update() function, after the bullet deactivation loop:
        // Check if all enemies are eliminated
        const activeEnemies = this.enemies.getChildren().filter(e => e.active).length;
        if (activeEnemies === 0 && this.gameStarted && !this.waveInProgress) {
            this.waveInProgress = true;
            this.time.delayedCall(2000, () => {
                this.spawnWave();
                this.waveInProgress = false;
            });
        }

Why use waveInProgress?

Without this flag, the "all enemies inactive" check would fire on every frame during the 2-second delay, scheduling dozens of overlapping spawnWave() calls. The waveInProgress flag ensures only one delayed call is active at a time.

  • Save index.html.

Before you refresh, think about what should happen when you press ENTER and start shooting. Will enemies disappear when hit? Will the score update? What happens after you clear all enemies?

  • Refresh your browser and press ENTER to start wave 1.
  • Shoot the red squares as they drift toward you.

Each enemy disappears when your bullet hits it, and the score jumps by 10. Once every enemy in the wave is gone, a 2-second pause passes and then the next wave spawns with more enemies moving slightly faster.

Bullets passing through enemies?

Check that your this.physics.add.overlap() line is inside create(), not inside update(). The overlap needs to be registered once, not every frame. Also confirm the callback name matches exactly: this.bulletHitEnemy (no parentheses after it).

Help me debug why my Phaser overlap collision is not detecting hits.

✔️ Awesome, I've got everything!

Great. Double check you have saved index.html and confirmed that enemies spawn, die when shot, and the next wave starts automatically.

ⓧ I'd like to double check the full code

Your index.html is now too long to show in one block. Scroll up and confirm each of these pieces is present in order:

  • In preload(): player texture, bullet texture, and enemy texture (red square, 24x24).
  • In create(): arena, player, keys, playerBullets group, enemies group, overlap registration, shooting listeners, game state vars (score, wave, gameStarted, waveInProgress), HUD text (scoreText, waveText, startText).
  • In update(): game start check with ENTER at the top (with return), movement, rotation, shooting, bullet deactivation, wave cleared check at the bottom.
  • Methods: fireBullet(), spawnWave(), bulletHitEnemy().

You have enemies that spawn, drift, and die. The score ticks up and waves escalate. But notice how the game feels too easy. Enemies just float toward you passively. They never shoot back, and you can survive forever by standing still and clicking. Next up, you will give them weapons and add a health system that forces you to play tactically.

Add Enemy AI, Health System, and Game Over

You have enemies spawning in waves, drifting toward you, and dying when shot. The problem is they never fight back. You can stand perfectly still and eliminate every wave without taking a scratch.

In this step, you'll give enemies the ability to shoot projectiles at the player, add a health bar with Call of Duty-style regeneration, and build a game over screen with restart functionality.

In this step, get ready to:
  • Add enemy bullet projectiles that fire at the player.
  • Implement a health system with damage and regeneration.
  • Create game over and restart functionality.
Create the enemy bullet system

Enemies need their own projectiles to create real danger. You'll generate a bullet texture, create a physics pool for enemy bullets, and wire up the firing logic so each enemy periodically shoots toward your position.

  • In index.html, find the preload() function. After the enemy texture block (ending with enemyGfx.destroy()), add this enemy bullet texture:
// Enemy bullet texture - small red-tinted circle
const enemyBulletGfx = this.make.graphics({ x: 0, y: 0, add: false });
enemyBulletGfx.fillStyle(0xff4444);
enemyBulletGfx.fillCircle(3, 3, 3);
enemyBulletGfx.generateTexture('enemyBullet', 6, 6);
enemyBulletGfx.destroy();
  • Now find the create() function. After the this.enemies group definition, add an enemy bullet pool and register an overlap between enemy bullets and the player:
// Enemy bullet pool
this.enemyBullets = this.physics.add.group({
    classType: Phaser.Physics.Arcade.Sprite,
    maxSize: 40,
    runChildUpdate: false
});
  • Find the existing this.physics.add.overlap(this.playerBullets, this.enemies, ...) line. Directly below it, add this second overlap:
// Detect enemy bullets hitting the player
this.physics.add.overlap(this.enemyBullets, this.player, this.enemyBulletHitPlayer, null, this);

What does this set up?

The enemy bullet pool works identically to your player bullet pool. It pre-allocates up to 40 bullet sprites that get recycled as they leave the screen.

The overlap tells Arcade Physics to call enemyBulletHitPlayer whenever an enemy bullet touches the player sprite. You'll write that callback shortly.

  • After the fireBullet method, add the enemyFire method that spawns a bullet from an enemy aimed at the player:
enemyFire(enemy, time) {
    const bullet = this.enemyBullets.get(enemy.x, enemy.y, 'enemyBullet');
    if (!bullet) return;
    bullet.setActive(true).setVisible(true);
    bullet.body.enable = true;
    // Aim directly at the player's current position
    const angle = Phaser.Math.Angle.Between(enemy.x, enemy.y, this.player.x, this.player.y);
    this.physics.velocityFromRotation(angle, 250, bullet.body.velocity);
}
  • In the update function, find the player bullet cleanup loop (the this.playerBullets.getChildren().forEach block). Directly after it, add the enemy AI firing loop and enemy bullet cleanup:
// Enemy AI - fire at player periodically
this.enemies.getChildren().forEach(enemy => {
    if (!enemy.active) return;
    const lastFired = enemy.getData('lastFired') || 0;
    const enemyFireRate = Math.max(800, 1500 - (this.wave * 50));
    if (time > lastFired + enemyFireRate) {
        this.enemyFire(enemy, time);
        enemy.setData('lastFired', time);
    }
});

// Deactivate enemy bullets that leave the arena
this.enemyBullets.getChildren().forEach(bullet => {
    if (bullet.active && (bullet.x < 0 || bullet.x > 800 || bullet.y < 0 || bullet.y > 600)) {
        bullet.setActive(false).setVisible(false);
        bullet.body.stop();
    }
});

What does this code do?

  • The AI loop checks every active enemy on each frame. Each enemy tracks its own lastFired timestamp using Phaser's data store.
  • The firing interval starts at 1500ms and decreases by 50ms per wave (minimum 800ms). Later waves are more aggressive.
  • Enemy bullets travel at 250 px/s toward wherever the player is at the moment of firing. Moving after the shot lets you dodge.
  • Save index.html and refresh your browser.
  • Press Enter to start a wave. Enemies now fire small red bullets at your position.

You'll notice the bullets pass right through you without any effect. That's because enemyBulletHitPlayer doesn't exist yet. The overlap is registered but has no callback to call.

Not seeing enemy bullets?

  • Open the browser console (F12). If you see an error about enemyBulletHitPlayer, that's expected for now. The bullets should still appear and fly.
  • Confirm that the enemyFire method is placed at the class level (inside GameScene), not nested inside another function.
  • Check that the AI loop is inside the update function, after the player bullet cleanup but before the wave-cleared check.

Why aren't my enemies firing bullets at the player?

Add the health bar and damage system

Bullets fly but deal no damage. You need a health system that tracks the player's HP, displays it visually, and responds to incoming hits with feedback.

  • In create(), find the line this.gameStarted = false;. Add these new state variables directly below it (before this.waveInProgress):
// Player health state
this.gameOver = false;
this.playerHealth = 100;
this.maxHealth = 100;
this.lastHitTime = 0;
  • Still in create(), find the this.waveText line. Directly after it, add the health bar rectangles:
// Health bar - background and fill
this.healthBarBg = this.add.rectangle(400, 580, 200, 16, 0x333333).setDepth(20);
this.healthBar = this.add.rectangle(400, 580, 200, 16, 0x00ff00).setDepth(20);
  • Save index.html and refresh your browser.
  • You should see a green health bar near the bottom of the arena.
  • Now add the enemyBulletHitPlayer callback and the updateHealthBar method. Place them after the bulletHitEnemy method:
enemyBulletHitPlayer(player, bullet) {
    bullet.setActive(false).setVisible(false);
    bullet.body.stop();
    // Reduce health and record hit time
    this.playerHealth -= 15;
    this.lastHitTime = this.time.now;
    this.updateHealthBar();
    // Flash the player red briefly
    player.setTint(0xff0000);
    this.time.delayedCall(100, () => { player.clearTint(); });
    if (this.playerHealth <= 0) {
        this.triggerGameOver();
    }
}
updateHealthBar() {
    // Resize bar width proportional to current health
    const width = (this.playerHealth / this.maxHealth) * 200;
    this.healthBar.setSize(width, 16);
    // Change color based on health level
    if (this.playerHealth > 60) this.healthBar.setFillStyle(0x00ff00);
    else if (this.playerHealth > 30) this.healthBar.setFillStyle(0xffff00);
    else this.healthBar.setFillStyle(0xff0000);
}

What does this code do?

  • Each hit deals 15 damage and records the current time in lastHitTime. This timestamp is what drives the regeneration delay.
  • The red tint flash gives instant feedback that you took damage. It clears after 100ms so the player still looks normal.
  • The health bar changes color as HP drops: green above 60, yellow above 30, red at 30 or below. This visual urgency tells you when to retreat.
  • Save index.html and refresh your browser.
  • Start a wave and let enemies shoot you. The health bar should shrink and change from green to yellow to red as you take hits.

Health bar not changing?

  • Make sure updateHealthBar is a method on the GameScene class (not nested inside another method).
  • Confirm you added the this.enemyBullets group and the overlap line from the previous substep. Without both, the callback never fires.

Help me debug why my health bar isn't responding to enemy bullets.

Add health regeneration and game over

The signature Call of Duty mechanic is health regeneration. If you avoid damage for 3 seconds, your health gradually refills. This rewards tactical movement over standing and shooting.

  • In the update function, find the wave-cleared check (the const activeEnemies line). Directly above it, add the health regeneration logic:
// Health regeneration - heal after 3 seconds of no damage
if (time - this.lastHitTime > 3000 && this.playerHealth < this.maxHealth) {
    this.playerHealth = Math.min(this.maxHealth, this.playerHealth + 0.5);
    this.updateHealthBar();
}

Why 0.5 per frame?

At 60 FPS, adding 0.5 HP per frame means full regeneration from near-death takes about 3 seconds. This feels responsive without being instant. The 3000ms delay before regen starts is the tension: you must stay safe for 3 full seconds before healing kicks in.

  • Now add the triggerGameOver method after updateHealthBar:
triggerGameOver() {
    this.gameOver = true;
    this.physics.pause();
    this.player.setTint(0xff0000);
    this.add.text(400, 280, 'GAME OVER', {
        fontSize: '48px', fill: '#ff0000'
    }).setOrigin(0.5).setDepth(30);
    this.add.text(400, 330, 'Score: ' + this.score + '  |  Wave: ' + this.wave, {
        fontSize: '20px', fill: '#ffffff'
    }).setOrigin(0.5).setDepth(30);
    this.add.text(400, 370, 'Press R to restart', {
        fontSize: '18px', fill: '#aaaaaa'
    }).setOrigin(0.5).setDepth(30);
}

What does triggerGameOver do?

  • this.physics.pause() freezes all physics bodies in place. Bullets stop mid-flight, enemies stop moving.
  • The game over screen shows your final score and which wave you reached, giving the player a reason to try again.
  • Setting this.gameOver = true will block the update loop from processing further game logic.
  • Finally, add the game over check at the very top of the update function. Find the existing if (!this.gameStarted) check and add this block directly above it:
// If game is over, only listen for restart
if (this.gameOver) {
    if (Phaser.Input.Keyboard.JustDown(this.keys.R)) {
        this.scene.restart();
    }
    return;
}

Why does this go at the top of update?

The game over check returns early from update, preventing any movement, shooting, or AI logic from running while the game over screen is displayed. The only input it processes is the R key for restart. this.scene.restart() resets the entire scene back to its initial state.

Before you test, what do you expect will happen when you stand still and let enemies shoot you for about 7 hits?

  • Save index.html and refresh your browser.
  • Press Enter to start a wave and let enemies hit you.

Your health drops by 15 per hit (7 hits kills you from full health). The bar turns yellow, then red, and when it empties you see the GAME OVER screen with your score and wave number. Press R and the game resets completely.

  • Test the regeneration: start a new game, take a few hits, then dodge all bullets for 3 seconds. Watch the health bar gradually refill.

Game over screen not appearing?

  • Make sure the this.gameOver variable is initialized to false in create(). Without it, the game over check at the top of update may behave unpredictably.
  • Confirm that enemyBulletHitPlayer calls this.triggerGameOver() when this.playerHealth <= 0. A missing this keyword will cause a reference error.
  • If health regeneration feels too fast or too slow, check that your regen code uses 0.5 (not 1 or 5).

Help me debug my game over or health regeneration system.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and that enemies shoot, the health bar works, and R restarts after death.

ⓧ I'd like to double check the full code

Here is the full index.html at this point. Since the file exceeds 30 lines, scroll back through the substeps above and confirm each code block is present in order:

  • In preload(): player texture, bullet texture, enemy texture, then the enemy bullet texture (enemyBullet key, color 0xff4444).
  • In create(): enemyBullets group (maxSize 40), the new overlap line, gameOver/playerHealth/maxHealth/lastHitTime variables, and the healthBarBg/healthBar rectangles.
  • In update(): game over check at the top, enemy AI firing loop, enemy bullet cleanup loop, and health regen check (all above the wave-cleared logic).
  • Four new methods: enemyFire, enemyBulletHitPlayer, updateHealthBar, and triggerGameOver.

You've built a complete combat loop. Enemies hunt you, shoot back, and each wave gets harder. The health regen system forces you to play tactically: push aggressively to clear waves, then pull back to heal. Next up, you'll take this further in the Secret Mission with weapon switching.

Secret mission

Weapon Switching - Assault Rifle and Shotgun

Your single weapon makes every fight feel the same. Real tactical shooters give you a loadout. Add an assault rifle for rapid suppression and a shotgun that fires a devastating spread of pellets at close range. Press Q to switch between them mid-combat and watch how weapon choice changes your tactics.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The tactical-ops project folder on your Desktop containing index.html.
  • A local http-server process (run via npx, no global install).

Keep everything running

No action needed. Choose this if you are still actively building or want to keep playing your game.

  • Your tactical-ops folder stays on your Desktop.
  • To play again anytime, open a terminal in the tactical-ops folder and run npx http-server, then open http://localhost:8080 in your browser.
  • No cloud resources or subscriptions are active.

Pause - I'll come back to this later

Stop the running server to free up your terminal. Your project files stay intact for next time.

  • Switch to the terminal where http-server is running.
  • Press Ctrl+C to stop the server.
  • Your tactical-ops folder remains on your Desktop. To resume, open a terminal in that folder and run npx http-server again.

Delete - I don't want to use this again

Remove all project files and start fresh. Nothing else to clean up since no global packages were installed.

  • Switch to the terminal where http-server is running and press Ctrl+C to stop it.
  • Delete the project folder by running the command for your operating system:

macOS

rm -rf ~/Desktop/tactical-ops

Windows

rmdir /s /q %USERPROFILE%\Desktop\tactical-ops
  • Confirm the folder is gone by checking your Desktop. You should no longer see a tactical-ops folder.

Nice Work!

Nice Work!

You just built a complete top-down tactical shooter from scratch in a single HTML file. Your game has real physics, enemy AI, health regeneration, and escalating wave difficulty.

You've learned how to:

  • Build a velocity-based movement system with WASD controls and real-time mouse-aimed rotation using Phaser 3 Arcade Physics.
  • Create a performant bullet pooling system using Physics Groups with maxSize limits that recycles projectiles instead of allocating new memory every frame.
  • Implement a full combat loop with enemy wave spawning, AI that tracks and fires at the player, Call of Duty-style health regeneration, and wave-based difficulty scaling.
  • Secret Mission: Added weapon switching with distinct physics behaviors. A rapid single-fire assault rifle and a spread-pattern shotgun with range falloff.

Ready to quiz yourself?