Created
June 18, 2026 10:44
-
-
Save jamesseanwright/dfea9f819b48b51b31986df12825f110 to your computer and use it in GitHub Desktop.
Simple HTML Canvas 2D platformer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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