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
IsKeyDownfor continuous things: "is the jump key held right now", which we need for variable jump height. - Use
IsKeyPressedfor 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
IsKeyPressedis 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 * dtintegrates 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.squashis positive right after take-off and negative right after landing, decaying to zero viaapproach. 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.animjust accumulates (faster the faster we run), and feeding it throughmath.sinswings 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
- Tune
JUMP_SPEEDandGRAVITYtogether to make a floatier or snappier jump. What ratio feels best? - Temporarily set
COYOTE_TIMEandJUMP_BUFFERto0. Play. Then restore them, and feel the difference the forgiveness makes. - Change
JUMP_CUTto1.0(no cut) and to0.1(a hard cut). Describe how each feels. - Make the legs swing faster by changing the
/ 42inp.anim += dt * g.speed / 42.
Further reading
- raylib cheatsheet (input keys, shapes): https://www.raylib.com/cheatsheet/cheatsheet.html
rl.KeyboardKeyvalues: in the binding, or the cheatsheet's Keyboard section.- Odin
core:math(sin,min,max,clampare builtins): https://pkg.odin-lang.org/core/math/ - On coyote time and jump buffering as game-feel techniques: search "platformer jump feel coyote time".