Step 5: Move with D-Pad

Read controller input to move the player and switch between idle and walk animations.

What you will learn

  • Reading D-pad input with IsButtonDown
  • Moving the player by changing world position
  • Flipping the sprite horizontally with DrawSpriteOptions
  • Switching animation clips based on state

The code

//go:generate sh -c "cd assets-src && go run gen_assets.go"
//go:generate sh -c "mkdir -p assets && go run github.com/drpaneas/gosprite64/cmd/mk2dsheet -in assets-src/character.png -out assets/character.sheet -tile-width 16 -tile-height 16 && go run github.com/drpaneas/gosprite64/cmd/mk2dsheet -in assets-src/tiles.png -out assets/tiles.sheet -tile-width 8 -tile-height 8 && go run github.com/drpaneas/gosprite64/cmd/mk2dmap -in assets-src/level.json -out assets/level.map && go run github.com/drpaneas/gosprite64/cmd/mk2danim -in assets-src/anims.json -out assets/anims.anim && go run github.com/drpaneas/gosprite64/cmd/mk2dbundle -sheet assets/tiles.sheet -map assets/level.map -anim assets/anims.anim -out assets/level.bundle"

package main

import (
	"github.com/drpaneas/gosprite64"
)

type Game struct {
	scene   *gosprite64.Scene
	camera  *gosprite64.Camera
	charSS  *gosprite64.SpriteSheet
	player  *gosprite64.AnimationPlayer
	idle    gosprite64.AnimationClip
	walk    gosprite64.AnimationClip
	playerX float32
	playerY float32
	flipH   bool
	moving  bool
	curClip string
}

func (g *Game) Init() {
	bundle, err := gosprite64.OpenBundle("assets/level.bundle")
	if err != nil {
		panic(err)
	}

	scene, err := gosprite64.LoadScene(bundle)
	if err != nil {
		panic(err)
	}
	g.scene = scene
	g.camera = &gosprite64.Camera{Width: 288, Height: 216}

	charSheet, err := gosprite64.LoadSpriteSheet("assets/character.sheet")
	if err != nil {
		panic(err)
	}
	g.charSS = charSheet

	animSet, err := bundle.LoadAnimation("anims")
	if err != nil {
		panic(err)
	}

	idleClip, ok := animSet.Clip("idle")
	if !ok {
		panic("idle clip not found")
	}
	g.idle = idleClip

	walkClip, ok := animSet.Clip("walk")
	if !ok {
		panic("walk clip not found")
	}
	g.walk = walkClip

	g.playerX = 144
	g.playerY = 108

	g.player = gosprite64.NewAnimationPlayer()
	g.player.SetLoop(true)
	g.player.Play(g.idle)
	g.curClip = "idle"
}

func (g *Game) Update() {
	g.moving = false
	speed := float32(2)

	if gosprite64.IsButtonDown(gosprite64.ButtonDPadLeft) {
		g.playerX -= speed
		g.flipH = true
		g.moving = true
	}
	if gosprite64.IsButtonDown(gosprite64.ButtonDPadRight) {
		g.playerX += speed
		g.flipH = false
		g.moving = true
	}
	if gosprite64.IsButtonDown(gosprite64.ButtonDPadUp) {
		g.playerY -= speed
		g.moving = true
	}
	if gosprite64.IsButtonDown(gosprite64.ButtonDPadDown) {
		g.playerY += speed
		g.moving = true
	}

	if g.moving {
		if g.curClip != "walk" {
			g.player.Play(g.walk)
			g.curClip = "walk"
		}
	} else {
		if g.curClip != "idle" {
			g.player.Play(g.idle)
			g.curClip = "idle"
		}
	}
	g.player.Advance(1)
}

func (g *Game) Draw() {
	gosprite64.ClearScreen()
	g.scene.Draw(g.camera)

	frame := g.player.Frame()
	gosprite64.DrawWorldSpriteWithOptions(g.charSS, frame, g.playerX, g.playerY, g.camera, gosprite64.DrawSpriteOptions{
		FlipH: g.flipH,
	})
}

func main() {
	gosprite64.Run(&Game{})
}

How it works

Reading input

if gosprite64.IsButtonDown(gosprite64.ButtonDPadLeft) {
    g.playerX -= speed
    g.flipH = true
    g.moving = true
}

IsButtonDown returns true every frame the button is held. The D-pad constants are:

ConstantButton
ButtonDPadUpD-pad up
ButtonDPadDownD-pad down
ButtonDPadLeftD-pad left
ButtonDPadRightD-pad right

Each frame, we check all four directions. Multiple directions can be pressed simultaneously for diagonal movement. We track whether any direction was pressed in g.moving.

Horizontal flip

When moving left, we set g.flipH = true. When moving right, g.flipH = false. In Draw, we pass this to DrawSpriteOptions:

gosprite64.DrawWorldSpriteWithOptions(g.charSS, frame, g.playerX, g.playerY, g.camera, gosprite64.DrawSpriteOptions{
    FlipH: g.flipH,
})

DrawWorldSpriteWithOptions works like DrawWorldSprite but accepts extra options. FlipH: true mirrors the sprite horizontally so the character faces left without needing separate left-facing art.

Switching animations

if g.moving {
    if g.curClip != "walk" {
        g.player.Play(g.walk)
        g.curClip = "walk"
    }
} else {
    if g.curClip != "idle" {
        g.player.Play(g.idle)
        g.curClip = "idle"
    }
}

We only call Play() when the clip actually changes. Calling Play() resets the animation to frame 0, so calling it every frame would prevent the animation from advancing. The curClip string tracks which clip is currently active.

The walk clip plays at 8 FPS (faster leg movement), while idle plays at 4 FPS (slow breathing). The AnimationPlayer handles the FPS conversion internally.

Build and run

go generate ./examples/platformer
GOENV=n64.env go1.24.5-embedded build -o examples/platformer/game.elf ./examples/platformer

Build and run to see the character respond to the D-pad. Push left or right to walk (the sprite flips direction). Push up or down to move vertically. Release all buttons to see the idle animation. The walk animation is noticeably faster than idle.