The raised platforms finally hold you up. Space jumps. Gravity pulls you down.
Try it
Jump onto the first platform. Press jump again just before landing. The character remembers that press for 0.14 seconds and jumps when its feet touch terrain. Walk off an edge and jump immediately. The 0.1 second coyote window gives you a small margin after leaving the platform.
Keep movement rules in your source
movePlayer owns the jump buffer, vertical velocity and collision response.
It receives intent and seconds; it never reads browser events or starts a loop.
The scene calls it exactly once per update.
The player collision box is 24 by 32 logical units. The artwork can be larger without making the character collide with invisible padding. This example handles static axis-aligned platforms. Moving platforms and slopes need different rules.
The shared source below shows horizontal movement first, then vertical movement. A downward crossing snaps the feet to a platform and clears vertical velocity. The upward case stops the head at the underside.
Read the shared trail source
import { createGame, Graphics } from "@hopkit/game"
import type { LessonOptions } from "./types.js"
export function makeGame(canvas: HTMLCanvasElement, options: LessonOptions) {
return createGame({
canvas,
signal: options.signal,
background: "#c9e5d8",
assets: {
hero: { image: `${options.art}character_green_idle.png` },
jump: { image: `${options.art}character_green_jump.png` },
walkA: { image: `${options.art}character_green_walk_a.png` },
walkB: { image: `${options.art}character_green_walk_b.png` },
coin: { image: `${options.art}coin_gold.png` },
ground: { image: `${options.art}terrain_grass_horizontal_middle.png` },
bush: { image: `${options.art}bush.png` },
mushroom: { image: `${options.art}mushroom_red.png` },
},
sounds: { jump: `${options.art}sfx_jump.ogg`, gem: `${options.art}sfx_gem.ogg` },
actions: {
left: ["ArrowLeft", "KeyA"],
right: ["ArrowRight", "KeyD"],
jump: ["Space", "ArrowUp", "KeyW"],
pause: ["Escape", "KeyP"],
},
})
}
export type TrailGame = Awaited<ReturnType<typeof makeGame>>
export type TrailScene = Parameters<Parameters<TrailGame["scene"]>[0]>[0]
export interface Platform {
x: number
y: number
width: number
height: number
}
export interface Player {
x: number
y: number
vy: number
grounded: boolean
buffer: number
coyote: number
}
export const trailWidth = 1280
export const platforms: Platform[] = [
{ x: 0, y: 304, width: 1280, height: 56 },
{ x: 240, y: 240, width: 160, height: 32 },
{ x: 480, y: 190, width: 128, height: 32 },
{ x: 720, y: 240, width: 160, height: 32 },
{ x: 992, y: 200, width: 128, height: 32 },
]
export function playerAtStart(): Player {
return { x: 72, y: 288, vy: 0, grounded: false, buffer: 0, coyote: 0 }
}
export function drawTrail(scene: TrailScene, terrain = platforms): void {
const hills = new Graphics()
.ellipse(180, 340, 250, 190)
.fill(0x9acbb2)
.ellipse(600, 370, 270, 190)
.fill(0xb2d9b8)
.ellipse(1050, 360, 280, 190)
.fill(0x9acbb2)
scene.add(hills)
for (const platform of terrain) {
const soil = new Graphics()
.rect(platform.x, platform.y + 18, platform.width, platform.height)
.fill(0x79503a)
scene.add(soil)
for (let x = platform.x; x < platform.x + platform.width; x += 32) {
scene.sprite("ground", { position: { x, y: platform.y }, width: 32, height: 32 })
}
}
for (const x of [140, 416, 670, 922, 1160]) {
scene.sprite("bush", { position: { x, y: 274 }, width: 48, height: 32 })
}
scene.sprite("mushroom", { position: { x: 650, y: 276 }, width: 28, height: 28 })
}
/** The player owns position and velocity. Collision changes this same object. */
export function movePlayer(
player: Player,
direction: number,
jumpPressed: boolean,
dt: number,
options: LessonOptions,
terrain = platforms,
): boolean {
player.buffer = jumpPressed ? 0.14 : Math.max(0, player.buffer - dt)
player.coyote = player.grounded ? 0.1 : Math.max(0, player.coyote - dt)
let jumped = false
if (player.buffer > 0 && player.coyote > 0) {
player.vy = -options.jump
player.buffer = 0
player.coyote = 0
player.grounded = false
jumped = true
}
const halfWidth = 12
const halfHeight = 16
player.x = Math.max(
halfWidth,
Math.min(trailWidth - halfWidth, player.x + direction * options.speed * dt),
)
for (const box of terrain) {
if (player.y + halfHeight <= box.y || player.y - halfHeight >= box.y + box.height) continue
if (player.x + halfWidth <= box.x || player.x - halfWidth >= box.x + box.width) continue
player.x =
direction > 0 ? box.x - halfWidth : direction < 0 ? box.x + box.width + halfWidth : player.x
}
const previousY = player.y
player.vy += 1050 * dt
player.y += player.vy * dt
player.grounded = false
for (const box of terrain) {
if (player.x + halfWidth <= box.x || player.x - halfWidth >= box.x + box.width) continue
if (player.vy >= 0 && previousY + halfHeight <= box.y && player.y + halfHeight >= box.y) {
player.y = box.y - halfHeight
player.vy = 0
player.grounded = true
} else if (
player.vy < 0 &&
previousY - halfHeight >= box.y + box.height &&
player.y - halfHeight <= box.y + box.height
) {
player.y = box.y + box.height + halfHeight
player.vy = 0
}
}
return jumped
}
export function addCoins(scene: TrailScene, terrain = platforms) {
const coins = terrain
.slice(1)
.map((box) => ({ x: box.x + box.width / 2, y: box.y - 28, collected: false }))
const sprites = coins.map((coin) =>
scene.sprite("coin", { bind: coin, anchor: "center", width: 24, height: 24 }),
)
const flag = new Graphics()
.rect(1215, 230, 4, 74)
.fill(0x334f3d)
.poly([1219, 230, 1255, 242, 1219, 257])
.fill(0xda6556)
scene.add(flag)
const count = scene.text("0 / 4 coins", {
x: 18,
y: 16,
fontSize: 18,
fill: 0x203a30,
fontWeight: "bold",
})
let labelWidth = 0
let labelResolution = 0
count.zIndex = 1
scene.render(() => {
const width = scene.pixi.canvas.clientWidth
const resolution = scene.pixi.renderer.resolution
if (!width || (width === labelWidth && resolution === labelResolution)) return
labelWidth = width
labelResolution = resolution
const unit = 640 / width
count.scale.set(unit)
count.resolution = scene.pixi.renderer.resolution * unit
count.position.set(16 * unit, (width < 520 ? 118 : 22) * unit)
count.style.fontSize = 16
})
return {
collect(player: Player): number {
let added = 0
coins.forEach((coin, index) => {
if (coin.collected || Math.hypot(player.x - coin.x, player.y - coin.y) > 30) return
coin.collected = true
sprites[index]!.visible = false
added++
})
count.text = `${coins.filter((coin) => coin.collected).length} / ${coins.length} coins`
return added
},
finished(player: Player): boolean {
return coins.every((coin) => coin.collected) && player.x >= 1200
},
}
}Make one change
Set Jump strength to 360. Which coins would become unreachable? Bring it back above 430 before the next chapter.