Odin't Know That Β· Chapter 4

Chapter 4: The player: a wolf that runs and jumps

This chapter:

  • Read the keyboard two ways: held keys and just-pressed keys.
  • Give the wolf gravity and a jump that feels right: variable height, coyote time, a jump buffer.
  • Draw the wolf entirely from primitives, with legs that animate as it runs.

Where we left off: you can move a rectangle at a real-world speed and draw dynamic text safely.


We'll build the wolf on a flat ground line this chapter, with no scrolling and no gaps yet. The jump physics we write here is exactly the code the finished game uses; only the world around it changes later.

4.1 Two kinds of key

raylib exposes keyboard state two ways, and the difference matters:

rl.IsKeyDown(.SPACE)     // true every frame the key is HELD
rl.IsKeyPressed(.SPACE)  // true only on the FRAME it went down (an edge)

Keys are named on the rl.KeyboardKey enum, so .SPACE, .UP, .ENTER, .M, .R.

  • Use IsKeyDown for continuous things: "is the jump key held right now", which we need for variable jump height.
  • Use IsKeyPressed for one-shot things: "did the player just ask to jump", which should fire once per press, not once per frame.

Why the edge version saves us grief later. Because IsKeyPressed is true for a single frame, a press that starts a run on the title screen can't accidentally also register as a jump on the next frame. In Yo Odin (https://nofuss.co.za/yo_odin/) this had to be built by hand with a latch; raylib hands it to us. We still lean on it deliberately in Chapter 9's state machine.

4.2 The player, and a block of tuning constants

Group the numbers that define how the wolf feels at the top, as named constants. When you tune the game, you tune here, not by hunting through the logic:

// game.odin: tuning (excerpt)
GROUND_Y :: 400.0   // world-y of the ground surface; the wolf's feet rest here
PLAYER_W :: 46.0
PLAYER_H :: 40.0

GRAVITY     :: 2200.0
JUMP_SPEED  :: 780.0 // initial upward speed of a jump
JUMP_CUT    :: 0.45  // release early to cut the jump short (variable height)
COYOTE_TIME :: 0.10  // grace period to still jump just after leaving the ground
JUMP_BUFFER :: 0.12  // press slightly early and the jump still fires on landing

And the player itself:

// player.odin
Player :: struct {
	pos:         Vec2, // x = world centre, y = feet (bottom); x only ever grows
	vel:         Vec2, // only y is used (vertical); horizontal comes from the run speed
	on_ground:   bool,
	coyote:      f32,  // time left in which a jump is still allowed after leaving ground
	jump_buffer: f32,  // time left in which a queued press will fire on landing
	jump_held:   bool, // is the jump key currently down? (for variable height)
	jump_active: bool, // are we in the rising, still-cuttable part of a jump?
	squash:      f32,  // >0 stretch (take-off), <0 squash (landing), decays to 0
	anim:        f32,  // run-cycle accumulator, drives the legs
}

Two modelling choices to flag. First, pos.y is the wolf's feet (its bottom edge), so "on the ground" is simply pos.y == GROUND_Y. Second, the wolf's horizontal motion isn't a velocity we integrate; it just advances at the current run speed each frame. The only physics is vertical.

4.3 A jump that feels right

A naΓ―ve jump, "on key press, set upward velocity; apply gravity", feels stiff and unfair. Three small refinements, each a few lines, turn it into something that feels good. Here's the whole update, then we'll walk the interesting parts:

// player.odin
update_player :: proc(dt: f32) {
	p := &g.player

	// Auto-run: world-x advances at the current speed; legs swing faster the faster we go.
	p.pos.x += g.speed * dt
	p.anim += dt * g.speed / 42

	// Coyote time and the jump buffer both count down in real seconds.
	if p.on_ground {
		p.coyote = COYOTE_TIME
	} else {
		p.coyote = max(0, p.coyote - dt)
	}
	p.jump_buffer = max(0, p.jump_buffer - dt)

	// Start a jump if one is buffered and we're on the ground (or within coyote time).
	if p.jump_buffer > 0 && p.coyote > 0 {
		p.vel.y = -JUMP_SPEED
		p.on_ground = false
		p.coyote = 0
		p.jump_buffer = 0
		p.jump_active = true
		p.squash = 0.5
		play_sound(.Jump)          // (Chapter 10: a stub for now)
		spawn_dust(p.pos, 8)       // (Chapter 8: a stub for now)
	}

	// Variable height: releasing the key while still rising cuts the upward speed once.
	if p.jump_active && !p.jump_held && p.vel.y < 0 {
		p.vel.y *= JUMP_CUT
		p.jump_active = false
	}
	if p.vel.y >= 0 do p.jump_active = false

	// Gravity, then integrate vertical position.
	p.vel.y += GRAVITY * dt
	p.pos.y += p.vel.y * dt

	// The squash/stretch relaxes back to neutral.
	p.squash = approach(p.squash, 0, dt * 4)

	// Land only where there is ground. (This chapter: ground is everywhere.)
	solid := ground_solid_at(p.pos.x)
	if solid && p.pos.y >= GROUND_Y && p.vel.y >= 0 {
		if !p.on_ground {
			p.squash = -0.5
			spawn_dust(p.pos, 10)
		}
		p.pos.y = GROUND_Y
		p.vel.y = 0
		p.on_ground = true
	} else {
		p.on_ground = false
	}

	if p.pos.y > FALL_KILL_Y do kill_player()   // (falls matter once we add gaps)
}

Coyote time. Ever pressed jump a hair after running off a ledge and been annoyed nothing happened? coyote is a tiny window (0.1s) after leaving the ground during which a jump still counts. While on_ground, it's topped back up to COYOTE_TIME; once airborne it ticks down. The jump condition checks p.coyote > 0, not p.on_ground, so those last few milliseconds of grace are forgiving.

The jump buffer. The mirror problem: pressing jump a hair before landing, and having it ignored. When the player presses jump we don't jump immediately: we set jump_buffer = JUMP_BUFFER (done in input handling, next section). It ticks down each frame, and the jump fires as soon as both a buffered press and coyote/ground are true. So an early press "waits" up to 0.12s to fire on landing.

Variable height. Holding jump should leap higher than a tap. We start every jump at full -JUMP_SPEED, but if the player releases the key while still rising (p.vel.y < 0), we cut the upward speed once (*= JUMP_CUT). Tap-and-release β†’ a low hop; hold β†’ the full arc.

Why negative velocity is "up". Remember Chapter 3: y grows downward. So upward motion is a negative vel.y, and gravity is a positive number added to it each frame, curving the wolf back down. p.pos.y += p.vel.y * dt integrates it.

The approach helper (nudging a value toward a target by a capped step) lives in game.odin:

approach :: proc(a, b, step: f32) -> f32 {
	if a < b do return min(b, a + step)
	if a > b do return max(b, a - step)
	return a
}

For this chapter, stub the two functions the finished game fills in later, so it compiles and runs:

// temporary stubs, replaced by the real systems in Chapters 6, 8, 10 and 7.
// (Snd and Vec2 here match the final types, so nothing needs changing later.)
Snd :: enum { Jump, Rune, Hit }
ground_solid_at :: proc(x: f32) -> bool { return true } // no gaps yet (Ch. 6)
spawn_dust :: proc(pos: Vec2, count: int) {}            // Ch. 8
play_sound :: proc(which: Snd) {}                       // Ch. 10
kill_player :: proc() {}                                // Ch. 7

4.4 Feeding input to the jump

Input lives in handle_input (in game.odin). The one-shot press sets the buffer; the held state drives variable height:

// while playing:
if rl.IsKeyPressed(.SPACE) || rl.IsKeyPressed(.UP) do g.player.jump_buffer = JUMP_BUFFER
g.player.jump_held = rl.IsKeyDown(.SPACE) || rl.IsKeyDown(.UP)

Notice the buffer is set on the edge (IsKeyPressed) and the held flag is read every frame (IsKeyDown): exactly the two-kinds-of-key split from Β§4.1.

4.5 Drawing the wolf

No sprite: the wolf is primitives, scaled by squash so it stretches on take-off and squashes on landing. Here's the essence (the full version is in draw.odin):

// draw.odin (excerpt)
draw_player :: proc() {
	p := g.player
	sx := 1 - p.squash * 0.25 // take-off β†’ narrower; landing β†’ wider
	sy := 1 + p.squash * 0.35 // take-off β†’ taller;   landing β†’ shorter
	w := PLAYER_W * sx
	h := PLAYER_H * sy
	cx := p.pos.x
	feet := p.pos.y
	body_top := feet - h
	body_bot := feet - 8      // leave room for legs

	// Legs: four swinging lines; planted when airborne so they don't flail.
	airborne := !p.on_ground
	for i in 0 ..< 4 {
		lx := cx - w * 0.32 + f32(i) * (w * 0.64 / 3)
		swing := f32(6)
		if !airborne do swing = math.sin(p.anim + f32(i) * 1.6) * 9
		rl.DrawLineEx({lx, body_bot}, {lx + swing, feet}, 4, WOLF_DARK)
	}

	// Body, head, ear, snout, eye: rectangles, triangles and a circle.
	rl.DrawRectangleRec({cx - w * 0.5, body_top + h * 0.28, w, body_bot - (body_top + h * 0.28)}, WOLF_COLOR)
	// ... head and features (see draw.odin) ...
}

Two ideas worth lifting out:

  • Squash-and-stretch from one number. p.squash is positive right after take-off and negative right after landing, decaying to zero via approach. The single pair of formulas above turns that into "tall and thin" then "short and wide" then neutral: the cheapest, most effective bit of life you can give a character. It's finished properly in Chapter 8.
  • A run cycle without frames. There are no animation frames. p.anim just accumulates (faster the faster we run), and feeding it through math.sin swings each leg on a slightly offset phase. When airborne, the legs plant instead of flailing.

Checkpoint

On a flat ground line, the wolf runs, its legs cycle, and it jumps with a genuinely nice arc: hold for height, tap for a hop, with coyote-time and buffer forgiveness so the controls feel fair rather than fussy. It squashes on landing and stretches on take-off. Everything vertical is real physics driven by a clamped dt. Next chapter we make the world move.

Exercises

  1. Tune JUMP_SPEED and GRAVITY together to make a floatier or snappier jump. What ratio feels best?
  2. Temporarily set COYOTE_TIME and JUMP_BUFFER to 0. Play. Then restore them, and feel the difference the forgiveness makes.
  3. Change JUMP_CUT to 1.0 (no cut) and to 0.1 (a hard cut). Describe how each feels.
  4. Make the legs swing faster by changing the / 42 in p.anim += dt * g.speed / 42.

Further reading