Camera and Scrolling
The Camera struct defines the visible region of your tile world. By moving the camera, you scroll through maps that are larger than the screen. This page covers camera creation, manual scrolling, smooth follow, bounds clamping, screen shake, and the scene.Draw(camera) rendering call.
Camera struct
type Camera struct {
X, Y int // top-left corner of the viewport in world pixels
Width, Height int // viewport size in pixels
Zoom float32 // zoom level (0 or unset defaults to 1.0)
FollowTarget *math2d.Vec2 // world position to follow
FollowSpeed float32 // lerp speed: 0.0-1.0 (1.0 = instant snap)
Bounds *math2d.Rect // optional clamping rectangle
}
Create a camera by specifying the viewport dimensions. On the N64, the standard logical resolution is 288x216:
camera := &gosprite64.Camera{Width: 288, Height: 216}
Drawing with a camera
Pass the camera to scene.Draw each frame:
func (g *Game) Draw() {
gosprite64.ClearScreen()
g.scene.Draw(g.camera)
}
scene.Draw renders only the tiles visible within the camera's viewport. The renderer uses chunk-based culling to skip off-screen regions efficiently. If the camera is nil, the scene uses its default camera (positioned at the origin).
Manual scrolling
Move the camera by updating X and Y directly. The simplegame example scrolls with the D-pad:
func (g *Game) Update() {
speed := 1
if gosprite64.IsButtonDown(gosprite64.ButtonDPadUp) {
g.camera.Y -= speed
}
if gosprite64.IsButtonDown(gosprite64.ButtonDPadDown) {
g.camera.Y += speed
}
if gosprite64.IsButtonDown(gosprite64.ButtonDPadLeft) {
g.camera.X -= speed
}
if gosprite64.IsButtonDown(gosprite64.ButtonDPadRight) {
g.camera.X += speed
}
}
Clamping to map bounds
Without clamping, the camera can scroll past the map edges, showing empty space. There are two approaches to restrict the camera.
Manual clamping
Compute the maximum scroll position from the map's pixel dimensions:
m := g.scene.Map()
maxX := m.PixelWidth() - g.camera.Width
maxY := m.PixelHeight() - g.camera.Height
if g.camera.X < 0 {
g.camera.X = 0
}
if g.camera.Y < 0 {
g.camera.Y = 0
}
if g.camera.X > maxX {
g.camera.X = maxX
}
if g.camera.Y > maxY {
g.camera.Y = maxY
}
Using Camera.Bounds
Set the Bounds field and call ClampToBounds for automatic clamping:
m := g.scene.Map()
g.camera.Bounds = &math2d.Rect{
X: 0, Y: 0,
W: float32(m.PixelWidth()),
H: float32(m.PixelHeight()),
}
// In Update(), after moving the camera:
g.camera.ClampToBounds()
ClampToBounds ensures the viewport stays within the bounds rectangle. It accounts for the viewport size so the right/bottom edges do not exceed the map. If Bounds is nil, the call is a no-op.
Smooth camera follow
To make the camera track a player or other target smoothly, set FollowTarget and FollowSpeed, then call UpdateFollow each frame:
g.camera.FollowTarget = &math2d.Vec2{X: playerX, Y: playerY}
g.camera.FollowSpeed = 0.1 // smooth lerp (1.0 = instant snap)
func (g *Game) Update() {
g.camera.FollowTarget.X = g.playerX
g.camera.FollowTarget.Y = g.playerY
g.camera.UpdateFollow()
g.camera.ClampToBounds()
}
UpdateFollow lerps the camera position toward the target, centering it in the viewport. A speed of 0.1 gives a smooth trailing feel; 1.0 snaps instantly. When the camera is within 1 pixel of the target, it snaps to avoid sub-pixel jitter.
Coordinate conversion
WorldToScreen converts a world position to screen coordinates, accounting for camera position and zoom:
screenX, screenY := g.camera.WorldToScreen(worldX, worldY)
This is useful for placing UI elements or debug overlays relative to world objects.
Zoom
Set the Zoom field to scale the viewport. A zoom of 2.0 means each world pixel occupies 2 screen pixels (zoomed in). The default zoom is 1.0.
g.camera.Zoom = 2.0
EffectiveZoom returns the active zoom level, defaulting to 1.0 when Zoom is zero:
z := g.camera.EffectiveZoom()
Screen shake
Camera shake adds visual impact to events like explosions or hits. The system uses a trauma model where shake magnitude is the square of the trauma value, producing a natural decay.
// On impact:
g.camera.AddTrauma(0.5) // 0.0-1.0, multiple hits accumulate up to 1.0
// Every frame in Update():
g.camera.UpdateShake() // decays trauma over time
// When drawing, apply the shake offset:
shakeX, shakeY := g.camera.ShakeOffset()
// Use shakeX/shakeY as an additional draw offset
AddTrauma(amount)adds to the trauma value, capping at 1.0UpdateShake()decays trauma by 1/60 per frameShakeOffset()returns pixel displacement for the current frame, with a maximum offset of 8 pixels in each direction
Complete example
This is a condensed version of examples/simplegame:
type Game struct {
scene *gosprite64.Scene
camera *gosprite64.Camera
}
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}
}
func (g *Game) Update() {
speed := 1
if gosprite64.IsButtonDown(gosprite64.ButtonDPadUp) { g.camera.Y -= speed }
if gosprite64.IsButtonDown(gosprite64.ButtonDPadDown) { g.camera.Y += speed }
if gosprite64.IsButtonDown(gosprite64.ButtonDPadLeft) { g.camera.X -= speed }
if gosprite64.IsButtonDown(gosprite64.ButtonDPadRight) { g.camera.X += speed }
m := g.scene.Map()
maxX := m.PixelWidth() - g.camera.Width
maxY := m.PixelHeight() - g.camera.Height
if g.camera.X < 0 { g.camera.X = 0 }
if g.camera.Y < 0 { g.camera.Y = 0 }
if g.camera.X > maxX { g.camera.X = maxX }
if g.camera.Y > maxY { g.camera.Y = maxY }
}
func (g *Game) Draw() {
gosprite64.ClearScreen()
g.scene.Draw(g.camera)
stats := g.scene.Stats()
gosprite64.DrawText(fmt.Sprintf("vis:%d", stats.VisibleTiles), 2, 2, gosprite64.White)
}