Let’s create a Stick Hero Game using HTML, CSS, and JavaScript, where you control a character by stretching a stick to reach the next platform with perfect timing.
We’ll use:
- HTML to structure the game layout including the canvas, score, instructions, and restart button.
- CSS to style the game interface, position elements, and create a clean centered layout with smooth UI feedback.
- JavaScript to handle the full game logic like stick stretching, rotation, hero movement, collision detection, scoring system, and animations using
requestAnimationFramefor smooth gameplay.
This project is perfect for learning how to build interactive canvas-based games, manage game states, and create engaging animations. Whether you’re a beginner or experienced, this Stick Hero Game helps you understand real game mechanics and makes your portfolio more fun and dynamic 🎮🔥
HTML :
This HTML sets up a simple Stick Hero game layout where the <canvas> (id=”game”) is the main area where the game is drawn using JavaScript, while elements like #score, #introduction, #perfect, and the RESTART button are used to display game info and controls; the page links an external CSS file for styling (style.css) and a JavaScript file (script.js) that handles all the game logic and interactions like stretching the stick and updating the score.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Stick Hero Game | @coding.stella</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<div class="container">
<div id="score"></div>
<canvas id="game" width="375" height="375"></canvas>
<div id="introduction">Hold down the mouse to stretch out a stick</div>
<div id="perfect">DOUBLE SCORE</div>
<button id="restart">RESTART</button>
</div>
<script src="./script.js"></script>
</body>
</html>
CSS :
This CSS styles the game UI by making the page full-height with no margins, centering everything using .container with flexbox, and setting a clean font and pointer cursor; it positions the score at the top-right, shows instructions in the center with a fade effect, styles the restart button as a hidden red circular button that appears later, and adds a “perfect” text element with opacity animation for smooth visual feedback during gameplay.
html,
body {
height: 100%;
margin: 0;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
cursor: pointer;
}
.container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
}
#score {
position: absolute;
top: 30px;
right: 30px;
font-size: 2em;
font-weight: 900;
}
#introduction {
width: 200px;
height: 150px;
position: absolute;
font-weight: 600;
font-size: 0.8em;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
text-align: center;
transition: opacity 2s;
}
#restart {
width: 120px;
height: 120px;
position: absolute;
border-radius: 50%;
color: white;
background-color: red;
border: none;
font-weight: 700;
font-size: 1.2em;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
display: none;
cursor: pointer;
}
#perfect {
position: absolute;
opacity: 0;
transition: opacity 2s;
}
JavaScript:
This JavaScript creates the full Stick Hero game logic using a canvas by managing game states like stretching, turning, walking, and falling, where the player holds the mouse to grow a stick, releases to drop it, and the hero walks across to the next platform if the length is correct; it uses arrays to store platforms, sticks, and trees, generates random elements for each round, updates positions using an animation loop (requestAnimationFrame), checks collisions and “perfect” hits for scoring, and continuously redraws everything including background, hero, platforms, and stick to create smooth gameplay.
// Extend the base functionality of JavaScript
Array.prototype.last = function () {
return this[this.length - 1];
};
// A sinus function that acceps degrees instead of radians
Math.sinus = function (degree) {
return Math.sin((degree / 180) * Math.PI);
};
// Game data
let phase = "waiting"; // waiting | stretching | turning | walking | transitioning | falling
let lastTimestamp; // The timestamp of the previous requestAnimationFrame cycle
let heroX; // Changes when moving forward
let heroY; // Only changes when falling
let sceneOffset; // Moves the whole game
let platforms = [];
let sticks = [];
let trees = [];
// Todo: Save high score to localStorage (?)
let score = 0;
// Configuration
const canvasWidth = 375;
const canvasHeight = 375;
const platformHeight = 100;
const heroDistanceFromEdge = 10; // While waiting
const paddingX = 100; // The waiting position of the hero in from the original canvas size
const perfectAreaSize = 10;
// The background moves slower than the hero
const backgroundSpeedMultiplier = 0.2;
const hill1BaseHeight = 100;
const hill1Amplitude = 10;
const hill1Stretch = 1;
const hill2BaseHeight = 70;
const hill2Amplitude = 20;
const hill2Stretch = 0.5;
const stretchingSpeed = 4; // Milliseconds it takes to draw a pixel
const turningSpeed = 4; // Milliseconds it takes to turn a degree
const walkingSpeed = 4;
const transitioningSpeed = 2;
const fallingSpeed = 2;
const heroWidth = 17; // 24
const heroHeight = 30; // 40
const canvas = document.getElementById("game");
canvas.width = window.innerWidth; // Make the Canvas full screen
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");
const introductionElement = document.getElementById("introduction");
const perfectElement = document.getElementById("perfect");
const restartButton = document.getElementById("restart");
const scoreElement = document.getElementById("score");
// Initialize layout
resetGame();
// Resets game variables and layouts but does not start the game (game starts on keypress)
function resetGame() {
// Reset game progress
phase = "waiting";
lastTimestamp = undefined;
sceneOffset = 0;
score = 0;
introductionElement.style.opacity = 1;
perfectElement.style.opacity = 0;
restartButton.style.display = "none";
scoreElement.innerText = score;
// The first platform is always the same
// x + w has to match paddingX
platforms = [{ x: 50, w: 50 }];
generatePlatform();
generatePlatform();
generatePlatform();
generatePlatform();
sticks = [{ x: platforms[0].x + platforms[0].w, length: 0, rotation: 0 }];
trees = [];
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
generateTree();
heroX = platforms[0].x + platforms[0].w - heroDistanceFromEdge;
heroY = 0;
draw();
}
function generateTree() {
const minimumGap = 30;
const maximumGap = 150;
// X coordinate of the right edge of the furthest tree
const lastTree = trees[trees.length - 1];
let furthestX = lastTree ? lastTree.x : 0;
const x =
furthestX +
minimumGap +
Math.floor(Math.random() * (maximumGap - minimumGap));
const treeColors = ["#6D8821", "#8FAC34", "#98B333"];
const color = treeColors[Math.floor(Math.random() * 3)];
trees.push({ x, color });
}
function generatePlatform() {
const minimumGap = 40;
const maximumGap = 200;
const minimumWidth = 20;
const maximumWidth = 100;
// X coordinate of the right edge of the furthest platform
const lastPlatform = platforms[platforms.length - 1];
let furthestX = lastPlatform.x + lastPlatform.w;
const x =
furthestX +
minimumGap +
Math.floor(Math.random() * (maximumGap - minimumGap));
const w =
minimumWidth + Math.floor(Math.random() * (maximumWidth - minimumWidth));
platforms.push({ x, w });
}
resetGame();
// If space was pressed restart the game
window.addEventListener("keydown", function (event) {
if (event.key == " ") {
event.preventDefault();
resetGame();
return;
}
});
window.addEventListener("mousedown", function (event) {
if (phase == "waiting") {
lastTimestamp = undefined;
introductionElement.style.opacity = 0;
phase = "stretching";
window.requestAnimationFrame(animate);
}
});
window.addEventListener("mouseup", function (event) {
if (phase == "stretching") {
phase = "turning";
}
});
window.addEventListener("resize", function (event) {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
draw();
});
window.requestAnimationFrame(animate);
// The main game loop
function animate(timestamp) {
if (!lastTimestamp) {
lastTimestamp = timestamp;
window.requestAnimationFrame(animate);
return;
}
switch (phase) {
case "waiting":
return; // Stop the loop
case "stretching": {
sticks.last().length += (timestamp - lastTimestamp) / stretchingSpeed;
break;
}
case "turning": {
sticks.last().rotation += (timestamp - lastTimestamp) / turningSpeed;
if (sticks.last().rotation > 90) {
sticks.last().rotation = 90;
const [nextPlatform, perfectHit] = thePlatformTheStickHits();
if (nextPlatform) {
// Increase score
score += perfectHit ? 2 : 1;
scoreElement.innerText = score;
if (perfectHit) {
perfectElement.style.opacity = 1;
setTimeout(() => (perfectElement.style.opacity = 0), 1000);
}
generatePlatform();
generateTree();
generateTree();
}
phase = "walking";
}
break;
}
case "walking": {
heroX += (timestamp - lastTimestamp) / walkingSpeed;
const [nextPlatform] = thePlatformTheStickHits();
if (nextPlatform) {
// If hero will reach another platform then limit it's position at it's edge
const maxHeroX = nextPlatform.x + nextPlatform.w - heroDistanceFromEdge;
if (heroX > maxHeroX) {
heroX = maxHeroX;
phase = "transitioning";
}
} else {
// If hero won't reach another platform then limit it's position at the end of the pole
const maxHeroX = sticks.last().x + sticks.last().length + heroWidth;
if (heroX > maxHeroX) {
heroX = maxHeroX;
phase = "falling";
}
}
break;
}
case "transitioning": {
sceneOffset += (timestamp - lastTimestamp) / transitioningSpeed;
const [nextPlatform] = thePlatformTheStickHits();
if (sceneOffset > nextPlatform.x + nextPlatform.w - paddingX) {
// Add the next step
sticks.push({
x: nextPlatform.x + nextPlatform.w,
length: 0,
rotation: 0
});
phase = "waiting";
}
break;
}
case "falling": {
if (sticks.last().rotation < 180)
sticks.last().rotation += (timestamp - lastTimestamp) / turningSpeed;
heroY += (timestamp - lastTimestamp) / fallingSpeed;
const maxHeroY =
platformHeight + 100 + (window.innerHeight - canvasHeight) / 2;
if (heroY > maxHeroY) {
restartButton.style.display = "block";
return;
}
break;
}
default:
throw Error("Wrong phase");
}
draw();
window.requestAnimationFrame(animate);
lastTimestamp = timestamp;
}
// Returns the platform the stick hit (if it didn't hit any stick then return undefined)
function thePlatformTheStickHits() {
if (sticks.last().rotation != 90)
throw Error(`Stick is ${sticks.last().rotation}°`);
const stickFarX = sticks.last().x + sticks.last().length;
const platformTheStickHits = platforms.find(
(platform) => platform.x < stickFarX && stickFarX < platform.x + platform.w
);
// If the stick hits the perfect area
if (
platformTheStickHits &&
platformTheStickHits.x + platformTheStickHits.w / 2 - perfectAreaSize / 2 <
stickFarX &&
stickFarX <
platformTheStickHits.x + platformTheStickHits.w / 2 + perfectAreaSize / 2
)
return [platformTheStickHits, true];
return [platformTheStickHits, false];
}
function draw() {
ctx.save();
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
drawBackground();
// Center main canvas area to the middle of the screen
ctx.translate(
(window.innerWidth - canvasWidth) / 2 - sceneOffset,
(window.innerHeight - canvasHeight) / 2
);
// Draw scene
drawPlatforms();
drawHero();
drawSticks();
// Restore transformation
ctx.restore();
}
restartButton.addEventListener("click", function (event) {
event.preventDefault();
resetGame();
restartButton.style.display = "none";
});
function drawPlatforms() {
platforms.forEach(({ x, w }) => {
// Draw platform
ctx.fillStyle = "black";
ctx.fillRect(
x,
canvasHeight - platformHeight,
w,
platformHeight + (window.innerHeight - canvasHeight) / 2
);
// Draw perfect area only if hero did not yet reach the platform
if (sticks.last().x < x) {
ctx.fillStyle = "red";
ctx.fillRect(
x + w / 2 - perfectAreaSize / 2,
canvasHeight - platformHeight,
perfectAreaSize,
perfectAreaSize
);
}
});
}
function drawHero() {
ctx.save();
ctx.fillStyle = "black";
ctx.translate(
heroX - heroWidth / 2,
heroY + canvasHeight - platformHeight - heroHeight / 2
);
// Body
drawRoundedRect(
-heroWidth / 2,
-heroHeight / 2,
heroWidth,
heroHeight - 4,
5
);
// Legs
const legDistance = 5;
ctx.beginPath();
ctx.arc(legDistance, 11.5, 3, 0, Math.PI * 2, false);
ctx.fill();
ctx.beginPath();
ctx.arc(-legDistance, 11.5, 3, 0, Math.PI * 2, false);
ctx.fill();
// Eye
ctx.beginPath();
ctx.fillStyle = "white";
ctx.arc(5, -7, 3, 0, Math.PI * 2, false);
ctx.fill();
// Band
ctx.fillStyle = "red";
ctx.fillRect(-heroWidth / 2 - 1, -12, heroWidth + 2, 4.5);
ctx.beginPath();
ctx.moveTo(-9, -14.5);
ctx.lineTo(-17, -18.5);
ctx.lineTo(-14, -8.5);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-10, -10.5);
ctx.lineTo(-15, -3.5);
ctx.lineTo(-5, -7);
ctx.fill();
ctx.restore();
}
function drawRoundedRect(x, y, width, height, radius) {
ctx.beginPath();
ctx.moveTo(x, y + radius);
ctx.lineTo(x, y + height - radius);
ctx.arcTo(x, y + height, x + radius, y + height, radius);
ctx.lineTo(x + width - radius, y + height);
ctx.arcTo(x + width, y + height, x + width, y + height - radius, radius);
ctx.lineTo(x + width, y + radius);
ctx.arcTo(x + width, y, x + width - radius, y, radius);
ctx.lineTo(x + radius, y);
ctx.arcTo(x, y, x, y + radius, radius);
ctx.fill();
}
function drawSticks() {
sticks.forEach((stick) => {
ctx.save();
// Move the anchor point to the start of the stick and rotate
ctx.translate(stick.x, canvasHeight - platformHeight);
ctx.rotate((Math.PI / 180) * stick.rotation);
// Draw stick
ctx.beginPath();
ctx.lineWidth = 2;
ctx.moveTo(0, 0);
ctx.lineTo(0, -stick.length);
ctx.stroke();
// Restore transformations
ctx.restore();
});
}
function drawBackground() {
// Draw sky
var gradient = ctx.createLinearGradient(0, 0, 0, window.innerHeight);
gradient.addColorStop(0, "#BBD691");
gradient.addColorStop(1, "#FEF1E1");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
// Draw hills
drawHill(hill1BaseHeight, hill1Amplitude, hill1Stretch, "#95C629");
drawHill(hill2BaseHeight, hill2Amplitude, hill2Stretch, "#659F1C");
// Draw trees
trees.forEach((tree) => drawTree(tree.x, tree.color));
}
// A hill is a shape under a stretched out sinus wave
function drawHill(baseHeight, amplitude, stretch, color) {
ctx.beginPath();
ctx.moveTo(0, window.innerHeight);
ctx.lineTo(0, getHillY(0, baseHeight, amplitude, stretch));
for (let i = 0; i < window.innerWidth; i++) {
ctx.lineTo(i, getHillY(i, baseHeight, amplitude, stretch));
}
ctx.lineTo(window.innerWidth, window.innerHeight);
ctx.fillStyle = color;
ctx.fill();
}
function drawTree(x, color) {
ctx.save();
ctx.translate(
(-sceneOffset * backgroundSpeedMultiplier + x) * hill1Stretch,
getTreeY(x, hill1BaseHeight, hill1Amplitude)
);
const treeTrunkHeight = 5;
const treeTrunkWidth = 2;
const treeCrownHeight = 25;
const treeCrownWidth = 10;
// Draw trunk
ctx.fillStyle = "#7D833C";
ctx.fillRect(
-treeTrunkWidth / 2,
-treeTrunkHeight,
treeTrunkWidth,
treeTrunkHeight
);
// Draw crown
ctx.beginPath();
ctx.moveTo(-treeCrownWidth / 2, -treeTrunkHeight);
ctx.lineTo(0, -(treeTrunkHeight + treeCrownHeight));
ctx.lineTo(treeCrownWidth / 2, -treeTrunkHeight);
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
}
function getHillY(windowX, baseHeight, amplitude, stretch) {
const sineBaseY = window.innerHeight - baseHeight;
return (
Math.sinus((sceneOffset * backgroundSpeedMultiplier + windowX) * stretch) *
amplitude +
sineBaseY
);
}
function getTreeY(x, baseHeight, amplitude) {
const sineBaseY = window.innerHeight - baseHeight;
return Math.sinus(x) * amplitude + sineBaseY;
}
In conclusion, the Stick Hero Game is a great project to understand how HTML, CSS, and JavaScript work together to create an interactive experience, combining canvas rendering, animations, and user input to simulate real game mechanics 🎮
If your project has problems, don’t worry. Just click to download the source code and face your coding challenges with excitement. Have fun coding!
