hopkit

Find your next move

Search the guide, API, and recipes.

    Pocket Trail / Chapter 04 of 08

    A forgiving jump

    Add platforms and a jump that feels right.

    Download checkpoint
    src/chapters/04.ts
    import { makeGame, drawTrail, movePlayer, playerAtStart, trailWidth } from "../trail.js"
    import type { LessonOptions } from "../types.js"
    
    export async function mount(canvas: HTMLCanvasElement, options: LessonOptions) {
      const game = await makeGame(canvas, options)
      game.scene((scene) => {
        drawTrail(scene)
        const player = playerAtStart()
        scene.sprite("hero", { bind: player, anchor: "center", width: 64, height: 64 })
        scene.camera.follow(player, {
          bounds: { x: 0, y: 0, width: trailWidth, height: 360 },
          speed: options.cameraSpeed,
        })
        scene.update((dt) => {
          movePlayer(
            player,
            scene.input.axis("left", "right"),
            scene.input.pressed("jump"),
            dt,
            options,
          )
        })
        options.status("Space jumps. Press just before landing to feel the jump buffer.")
        options.ready(scene)
      })
      return { game, restart: () => game.restart() }
    }
    Live gameOpen game ↗
    Play, change a setting, and play again.How it works ↓

    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
    src/trail.ts
    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.