Bunyip a game engine in Go GitHub

Example examples/physics2d

Physics 2D

Physics 2D

This program is the 2D half of phys. Static walls make a pit, a rotated static box makes a ramp, a kinematic paddle sweeps back and forth, a car on two sprung wheel joints drives up and down the floor, a circular trigger tints whatever passes through it, and two dozen dynamic circles, boxes and triangles fall into the pile. A ray is cast from the top left corner to the pointer every update and the entity it hits is named.

Physics in Bunyip is a set of components on the entity component system. A body is phys.Body2, a shape is phys.Collider2, and the position they act on is gfx.Transform2, the same component the drawing reads. The simulation is a system registered on the world, and the game steps it by calling world.Update. Names carry the dimension, so Body2 and Box2 are the 2D forms and Circle has no suffix because it exists in one dimension only. The physics guide covers the model, and the entities and systems guide the world.

Run it:

go run ./examples/physics2d -seconds 3 -shot out.png

The flags are -seconds N and -shot file.png. A left click drops a new random shape at the pointer, R resets the scene and Escape quits.

Package and state

look is the game's own component: the colour to draw a collider in, and a value that fades after the entity has touched the trigger. Storing it as a component rather than in a map keeps it in the same tables as the transform, so one query reads all three.

ecs.Query3 is a query over three component types, cached on the world. It is created once and walked every frame.

// Command physics2d shows the 2D rigid bodies: balls, boxes and a
// triangle fall into a pit, a kinematic paddle sweeps through them, a
// car on sprung wheel joints drives back and forth along the floor, a
// trigger zone tints whatever enters it, and a raycast from the corner
// to the pointer reports what it hits. Click to drop more shapes at the
// pointer; R resets; Escape quits.
package main

import (
	"flag"
	"fmt"
	"image"
	"image/color"
	"math"
	"os"

	"golang.org/x/image/font/gofont/goregular"

	"github.com/matjam/bunyip/ecs"
	"github.com/matjam/bunyip/engine"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/input"
	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/phys"
	"github.com/matjam/bunyip/rng"
)

// look says how to draw an entity's collider.
type look struct {
	Color gfx.Color
	Hot   float32 // fades after touching the trigger
}

type game struct {
	seconds float64
	shot    string

	font    *gfx.Font
	white   *gfx.Texture
	dot     *gfx.Texture
	world   *ecs.World
	shapes  *ecs.Query3[gfx.Transform2, phys.Collider2, look]
	random  *rng.Rand
	paddle  ecs.Entity
	trigger ecs.Entity
	car     ecs.Entity
	wheels  [2]ecs.Entity
	axles   [2]ecs.Entity // the wheel joints, whose motors drive the car
	forward bool
	hit     string
	rayEnd  lin.Vec2

	shotDone bool
}

// Collision layers: the car's own parts pass through each other and
// through nothing else.
const (
	layerWorld  = 1
	layerFrame  = 2
	layerWheels = 4
)

The car is five entities: a chassis, two wheels and the two joints holding them on. The joints are kept because their motors are what drives the car, and a joint is an ordinary entity with a joint component, so driving means fetching that component and writing a field.

The three layer constants are a bitmask scheme rather than an enumeration: each is a single bit, so a collider's Mask can name a set of layers. layerWorld is everything that is not part of the car.

Init: the textures

Two textures cover every shape here: a two by two white image stretched into rectangles, and a soft-edged disc for circles. The disc asks for Linear: true so it stays smooth when it is scaled up. rng.New(9) seeds the shape generator so a screenshot of the pile is reproducible.

func (g *game) Init(ctx *engine.Context) error {
	var err error
	if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 15, gfx.FontOptions{}); err != nil {
		return err
	}
	white := image.NewRGBA(image.Rect(0, 0, 2, 2))
	for i := range white.Pix {
		white.Pix[i] = 255
	}
	if g.white, err = ctx.Gfx.NewTexture(white, gfx.TextureOptions{}); err != nil {
		return err
	}
	if g.dot, err = ctx.Gfx.NewTexture(circle(64), gfx.TextureOptions{Linear: true}); err != nil {
		return err
	}
	g.random = rng.New(9)
	g.reset(ctx)
	return nil
}

Building the scene

reset builds a whole world from nothing, which is why R can restart the scene by calling it again.

phys.Settings2 is a resource on the world rather than a component: one value the simulation reads. Gravity is positive on Y because 2D view units run downwards from the top left. Substeps splits each step into smaller integrations and Iterations is the solver's passes per substep; both trade time for a stiffer pile.

The three walls and the ramp are colliders with no body, which is what makes them static: they collide and never move. The paddle is phys.Kinematic2, a body that is moved by setting its velocity and is not pushed back by what it hits. The trigger is a collider with Trigger: true, so overlaps are reported as events and never resolved.

w.SpawnWith creates an entity with the components given. gfx.At2 is the short form of a gfx.Transform2 at a position with no rotation.

Two systems are registered. phys.System2 is the simulation. The second is a closure over the game: it reads this step's phys.Trigger2 events, lights up the entity on the other side of each overlap, and fades every Hot value towards zero. Events are drained per step, so a system reads them by asking the world rather than by subscribing.

func (g *game) reset(ctx *engine.Context) {
	w := ecs.NewWorld()
	g.world = w
	g.shapes = w.Query3[gfx.Transform2, phys.Collider2, look]()
	// Screen space: y grows downward, so gravity is positive, in pixels/s².
	w.SetResource(phys.Settings2{Gravity: lin.V2(0, 900), Substeps: 4, Iterations: 10})
	W, H := ctx.Width, ctx.Height
	wallLook := look{Color: gfx.RGB(70, 74, 90)}
	w.SpawnWith(gfx.At2(W/2, H-20), phys.Collider2{Shape: phys.Box2{HalfW: W / 2, HalfH: 20}}, wallLook)
	w.SpawnWith(gfx.At2(20, H/2), phys.Collider2{Shape: phys.Box2{HalfW: 20, HalfH: H / 2}}, wallLook)
	w.SpawnWith(gfx.At2(W-20, H/2), phys.Collider2{Shape: phys.Box2{HalfW: 20, HalfH: H / 2}}, wallLook)
	// A ramp: a static rotated box.
	w.SpawnWith(gfx.Transform2{Position: lin.V2(W*0.25, H*0.55), Rotation: 0.35}, phys.Collider2{Shape: phys.Box2{HalfW: 160, HalfH: 12}}, wallLook)
	// A kinematic paddle sweeps back and forth; bodies ride it.
	paddle := phys.Kinematic2()
	g.paddle = w.SpawnWith(gfx.At2(W*0.7, H*0.7), paddle, phys.Collider2{Shape: phys.Box2{HalfW: 90, HalfH: 10}}, look{Color: gfx.RGB(255, 200, 90)})
	// A trigger zone: overlaps are reported, never resolved.
	g.trigger = w.SpawnWith(gfx.At2(W*0.5, H*0.3), phys.Collider2{Shape: phys.Circle{Radius: 60}, Trigger: true}, look{Color: gfx.RGBA(120, 200, 255, 60)})
	// The chassis rides an axle drop above the wheels, which rest on the
	// floor whose top is 40 below the bottom of the view.
	g.buildCar(w, lin.V2(140, H-66))
	for i := range 24 {
		g.spawn(lin.V2(W*0.3+float32(i%6)*50, 60+float32(i/6)*60))
	}
	w.AddSystem("physics", phys.System2)
	// Entities that enter the trigger light up.
	w.AddSystem("trigger", func(w *ecs.World, dt float64) {
		for _, ev := range w.Events[phys.Trigger2]() {
			if l, ok := w.Get[look](ev.Other); ok {
				l.Hot = 1
			}
		}
		g.shapes.Each(func(e ecs.Entity, _ *gfx.Transform2, _ *phys.Collider2, l *look) {
			l.Hot = max(0, l.Hot-float32(dt))
		})
	})
}

The car: two wheel joints as a suspension

buildCar is the whole vehicle. A phys.WheelJoint2 is a wheel on a suspension: A is the chassis, B is the wheel, the wheel spins freely about its centre, and the centre is free to slide along Axis in the chassis frame. That one joint is doing two jobs a game would otherwise build from a slider and a hinge.

AnchorA is where the wheel sits when the suspension is at rest, given in the chassis frame, which is why the parts are spawned at exactly that offset: spawning them where the joint wants them means the springs start at rest rather than snapping the car into shape on the first step.

The spring is described in physical terms rather than as a stiffness. Frequency: 5 is five hertz, how often the suspension would bounce a second if it were undamped, and DampingRatio: 0.8 is a fraction of critical damping, where 1 settles without overshooting. Describing it this way means the response does not change when the chassis mass changes: an eight-kilogram body and a one-kilogram body on the same numbers behave the same.

MaxMotorTorque: 60000 gives the axles something to push with, but no MotorSpeed is set here; drive sets that every update, which is what steers the car.

The layers keep the car from fighting itself. The chassis is on layerFrame and the wheels on layerWheels, and both mask only layerWorld, so neither collides with the other or with the other wheel: the joint holds them together, and a contact between them would only jitter. Everything else in the scene leaves Layers zero, which collides with everything.

// buildCar puts a chassis on two wheels held by WheelJoint2 springs,
// with the axle motors driving the wheels. The wheel's centre is where
// the joint's AnchorA in the chassis frame points, so the suspension
// rests at the height the parts were spawned at.
func (g *game) buildCar(w *ecs.World, at lin.Vec2) {
	const (
		halfW  = 34.0 // chassis half width
		halfH  = 10.0 // chassis half height
		radius = 14.0 // wheel radius
		axleX  = 22.0 // wheel offset from the chassis centre
		axleY  = 12.0 // wheel drop below the chassis centre
	)
	frame := phys.Dynamic2(8)
	frame.Friction = 0.6
	g.car = w.SpawnWith(gfx.Transform2{Position: at}, frame,
		phys.Collider2{Shape: phys.Box2{HalfW: halfW, HalfH: halfH},
			Layers: phys.Layers{Layer: layerFrame, Mask: layerWorld}},
		look{Color: gfx.RGB(230, 110, 110)})
	for i, dx := range [2]float32{-axleX, axleX} {
		wheel := phys.Dynamic2(1)
		wheel.Friction = 0.9
		g.wheels[i] = w.SpawnWith(gfx.Transform2{Position: at.Add(lin.V2(dx, axleY))}, wheel,
			phys.Collider2{Shape: phys.Circle{Radius: radius},
				Layers: phys.Layers{Layer: layerWheels, Mask: layerWorld}},
			look{Color: gfx.RGB(24, 26, 34)})
		g.axles[i] = w.SpawnWith(phys.WheelJoint2{A: g.car, B: g.wheels[i],
			AnchorA: lin.V2(dx, axleY), Axis: lin.V2(0, 1),
			Frequency: 5, DampingRatio: 0.8, MaxMotorTorque: 60000})
	}
	g.forward = true
}

The wheels' high friction, 0.9 against the chassis's 0.6, is what turns motor torque into motion. A motor that spins a frictionless wheel moves nothing; the car pulls itself along the floor through the wheel contacts, exactly as a real one does, so a slippery floor would leave it spinning its wheels in place.

drive reverses at either end and writes MotorSpeed on both axles. Nothing here applies a force to the chassis: the motors turn the wheels, the wheels grip the floor, and the car follows. The comment about the sign is worth reading twice, because 2D view units put Y downwards, which flips the sense of a positive angle: a wheel turning the way its angle grows rolls to the right, not the left.

// drive turns the wheels, reversing when the car nears a wall. Screen
// coordinates grow downward, so a wheel turning the way the angle grows
// rolls to the right.
func (g *game) drive(ctx *engine.Context) {
	t, ok := g.world.Get[gfx.Transform2](g.car)
	if !ok {
		return
	}
	if t.Position.X > ctx.Width-120 {
		g.forward = false
	} else if t.Position.X < 120 {
		g.forward = true
	}
	speed := float32(16)
	if !g.forward {
		speed = -16
	}
	for _, e := range g.axles {
		if j, ok := g.world.Get[phys.WheelJoint2](e); ok {
			j.MotorSpeed = speed
		}
	}
}

ecs.World.Get returns a pointer into the component's table, so writing j.MotorSpeed changes the joint the solver will read this step. The ok on the transform lookup checks that the entity has that component in the current world. Handles are scoped to a world: the same numeric handle can resolve to a different entity in a new world. reset therefore also replaces the stored car handles.

Dropping shapes

spawn drops one random shape. phys.Dynamic2(1) is a body of mass one; Restitution is bounciness from zero to one and Friction resists sliding. The shape is one of phys.Circle, phys.Box2 or phys.Polygon2, all of which satisfy phys.Shape2. A polygon's points are in the body's own frame and must be convex, in either winding order; the triangle here is built around the origin so it spins about its middle. Rotation is in radians.

// spawn drops a random shape at p.
func (g *game) spawn(p lin.Vec2) {
	body := phys.Dynamic2(1)
	body.Restitution = g.random.Between(0, 0.5)
	body.Friction = 0.4
	c := gfx.RGB(uint8(120+g.random.Intn(120)), uint8(120+g.random.Intn(120)), uint8(120+g.random.Intn(120)))
	var shape phys.Shape2
	switch g.random.Intn(3) {
	case 0:
		shape = phys.Circle{Radius: g.random.Between(12, 24)}
	case 1:
		shape = phys.Box2{HalfW: g.random.Between(12, 28), HalfH: g.random.Between(12, 28)}
	default:
		r := g.random.Between(18, 30)
		shape = phys.Polygon2{Points: []lin.Vec2{{X: 0, Y: -r}, {X: r * 0.87, Y: r * 0.5}, {X: -r * 0.87, Y: r * 0.5}}}
	}
	g.world.SpawnWith(gfx.Transform2{Position: p, Rotation: g.random.Float() * 6}, body, phys.Collider2{Shape: shape}, look{Color: c})
}
func (g *game) Shutdown(ctx *engine.Context) {
	g.dot.Destroy()
	g.white.Destroy()
	g.font.Destroy()
}

Update: input, the paddle and the ray

Update runs at the fixed step, which is what the simulation needs: a fixed ctx.Delta keeps the numerical step independent of rendering speed. It does not by itself guarantee identical results across machines.

The paddle is driven by writing its velocity. ecs.World.Get returns a pointer to the component in its table, so assigning through it changes the world's copy. A kinematic body ignores forces, so a velocity written here holds until it is written again, and bodies resting on the paddle are carried along by the contact.

The paddle and the car are the two ways to move something, side by side. The paddle is told where to go and nothing can argue with it; the car is given torque at its wheels and has to find traction. A kinematic body suits a lift or a moving platform, whose path the game decides; a driven body suits anything that should be stopped by a wall it drives into.

Both are set before world.Update, because the step reads what is in the components at the moment it runs.

g.world.Update(ctx.Delta) runs the registered systems in order, which is where the simulation actually steps.

The raycast comes after the step, so it sees the positions the frame will draw. phys.Ray2 is an origin and a direction whose length is the distance to search; the final argument of phys.Raycast2 is a layer mask, and zero means every layer. Triggers are never hit by a ray. The hit carries the entity, the point, the surface normal and the distance, and the drawing uses the point to stop the line where it struck.

func (g *game) Update(ctx *engine.Context) error {
	in := ctx.Input
	if in.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	if in.KeyPressed(input.KeyR) {
		g.reset(ctx)
	}
	if in.MousePressed(input.MouseLeft) {
		x, y := in.Mouse()
		g.spawn(lin.V2(float32(x), float32(y)))
	}
	if b, ok := g.world.Get[phys.Body2](g.paddle); ok {
		b.Vel = lin.V2(220*float32(math.Sin(ctx.Time*0.8)), 0)
	}
	g.drive(ctx)
	g.world.Update(ctx.Delta)
	// A raycast from the corner to the pointer.
	x, y := in.Mouse()
	ray := phys.Ray2{Origin: lin.V2(40, 40), Dir: lin.V2(float32(x)-40, float32(y)-40)}
	if hit, ok := phys.Raycast2(g.world, ray, 0); ok {
		g.hit = fmt.Sprintf("ray hits %v at (%.0f, %.0f)", hit.Entity, hit.Point.X, hit.Point.Y)
		g.rayEnd = hit.Point
	} else {
		g.hit = "ray hits nothing"
		g.rayEnd = ray.Origin.Add(ray.Dir)
	}
	return nil
}

Draw: the shapes

One walk of the query draws everything. The Each callback is handed pointers into the component tables, so no copying happens and the values seen are the ones the simulation just wrote.

A collider's shape is an interface value, so a type switch decides how to draw it. t.Apply takes a sprite and returns it positioned and rotated by the transform, which keeps the drawing in step with the body without recomputing the matrix. UV1: lin.V2(1, 1) uses the whole texture. Triangles are drawn as three lines, rotating each point by the transform's own angle. This example chooses outlines; gfx.FillPolygon can draw a filled triangle instead.

The wheels get a second pass afterwards, drawing a diameter through each one. A disc drawn from a texture looks identical however fast it turns, so without the spoke there is nothing on screen to show that the motors are doing anything; with it, a stopped car and a car spinning its wheels against a wall look different.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	g.shapes.Each(func(e ecs.Entity, t *gfx.Transform2, c *phys.Collider2, l *look) {
		col := l.Color
		if l.Hot > 0 {
			col = gfx.Color{R: min(1, col.R+l.Hot), G: col.G, B: col.B, A: col.A}
		}
		switch s := c.Shape.(type) {
		case phys.Circle:
			gr.Draw(g.dot, t.Apply(gfx.Sprite{Size: lin.V2(2*s.Radius, 2*s.Radius), UV1: lin.V2(1, 1), Color: col}))
		case phys.Box2:
			gr.Draw(g.white, t.Apply(gfx.Sprite{Size: lin.V2(2*s.HalfW, 2*s.HalfH), UV1: lin.V2(1, 1), Color: col}))
		case phys.Polygon2:
			cs, sn := float32(math.Cos(float64(t.Rotation))), float32(math.Sin(float64(t.Rotation)))
			n := len(s.Points)
			for i := range n {
				a, b := s.Points[i], s.Points[(i+1)%n]
				wa := t.Position.Add(lin.V2(cs*a.X-sn*a.Y, sn*a.X+cs*a.Y))
				wb := t.Position.Add(lin.V2(cs*b.X-sn*b.Y, sn*b.X+cs*b.Y))
				g.segment(gr, wa, wb, 3, col)
			}
		}
	})
	// A spoke on each wheel, so the drive is visible.
	for _, e := range g.wheels {
		t, ok := g.world.Get[gfx.Transform2](e)
		c, ok2 := g.world.Get[phys.Collider2](e)
		if !ok || !ok2 {
			continue
		}
		r := c.Shape.(phys.Circle).Radius
		dir := lin.V2(float32(math.Cos(float64(t.Rotation))), float32(math.Sin(float64(t.Rotation))))
		g.segment(gr, t.Position.Sub(dir.Mul(r)), t.Position.Add(dir.Mul(r)), 3, gfx.RGB(200, 200, 215))
	}
	g.segment(gr, lin.V2(40, 40), g.rayEnd, 2, gfx.RGBA(255, 255, 120, 200))
	gr.DrawText(g.font, g.hit+"; click to drop shapes, R resets", 48, 30, gfx.RGB(230, 230, 240))
	gr.DrawText(g.font, fmt.Sprintf("%d bodies", g.world.Count[phys.Body2]()), 48, 52, gfx.RGB(170, 170, 190))
	return nil
}

segment draws a line as a thin rectangle: the midpoint is the sprite's position, the length its width and the angle between the endpoints its rotation. It is the smallest way to draw a line with the sprite path. gfx also has a vector path API, which the vector example uses.

// segment draws a line as a thin rotated rectangle.
func (g *game) segment(gr *gfx.Graphics, a, b lin.Vec2, thick float32, col gfx.Color) {
	d := b.Sub(a)
	t := gfx.Transform2{Position: a.Add(b).Mul(0.5), Rotation: float32(math.Atan2(float64(d.Y), float64(d.X)))}
	gr.Draw(g.white, t.Apply(gfx.Sprite{Size: lin.V2(d.Len(), thick), UV1: lin.V2(1, 1), Color: col}))
}

circle builds the disc texture: white everywhere, with the alpha falling off over the last unit of the radius, which is a cheap antialiased edge. It returns an image.NRGBA, unpremultiplied, and NewTexture premultiplies it in linear light on the way to the GPU.

func circle(size int) image.Image {
	img := image.NewNRGBA(image.Rect(0, 0, size, size))
	r := float64(size) / 2
	for y := range size {
		for x := range size {
			d := math.Hypot(float64(x)+0.5-r, float64(y)+0.5-r)
			a := math.Max(0, math.Min(1, r-d))
			img.SetNRGBA(x, y, color.NRGBA{255, 255, 255, uint8(255 * a)})
		}
	}
	return img
}

main

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip physics 2D", Width: 960, Height: 640},
		&game{seconds: *seconds, shot: *shot})
	if err != nil {
		fmt.Fprintln(os.Stderr, "physics2d:", err)
		os.Exit(1)
	}
}

What to try

  • Raise Substeps and Iterations in reset and watch the pile settle harder and the frame cost rise.
  • Set Restitution to 0.9 in spawn and drop a few shapes onto the ramp.
  • Make the trigger a solid collider by removing Trigger: true in reset, and see the overlap resolved instead of reported.
  • Give the paddle a vertical velocity as well in Update and watch bodies ride it.
  • Drop Frequency in buildCar to 1 and watch the car wallow, then raise it to 20 for a suspension that barely gives. Setting it to 0 leaves the suspension axis free, without a spring restoring its offset.
  • Set DampingRatio to 0.05 and drive the car over the debris; the bouncing takes a long time to die away.
  • Cut MaxMotorTorque in buildCar to 600 and watch the car struggle up the ramp, or set the wheels' Friction to 0.05 and watch them spin without moving it.
  • Give the car's chassis and wheels the same layer in buildCar and see what a joint holding two colliding bodies together does.
  • Cast the ray from the pointer in a direction in Update rather than towards it, and draw the surface normal the hit reports.

Source files

main.go

The whole directory on GitHub