Skip to content

Instantly share code, notes, and snippets.

@jamesseanwright
Created June 18, 2026 10:44
Show Gist options
  • Select an option

  • Save jamesseanwright/dfea9f819b48b51b31986df12825f110 to your computer and use it in GitHub Desktop.

Select an option

Save jamesseanwright/dfea9f819b48b51b31986df12825f110 to your computer and use it in GitHub Desktop.
Simple HTML Canvas 2D platformer
const canvas = document.querySelector<HTMLCanvasElement>("#out");
if (!canvas) {
throw new Error("unable to locate output canvas in DOM");
}
const context = canvas.getContext("2d");
if (!context) {
throw new Error("unable to get 2D rendering context from canvas");
}
const worldToScreen = (world: number, screen: number) => world * screen;
const V_EPSILON = 0.01; // provides floating number tolerance for vertical intersections/collisions
const GRAVITY = 0.0001;
const ZOOM = 1.25;
const BALL_RADIUS = 0.025;
const BALL_INITIAL_POSITION = BALL_RADIUS + 0.001;
const BALL_SPEED = 0.01;
const BALL_FILL_STYLE = "black";
const GROUND_LENGTH = 1;
const GROUND_DEPTH = 0.125;
const PLATFORM_DEPTH = 0.05;
const PLATFORM_FILL_STYLE = context.createLinearGradient(
0,
0,
worldToScreen(1, canvas.width),
0,
);
PLATFORM_FILL_STYLE.addColorStop(0, "lightgreen");
PLATFORM_FILL_STYLE.addColorStop(1, "green");
const withinVEpsilon = (y: number, targetY: number) =>
y >= targetY && y <= targetY + V_EPSILON;
type BallState = "stationary" | "ascending" | "descending";
interface Ball {
state: BallState;
x: number;
y: number;
xVelocity: number;
yVelocity: number;
}
interface Platform {
x: number;
y: number;
width: number;
height: number;
}
interface TrackingCamera {
ctx: CanvasRenderingContext2D;
ball: Ball;
}
const createTrackingCamera = (
ctx: CanvasRenderingContext2D,
ball: Ball,
): TrackingCamera => ({
ctx,
ball,
});
const updateTrackingCamera = (camera: TrackingCamera) => {
camera.ctx.translate(
worldToScreen(
-camera.ball.x * camera.ball.xVelocity,
camera.ctx.canvas.width,
),
worldToScreen(
-camera.ball.y * camera.ball.yVelocity,
camera.ctx.canvas.height,
),
);
};
const resetTrackingCamera = (camera: TrackingCamera) => {
camera.ctx.resetTransform();
camera.ctx.scale(ZOOM, ZOOM);
};
const createPlatform = (
x: number,
y: number,
width: number,
height: number,
): Platform => ({
x,
y,
width,
height,
});
const createBall = (): Ball => ({
state: "descending",
x: BALL_INITIAL_POSITION,
y: BALL_INITIAL_POSITION,
xVelocity: 0,
yVelocity: 0,
});
const launchBall = (targetX: number, targetY: number) => {
if (ball.state !== "stationary") {
return;
}
ball.state = "ascending";
const diffX = targetX - ball.x;
const diffY = targetY - ball.y;
const angle = Math.atan2(diffY, diffX);
ball.xVelocity = Math.cos(angle) * BALL_SPEED;
ball.yVelocity = Math.sin(angle) * BALL_SPEED;
};
const updateBall = (
ball: Ball,
platforms: Platform[],
camera: TrackingCamera,
deltaTime: DOMHighResTimeStamp,
) => {
const shouldReset = ball.y - BALL_RADIUS > 1;
if (shouldReset) {
ball.state = "descending";
ball.x = BALL_INITIAL_POSITION;
ball.y = BALL_INITIAL_POSITION;
ball.xVelocity = 0;
ball.yVelocity = 0;
resetTrackingCamera(camera);
return;
}
const shouldTransitionToDescending =
ball.state === "ascending" && ball.yVelocity > 0;
if (shouldTransitionToDescending) {
ball.state = "descending";
}
const hasHitPlatform =
ball.state === "descending" &&
platforms.some(
(p) =>
ball.x >= p.x &&
ball.x <= p.x + p.width &&
withinVEpsilon(ball.y + BALL_RADIUS, p.y),
);
if (hasHitPlatform) {
ball.state = "stationary";
ball.xVelocity = 0;
ball.yVelocity = 0;
}
if (ball.state !== "stationary") {
ball.yVelocity += GRAVITY;
}
ball.x += ball.xVelocity * deltaTime;
ball.y += ball.yVelocity * deltaTime;
};
const renderBall = (ctx: CanvasRenderingContext2D, ball: Ball) => {
ctx.beginPath();
ctx.ellipse(
worldToScreen(ball.x, ctx.canvas.width),
worldToScreen(ball.y, ctx.canvas.height),
worldToScreen(BALL_RADIUS, ctx.canvas.width),
worldToScreen(BALL_RADIUS, ctx.canvas.height),
0,
0,
Math.PI * 2,
);
ctx.fillStyle = BALL_FILL_STYLE;
ctx.fill();
};
const renderPlatform = (ctx: CanvasRenderingContext2D, platform: Platform) => {
ctx.fillStyle = PLATFORM_FILL_STYLE;
ctx.fillRect(
worldToScreen(platform.x, ctx.canvas.width),
worldToScreen(platform.y, ctx.canvas.height),
worldToScreen(platform.width, ctx.canvas.width),
worldToScreen(platform.height, ctx.canvas.height),
);
};
const platforms = (
[
[0, 1 - GROUND_DEPTH, 1, GROUND_DEPTH],
[0.125, 0.175, 0.2, PLATFORM_DEPTH],
[0.6, 0.6, 0.2, PLATFORM_DEPTH],
] as const
).map(([x, y, width, height]) => createPlatform(x, y, width, height));
const ball = createBall();
const camera = createTrackingCamera(context, ball);
resetTrackingCamera(camera);
canvas.addEventListener("click", (e) => {
launchBall(e.clientX / canvas.width, e.clientY / canvas.height);
});
let lastFrameTimeMS = 0;
const onFrame = (deltaTime: DOMHighResTimeStamp) => {
context.clearRect(
0,
0,
worldToScreen(ball.x, canvas.width) + canvas.width,
worldToScreen(ball.y, canvas.height) + canvas.height,
);
updateBall(ball, platforms, camera, deltaTime);
updateTrackingCamera(camera);
renderBall(context, ball);
for (const platform of platforms) {
renderPlatform(context, platform);
}
};
const loop = (frameTimeMS: DOMHighResTimeStamp) => {
const deltaTime = 1 - (frameTimeMS - lastFrameTimeMS) / 1000;
onFrame(deltaTime);
lastFrameTimeMS = frameTimeMS;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment