Luffy's Gum-Gum Platformer
Build a 2D browser platformer with physics, enemies, and ability unlocks.
Introduction
30 Second Summary
Every platformer game you have ever played runs on the same set of hidden systems. Gravity pulls your character down, invisible boxes detect when you land on a platform, and a loop redraws the screen 60 times per second to create the illusion of motion.
In this project, you will build a 2D platformer game starring Luffy from One Piece. Using vanilla JavaScript and HTML5 Canvas, you'll create a complete game with physics, combat, enemy AI, and a progressive ability unlock system.
What You'll Build
A browser-based 2D platformer built entirely with vanilla JavaScript and HTML5 Canvas, featuring two levels with Marine enemies to defeat, platforms to navigate, and a Gum-Gum Pistol ability that unlocks between levels.
By the end of this project, you'll have:
- A playable Luffy character with gravity physics, jump mechanics, and platform collision detection.
- Marine enemies with patrol AI, health systems, and a punch combat mechanic.
- A two-level game with a game loop, ability unlock screen, and HUD displaying health, level, and available abilities.
- Secret Mission: Add a mini-boss at the end of Level 2 that requires combining both abilities to defeat.
Are there any prerequisites?
You should have basic familiarity with JavaScript, HTML, and CSS. No game development experience is needed.
Set Up the Project Files
Every game needs a stage to perform on. For browser games, that stage is the HTML5 Canvas element.
In this step, you'll create your project folder, set up the HTML document with a Canvas, and confirm everything renders in the browser.
In this step, get ready to:
- Create a project folder with two files.
- Build an HTML page with a Canvas element for rendering the game.
- Verify the Canvas appears in your browser.
Create project folder and files
- In VS Code, open a new terminal by clicking Terminal in the top menu bar, then clicking 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 luffy-platformer
cd luffy-platformer
- Open this folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the luffy-platformer folder, and click Select Folder.
- If VS Code asks whether you trust the authors of the files in this folder, click Yes, I trust the authors.
You should now see an empty luffy-platformer folder in the Explorer sidebar on the left.
- Create the first file by hovering over the LUFFY-PLATFORMER heading in the Explorer sidebar and clicking the New File icon (a page with a plus sign).
- Type index.html and press Enter.
- Create the second file the same way. Click the New File icon again.
- Type game.js and press Enter.
- Confirm the files exist by checking the Explorer sidebar. You should see both index.html and game.js listed under the folder name.
Don't see the New File icon?
Make sure you have the luffy-platformer folder open as your workspace. If the Explorer sidebar is empty or shows a different folder, go to File > Open Folder and select luffy-platformer from your Desktop.
Help me find the New File icon in VS Code
Build the HTML shell with Canvas
The Canvas element gives you a drawable surface inside the browser. All of Luffy's world will be rendered onto this single element using JavaScript.
- Click on index.html in the Explorer sidebar to open it in the editor.
- Paste the following code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Luffy's Gum-Gum Platformer</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #1a1a2e;
}
canvas {
border: 3px solid #333;
border-radius: 4px;
}
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="500"></canvas>
<script src="game.js"></script>
</body>
</html>
What does this code do?
- The <style> block removes default margins, then uses flexbox to center the canvas vertically and horizontally on a dark background.
- The <canvas> element is your game's drawing surface. It is 800 pixels wide and 500 pixels tall, identified by id="gameCanvas" so your JavaScript can find it.
- The <script src="game.js"> tag links your (currently empty) JavaScript file. All game logic will go there.
- Save index.html by pressing Ctrl+S (Windows) or Cmd+S (macOS).
Seeing a white dot next to the filename?
A white dot on the tab means the file has unsaved changes. Press Ctrl+S (Windows) or Cmd+S (macOS) to save. The dot disappears once saved.
Help me save my file in VS Code
Verify the setup
- Open your luffy-platformer folder in File Explorer (Windows) or Finder (macOS).
- Double-click index.html to open it in your default browser.
You should see a dark page with a bordered rectangle in the center. That rectangle is your 800x500 Canvas where the entire game will be drawn.
Don't see the canvas?
Make sure you saved index.html before opening it. Check that the file is not blank in your browser by right-clicking the page and selecting View Page Source. You should see your HTML code there.
If the page is completely white, double-check that your <style> block includes background-color: #1a1a2e on the body.
Help me troubleshoot my canvas not appearing
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, and keep the browser tab open for later.
ⓧ I'd like to double check the full code
Here is the complete index.html file. Make sure yours matches exactly:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Luffy's Gum-Gum Platformer</title>
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #1a1a2e;
}
canvas {
border: 3px solid #333;
border-radius: 4px;
}
</style>
</head>
<body>
<canvas id="gameCanvas" width="800" height="500"></canvas>
<script src="game.js"></script>
</body>
</html>
Your game.js file should exist but remain empty for now.
Your stage is set. Next up, you'll bring Luffy to life with a game loop and player controls.
Build the Game Loop and Player
Your canvas is ready and waiting for something to happen on screen. The next goal is to bring it to life with a game loop and a playable character.
A game loop is a function that runs over and over (about 60 times per second), clearing the screen and redrawing everything in its new position each time. This creates the illusion of movement. In this step, you will build that loop, define Luffy as a set of Canvas shapes, and wire up keyboard controls so he can move and jump.
In this step, get ready to:
- Set up the game loop and physics constants.
- Create the player object and render Luffy with Canvas shapes.
- Add keyboard-driven movement and gravity.
Set up the game loop and constants
Every game needs a heartbeat. Before drawing anything, you need a loop that clears and repaints the screen every frame, plus a set of constants that control how fast objects move and how strong gravity pulls.
- Open game.js in VS Code and add the following code at the very top of the empty file:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Physics and movement constants
const GRAVITY = 0.5;
const PLAYER_SPEED = 5;
const JUMP_FORCE = -12;
const ENEMY_SPEED = 1.5;
const PROJECTILE_SPEED = 10;
// Game state tracking
let currentLevel = 1;
let gameState = 'playing';
let abilities = {
punch: true,
gumGumPistol: false
};
What does this code do?
The first two lines grab a reference to the <canvas> element in your HTML and get its 2D drawing context. Every shape you draw goes through ctx.
The constants define how the physics feel. GRAVITY pulls the player down each frame, JUMP_FORCE is negative because upward is negative on the canvas Y axis, and the speed values control how fast characters and projectiles move.
The gameState variable tracks which screen the player sees (playing, game over, level complete). The abilities object tracks which attacks Luffy has unlocked.
- Below the code you just added, add the game loop function and its startup call:
// Main game loop - runs ~60 times per second
function gameLoop() {
// Clear the canvas and fill with sky-blue background
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
requestAnimationFrame(gameLoop);
}
// Start the loop
gameLoop();
What is requestAnimationFrame?
requestAnimationFrame tells the browser to call your function again before the next screen repaint. This creates a smooth loop running at approximately 60 frames per second. Each frame, the canvas is wiped clean with a sky-blue rectangle, then everything gets redrawn in its updated position.
- Save game.js and refresh your browser.
- You should see the canvas fill with a bright sky-blue colour instead of the previous blank/transparent look.
Still seeing a blank canvas?
Make sure the id in document.getElementById('gameCanvas') matches the id="gameCanvas" attribute on your <canvas> element in index.html. They are case-sensitive.
Also check that game.js is saved in the same folder as index.html.
help me debug why my canvas is still blank
Create the player object and render Luffy
Now you need something to draw on that sky-blue background. You will define Luffy as a JavaScript object with properties for position, velocity, and state, then write a function that paints him using Canvas shapes.
- In game.js, add the following code between the abilities object and the gameLoop function:
// Keyboard input tracking
const keys = {};
document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup', (e) => { keys[e.key] = false; });
// Player properties
const player = {
x: 50,
y: 300,
width: 40,
height: 60,
vx: 0,
vy: 0,
grounded: false,
health: 3,
maxHealth: 3,
facing: 1,
attacking: false,
attackTimer: 0,
attackCooldown: 0
};
// Active projectiles array
let projectiles = [];
What does this code do?
The keys object stores which keys are currently held down. When a key is pressed, its entry becomes true. When released, it becomes false. This pattern lets the game loop check key state continuously rather than reacting to single presses.
The player object holds everything about Luffy: where he is (x, y), how fast he is moving (vx, vy), whether he is on the ground, his health, and attack state. facing is 1 for right and -1 for left.
- Directly below the projectiles array (still above gameLoop), add the drawPlayer function:
// Draw Luffy using Canvas shapes
function drawPlayer() {
// Red vest (torso)
ctx.fillStyle = '#CC0000';
ctx.fillRect(player.x + 8, player.y + 20, 24, 25);
// Head (skin-coloured circle)
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(player.x + player.width / 2, player.y + 12, 12, 0, Math.PI * 2);
ctx.fill();
// Straw hat (ellipse + brim)
ctx.fillStyle = '#F5DEB3';
ctx.beginPath();
ctx.ellipse(player.x + player.width / 2, player.y + 5, 14, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#CC9900';
ctx.fillRect(player.x + 6, player.y + 4, 28, 4);
// Blue shorts (two legs)
ctx.fillStyle = '#0044AA';
ctx.fillRect(player.x + 10, player.y + 45, 8, 15);
ctx.fillRect(player.x + 22, player.y + 45, 8, 15);
// Arms (skin-coloured rectangles)
ctx.fillStyle = '#F4C28C';
ctx.fillRect(player.x + 2, player.y + 22, 6, 20);
ctx.fillRect(player.x + 32, player.y + 22, 6, 20);
// Punch animation (extends a fist when attacking)
if (player.attacking) {
ctx.fillStyle = '#F4C28C';
if (player.facing === 1) {
ctx.fillRect(player.x + player.width, player.y + 20, 50, 15);
} else {
ctx.fillRect(player.x - 50, player.y + 20, 50, 15);
}
}
}
What does this code do?
This function draws Luffy piece by piece using Canvas primitives. fillRect draws rectangles (vest, hat brim, shorts, arms). arc draws a circle (head). ellipse draws the rounded straw hat dome.
Each shape is positioned relative to player.x and player.y, so when those values change, Luffy's entire body moves together. The punch animation section draws an extended fist in front of (or behind) Luffy when player.attacking is true.
You will not see Luffy on screen yet. The game loop does not call drawPlayer yet. The next substep wires everything together.
- Save game.js.
Add movement and gravity
The final piece is an updatePlayer function that reads keyboard input, applies gravity, and clamps Luffy to the screen edges. Once it exists, you will update the game loop to call both updatePlayer and drawPlayer each frame.
- In game.js, add the updatePlayer function between drawPlayer and gameLoop:
// Update player position and physics each frame
function updatePlayer() {
// Horizontal movement from arrow keys
if (keys['ArrowLeft']) {
player.vx = -PLAYER_SPEED;
player.facing = -1;
} else if (keys['ArrowRight']) {
player.vx = PLAYER_SPEED;
player.facing = 1;
} else {
player.vx = 0;
}
// Jump when grounded
if (keys[' '] && player.grounded) {
player.vy = JUMP_FORCE;
player.grounded = false;
}
// Apply gravity and move
player.vy += GRAVITY;
player.x += player.vx;
player.y += player.vy;
// Keep player within horizontal screen bounds
if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
// Tick attack timers
if (player.attackTimer > 0) player.attackTimer--;
if (player.attackTimer <= 0) player.attacking = false;
if (player.attackCooldown > 0) player.attackCooldown--;
}
What does this code do?
Each frame, the function checks if ArrowLeft or ArrowRight is held and sets the horizontal velocity accordingly. The Space key (' ' in the keyboard event) triggers a jump only when player.grounded is true, preventing infinite mid-air jumps.
Gravity works by adding GRAVITY to player.vy every frame. This makes the vertical velocity increase over time, simulating acceleration downward. The position is then updated by adding velocity to position.
The bounds check prevents Luffy from walking off the left or right edges. Platforms (added in the next step) will handle the vertical ground check.
- Now update the gameLoop function to call both updatePlayer and drawPlayer. Find your current gameLoop function:
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
requestAnimationFrame(gameLoop);
}
- Replace it with this updated version that runs the player logic when the game state is 'playing':
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'playing') {
updatePlayer();
drawPlayer();
}
requestAnimationFrame(gameLoop);
}
Why wrap in a gameState check?
Later, the game will have multiple states: playing, level complete, game over, and won. By checking gameState now, the loop is already set up to show different screens without running player physics when the game is paused or ended.
- Save game.js and refresh your browser.
- You should see Luffy (red vest, straw hat, blue shorts) appear on the sky-blue canvas. He immediately falls off the bottom of the screen due to gravity. Press the Left and Right arrow keys quickly after refreshing to see him move horizontally before he falls.
Why does Luffy fall off screen?
Gravity pulls him down each frame, but there are no platforms to land on yet. In the next step, you will add platforms and collision detection so Luffy can stand on solid ground and jump between surfaces.
Luffy not appearing at all?
Open your browser's developer console (F12 then click the Console tab) and check for red error messages. A typo in a function name or a missing bracket is the most common cause.
Make sure drawPlayer and updatePlayer are both defined above the gameLoop function, and that gameLoop() is the very last line of the file.
help me debug why Luffy is not showing up on my canvas
✔️ Awesome, I've got everything!
Great. Double check you have saved game.js and that Luffy appears briefly before falling.
ⓧ I'd like to double check the full code
Here is the complete game.js at the end of this step:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const GRAVITY = 0.5;
const PLAYER_SPEED = 5;
const JUMP_FORCE = -12;
const ENEMY_SPEED = 1.5;
const PROJECTILE_SPEED = 10;
let currentLevel = 1;
let gameState = 'playing';
let abilities = {
punch: true,
gumGumPistol: false
};
const keys = {};
document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup', (e) => { keys[e.key] = false; });
const player = {
x: 50,
y: 300,
width: 40,
height: 60,
vx: 0,
vy: 0,
grounded: false,
health: 3,
maxHealth: 3,
facing: 1,
attacking: false,
attackTimer: 0,
attackCooldown: 0
};
let projectiles = [];
function drawPlayer() {
ctx.fillStyle = '#CC0000';
ctx.fillRect(player.x + 8, player.y + 20, 24, 25);
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(player.x + player.width / 2, player.y + 12, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#F5DEB3';
ctx.beginPath();
ctx.ellipse(player.x + player.width / 2, player.y + 5, 14, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#CC9900';
ctx.fillRect(player.x + 6, player.y + 4, 28, 4);
ctx.fillStyle = '#0044AA';
ctx.fillRect(player.x + 10, player.y + 45, 8, 15);
ctx.fillRect(player.x + 22, player.y + 45, 8, 15);
ctx.fillStyle = '#F4C28C';
ctx.fillRect(player.x + 2, player.y + 22, 6, 20);
ctx.fillRect(player.x + 32, player.y + 22, 6, 20);
if (player.attacking) {
ctx.fillStyle = '#F4C28C';
if (player.facing === 1) {
ctx.fillRect(player.x + player.width, player.y + 20, 50, 15);
} else {
ctx.fillRect(player.x - 50, player.y + 20, 50, 15);
}
}
}
function updatePlayer() {
if (keys['ArrowLeft']) {
player.vx = -PLAYER_SPEED;
player.facing = -1;
} else if (keys['ArrowRight']) {
player.vx = PLAYER_SPEED;
player.facing = 1;
} else {
player.vx = 0;
}
if (keys[' '] && player.grounded) {
player.vy = JUMP_FORCE;
player.grounded = false;
}
player.vy += GRAVITY;
player.x += player.vx;
player.y += player.vy;
if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
if (player.attackTimer > 0) player.attackTimer--;
if (player.attackTimer <= 0) player.attacking = false;
if (player.attackCooldown > 0) player.attackCooldown--;
}
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'playing') {
updatePlayer();
drawPlayer();
}
requestAnimationFrame(gameLoop);
}
gameLoop();
Luffy is alive and moving, but he has nowhere to land. Next up, you will build platforms with collision detection so he can stand, jump, and navigate a level.
Create Platforms and Collision Detection
Luffy can move and jump, but there is nowhere to land. Without platforms, gravity pulls the player off the screen every frame.
In this step, you will build the level structure and teach the game how to detect when objects overlap. This is the foundation of all interactions in a platformer.
In this step, get ready to:
- Define platform data for two levels and render them as grassy rectangles.
- Implement AABB collision detection so Luffy can stand on platforms.
- Add a level exit that activates when all enemies are defeated.
Define platform data and render platforms
Every level needs a layout. You will define the positions and sizes of all platforms, enemies, and exits as a data object so the game can load different levels dynamically.
- In game.js, find the line let projectiles = []; and add the following level definitions directly below it:
const levels = {
1: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 150, y: 360, width: 120, height: 20 },
{ x: 350, y: 300, width: 120, height: 20 },
{ x: 550, y: 240, width: 120, height: 20 },
{ x: 650, y: 360, width: 120, height: 20 }
],
enemies: [
{ x: 200, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 150, endX: 350 },
{ x: 400, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 350, endX: 470 },
{ x: 680, y: 320, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 650, endX: 770 }
],
exit: { x: 580, y: 180, width: 40, height: 60 }
},
2: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 100, y: 360, width: 100, height: 20 },
{ x: 280, y: 300, width: 100, height: 20 },
{ x: 450, y: 240, width: 100, height: 20 },
{ x: 600, y: 180, width: 100, height: 20 },
{ x: 300, y: 400, width: 100, height: 20 },
{ x: 700, y: 320, width: 80, height: 20 }
],
enemies: [
{ x: 150, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 50, endX: 300 },
{ x: 300, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 280, endX: 380 },
{ x: 470, y: 200, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 450, endX: 550 },
{ x: 720, y: 280, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 700, endX: 780 }
],
exit: { x: 620, y: 120, width: 40, height: 60 }
}
};
What does this code do?
The levels object stores the complete blueprint for each level. Each level defines three things:
- platforms is an array of rectangles the player can stand on. The first entry (y: 460, width: 800) spans the full canvas width as the ground.
- enemies defines Marine positions, patrol bounds, and health. Level 2 enemies have 2 health instead of 1.
- exit places a door the player must reach after defeating all enemies.
- Directly below the levels object, add the runtime arrays and the loadLevel function:
let enemies = [];
let platforms = [];
let exit = {};
function loadLevel(level) {
const data = levels[level];
platforms = data.platforms.map(p => ({ ...p }));
enemies = data.enemies.map(e => ({ ...e }));
exit = { ...data.exit };
player.x = 50;
player.y = 300;
player.vx = 0;
player.vy = 0;
player.health = player.maxHealth;
projectiles = [];
gameState = 'playing';
}
Why copy with the spread operator?
The .map(p => ({ ...p })) creates fresh copies of each platform and enemy object. Without this, restarting the level would reference the original data and any changes (like enemy health) would persist permanently.
The function also resets the player to the starting position and restores full health, giving a clean slate each time a level loads.
- Now add the drawPlatforms function below drawPlayer:
function drawPlatforms() {
for (const platform of platforms) {
// Dark green body
ctx.fillStyle = '#4A8C3F';
ctx.fillRect(platform.x, platform.y, platform.width, platform.height);
// Lighter green top edge (grass effect)
ctx.fillStyle = '#66BB55';
ctx.fillRect(platform.x, platform.y, platform.width, 4);
}
}
What does this code do?
Each platform is drawn as a dark green rectangle with a 4-pixel lighter strip along the top. This gives a simple grass effect without any external images.
- Save game.js.
- You will not see platforms on screen yet because the game loop does not call drawPlatforms(). You will wire that up at the end of this step.
Seeing a syntax error in the console?
Make sure the levels object ends with a closing }; and that every inner array uses square brackets. A missing comma between objects is the most common typo here.
Help me debug a syntax error in my levels object.
Implement AABB collision detection
For Luffy to stand on platforms instead of falling through them, the game needs to detect when two rectangles overlap. The standard technique for this in 2D games is called AABB (Axis-Aligned Bounding Box) collision detection.
- Add the isColliding function directly above updatePlayer:
// Check if two rectangles overlap (AABB collision)
function isColliding(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
How does AABB collision work?
Two rectangles overlap if and only if all four of these conditions are true: rectangle A's left edge is left of B's right edge, A's right edge is right of B's left edge, A's top edge is above B's bottom edge, and A's bottom edge is below B's top edge.
If any one condition is false, there is a gap between the rectangles and they are not colliding.
Now you need to use this function inside updatePlayer to resolve collisions with platforms each frame.
- In the updatePlayer function, find this line:
player.y += player.vy;
- Directly below it (and above the existing bounds check if (player.x < 0)), add the platform collision loop:
player.grounded = false;
for (const platform of platforms) {
if (isColliding(player, platform)) {
// Landing on top of a platform
if (player.vy > 0 && player.y + player.height - player.vy <= platform.y + 1) {
player.y = platform.y - player.height;
player.vy = 0;
player.grounded = true;
// Hitting the bottom of a platform
} else if (player.vy < 0 && player.y - player.vy >= platform.y + platform.height - 1) {
player.y = platform.y + platform.height;
player.vy = 0;
// Hitting the side from the right
} else if (player.vx > 0) {
player.x = platform.x - player.width;
// Hitting the side from the left
} else if (player.vx < 0) {
player.x = platform.x + platform.width;
}
}
}
What does this collision loop do?
- player.grounded = false resets the grounded flag every frame. It only becomes true again if the collision loop finds the player on top of a platform.
- The first if checks whether the player is falling downward and was above the platform last frame. If so, snap the player to the platform's top and allow jumping again.
- The second branch handles hitting a platform from below (bumping your head). It stops upward velocity.
- The final branches handle running into a platform's side, pushing the player back.
- Save game.js.
- You will not see a visible difference yet because the game loop still needs updating. The next substep wires everything together.
Player falls through platforms later?
Make sure the collision loop is placed between player.y += player.vy; and the bounds check lines. If it comes after the bounds check, the position corrections can conflict.
My player is falling through platforms. Help me debug.
Add the level exit
The player needs a goal. You will add a golden door that activates only after all enemies are cleared, plus a safety check for falling off the map.
- Add the drawExit function below drawPlatforms:
function drawExit() {
// Glow gold when all enemies defeated, grey when locked
const glow = enemies.length === 0;
ctx.fillStyle = glow ? '#FFD700' : '#555555';
ctx.fillRect(exit.x, exit.y, exit.width, exit.height);
// Border around the exit door
ctx.strokeStyle = glow ? '#FFA500' : '#333333';
ctx.lineWidth = 2;
ctx.strokeRect(exit.x, exit.y, exit.width, exit.height);
// EXIT label above the door
ctx.fillStyle = glow ? '#FFD700' : '#666666';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('EXIT', exit.x + exit.width / 2, exit.y - 5);
}
What does this code do?
The exit door changes appearance based on whether all enemies are defeated. When enemies.length === 0, it glows gold to signal the player can proceed. Otherwise it stays grey, telling the player to keep fighting.
Now add the exit collision check and a safety net for falling off the screen.
- In updatePlayer, find the last cooldown line:
if (player.attackCooldown > 0) player.attackCooldown--;
- Add the following code directly below it (before the function's closing }):
// Check if player reached the exit with all enemies defeated
if (isColliding(player, exit) && enemies.length === 0) {
if (currentLevel === 1) {
gameState = 'levelComplete';
} else {
gameState = 'won';
}
}
// Player fell off the screen
if (player.y > canvas.height) {
player.health = 0;
gameState = 'gameOver';
}
What does this code do?
- The exit check uses isColliding to detect when the player touches the door. It only triggers if all enemies are gone.
- Level 1 completion sets gameState to 'levelComplete' (for the unlock screen you will build later). Level 2 completion sets it to 'won'.
- The fall check triggers 'gameOver' if the player drops below the canvas, preventing an infinite fall.
Finally, update the game loop to draw platforms and the exit, and call loadLevel at startup.
- Find your gameLoop function. Inside the if (gameState === 'playing') block, find this line:
updatePlayer();
drawPlayer();
- Replace it with these four lines, adding drawPlatforms() and drawExit() between the update and draw calls:
updatePlayer();
drawPlatforms();
drawExit();
drawPlayer();
- At the very bottom of game.js, find this line:
gameLoop();
- Add loadLevel(1); directly above it so the level data loads before the loop starts:
loadLevel(1);
gameLoop();
- Save game.js and refresh your browser.
- You should see green platforms at various heights and a grey EXIT door on the upper-right platform. Luffy now lands on platforms instead of falling endlessly.
Luffy still falls off the screen?
Make sure you added loadLevel(1); before gameLoop(); at the bottom of the file. Without it, the platforms array stays empty and there is nothing to collide with.
Also confirm the collision loop is placed between player.y += player.vy; and the bounds check. If it is missing or in the wrong spot, collisions will not resolve.
My platforms are visible but Luffy falls through them. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved game.js and that Luffy can jump between platforms without falling through them.
ⓧ I'd like to double check the full code
Here is the full game.js at this point:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const GRAVITY = 0.5;
const PLAYER_SPEED = 5;
const JUMP_FORCE = -12;
const ENEMY_SPEED = 1.5;
const PROJECTILE_SPEED = 10;
let currentLevel = 1;
let gameState = 'playing';
let abilities = {
punch: true,
gumGumPistol: false
};
const keys = {};
document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup', (e) => { keys[e.key] = false; });
const player = {
x: 50,
y: 300,
width: 40,
height: 60,
vx: 0,
vy: 0,
grounded: false,
health: 3,
maxHealth: 3,
facing: 1,
attacking: false,
attackTimer: 0,
attackCooldown: 0
};
let projectiles = [];
const levels = {
1: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 150, y: 360, width: 120, height: 20 },
{ x: 350, y: 300, width: 120, height: 20 },
{ x: 550, y: 240, width: 120, height: 20 },
{ x: 650, y: 360, width: 120, height: 20 }
],
enemies: [
{ x: 200, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 150, endX: 350 },
{ x: 400, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 350, endX: 470 },
{ x: 680, y: 320, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 650, endX: 770 }
],
exit: { x: 580, y: 180, width: 40, height: 60 }
},
2: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 100, y: 360, width: 100, height: 20 },
{ x: 280, y: 300, width: 100, height: 20 },
{ x: 450, y: 240, width: 100, height: 20 },
{ x: 600, y: 180, width: 100, height: 20 },
{ x: 300, y: 400, width: 100, height: 20 },
{ x: 700, y: 320, width: 80, height: 20 }
],
enemies: [
{ x: 150, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 50, endX: 300 },
{ x: 300, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 280, endX: 380 },
{ x: 470, y: 200, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 450, endX: 550 },
{ x: 720, y: 280, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 700, endX: 780 }
],
exit: { x: 620, y: 120, width: 40, height: 60 }
}
};
let enemies = [];
let platforms = [];
let exit = {};
function loadLevel(level) {
const data = levels[level];
platforms = data.platforms.map(p => ({ ...p }));
enemies = data.enemies.map(e => ({ ...e }));
exit = { ...data.exit };
player.x = 50;
player.y = 300;
player.vx = 0;
player.vy = 0;
player.health = player.maxHealth;
projectiles = [];
gameState = 'playing';
}
function isColliding(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
function updatePlayer() {
if (keys['ArrowLeft']) {
player.vx = -PLAYER_SPEED;
player.facing = -1;
} else if (keys['ArrowRight']) {
player.vx = PLAYER_SPEED;
player.facing = 1;
} else {
player.vx = 0;
}
if (keys[' '] && player.grounded) {
player.vy = JUMP_FORCE;
player.grounded = false;
}
player.vy += GRAVITY;
player.x += player.vx;
player.y += player.vy;
player.grounded = false;
for (const platform of platforms) {
if (isColliding(player, platform)) {
if (player.vy > 0 && player.y + player.height - player.vy <= platform.y + 1) {
player.y = platform.y - player.height;
player.vy = 0;
player.grounded = true;
} else if (player.vy < 0 && player.y - player.vy >= platform.y + platform.height - 1) {
player.y = platform.y + platform.height;
player.vy = 0;
} else if (player.vx > 0) {
player.x = platform.x - player.width;
} else if (player.vx < 0) {
player.x = platform.x + platform.width;
}
}
}
if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
if (player.attackTimer > 0) player.attackTimer--;
if (player.attackTimer <= 0) player.attacking = false;
if (player.attackCooldown > 0) player.attackCooldown--;
if (isColliding(player, exit) && enemies.length === 0) {
if (currentLevel === 1) {
gameState = 'levelComplete';
} else {
gameState = 'won';
}
}
if (player.y > canvas.height) {
player.health = 0;
gameState = 'gameOver';
}
}
function drawPlayer() {
ctx.fillStyle = '#CC0000';
ctx.fillRect(player.x + 8, player.y + 20, 24, 25);
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(player.x + player.width / 2, player.y + 12, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#F5DEB3';
ctx.beginPath();
ctx.ellipse(player.x + player.width / 2, player.y + 5, 14, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#CC9900';
ctx.fillRect(player.x + 6, player.y + 4, 28, 4);
ctx.fillStyle = '#0044AA';
ctx.fillRect(player.x + 10, player.y + 45, 8, 15);
ctx.fillRect(player.x + 22, player.y + 45, 8, 15);
ctx.fillStyle = '#F4C28C';
ctx.fillRect(player.x + 2, player.y + 22, 6, 20);
ctx.fillRect(player.x + 32, player.y + 22, 6, 20);
if (player.attacking) {
ctx.fillStyle = '#F4C28C';
if (player.facing === 1) {
ctx.fillRect(player.x + player.width, player.y + 20, 50, 15);
} else {
ctx.fillRect(player.x - 50, player.y + 20, 50, 15);
}
}
}
function drawPlatforms() {
for (const platform of platforms) {
ctx.fillStyle = '#4A8C3F';
ctx.fillRect(platform.x, platform.y, platform.width, platform.height);
ctx.fillStyle = '#66BB55';
ctx.fillRect(platform.x, platform.y, platform.width, 4);
}
}
function drawExit() {
const glow = enemies.length === 0;
ctx.fillStyle = glow ? '#FFD700' : '#555555';
ctx.fillRect(exit.x, exit.y, exit.width, exit.height);
ctx.strokeStyle = glow ? '#FFA500' : '#333333';
ctx.lineWidth = 2;
ctx.strokeRect(exit.x, exit.y, exit.width, exit.height);
ctx.fillStyle = glow ? '#FFD700' : '#666666';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('EXIT', exit.x + exit.width / 2, exit.y - 5);
}
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'playing') {
updatePlayer();
drawPlatforms();
drawExit();
drawPlayer();
}
requestAnimationFrame(gameLoop);
}
loadLevel(1);
gameLoop();
Luffy can now explore the level by jumping between platforms. Next up, you will populate those platforms with Marine enemies and give Luffy a punch attack to fight back.
Add Enemies and Combat
You have a world with platforms and an exit door, but Luffy has no one to fight. A platformer needs enemies to challenge the player and give combat meaning.
In this step, you will bring Marine enemies to life with patrol AI, give Luffy a punch attack, and wire up the damage system that creates real stakes. When Luffy runs out of health, the game ends.
In this step, get ready to:
- Create Marine enemies that patrol back and forth between set bounds.
- Implement a punch attack with a hitbox and cooldown timer.
- Handle player-enemy collisions, health tracking, and a game over screen.
Create Marine enemies with patrol AI
The enemy data already lives inside your levels object from the previous step. Each enemy has position, velocity, and patrol bounds. Now you need a function that draws them on screen.
- In game.js, find the drawPlayer function's closing brace. Add the following drawEnemies function below it:
function drawEnemies() {
for (const enemy of enemies) {
// White Marine uniform body
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(enemy.x + 5, enemy.y + 15, 25, 25);
// Skin-colored head
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(enemy.x + enemy.width / 2, enemy.y + 10, 10, 0, Math.PI * 2);
ctx.fill();
// Blue Marine cap
ctx.fillStyle = '#003366';
ctx.fillRect(enemy.x + 4, enemy.y, 27, 10);
// Red health indicator rectangles
ctx.fillStyle = '#FF0000';
for (let i = 0; i < enemy.health; i++) {
ctx.fillRect(enemy.x + 8 + i * 12, enemy.y - 8, 10, 5);
}
}
}
What does this code do?
This function loops through every enemy in the active enemies array and draws each one using Canvas shapes.
- The white rectangle is the Marine uniform torso.
- The arc draws a circular skin-colored head.
- The dark blue rectangle on top is the Marine cap.
- The health loop draws one red bar per health point above the enemy, so you can see how much damage they have left.
Now you need to call drawEnemies inside the game loop so the enemies appear each frame.
- Scroll down to the gameLoop function and find this line inside the if (gameState === 'playing') block:
drawExit();
drawPlayer();
- Add drawEnemies(); between those two lines, so it now looks like this:
drawExit();
drawEnemies();
drawPlayer();
- Save game.js and refresh your browser.
- You should see three white-uniformed Marines standing on the platforms with blue caps and small red health bars above them.
Don't see any enemies?
- Make sure drawEnemies(); is inside the if (gameState === 'playing') block, not outside it.
- Check your browser console for errors. A typo in the function name will silently fail.
- Confirm drawEnemies(); comes BEFORE drawPlayer(); so enemies render behind Luffy.
Why aren't my enemies showing up on the canvas?
Implement the punch attack
The enemies are visible but they cannot move or be defeated yet. You need input handling for the punch, a hitbox that detects overlap with enemies, and patrol logic that makes the Marines walk back and forth.
First, add the Z key punch trigger to updatePlayer. This sets the attacking flag and starts a cooldown timer.
- In the updatePlayer function, find the jump block that ends with:
if (keys[' '] && player.grounded) {
player.vy = JUMP_FORCE;
player.grounded = false;
}
- Add this punch trigger block directly below it (before the player.vy += GRAVITY; line):
if (keys['z'] && player.attackCooldown <= 0) {
player.attacking = true;
player.attackTimer = 15;
player.attackCooldown = 25;
}
What does this code do?
- When the player presses Z and the cooldown has expired, attacking becomes true. This triggers the fist visual you already have in drawPlayer.
- The attackTimer of 15 frames controls how long the punch animation lasts. The attackCooldown of 25 frames prevents spamming.
Now you need the updateEnemies function. This single function handles patrol movement, punch detection, projectile collision, and removing defeated enemies.
- Add the following updateEnemies function below the closing brace of updatePlayer:
function updateEnemies() {
for (const enemy of enemies) {
// Patrol: move enemy and reverse at bounds
enemy.x += enemy.vx;
if (enemy.x <= enemy.startX || enemy.x + enemy.width >= enemy.endX) {
enemy.vx *= -1;
}
// Damage player on contact (if not attacking)
if (isColliding(player, enemy) && !player.attacking) {
player.health--;
player.x = 50;
player.y = 300;
player.vy = 0;
if (player.health <= 0) {
gameState = 'gameOver';
}
break;
}
// Punch hitbox damages enemy
if (player.attacking) {
const punchBox = {
x: player.facing === 1 ? player.x + player.width : player.x - 50,
y: player.y + 10,
width: 50,
height: 30
};
if (isColliding(punchBox, enemy)) {
enemy.health--;
}
}
}
// Projectile-enemy collision (for Gum-Gum Pistol later)
for (let i = projectiles.length - 1; i >= 0; i--) {
for (const enemy of enemies) {
if (isColliding(projectiles[i], enemy)) {
enemy.health--;
projectiles.splice(i, 1);
break;
}
}
}
// Remove enemies with zero health
enemies = enemies.filter(e => e.health > 0);
}
What does this code do?
This function runs every frame and manages all enemy behaviour.
- Patrol logic moves each enemy by its vx and reverses direction when it hits startX or endX.
- If Luffy touches an enemy while NOT attacking, the player loses one health and resets to the start position.
- The punchBox creates an invisible rectangle in front of Luffy. If it overlaps an enemy during a punch, the enemy takes damage.
- The projectile loop checks for ranged attack hits. This will matter once the Gum-Gum Pistol is unlocked in a later step.
- Finally, enemies.filter removes any enemy whose health dropped to zero.
What is AABB collision detection?
AABB stands for Axis-Aligned Bounding Box. It checks whether two rectangles overlap by comparing their edges. If all four edge comparisons pass, the objects are touching.
The punchBox is a temporary bounding box that only exists during the attack frames. It extends 50 pixels in front of Luffy based on his facing direction.
The last piece is calling updateEnemies from the game loop so enemies move and react each frame.
- In the gameLoop function, find this line:
updatePlayer();
- Add updateEnemies(); directly below it, so it reads:
updatePlayer();
updateEnemies();
- Save game.js and refresh your browser.
- The Marines now patrol back and forth on their platforms. Press Z to punch. When the fist connects, the enemy's red health bar shrinks and the enemy disappears at zero health.
Punch not hitting enemies?
- Make sure the Z key block is placed AFTER the jump check and BEFORE player.vy += GRAVITY; inside updatePlayer.
- Check that you used a lowercase 'z' in the key check. JavaScript key values are case-sensitive.
- Confirm you can see the fist extending from Luffy when you press Z. If the fist appears but enemies don't take damage, double-check the punchBox coordinates in updateEnemies.
Help me debug why my punch attack isn't damaging enemies.
Handle player-enemy collision and game over
The damage logic is already wired up inside updateEnemies. Walking into a Marine costs one health and resets Luffy's position. When health hits zero, gameState changes to 'gameOver'. But the game loop does not know what to do with that state yet.
- Add the drawGameOver function below the closing brace of drawExit:
function drawGameOver() {
// Dark semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// GAME OVER text in red
ctx.fillStyle = '#FF0000';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', canvas.width / 2, canvas.height / 2 - 20);
// Restart prompt
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.fillText('Press Enter to restart', canvas.width / 2, canvas.height / 2 + 20);
}
What does this code do?
- The semi-transparent black rectangle creates a dark overlay that dims the game world behind it.
- The red "GAME OVER" text and white restart prompt are drawn centered on the canvas.
Now wire the game over state into the game loop so the screen renders and the Enter key restarts the game.
- In the gameLoop function, find the closing brace of the if (gameState === 'playing') block. Add this else if block directly after it:
} else if (gameState === 'gameOver') {
drawGameOver();
if (keys['Enter']) {
currentLevel = 1;
abilities.gumGumPistol = false;
loadLevel(1);
}
}
What does this code do?
- When gameState is 'gameOver', the loop stops updating the player and enemies and draws the game over screen instead.
- Pressing Enter resets the level to 1, removes the Gum-Gum Pistol ability, and calls loadLevel(1) to start fresh.
- Save game.js and refresh your browser.
- Walk Luffy into a Marine three times (without pressing Z). You should see the dark overlay with "GAME OVER" in red and the restart prompt in white.
- Press Enter. The game restarts from the beginning with full health.
Game over screen not appearing?
- Check that the else if is chained directly after the if (gameState === 'playing') block's closing brace. A stray semicolon or extra brace will break the chain.
- Verify the player-enemy collision code uses !player.attacking in the check. If you left out the exclamation mark, collision triggers while punching too.
- If the game just freezes instead of showing the overlay, your drawGameOver function might have a syntax error. Check the console.
Why isn't my game over screen showing when I lose all health?
Why does the game use a state machine?
The gameState variable acts as a simple state machine. Instead of tracking many boolean flags, one string controls what the entire game loop does each frame.
This pattern scales cleanly. You will add 'levelComplete' and 'won' states in the next step without touching the playing or game over logic.
✔️ Awesome, I've got everything!
Great. Double check you have saved game.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete game.js at this point. Compare your file against it line by line.
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const GRAVITY = 0.5;
const PLAYER_SPEED = 5;
const JUMP_FORCE = -12;
const ENEMY_SPEED = 1.5;
const PROJECTILE_SPEED = 10;
let currentLevel = 1;
let gameState = 'playing';
let abilities = {
punch: true,
gumGumPistol: false
};
const keys = {};
document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup', (e) => { keys[e.key] = false; });
const player = {
x: 50,
y: 300,
width: 40,
height: 60,
vx: 0,
vy: 0,
grounded: false,
health: 3,
maxHealth: 3,
facing: 1,
attacking: false,
attackTimer: 0,
attackCooldown: 0
};
let projectiles = [];
const levels = {
1: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 150, y: 360, width: 120, height: 20 },
{ x: 350, y: 300, width: 120, height: 20 },
{ x: 550, y: 240, width: 120, height: 20 },
{ x: 650, y: 360, width: 120, height: 20 }
],
enemies: [
{ x: 200, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 150, endX: 350 },
{ x: 400, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 350, endX: 470 },
{ x: 680, y: 320, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 650, endX: 770 }
],
exit: { x: 580, y: 180, width: 40, height: 60 }
},
2: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 100, y: 360, width: 100, height: 20 },
{ x: 280, y: 300, width: 100, height: 20 },
{ x: 450, y: 240, width: 100, height: 20 },
{ x: 600, y: 180, width: 100, height: 20 },
{ x: 300, y: 400, width: 100, height: 20 },
{ x: 700, y: 320, width: 80, height: 20 }
],
enemies: [
{ x: 150, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 50, endX: 300 },
{ x: 300, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 280, endX: 380 },
{ x: 470, y: 200, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 450, endX: 550 },
{ x: 720, y: 280, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 700, endX: 780 }
],
exit: { x: 620, y: 120, width: 40, height: 60 }
}
};
let enemies = [];
let platforms = [];
let exit = {};
function loadLevel(level) {
const data = levels[level];
platforms = data.platforms.map(p => ({ ...p }));
enemies = data.enemies.map(e => ({ ...e }));
exit = { ...data.exit };
player.x = 50;
player.y = 300;
player.vx = 0;
player.vy = 0;
player.health = player.maxHealth;
projectiles = [];
gameState = 'playing';
}
function isColliding(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
function updatePlayer() {
if (keys['ArrowLeft']) {
player.vx = -PLAYER_SPEED;
player.facing = -1;
} else if (keys['ArrowRight']) {
player.vx = PLAYER_SPEED;
player.facing = 1;
} else {
player.vx = 0;
}
if (keys[' '] && player.grounded) {
player.vy = JUMP_FORCE;
player.grounded = false;
}
if (keys['z'] && player.attackCooldown <= 0) {
player.attacking = true;
player.attackTimer = 15;
player.attackCooldown = 25;
}
player.vy += GRAVITY;
player.x += player.vx;
player.y += player.vy;
player.grounded = false;
for (const platform of platforms) {
if (isColliding(player, platform)) {
if (player.vy > 0 && player.y + player.height - player.vy <= platform.y + 1) {
player.y = platform.y - player.height;
player.vy = 0;
player.grounded = true;
} else if (player.vy < 0 && player.y - player.vy >= platform.y + platform.height - 1) {
player.y = platform.y + platform.height;
player.vy = 0;
} else if (player.vx > 0) {
player.x = platform.x - player.width;
} else if (player.vx < 0) {
player.x = platform.x + platform.width;
}
}
}
if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
if (player.attackTimer > 0) player.attackTimer--;
if (player.attackTimer <= 0) player.attacking = false;
if (player.attackCooldown > 0) player.attackCooldown--;
if (isColliding(player, exit) && enemies.length === 0) {
if (currentLevel === 1) {
gameState = 'levelComplete';
} else {
gameState = 'won';
}
}
if (player.y > canvas.height) {
player.health = 0;
gameState = 'gameOver';
}
}
function updateEnemies() {
for (const enemy of enemies) {
enemy.x += enemy.vx;
if (enemy.x <= enemy.startX || enemy.x + enemy.width >= enemy.endX) {
enemy.vx *= -1;
}
if (isColliding(player, enemy) && !player.attacking) {
player.health--;
player.x = 50;
player.y = 300;
player.vy = 0;
if (player.health <= 0) {
gameState = 'gameOver';
}
break;
}
if (player.attacking) {
const punchBox = {
x: player.facing === 1 ? player.x + player.width : player.x - 50,
y: player.y + 10,
width: 50,
height: 30
};
if (isColliding(punchBox, enemy)) {
enemy.health--;
}
}
}
for (let i = projectiles.length - 1; i >= 0; i--) {
for (const enemy of enemies) {
if (isColliding(projectiles[i], enemy)) {
enemy.health--;
projectiles.splice(i, 1);
break;
}
}
}
enemies = enemies.filter(e => e.health > 0);
}
function drawPlayer() {
ctx.fillStyle = '#CC0000';
ctx.fillRect(player.x + 8, player.y + 20, 24, 25);
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(player.x + player.width / 2, player.y + 12, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#F5DEB3';
ctx.beginPath();
ctx.ellipse(player.x + player.width / 2, player.y + 5, 14, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#CC9900';
ctx.fillRect(player.x + 6, player.y + 4, 28, 4);
ctx.fillStyle = '#0044AA';
ctx.fillRect(player.x + 10, player.y + 45, 8, 15);
ctx.fillRect(player.x + 22, player.y + 45, 8, 15);
ctx.fillStyle = '#F4C28C';
ctx.fillRect(player.x + 2, player.y + 22, 6, 20);
ctx.fillRect(player.x + 32, player.y + 22, 6, 20);
if (player.attacking) {
ctx.fillStyle = '#F4C28C';
if (player.facing === 1) {
ctx.fillRect(player.x + player.width, player.y + 20, 50, 15);
} else {
ctx.fillRect(player.x - 50, player.y + 20, 50, 15);
}
}
}
function drawEnemies() {
for (const enemy of enemies) {
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(enemy.x + 5, enemy.y + 15, 25, 25);
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(enemy.x + enemy.width / 2, enemy.y + 10, 10, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#003366';
ctx.fillRect(enemy.x + 4, enemy.y, 27, 10);
ctx.fillStyle = '#FF0000';
for (let i = 0; i < enemy.health; i++) {
ctx.fillRect(enemy.x + 8 + i * 12, enemy.y - 8, 10, 5);
}
}
}
function drawPlatforms() {
for (const platform of platforms) {
ctx.fillStyle = '#4A8C3F';
ctx.fillRect(platform.x, platform.y, platform.width, platform.height);
ctx.fillStyle = '#66BB55';
ctx.fillRect(platform.x, platform.y, platform.width, 4);
}
}
function drawExit() {
const glow = enemies.length === 0;
ctx.fillStyle = glow ? '#FFD700' : '#555555';
ctx.fillRect(exit.x, exit.y, exit.width, exit.height);
ctx.strokeStyle = glow ? '#FFA500' : '#333333';
ctx.lineWidth = 2;
ctx.strokeRect(exit.x, exit.y, exit.width, exit.height);
ctx.fillStyle = glow ? '#FFD700' : '#666666';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('EXIT', exit.x + exit.width / 2, exit.y - 5);
}
function drawGameOver() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FF0000';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', canvas.width / 2, canvas.height / 2 - 20);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.fillText('Press Enter to restart', canvas.width / 2, canvas.height / 2 + 20);
}
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'playing') {
updatePlayer();
updateEnemies();
drawPlatforms();
drawExit();
drawEnemies();
drawPlayer();
} else if (gameState === 'gameOver') {
drawGameOver();
if (keys['Enter']) {
currentLevel = 1;
abilities.gumGumPistol = false;
loadLevel(1);
}
}
requestAnimationFrame(gameLoop);
}
loadLevel(1);
gameLoop();
Luffy can now fight Marines, take damage, and see a proper game over. Next up, you will build the ability unlock system that rewards Luffy with the Gum-Gum Pistol ranged attack after clearing Level 1.
Build the Ability Unlock System and Level Progression
You have a working platformer with enemies, combat, and platforms. Now it is time to tie everything together with a progression system that rewards the player for clearing each level.
In this step, you will build the screens that appear between levels, implement Luffy's signature Gum-Gum Pistol as a ranged attack, and add a heads-up display so the player always knows their health, abilities, and objectives.
In this step, get ready to:
- Create the level transition and ability unlock screen.
- Implement the Gum-Gum Pistol ranged attack with projectile physics.
- Add the HUD, win screen, and wire everything into the game loop.
Create the level transition and unlock screen
When Luffy clears all Marines in Level 1 and reaches the exit, the game needs to celebrate the accomplishment and introduce a new ability. This overlay screen acts as the bridge between levels.
- In game.js, find the drawGameOver function. Add the following drawLevelComplete function directly above it:
// Draw level complete screen
function drawLevelComplete() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFD700';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('LEVEL COMPLETE!', canvas.width / 2, 180);
ctx.fillStyle = '#FF4444';
ctx.font = '28px Arial';
ctx.fillText('NEW ABILITY UNLOCKED!', canvas.width / 2, 240);
ctx.fillStyle = '#FFFFFF';
ctx.font = '22px Arial';
ctx.fillText('Gum-Gum Pistol', canvas.width / 2, 290);
ctx.font = '16px Arial';
ctx.fillText('Press X to fire a stretching fist!', canvas.width / 2, 320);
ctx.fillStyle = '#AAAAAA';
ctx.fillText('Press Enter to continue...', canvas.width / 2, 380);
}
What does this code do?
This function draws a dark semi-transparent overlay on top of the frozen game scene. It displays the level completion message, announces the new ability, and tells the player how to use it.
The rgba(0, 0, 0, 0.7) fill creates a 70% opaque black overlay so the player can still see the level behind the text.
- Save game.js.
- You won't see this screen yet. The game loop still needs to call this function, which you will wire up in substep 3.
Implement the Gum-Gum Pistol (ranged attack)
The Gum-Gum Pistol is Luffy's signature move. When the player presses X (after unlocking the ability in Level 2), a stretching fist projectile fires in the direction Luffy is facing. You need to add the firing logic and a function to move projectiles each frame.
- In game.js, scroll to the updatePlayer function.
- Find the Z key punch block that ends with player.attackCooldown = 25; and its closing brace.
- Find this line:
if (keys['z'] && player.attackCooldown <= 0) {
player.attacking = true;
player.attackTimer = 15;
player.attackCooldown = 25;
}
player.vy += GRAVITY;
- Add the following Gum-Gum Pistol firing code between the Z key block and the player.vy += GRAVITY; line, so it looks like this:
if (keys['z'] && player.attackCooldown <= 0) {
player.attacking = true;
player.attackTimer = 15;
player.attackCooldown = 25;
}
if (keys['x'] && abilities.gumGumPistol && player.attackCooldown <= 0) {
projectiles.push({
x: player.x + (player.facing === 1 ? player.width : -20),
y: player.y + player.height / 2 - 10,
width: 20,
height: 20,
vx: PROJECTILE_SPEED * player.facing
});
player.attackCooldown = 30;
}
player.vy += GRAVITY;
What does this code do?
When the X key is pressed, the code first checks that abilities.gumGumPistol is true. This means the attack only works after Level 1 is complete.
The projectile spawns at Luffy's hand position and travels at PROJECTILE_SPEED * player.facing so it flies left or right depending on which way Luffy faces. The 30-frame cooldown prevents spamming.
Now you need a function that moves each projectile and removes ones that fly off screen.
- Find the updateEnemies function's closing brace. Add the following updateProjectiles function below it:
// Update projectiles
function updateProjectiles() {
for (let i = projectiles.length - 1; i >= 0; i--) {
projectiles[i].x += projectiles[i].vx;
if (projectiles[i].x < 0 || projectiles[i].x > canvas.width) {
projectiles.splice(i, 1);
}
}
}
Why loop backwards?
The loop counts from the last index down to zero. When you remove an item with splice, everything above it shifts down. Looping backwards means removed items never affect the indices you have not visited yet.
- Save game.js.
- You won't see projectiles yet. The next substep wires updateProjectiles and drawProjectiles into the game loop.
Add the HUD, win screen, and wire up the game loop
The player needs to see their health, current level, available abilities, and how many enemies remain. You also need a victory screen for when Luffy clears Level 2.
- In game.js, find the drawProjectiles function's closing brace. Add the following drawHUD function below it:
// Draw HUD
function drawHUD() {
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.fillText('Health: ', 10, 25);
for (let i = 0; i < player.maxHealth; i++) {
ctx.fillStyle = i < player.health ? '#FF0000' : '#333333';
ctx.fillRect(75 + i * 25, 12, 20, 16);
}
ctx.fillStyle = '#FFFFFF';
ctx.fillText('Level: ' + currentLevel, 10, 50);
ctx.fillText('Abilities:', 600, 25);
ctx.fillStyle = '#FFAA00';
ctx.fillText('[Z] Punch', 600, 45);
if (abilities.gumGumPistol) {
ctx.fillStyle = '#FF4444';
ctx.fillText('[X] Gum-Gum Pistol', 600, 65);
}
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'left';
ctx.fillText('Enemies: ' + enemies.length, 10, 75);
if (enemies.length === 0) {
ctx.fillStyle = '#FFD700';
ctx.textAlign = 'center';
ctx.fillText('Exit unlocked!', canvas.width / 2, 25);
ctx.textAlign = 'left';
}
}
What does this code do?
The HUD draws health as colored rectangles (red for remaining, dark for lost). It shows the current level number, a list of available abilities, and how many enemies are left.
When all enemies are defeated, a golden "Exit unlocked!" message appears at the top center. The Gum-Gum Pistol ability only shows once abilities.gumGumPistol becomes true after Level 1.
- Now add the drawWinScreen function directly below drawGameOver:
// Draw win screen
function drawWinScreen() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFD700';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2 - 40);
ctx.fillStyle = '#FFFFFF';
ctx.font = '20px Arial';
ctx.fillText('Luffy defeated all Marines!', canvas.width / 2, canvas.height / 2 + 10);
ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial';
ctx.fillText('Press Enter to play again', canvas.width / 2, canvas.height / 2 + 50);
}
What does this code do?
This screen appears when Luffy reaches the Level 2 exit after clearing all enemies. It uses the same dark overlay pattern as the level complete and game over screens, with a golden congratulatory message.
The final piece is updating the game loop to call all your new functions and handle the new game states.
- Find your existing gameLoop function and replace it entirely with this updated version:
// Main game loop
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'playing') {
updatePlayer();
updateEnemies();
updateProjectiles();
drawPlatforms();
drawExit();
drawEnemies();
drawProjectiles();
drawPlayer();
drawHUD();
} else if (gameState === 'levelComplete') {
drawPlatforms();
drawExit();
drawPlayer();
drawLevelComplete();
if (keys['Enter']) {
abilities.gumGumPistol = true;
currentLevel = 2;
loadLevel(2);
}
} else if (gameState === 'gameOver') {
drawGameOver();
if (keys['Enter']) {
currentLevel = 1;
abilities.gumGumPistol = false;
loadLevel(1);
}
} else if (gameState === 'won') {
drawWinScreen();
if (keys['Enter']) {
currentLevel = 1;
abilities.gumGumPistol = false;
loadLevel(1);
}
}
requestAnimationFrame(gameLoop);
}
What changed in the game loop?
- updateProjectiles() moves projectiles each frame during the playing state.
- drawProjectiles() and drawHUD() are now called every frame while playing.
- The levelComplete state freezes the game, shows the unlock screen, and waits for Enter to proceed to Level 2.
- The won state shows the victory screen and resets the game on Enter.
- Save game.js and refresh your browser.
- You should see the HUD in the top-left (Health, Level, Enemies) and top-right (Abilities list showing [Z] Punch).
- Defeat all three Marines with Z punches. The exit door turns gold and "Exit unlocked!" appears.
- Walk into the golden exit. The LEVEL COMPLETE screen appears with "NEW ABILITY UNLOCKED!" text.
- Press Enter. Level 2 loads with four enemies (2 health each). The HUD now shows [X] Gum-Gum Pistol.
- Press X to fire a stretching fist projectile. Clear all Level 2 enemies and reach the exit to see the YOU WIN screen.
Game not progressing to Level 2?
- Make sure all enemies are defeated before touching the exit. The exit only activates when enemies.length === 0.
- Check that your drawLevelComplete function is placed above drawGameOver, not inside another function.
- If X key does not fire in Level 2, confirm that the levelComplete handler sets abilities.gumGumPistol = true before calling loadLevel(2).
My game isn't transitioning between levels correctly after I defeat all enemies and touch the exit.
✔️ Awesome, I've got everything!
Great. Double check you have saved game.js and that you can play through both levels successfully.
ⓧ I'd like to double check the full code
Here is the complete game.js file at this point:
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
// Game constants
const GRAVITY = 0.5;
const PLAYER_SPEED = 5;
const JUMP_FORCE = -12;
const ENEMY_SPEED = 1.5;
const PROJECTILE_SPEED = 10;
// Game state
let currentLevel = 1;
let gameState = 'playing';
let abilities = {
punch: true,
gumGumPistol: false
};
// Input
const keys = {};
document.addEventListener('keydown', (e) => { keys[e.key] = true; });
document.addEventListener('keyup', (e) => { keys[e.key] = false; });
// Player
const player = {
x: 50,
y: 300,
width: 40,
height: 60,
vx: 0,
vy: 0,
grounded: false,
health: 3,
maxHealth: 3,
facing: 1,
attacking: false,
attackTimer: 0,
attackCooldown: 0
};
// Projectiles
let projectiles = [];
// Level definitions
const levels = {
1: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 150, y: 360, width: 120, height: 20 },
{ x: 350, y: 300, width: 120, height: 20 },
{ x: 550, y: 240, width: 120, height: 20 },
{ x: 650, y: 360, width: 120, height: 20 }
],
enemies: [
{ x: 200, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 150, endX: 350 },
{ x: 400, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 350, endX: 470 },
{ x: 680, y: 320, width: 35, height: 40, vx: ENEMY_SPEED, health: 1, startX: 650, endX: 770 }
],
exit: { x: 580, y: 180, width: 40, height: 60 }
},
2: {
platforms: [
{ x: 0, y: 460, width: 800, height: 40 },
{ x: 100, y: 360, width: 100, height: 20 },
{ x: 280, y: 300, width: 100, height: 20 },
{ x: 450, y: 240, width: 100, height: 20 },
{ x: 600, y: 180, width: 100, height: 20 },
{ x: 300, y: 400, width: 100, height: 20 },
{ x: 700, y: 320, width: 80, height: 20 }
],
enemies: [
{ x: 150, y: 420, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 50, endX: 300 },
{ x: 300, y: 260, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 280, endX: 380 },
{ x: 470, y: 200, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 450, endX: 550 },
{ x: 720, y: 280, width: 35, height: 40, vx: ENEMY_SPEED, health: 2, startX: 700, endX: 780 }
],
exit: { x: 620, y: 120, width: 40, height: 60 }
}
};
let enemies = [];
let platforms = [];
let exit = {};
function loadLevel(level) {
const data = levels[level];
platforms = data.platforms.map(p => ({ ...p }));
enemies = data.enemies.map(e => ({ ...e }));
exit = { ...data.exit };
player.x = 50;
player.y = 300;
player.vx = 0;
player.vy = 0;
player.health = player.maxHealth;
projectiles = [];
gameState = 'playing';
}
// Collision detection (AABB)
function isColliding(a, b) {
return a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y;
}
// Update player
function updatePlayer() {
if (keys['ArrowLeft']) {
player.vx = -PLAYER_SPEED;
player.facing = -1;
} else if (keys['ArrowRight']) {
player.vx = PLAYER_SPEED;
player.facing = 1;
} else {
player.vx = 0;
}
if (keys[' '] && player.grounded) {
player.vy = JUMP_FORCE;
player.grounded = false;
}
if (keys['z'] && player.attackCooldown <= 0) {
player.attacking = true;
player.attackTimer = 15;
player.attackCooldown = 25;
}
if (keys['x'] && abilities.gumGumPistol && player.attackCooldown <= 0) {
projectiles.push({
x: player.x + (player.facing === 1 ? player.width : -20),
y: player.y + player.height / 2 - 10,
width: 20,
height: 20,
vx: PROJECTILE_SPEED * player.facing
});
player.attackCooldown = 30;
}
player.vy += GRAVITY;
player.x += player.vx;
player.y += player.vy;
player.grounded = false;
for (const platform of platforms) {
if (isColliding(player, platform)) {
if (player.vy > 0 && player.y + player.height - player.vy <= platform.y + 1) {
player.y = platform.y - player.height;
player.vy = 0;
player.grounded = true;
} else if (player.vy < 0 && player.y - player.vy >= platform.y + platform.height - 1) {
player.y = platform.y + platform.height;
player.vy = 0;
} else if (player.vx > 0) {
player.x = platform.x - player.width;
} else if (player.vx < 0) {
player.x = platform.x + platform.width;
}
}
}
if (player.x < 0) player.x = 0;
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
if (player.attackTimer > 0) player.attackTimer--;
if (player.attackTimer <= 0) player.attacking = false;
if (player.attackCooldown > 0) player.attackCooldown--;
if (isColliding(player, exit) && enemies.length === 0) {
if (currentLevel === 1) {
gameState = 'levelComplete';
} else {
gameState = 'won';
}
}
if (player.y > canvas.height) {
player.health = 0;
gameState = 'gameOver';
}
}
// Update enemies
function updateEnemies() {
for (const enemy of enemies) {
enemy.x += enemy.vx;
if (enemy.x <= enemy.startX || enemy.x + enemy.width >= enemy.endX) {
enemy.vx *= -1;
}
if (isColliding(player, enemy) && !player.attacking) {
player.health--;
player.x = 50;
player.y = 300;
player.vy = 0;
if (player.health <= 0) {
gameState = 'gameOver';
}
break;
}
if (player.attacking) {
const punchBox = {
x: player.facing === 1 ? player.x + player.width : player.x - 50,
y: player.y + 10,
width: 50,
height: 30
};
if (isColliding(punchBox, enemy)) {
enemy.health--;
}
}
}
for (let i = projectiles.length - 1; i >= 0; i--) {
for (const enemy of enemies) {
if (isColliding(projectiles[i], enemy)) {
enemy.health--;
projectiles.splice(i, 1);
break;
}
}
}
enemies = enemies.filter(e => e.health > 0);
}
// Update projectiles
function updateProjectiles() {
for (let i = projectiles.length - 1; i >= 0; i--) {
projectiles[i].x += projectiles[i].vx;
if (projectiles[i].x < 0 || projectiles[i].x > canvas.width) {
projectiles.splice(i, 1);
}
}
}
// Draw player (Luffy)
function drawPlayer() {
ctx.fillStyle = '#CC0000';
ctx.fillRect(player.x + 8, player.y + 20, 24, 25);
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(player.x + player.width / 2, player.y + 12, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#F5DEB3';
ctx.beginPath();
ctx.ellipse(player.x + player.width / 2, player.y + 5, 14, 8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#CC9900';
ctx.fillRect(player.x + 6, player.y + 4, 28, 4);
ctx.fillStyle = '#0044AA';
ctx.fillRect(player.x + 10, player.y + 45, 8, 15);
ctx.fillRect(player.x + 22, player.y + 45, 8, 15);
ctx.fillStyle = '#F4C28C';
ctx.fillRect(player.x + 2, player.y + 22, 6, 20);
ctx.fillRect(player.x + 32, player.y + 22, 6, 20);
if (player.attacking) {
ctx.fillStyle = '#F4C28C';
if (player.facing === 1) {
ctx.fillRect(player.x + player.width, player.y + 20, 50, 15);
} else {
ctx.fillRect(player.x - 50, player.y + 20, 50, 15);
}
}
}
// Draw enemies
function drawEnemies() {
for (const enemy of enemies) {
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(enemy.x + 5, enemy.y + 15, 25, 25);
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(enemy.x + enemy.width / 2, enemy.y + 10, 10, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#003366';
ctx.fillRect(enemy.x + 4, enemy.y, 27, 10);
ctx.fillStyle = '#FF0000';
for (let i = 0; i < enemy.health; i++) {
ctx.fillRect(enemy.x + 8 + i * 12, enemy.y - 8, 10, 5);
}
}
}
// Draw platforms
function drawPlatforms() {
for (const platform of platforms) {
ctx.fillStyle = '#4A8C3F';
ctx.fillRect(platform.x, platform.y, platform.width, platform.height);
ctx.fillStyle = '#66BB55';
ctx.fillRect(platform.x, platform.y, platform.width, 4);
}
}
// Draw exit
function drawExit() {
const glow = enemies.length === 0;
ctx.fillStyle = glow ? '#FFD700' : '#555555';
ctx.fillRect(exit.x, exit.y, exit.width, exit.height);
ctx.strokeStyle = glow ? '#FFA500' : '#333333';
ctx.lineWidth = 2;
ctx.strokeRect(exit.x, exit.y, exit.width, exit.height);
ctx.fillStyle = glow ? '#FFD700' : '#666666';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('EXIT', exit.x + exit.width / 2, exit.y - 5);
}
// Draw projectiles
function drawProjectiles() {
for (const p of projectiles) {
ctx.fillStyle = '#F4C28C';
ctx.beginPath();
ctx.arc(p.x + p.width / 2, p.y + p.height / 2, 10, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#F4C28C';
const trailX = p.vx > 0 ? p.x - 30 : p.x + 10;
ctx.fillRect(trailX, p.y + 4, 30, 12);
}
}
// Draw HUD
function drawHUD() {
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.fillText('Health: ', 10, 25);
for (let i = 0; i < player.maxHealth; i++) {
ctx.fillStyle = i < player.health ? '#FF0000' : '#333333';
ctx.fillRect(75 + i * 25, 12, 20, 16);
}
ctx.fillStyle = '#FFFFFF';
ctx.fillText('Level: ' + currentLevel, 10, 50);
ctx.fillText('Abilities:', 600, 25);
ctx.fillStyle = '#FFAA00';
ctx.fillText('[Z] Punch', 600, 45);
if (abilities.gumGumPistol) {
ctx.fillStyle = '#FF4444';
ctx.fillText('[X] Gum-Gum Pistol', 600, 65);
}
ctx.fillStyle = '#FFFFFF';
ctx.textAlign = 'left';
ctx.fillText('Enemies: ' + enemies.length, 10, 75);
if (enemies.length === 0) {
ctx.fillStyle = '#FFD700';
ctx.textAlign = 'center';
ctx.fillText('Exit unlocked!', canvas.width / 2, 25);
ctx.textAlign = 'left';
}
}
// Draw level complete screen
function drawLevelComplete() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFD700';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('LEVEL COMPLETE!', canvas.width / 2, 180);
ctx.fillStyle = '#FF4444';
ctx.font = '28px Arial';
ctx.fillText('NEW ABILITY UNLOCKED!', canvas.width / 2, 240);
ctx.fillStyle = '#FFFFFF';
ctx.font = '22px Arial';
ctx.fillText('Gum-Gum Pistol', canvas.width / 2, 290);
ctx.font = '16px Arial';
ctx.fillText('Press X to fire a stretching fist!', canvas.width / 2, 320);
ctx.fillStyle = '#AAAAAA';
ctx.fillText('Press Enter to continue...', canvas.width / 2, 380);
}
// Draw game over screen
function drawGameOver() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FF0000';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('GAME OVER', canvas.width / 2, canvas.height / 2 - 20);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.fillText('Press Enter to restart', canvas.width / 2, canvas.height / 2 + 20);
}
// Draw win screen
function drawWinScreen() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#FFD700';
ctx.font = '36px Arial';
ctx.textAlign = 'center';
ctx.fillText('YOU WIN!', canvas.width / 2, canvas.height / 2 - 40);
ctx.fillStyle = '#FFFFFF';
ctx.font = '20px Arial';
ctx.fillText('Luffy defeated all Marines!', canvas.width / 2, canvas.height / 2 + 10);
ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial';
ctx.fillText('Press Enter to play again', canvas.width / 2, canvas.height / 2 + 50);
}
// Main game loop
function gameLoop() {
ctx.fillStyle = '#87CEEB';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (gameState === 'playing') {
updatePlayer();
updateEnemies();
updateProjectiles();
drawPlatforms();
drawExit();
drawEnemies();
drawProjectiles();
drawPlayer();
drawHUD();
} else if (gameState === 'levelComplete') {
drawPlatforms();
drawExit();
drawPlayer();
drawLevelComplete();
if (keys['Enter']) {
abilities.gumGumPistol = true;
currentLevel = 2;
loadLevel(2);
}
} else if (gameState === 'gameOver') {
drawGameOver();
if (keys['Enter']) {
currentLevel = 1;
abilities.gumGumPistol = false;
loadLevel(1);
}
} else if (gameState === 'won') {
drawWinScreen();
if (keys['Enter']) {
currentLevel = 1;
abilities.gumGumPistol = false;
loadLevel(1);
}
}
requestAnimationFrame(gameLoop);
}
// Start game
loadLevel(1);
gameLoop();
What is a state machine?
Your gameState variable is a simple state machine. The game can only be in one state at a time: playing, levelComplete, gameOver, or won.
Each state determines which update and draw functions run. This pattern prevents bugs like the player moving during the win screen or enemies updating while the game is paused.
Your game is now fully playable from start to finish. Two levels, progressive ability unlocks, a HUD, and proper win/lose conditions. Next up, the Secret Mission challenges you to add a mini-boss fight that tests both abilities.
Secret mission
Add a Mini-Boss Fight
Level 2 feels too easy once you have the Gum-Gum Pistol. What if clearing all the regular Marines triggered a boss encounter? A Marine Captain who requires both abilities to defeat would make Level 2 a real challenge.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in your browser, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files for future tinkering or delete them entirely.
Resources you used:
- The luffy-platformer folder on your Desktop (contains index.html and game.js).
Keep everything running
No action needed. Choose this if you want to keep playing the game, tweaking the code, or extending it with new levels and abilities.
- Your game files stay in the luffy-platformer folder on your Desktop.
- Open index.html in any browser to play again at any time.
Pause - I'll come back to this later
Since this is a local project with no running processes or servers, there is nothing to pause. Your files sit on disk and use negligible storage.
- Close the browser tab running the game.
- Close VS Code if you are done editing.
- Your project will be right where you left it whenever you return.
Delete - I don't want to use this again
Remove the project folder to free up disk space and start fresh.
- Close VS Code and any browser tabs running the game.
- Delete the luffy-platformer folder by running one of the following commands in your terminal.
macOS
rm -rf ~/Desktop/luffy-platformer
Windows
rmdir /s /q %USERPROFILE%\Desktop\luffy-platformer
- Confirm the folder is gone by checking your Desktop.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully playable 2D platformer from scratch using nothing but vanilla JavaScript and the HTML5 Canvas API.
You've learned how to:
- Built a complete game loop with gravity, velocity, and jump physics using requestAnimationFrame.
- Created AABB collision detection to handle platform landing, enemy hits, and projectile impacts.
- Designed a progressive ability unlock system where completing Level 1 grants the Gum-Gum Pistol ranged attack for Level 2.
- Secret Mission: Added a mini-boss Marine Captain that requires combining both abilities (stun with Gum-Gum Pistol, then punch while stunned) to defeat.
Ready to quiz yourself?