Game Engine Zero Vol 3 · The Platformer
ch 22 / 24
Chapter 22

Platformer Physics

Gravity and jump speed

A platformer player needs a floor, gravity and one upward speed. The program stores the player as a rectangle with a position and a velocity. Gravity adds 0.35 pixels a tick to the falling speed. A jump sets the vertical speed to -6.5 pixels a tick.

The sign matters. Down is positive on the screen, so a jump starts negative and gravity pulls the number back toward positive. The flat floor in this first program sits at row 152, with a 12 by 16 player standing on it.

∑ Math Interlude · jump height from two numbers

Start with the two numbers the game uses. The jump speed is 6.5 pixels a tick upward. Gravity removes 0.35 pixels a tick from that upward speed. A continuous estimate for the height is speed squared divided by twice the gravity: 6.5 × 6.5 ÷ (2 × 0.35) = 60.3571 pixels. The stepped game measures 57.1500 pixels because gravity is applied once before the first position change.

vthe upward jump speed as a positive size for the calculation: 6.5 px/tick
gthe downward acceleration: 0.35 px/tick²
heightv² ÷ 2g = 60.3571 px as the continuous estimate; the stepped run prints 57.1500 px

The physics package

Create internal/platform/physics.go. This package imports no Ebitengine code. It holds the rules that can run with a window open or with no window at all.

▣ Build · stage 1 · create internal/platform/physics.go
package platform

import "fmt"

const (
	Gravity      = 0.35
	JumpVelocity = -6.5
	RunSpeed     = 2.0
	MaxFall      = 8.0
	PlayerW      = 12.0
	PlayerH      = 16.0
	FloorY       = 152.0
	CoyoteTicks  = 6
	BufferTicks  = 6
)

type Input struct {
	Left, Right  bool
	JumpHeld     bool
	JumpPressed  bool
	JumpReleased bool
}

type Player struct {
	X, Y         float64
	VX, VY       float64
	Grounded     bool
	Coyote       int
	JumpBuffer   int
	Jumps        int
	LastJumpTick int
	LastLandTick int
}

type World struct {
	Player     Player
	Tick       int
	FloorY     float64
	LedgeRight float64
	MaxFall    float64
}

func NewWorld() *World {
	return &World{
		Player: Player{X: 48, Y: FloorY - PlayerH, Grounded: true, Coyote: CoyoteTicks, LastJumpTick: -1, LastLandTick: -1},
		FloorY: FloorY, LedgeRight: 320, MaxFall: MaxFall,
	}
}

func NewLedgeWorld() *World {
	w := NewWorld()
	w.Player.X = 58
	w.LedgeRight = 64
	return w
}

func (w *World) Step(in Input) {
	w.Tick++
	p := &w.Player
	p.VX = 0
	if in.Left {
		p.VX = -RunSpeed
	}
	if in.Right {
		p.VX = RunSpeed
	}
	p.X += p.VX

	if in.JumpPressed {
		p.JumpBuffer = BufferTicks
	} else if p.JumpBuffer > 0 {
		p.JumpBuffer--
	}
	if p.Grounded {
		p.Coyote = CoyoteTicks
	} else if p.Coyote > 0 {
		p.Coyote--
	}
	if p.JumpBuffer > 0 && p.Coyote > 0 {
		p.VY = JumpVelocity
		p.Grounded = false
		p.Coyote = 0
		p.JumpBuffer = 0
		p.Jumps++
		p.LastJumpTick = w.Tick
	}
	p.VY += Gravity
	if p.VY > w.MaxFall {
		p.VY = w.MaxFall
	}
	if in.JumpReleased && p.VY < JumpVelocity/2 {
		p.VY = JumpVelocity / 2
	}
	p.Y += p.VY
	if w.onFloor() && p.Y+PlayerH >= w.FloorY {
		p.Y = w.FloorY - PlayerH
		p.VY = 0
		if !p.Grounded {
			p.LastLandTick = w.Tick
		}
		p.Grounded = true
		p.Coyote = CoyoteTicks
	} else {
		p.Grounded = false
	}
}

func (w *World) onFloor() bool {
	centre := w.Player.X + PlayerW/2
	return centre <= w.LedgeRight
}

func (w *World) Bottom() float64 { return w.Player.Y + PlayerH }

func ApexReport() string {
	w := NewWorld()
	start := w.Bottom()
	w.Step(Input{JumpPressed: true, JumpHeld: true})
	apexTick, landTick := 0, 0
	apexBottom := w.Bottom()
	for i := 0; i < 180; i++ {
		w.Step(Input{JumpHeld: true})
		if w.Bottom() < apexBottom {
			apexBottom = w.Bottom()
			apexTick = w.Tick
		}
		if w.Player.Grounded && w.Tick > 1 {
			landTick = w.Tick
			break
		}
	}
	formula := JumpVelocity * JumpVelocity / (2 * Gravity)
	return fmt.Sprintf("formula %.4f px; apex tick %d bottom %.4f height %.4f px; landed tick %d", formula, apexTick, apexBottom, start-apexBottom, landTick)
}

func jumpApex(releaseTick int) (apexTick int, height float64) {
	w := NewWorld()
	start := w.Bottom()
	w.Step(Input{JumpPressed: true, JumpHeld: true})
	apexBottom := w.Bottom()
	for i := 0; i < 180; i++ {
		in := Input{JumpHeld: releaseTick == 0 || w.Tick+1 < releaseTick}
		if releaseTick != 0 && w.Tick+1 == releaseTick {
			in.JumpReleased = true
		}
		w.Step(in)
		if w.Bottom() < apexBottom {
			apexBottom = w.Bottom()
			apexTick = w.Tick
		}
		if w.Player.Grounded && w.Tick > 1 {
			break
		}
	}
	return apexTick, start - apexBottom
}

func JumpCutReport() string {
	heldTick, heldHeight := jumpApex(0)
	cutTick, cutHeight := jumpApex(5)
	return fmt.Sprintf("held jump: apex tick %d height %.4f px; released at tick 5: apex tick %d height %.4f px", heldTick, heldHeight, cutTick, cutHeight)
}

func CoyoteReport(delay int) string {
	w := NewLedgeWorld()
	for w.Player.Grounded {
		w.Step(Input{Right: true})
	}
	leftTick := w.Tick
	for i := 0; i < delay; i++ {
		w.Step(Input{Right: true})
	}
	w.Step(Input{Right: true, JumpPressed: true, JumpHeld: true})
	result := "missed"
	if w.Player.LastJumpTick == w.Tick {
		result = "jumped"
	}
	return fmt.Sprintf("left floor tick %d; pressed jump %d ticks later: %s at tick %d, coyote left %d", leftTick, delay+1, result, w.Tick, w.Player.Coyote)
}

func BufferReport(delay int) string {
	w := NewWorld()
	w.Player.Y = FloorY - PlayerH - 40
	w.Player.Grounded = false
	w.Player.Coyote = 0
	pressTick := 11
	for w.Tick < 40 {
		in := Input{}
		if w.Tick+1 == pressTick {
			in.JumpPressed = true
		}
		w.Step(in)
		if w.Player.LastJumpTick == w.Tick && w.Tick > pressTick {
			return fmt.Sprintf("pressed jump at tick %d, %d ticks before landing; buffered jump fired at tick %d", pressTick, delay, w.Tick)
		}
	}
	return fmt.Sprintf("pressed jump at tick %d, %d ticks before landing; no buffered jump fired", pressTick, delay)
}

func FallCapReport() string {
	w := NewWorld()
	w.Player.Y = -200
	w.Player.Grounded = false
	w.Player.Coyote = 0
	w.LedgeRight = -100
	for i := 0; i < 120; i++ {
		w.Step(Input{})
	}
	capped := w.Player.VY
	free := NewWorld()
	free.Player.Y = -200
	free.Player.Grounded = false
	free.Player.Coyote = 0
	free.LedgeRight = -100
	free.MaxFall = 1000
	for i := 0; i < 120; i++ {
		free.Step(Input{})
	}
	return fmt.Sprintf("with cap %.4f px/tick; without cap %.4f px/tick; tile height 16", capped, free.Player.VY)
}

type BoolWorld struct {
	Grounded bool
	Jumps    int
}

func (w *BoolWorld) Step(jumpPressed, onFloor bool) {
	w.Grounded = onFloor
	if jumpPressed && w.Grounded {
		w.Jumps++
		w.Grounded = false
	}
}

func BoolFailureReport() string {
	good := NewLedgeWorld()
	for good.Player.Grounded {
		good.Step(Input{Right: true})
	}
	good.Step(Input{Right: true, JumpPressed: true, JumpHeld: true})
	bad := &BoolWorld{Grounded: true}
	bad.Step(false, false)
	bad.Step(true, false)
	return fmt.Sprintf("counter window: %d jump at tick %d; boolean window: %d jumps", good.Player.Jumps, good.Player.LastJumpTick, bad.Jumps)
}

The game stores coyote time and jump buffering as numbers that count down. A boolean can remember only yes or no. A counter remembers how many ticks of forgiveness remain.

The platformer window

Create cmd/platformer/main.go. Ebitengine owns the window and asks for input. The platform package owns the player rules. The drawing code shows the floor and the player rectangle.

▣ Build · stage 2 · create cmd/platformer/main.go
package main

import (
	"flag"
	"fmt"
	"image/color"
	"log"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/inpututil"
	"github.com/hajimehoshi/ebiten/v2/vector"

	"gez/internal/platform"
)

const (
	screenW = 320
	screenH = 180
)

var play = flag.String("play", "", "print one platformer physics run and exit")

type Game struct {
	world *platform.World
}

func (g *Game) Update() error {
	in := platform.Input{
		Left:         ebiten.IsKeyPressed(ebiten.KeyArrowLeft) || ebiten.IsKeyPressed(ebiten.KeyA),
		Right:        ebiten.IsKeyPressed(ebiten.KeyArrowRight) || ebiten.IsKeyPressed(ebiten.KeyD),
		JumpHeld:     ebiten.IsKeyPressed(ebiten.KeySpace),
		JumpPressed:  inpututil.IsKeyJustPressed(ebiten.KeySpace),
		JumpReleased: inpututil.IsKeyJustReleased(ebiten.KeySpace),
	}
	g.world.Step(in)
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	drawWorld(screen, g.world)
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) {
	return screenW, screenH
}

func drawWorld(screen *ebiten.Image, w *platform.World) {
	screen.Fill(color.RGBA{R: 16, G: 20, B: 28, A: 255})
	vector.FillRect(screen, 0, float32(platform.FloorY), screenW, screenH-float32(platform.FloorY), color.RGBA{R: 82, G: 96, B: 80, A: 255}, false)
	p := w.Player
	vector.FillRect(screen, float32(p.X), float32(p.Y), float32(platform.PlayerW), float32(platform.PlayerH), color.RGBA{R: 230, G: 180, B: 70, A: 255}, false)
	vector.StrokeLine(screen, 0, float32(platform.FloorY), screenW, float32(platform.FloorY), 1, color.RGBA{R: 232, G: 232, B: 232, A: 255}, false)
}

func printPlay(name string) error {
	switch name {
	case "apex":
		fmt.Println(platform.ApexReport())
	case "jump-cut":
		fmt.Println(platform.JumpCutReport())
	case "coyote":
		fmt.Println(platform.CoyoteReport(0))
	case "buffer":
		fmt.Println(platform.BufferReport(4))
	case "cap":
		fmt.Println(platform.FallCapReport())
	case "bool":
		fmt.Println(platform.BoolFailureReport())
	case "":
		return nil
	default:
		return fmt.Errorf("no play run called %q", name)
	}
	return ebiten.Termination
}

func main() {
	flag.Parse()
	if *play != "" {
		if err := printPlay(*play); err != nil && err != ebiten.Termination {
			log.Fatal(err)
		}
		return
	}
	ebiten.SetWindowSize(960, 540)
	ebiten.SetWindowTitle("Platformer")
	ebiten.SetTPS(60)
	if err := ebiten.RunGame(&Game{world: platform.NewWorld()}); err != nil {
		log.Fatal(err)
	}
}
go run ./cmd/platformer -play apex
$ go run ./cmd/platformer -play apex
formula 60.3571 px; apex tick 18 bottom 94.8500 height 57.1500 px; landed tick 37

The printed line gives both numbers. The formula is the pencil estimate. The measured height is the stepped game. The difference comes from the order used in one fixed tick: jump speed is set, gravity is applied, then the position moves.

A dark game screen with a pale floor line near the bottom, green ground below it, and a small gold rectangle standing on the floor near the left side.
Figure 22.1 · the player starts on a flat floor before the jump.
The same dark game screen and floor, with the gold player rectangle high above the floor near the left side after the jump reaches its top.
Figure 22.2 · the same player at the measured apex of the jump.

Releasing jump for a short hop

A held jump reaches the full measured height. A released jump cuts the upward speed while the player is still rising, so a tap gives a short hop. The code reads the release edge once, then clamps the rising speed to half the jump speed.

▣ Build · stage 3 · run the release cut
go run ./cmd/platformer -play jump-cut
$ go run ./cmd/platformer -play jump-cut
held jump: apex tick 18 height 57.1500 px; released at tick 5: apex tick 14 height 39.2500 px

The player still starts with the same jump speed. The release changes the remaining upward speed before gravity and position finish that tick. The tap peaks four ticks earlier and 17.9000 pixels lower than the held jump.

Coyote time and jump buffering

A jump press is input, not a physics event. The game keeps that input alive for six ticks. It also keeps the last grounded tick alive for six ticks after the player leaves the floor.

▣ Build · stage 4 · run the two forgiveness windows
go run ./cmd/platformer -play coyote
go run ./cmd/platformer -play buffer
$ go run ./cmd/platformer -play coyote
left floor tick 1; pressed jump 1 ticks later: jumped at tick 2, coyote left 0
$ go run ./cmd/platformer -play buffer
pressed jump at tick 11, 4 ticks before landing; buffered jump fired at tick 16

The first run steps off a ledge and presses jump on the next tick. The floor is gone, but the coyote counter still has time left. The second run presses jump before landing. The buffered press waits until the floor contact gives the player coyote time again.

The fall speed cap

A platformer falls toward tiles. A 16-pixel tile can be skipped if the player moves more than one tile in one tick. The cap keeps the fall speed at 8 pixels a tick, below the tile height.

▣ Build · stage 5 · run the capped fall
go run ./cmd/platformer -play cap
$ go run ./cmd/platformer -play cap
with cap 8.0000 px/tick; without cap 42.0000 px/tick; tile height 16

The capped player can still fall fast. The uncapped player reaches 42 pixels in one tick after the same run, which is more than two tile rows. Collision code cannot push out of a tile it never saw.

Booleans miss the ledge jump

⚠ Worked failure · coyote time written as a boolean

Replace the counters with booleans in a small test run. The player leaves the floor, then presses jump on the next tick:

go run ./cmd/platformer -play bool
$ go run ./cmd/platformer -play bool
counter window: 1 jump at tick 2; boolean window: 0 jumps

The symptom is the missing jump. The boolean version reads the current floor contact only, so the answer is false after the first tick off the ledge. The fix is the counter: store six ticks of time and spend one each update.

What the jump counters and velocity can now answer

✓ Checkpoint · platformer physics
  • Compute the jump height estimate from 6.5 px/tick and 0.35 px/tick².
  • Explain why the stepped jump measures 57.1500 pixels instead of the continuous estimate.
  • Cut a held jump into a short hop by clamping upward velocity on release.
  • Use a coyote counter to accept a jump one tick after the player leaves the floor.
  • Use a jump-buffer counter to keep an early jump press until the landing tick.
  • Keep the fall speed below a 16-pixel tile by capping it at 8 px/tick.
⚡ Exercises · try first, then reveal
Exercise 1 · lower jump. Change the jump speed to -5.5 and run the apex print again.

The estimate becomes 43.2143 pixels. The measured height is lower for the same tick-order reason.

Exercise 2 · another release tick. Extend JumpCutReport so it also measures a jump released at tick 8, then run go run ./cmd/platformer -play jump-cut.

The new line gives you a third apex. It should sit between the tick-5 release and the held jump because the player kept the full upward speed longer.

Exercise 3 · visible cap. Change MaxFall to 4 and run the window.

The player takes longer to return to the floor. The cap changes only falling speed; the jump still starts at -6.5.