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.
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.
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.
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.
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.
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.
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.
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.
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
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
- 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.
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.