Back to Components
Stick Hero Game Using HTML Canvas and Pure JavaScript
Component

Stick Hero Game Using HTML Canvas and Pure JavaScript

CodewithLord
October 24, 2025

Build a Stick Hero-style game from scratch using HTML, CSS, and JavaScript with the Canvas API."

🧠 Description

Build a Stick Hero-style game from scratch using HTML, CSS, and JavaScript with the Canvas API. This fun and interactive project demonstrates animation, collision logic, background rendering, and responsive game control — all without external libraries.


💻 HTML Code


1<!DOCTYPE html> 2<html lang="en"> 3 4 <head> 5 <meta charset="UTF-8"> 6 <title>Stick Hero with Canvas</title> 7 <link rel="stylesheet" href="./style.css"> 8 9 </head> 10 11 <body> 12 <div class="container"> 13 <div id="score"></div> 14 <canvas id="game" width="375" height="375"></canvas> 15 <div id="introduction">Hold down the mouse to stretch out a stick</div> 16 <div id="perfect">DOUBLE SCORE</div> 17 <button id="restart">RESTART</button> 18</div> 19 20<a id="youtube" href="https://youtu.be/eue3UdFvwPo" target="_top"> 21 <span>See how this game was made</span> 22</a> 23<div id="youtube-card"> 24 How to create a Stick Hero game with JavaScript and HTML Canvas 25</div> 26 <script src="./script.js"></script> 27 28 </body> 29 30</html> 31

The HTML defines all the necessary game elements:

A (canvas) element (id="game") used to draw the platforms, hero, and background.

Text overlays for:

Score counter (#score)

Instructions (#introduction)

Perfect hit indicator (#perfect)

A Restart button (#restart) shown when the player fails.

A YouTube link that leads to the original video tutorial.

This minimal structure allows the game visuals to be rendered dynamically via the Canvas API.

CSS Code


1html, 2body { 3 height: 100%; 4 margin: 0; 5} 6 7body { 8 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 9 cursor: pointer; 10} 11 12.container { 13 display: flex; 14 justify-content: center; 15 align-items: center; 16 height: 100%; 17} 18 19#score { 20 position: absolute; 21 top: 30px; 22 right: 30px; 23 font-size: 2em; 24 font-weight: 900; 25} 26 27#introduction { 28 width: 200px; 29 height: 150px; 30 position: absolute; 31 font-weight: 600; 32 font-size: 0.8em; 33 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 34 text-align: center; 35 transition: opacity 2s; 36} 37 38#restart { 39 width: 120px; 40 height: 120px; 41 position: absolute; 42 border-radius: 50%; 43 color: white; 44 background-color: red; 45 border: none; 46 font-weight: 700; 47 font-size: 1.2em; 48 font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; 49 display: none; 50 cursor: pointer; 51} 52 53#perfect { 54 position: absolute; 55 opacity: 0; 56 transition: opacity 2s; 57} 58 59#youtube, 60#youtube-card { 61 display: none; 62} 63 64@media (min-height: 425px) { 65 /** Youtube logo by https://codepen.io/alvaromontoro */ 66 #youtube { 67 z-index: 2; 68 display: block; 69 width: 100px; 70 height: 70px; 71 position: absolute; 72 bottom: 20px; 73 left: 20px; 74 background: red; 75 border-radius: 50% / 11%; 76 transform: scale(0.8); 77 transition: transform 0.5s; 78 } 79 80 #youtube:hover, 81 #youtube:focus { 82 transform: scale(0.9); 83 } 84 85 #youtube::before { 86 content: ""; 87 display: block; 88 position: absolute; 89 top: 7.5%; 90 left: -6%; 91 width: 112%; 92 height: 85%; 93 background: red; 94 border-radius: 9% / 50%; 95 } 96 97 #youtube::after { 98 content: ""; 99 display: block; 100 position: absolute; 101 top: 20px; 102 left: 40px; 103 width: 45px; 104 height: 30px; 105 border: 15px solid transparent; 106 box-sizing: border-box; 107 border-left: 30px solid white; 108 } 109 110 #youtube span { 111 font-size: 0; 112 position: absolute; 113 width: 0; 114 height: 0; 115 overflow: hidden; 116 } 117 118 #youtube:hover + #youtube-card { 119 display: block; 120 position: absolute; 121 bottom: 12px; 122 left: 10px; 123 padding: 25px 25px 25px 130px; 124 width: 300px; 125 background-color: white; 126 } 127} 128

The CSS focuses on layout, positioning, and interface styling:

The entire game centers inside a .container, filling the viewport.

The scoreboard, instructions, and restart button are absolutely positioned for visibility.

The restart button is circular, red, and appears only after failure.

Transitions (like fading text or hover scaling) make the interface smooth.

A YouTube logo appears at the bottom for users on taller screens, created purely using CSS shapes and pseudo-elements (::before, ::after).

The CSS makes the game visually clean and responsive across screen sizes.


Javascipt Code


1/* 2 3 4 5If you want to know how this game was made, check out this video, that explains how it's made: 6 7https://youtu.be/eue3UdFvwPo 8 9Follow me on twitter for more: https://twitter.com/HunorBorbely 10 11*/ 12 13// Extend the base functionality of JavaScript 14Array.prototype.last = function () { 15 return this[this.length - 1]; 16}; 17 18// A sinus function that acceps degrees instead of radians 19Math.sinus = function (degree) { 20 return Math.sin((degree / 180) * Math.PI); 21}; 22 23// Game data 24let phase = "waiting"; // waiting | stretching | turning | walking | transitioning | falling 25let lastTimestamp; // The timestamp of the previous requestAnimationFrame cycle 26 27let heroX; // Changes when moving forward 28let heroY; // Only changes when falling 29let sceneOffset; // Moves the whole game 30 31let platforms = []; 32let sticks = []; 33let trees = []; 34 35// Todo: Save high score to localStorage (?) 36 37let score = 0; 38 39// Configuration 40const canvasWidth = 375; 41const canvasHeight = 375; 42const platformHeight = 100; 43const heroDistanceFromEdge = 10; // While waiting 44const paddingX = 100; // The waiting position of the hero in from the original canvas size 45const perfectAreaSize = 10; 46 47// The background moves slower than the hero 48const backgroundSpeedMultiplier = 0.2; 49 50const hill1BaseHeight = 100; 51const hill1Amplitude = 10; 52const hill1Stretch = 1; 53const hill2BaseHeight = 70; 54const hill2Amplitude = 20; 55const hill2Stretch = 0.5; 56 57const stretchingSpeed = 4; // Milliseconds it takes to draw a pixel 58const turningSpeed = 4; // Milliseconds it takes to turn a degree 59const walkingSpeed = 4; 60const transitioningSpeed = 2; 61const fallingSpeed = 2; 62 63const heroWidth = 17; // 24 64const heroHeight = 30; // 40 65 66const canvas = document.getElementById("game"); 67canvas.width = window.innerWidth; // Make the Canvas full screen 68canvas.height = window.innerHeight; 69 70const ctx = canvas.getContext("2d"); 71 72const introductionElement = document.getElementById("introduction"); 73const perfectElement = document.getElementById("perfect"); 74const restartButton = document.getElementById("restart"); 75const scoreElement = document.getElementById("score"); 76 77// Initialize layout 78resetGame(); 79 80// Resets game variables and layouts but does not start the game (game starts on keypress) 81function resetGame() { 82 // Reset game progress 83 phase = "waiting"; 84 lastTimestamp = undefined; 85 sceneOffset = 0; 86 score = 0; 87 88 introductionElement.style.opacity = 1; 89 perfectElement.style.opacity = 0; 90 restartButton.style.display = "none"; 91 scoreElement.innerText = score; 92 93 // The first platform is always the same 94 // x + w has to match paddingX 95 platforms = [{ x: 50, w: 50 }]; 96 generatePlatform(); 97 generatePlatform(); 98 generatePlatform(); 99 generatePlatform(); 100 101 sticks = [{ x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 }]; 102 103 trees = []; 104 generateTree(); 105 generateTree(); 106 generateTree(); 107 generateTree(); 108 generateTree(); 109 generateTree(); 110 generateTree(); 111 generateTree(); 112 generateTree(); 113 generateTree(); 114 115 heroX = platforms[0].x + platforms[0].w - heroDistanceFromEdge; 116 heroY = 0; 117 118 draw(); 119} 120 121function generateTree() { 122 const minimumGap = 30; 123 const maximumGap = 150; 124 125 // X coordinate of the right edge of the furthest tree 126 const lastTree = trees[trees.length - 1]; 127 let furthestX = lastTree ? lastTree.x : 0; 128 129 const x = 130 furthestX + 131 minimumGap + 132 Math.floor(Math.random() * (maximumGap - minimumGap)); 133 134 const treeColors = ["#6D8821", "#8FAC34", "#98B333"]; 135 const color = treeColors[Math.floor(Math.random() * 3)]; 136 137 trees.push({ x, color }); 138} 139 140function generatePlatform() { 141 const minimumGap = 40; 142 const maximumGap = 200; 143 const minimumWidth = 20; 144 const maximumWidth = 100; 145 146 // X coordinate of the right edge of the furthest platform 147 const lastPlatform = platforms[platforms.length - 1]; 148 let furthestX = lastPlatform.x + lastPlatform.w; 149 150 const x = 151 furthestX + 152 minimumGap + 153 Math.floor(Math.random() * (maximumGap - minimumGap)); 154 const w = 155 minimumWidth + Math.floor(Math.random() * (maximumWidth - minimumWidth)); 156 157 platforms.push({ x, w }); 158} 159 160resetGame(); 161 162// If space was pressed restart the game 163window.addEventListener("keydown", function (event) { 164 if (event.key == " ") { 165 event.preventDefault(); 166 resetGame(); 167 return; 168 } 169}); 170 171window.addEventListener("mousedown", function (event) { 172 if (phase == "waiting") { 173 lastTimestamp = undefined; 174 introductionElement.style.opacity = 0; 175 phase = "stretching"; 176 window.requestAnimationFrame(animate); 177 } 178}); 179 180window.addEventListener("mouseup", function (event) { 181 if (phase == "stretching") { 182 phase = "turning"; 183 } 184}); 185 186window.addEventListener("resize", function (event) { 187 canvas.width = window.innerWidth; 188 canvas.height = window.innerHeight; 189 draw(); 190}); 191 192window.requestAnimationFrame(animate); 193 194// The main game loop 195function animate(timestamp) { 196 if (!lastTimestamp) { 197 lastTimestamp = timestamp; 198 window.requestAnimationFrame(animate); 199 return; 200 } 201 202 switch (phase) { 203 case "waiting": 204 return; // Stop the loop 205 case "stretching": { 206 sticks.last().length += (timestamp - lastTimestamp) / stretchingSpeed; 207 break; 208 } 209 case "turning": { 210 sticks.last().rotation += (timestamp - lastTimestamp) / turningSpeed; 211 212 if (sticks.last().rotation > 90) { 213 sticks.last().rotation = 90; 214 215 const [nextPlatform, perfectHit] = thePlatformTheStickHits(); 216 if (nextPlatform) { 217 // Increase score 218 score += perfectHit ? 2 : 1; 219 scoreElement.innerText = score; 220 221 if (perfectHit) { 222 perfectElement.style.opacity = 1; 223 setTimeout(() => (perfectElement.style.opacity = 0), 1000); 224 } 225 226 generatePlatform(); 227 generateTree(); 228 generateTree(); 229 } 230 231 phase = "walking"; 232 } 233 break; 234 } 235 case "walking": { 236 heroX += (timestamp - lastTimestamp) / walkingSpeed; 237 238 const [nextPlatform] = thePlatformTheStickHits(); 239 if (nextPlatform) { 240 // If hero will reach another platform then limit it's position at it's edge 241 const maxHeroX = nextPlatform.x + nextPlatform.w - heroDistanceFromEdge; 242 if (heroX > maxHeroX) { 243 heroX = maxHeroX; 244 phase = "transitioning"; 245 } 246 } else { 247 // If hero won't reach another platform then limit it's position at the end of the pole 248 const maxHeroX = sticks.last().x + sticks.last().length + heroWidth; 249 if (heroX > maxHeroX) { 250 heroX = maxHeroX; 251 phase = "falling"; 252 } 253 } 254 break; 255 } 256 case "transitioning": { 257 sceneOffset += (timestamp - lastTimestamp) / transitioningSpeed; 258 259 const [nextPlatform] = thePlatformTheStickHits(); 260 if (sceneOffset > nextPlatform.x + nextPlatform.w - paddingX) { 261 // Add the next step 262 sticks.push({ 263 x: nextPlatform.x + nextPlatform.w, 264 length: 0, 265 rotation: 0 266 }); 267 phase = "waiting"; 268 } 269 break; 270 } 271 case "falling": { 272 if (sticks.last().rotation < 180) 273 sticks.last().rotation += (timestamp - lastTimestamp) / turningSpeed; 274 275 heroY += (timestamp - lastTimestamp) / fallingSpeed; 276 const maxHeroY = 277 platformHeight + 100 + (window.innerHeight - canvasHeight) / 2; 278 if (heroY > maxHeroY) { 279 restartButton.style.display = "block"; 280 return; 281 } 282 break; 283 } 284 default: 285 throw Error("Wrong phase"); 286 } 287 288 draw(); 289 window.requestAnimationFrame(animate); 290 291 lastTimestamp = timestamp; 292} 293 294// Returns the platform the stick hit (if it didn't hit any stick then return undefined) 295function thePlatformTheStickHits() { 296 if (sticks.last().rotation != 90) 297 throw Error(`Stick is ${sticks.last().rotation}°`); 298 const stickFarX = sticks.last().x + sticks.last().length; 299 300 const platformTheStickHits = platforms.find( 301 (platform) => platform.x < stickFarX && stickFarX < platform.x + platform.w 302 ); 303 304 // If the stick hits the perfect area 305 if ( 306 platformTheStickHits && 307 platformTheStickHits.x + platformTheStickHits.w / 2 - perfectAreaSize / 2 < 308 stickFarX && 309 stickFarX < 310 platformTheStickHits.x + platformTheStickHits.w / 2 + perfectAreaSize / 2 311 ) 312 return [platformTheStickHits, true]; 313 314 return [platformTheStickHits, false]; 315} 316 317function draw() { 318 ctx.save(); 319 ctx.clearRect(0, 0, window.innerWidth, window.innerHeight); 320 321 drawBackground(); 322 323 // Center main canvas area to the middle of the screen 324 ctx.translate( 325 (window.innerWidth - canvasWidth) / 2 - sceneOffset, 326 (window.innerHeight - canvasHeight) / 2 327 ); 328 329 // Draw scene 330 drawPlatforms(); 331 drawHero(); 332 drawSticks(); 333 334 // Restore transformation 335 ctx.restore(); 336} 337 338restartButton.addEventListener("click", function (event) { 339 event.preventDefault(); 340 resetGame(); 341 restartButton.style.display = "none"; 342}); 343 344function drawPlatforms() { 345 platforms.forEach(({ x, w }) => { 346 // Draw platform 347 ctx.fillStyle = "black"; 348 ctx.fillRect( 349 x, 350 canvasHeight - platformHeight, 351 w, 352 platformHeight + (window.innerHeight - canvasHeight) / 2 353 ); 354 355 // Draw perfect area only if hero did not yet reach the platform 356 if (sticks.last().x < x) { 357 ctx.fillStyle = "red"; 358 ctx.fillRect( 359 x + w / 2 - perfectAreaSize / 2, 360 canvasHeight - platformHeight, 361 perfectAreaSize, 362 perfectAreaSize 363 ); 364 } 365 }); 366} 367 368function drawHero() { 369 ctx.save(); 370 ctx.fillStyle = "black"; 371 ctx.translate( 372 heroX - heroWidth / 2, 373 heroY + canvasHeight - platformHeight - heroHeight / 2 374 ); 375 376 // Body 377 drawRoundedRect( 378 -heroWidth / 2, 379 -heroHeight / 2, 380 heroWidth, 381 heroHeight - 4, 382 5 383 ); 384 385 // Legs 386 const legDistance = 5; 387 ctx.beginPath(); 388 ctx.arc(legDistance, 11.5, 3, 0, Math.PI * 2, false); 389 ctx.fill(); 390 ctx.beginPath(); 391 ctx.arc(-legDistance, 11.5, 3, 0, Math.PI * 2, false); 392 ctx.fill(); 393 394 // Eye 395 ctx.beginPath(); 396 ctx.fillStyle = "white"; 397 ctx.arc(5, -7, 3, 0, Math.PI * 2, false); 398 ctx.fill(); 399 400 // Band 401 ctx.fillStyle = "red"; 402 ctx.fillRect(-heroWidth / 2 - 1, -12, heroWidth + 2, 4.5); 403 ctx.beginPath(); 404 ctx.moveTo(-9, -14.5); 405 ctx.lineTo(-17, -18.5); 406 ctx.lineTo(-14, -8.5); 407 ctx.fill(); 408 ctx.beginPath(); 409 ctx.moveTo(-10, -10.5); 410 ctx.lineTo(-15, -3.5); 411 ctx.lineTo(-5, -7); 412 ctx.fill(); 413 414 ctx.restore(); 415} 416 417function drawRoundedRect(x, y, width, height, radius) { 418 ctx.beginPath(); 419 ctx.moveTo(x, y + radius); 420 ctx.lineTo(x, y + height - radius); 421 ctx.arcTo(x, y + height, x + radius, y + height, radius); 422 ctx.lineTo(x + width - radius, y + height); 423 ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius); 424 ctx.lineTo(x + width, y + radius); 425 ctx.arcTo(x + width, y, x + width - radius, y, radius); 426 ctx.lineTo(x + radius, y); 427 ctx.arcTo(x, y, x, y + radius, radius); 428 ctx.fill(); 429} 430 431function drawSticks() { 432 sticks.forEach((stick) => { 433 ctx.save(); 434 435 // Move the anchor point to the start of the stick and rotate 436 ctx.translate(stick.x, canvasHeight - platformHeight); 437 ctx.rotate((Math.PI / 180) * stick.rotation); 438 439 // Draw stick 440 ctx.beginPath(); 441 ctx.lineWidth = 2; 442 ctx.moveTo(0, 0); 443 ctx.lineTo(0, -stick.length); 444 ctx.stroke(); 445 446 // Restore transformations 447 ctx.restore(); 448 }); 449} 450 451function drawBackground() { 452 // Draw sky 453 var gradient = ctx.createLinearGradient(0, 0, 0, window.innerHeight); 454 gradient.addColorStop(0, "#BBD691"); 455 gradient.addColorStop(1, "#FEF1E1"); 456 ctx.fillStyle = gradient; 457 ctx.fillRect(0, 0, window.innerWidth, window.innerHeight); 458 459 // Draw hills 460 drawHill(hill1BaseHeight, hill1Amplitude, hill1Stretch, "#95C629"); 461 drawHill(hill2BaseHeight, hill2Amplitude, hill2Stretch, "#659F1C"); 462 463 // Draw trees 464 trees.forEach((tree) => drawTree(tree.x, tree.color)); 465} 466 467// A hill is a shape under a stretched out sinus wave 468function drawHill(baseHeight, amplitude, stretch, color) { 469 ctx.beginPath(); 470 ctx.moveTo(0, window.innerHeight); 471 ctx.lineTo(0, getHillY(0, baseHeight, amplitude, stretch)); 472 for (let i = 0; i < window.innerWidth; i++) { 473 ctx.lineTo(i, getHillY(i, baseHeight, amplitude, stretch)); 474 } 475 ctx.lineTo(window.innerWidth, window.innerHeight); 476 ctx.fillStyle = color; 477 ctx.fill(); 478} 479 480function drawTree(x, color) { 481 ctx.save(); 482 ctx.translate( 483 (-sceneOffset * backgroundSpeedMultiplier + x) * hill1Stretch, 484 getTreeY(x, hill1BaseHeight, hill1Amplitude) 485 ); 486 487 const treeTrunkHeight = 5; 488 const treeTrunkWidth = 2; 489 const treeCrownHeight = 25; 490 const treeCrownWidth = 10; 491 492 // Draw trunk 493 ctx.fillStyle = "#7D833C"; 494 ctx.fillRect( 495 -treeTrunkWidth / 2, 496 -treeTrunkHeight, 497 treeTrunkWidth, 498 treeTrunkHeight 499 ); 500 501 // Draw crown 502 ctx.beginPath(); 503 ctx.moveTo(-treeCrownWidth / 2, -treeTrunkHeight); 504 ctx.lineTo(0, -(treeTrunkHeight + treeCrownHeight)); 505 ctx.lineTo(treeCrownWidth / 2, -treeTrunkHeight); 506 ctx.fillStyle = color; 507 ctx.fill(); 508 509 ctx.restore(); 510} 511 512function getHillY(windowX, baseHeight, amplitude, stretch) { 513 const sineBaseY = window.innerHeight - baseHeight; 514 return ( 515 Math.sinus((sceneOffset * backgroundSpeedMultiplier + windowX) * stretch) * 516 amplitude + 517 sineBaseY 518 ); 519} 520 521function getTreeY(x, baseHeight, amplitude) { 522 const sineBaseY = window.innerHeight - baseHeight; 523 return Math.sinus(x) * amplitude + sineBaseY; 524}

The JavaScript controls every aspect of gameplay using Canvas rendering, event listeners, and frame-by-frame animation.

Let’s break down the logic flow:

a. Game Initialization

The script sets up variables for:

Game state (phase) — determines whether the player is waiting, stretching, walking, etc.

Canvas dimensions and constants like hero size, platform height, and animation speeds.

Arrays for platforms, sticks, and trees that make up the scene.

The function resetGame() initializes everything, generates starting platforms and trees, and draws the first frame.

b. Game Phases

The game operates through several phases:

Waiting — the hero stands still.

Stretching — when the mouse is held down, the stick grows longer.

Turning — on mouse release, the stick rotates 90° to fall horizontally.

Walking — the hero walks along the stick.

Transitioning — when a platform is reached, the scene scrolls forward.

Falling — if the stick is too short or too long, the hero falls, and the restart button appears.

Each phase is updated frame-by-frame in the animate() loop via requestAnimationFrame().

c. Gameplay Mechanics

Stick Length: increases while the mouse is pressed.

Stick Rotation: animated when released until it’s horizontal.

Collision Detection: checks if the stick end lands within a platform’s boundaries (thePlatformTheStickHits()).

Perfect Hit: if the stick lands exactly in the small red “perfect area”, the player gets double points.

Scoring: displayed in the top-right corner and updated live.

Restart Button: restarts the game when clicked.

d. Canvas Drawing

The game uses several custom drawing functions:

drawPlatforms() — renders black platforms and red perfect zones.

drawHero() — draws the character using shapes and arcs.

drawSticks() — visualizes the stick growing, rotating, or falling.

drawBackground() — creates dynamic sky, hills, and trees with parallax movement for depth.

The background uses sinusoidal curves (Math.sinus) to generate smooth rolling hills.

e. Animations

The continuous animation loop:

Calculates time difference since the last frame.

Updates object positions (stick rotation, hero movement, background shift).

Clears and redraws the entire scene using ctx.clearRect().

Repeats using requestAnimationFrame() for smooth 60 FPS visuals.

Love this component?

Explore more components and build amazing UIs.

View All Components