Starting Position of TinyPong

TinyPong start position

Last week I got the micro:bit-v2 and the first thing I did was put Pong on it. I wrote the game in Go and compiled it with TinyGo (a Go compiler optimized for microcontrollers and other small contexts – and yes, garbage collection is supported!).

Gameplay Link to heading

The game is played between a human player and a naive computer opponent. A player scores a point by causing the ball to pass the other’s paddle, at which point the player who scored the point gets to serve the ball. The game continues until one player accumulates 5 points, then the players’ scores are displayed as a line of lit LEDs (one LED per point) on their side of the grid.

Score display after a game of TinyPong

A score of 5-3 after a game of TinyPong

The computer moves its paddle back and forth at a consistent rate and does not try to follow the ball when flung in its direction. When the computer gets the ball, it releases it immediately, without strategy (this creates conditions for the computer to score continuously).

The human player moves their paddle using the A button and releases the ball with the B button. Currently the player can only move in one direction until they hit the edge of the grid.

The Code Link to heading

TinyPong exists in a single source file, which I’ve split into pieces for ease of explanation. The source code is available if you’d like to read it as a continuous block, play with the code, or flash TinyPong to your own micro:bit-v2!

Imports Link to heading

For the game we need time from the Go standard library (to slow down the frequency of updating the game loop), TinyGo’s machine package to access the buttons, and the TinyGo microbitmatrix drivers to access individual LEDs in the grid.

import (
	"time"

	"github.com/tinygo-org/tinygo/src/machine"
	"tinygo.org/x/drivers/microbitmatrix"
)

Main Function Link to heading

The main function starts with defining and configuring the LED grid, the input buttons, and initializing the two player paddles and ball (which will be defined later on).

func main() {
	// set LED display
	display := microbitmatrix.New()
	display.Configure(microbitmatrix.Config{})

	// set buttons
	bta := machine.BUTTONA
	bta.Configure(machine.PinConfig{Mode: machine.PinInputPullup})
	btb := machine.BUTTONB
	btb.Configure(machine.PinConfig{Mode: machine.PinInputPullup})

	// set players, ball
	h := NewPlayer(0, 2)
	h.ball = true
	m := NewPlayer(4, 2)
	b := NewBall()

The first loop is the game loop, which is updated every 100000 microseconds. If the time.Since() check were not included, the game would go impossibly fast for a human player! I settled on this update rate because it feels reasonable, but it could be fun to change this speed as the game progresses.

The main game loop continues until one of the players scores 5 points. In the loop, the first thing that is checked is whether the ball has gone offscreen: if it has, the players’ positions are reset, and the player who caused the ball to go offscreen gets a point and receives the ball. Once this is resolved the computer opponent moves, and releases the ball if holding it. Then button B input is checked,

Main game loop:

  • first, check if the ball has gone off-screen. If it has:
    • players’ positions are reset
    • a point is assigned to the player who caused the ball to go offscreen, and
    • that player is given the ball
  • once this is resolved, the computer opponent moves (and releases the ball if they have it)
  • next, check if button B is pressed (if so, human player release ball if they have it)
  • if neither player has the ball, it is free to move and there is a check for whether it hits a paddle
  • then check if button A is pressed (if so, human player moves – direction cannot be changed at this time – and if they are holding the ball, the ball moves with them)

After all of these logic checks and updates, and still inside the loop, the grid is cleared and the pixels for the two player paddles and the ball are set and displayed.

	// game loop
	t := time.Now()
	for h.score < 5 && m.score < 5 {
		if time.Since(t).Microseconds() >= 100000 {
			// reset time
			t = time.Now()

			// check if ball is offscreen
			if b.Offscreen() {
				// reset player positions
				m.loc[1] = 2
				h.loc[1] = 2
				// add to score
				// assign ball to player who scored
				if b.loc[0] == -1 {
					m.score++
					m.Get(b)
					b.loc[0] = 3
				}
				if b.loc[0] == 5 {
					h.score++
					h.Get(b)
					b.loc[0] = 1
				}
			}
			
			//if ball isn't held, move the ball
			if !m.ball && !h.ball {
				b.Move()
				// if ball hits paddle, reverse both dirs
				b.Hit(m, h)
			}
			
			// move machine player, release ball if machine carrying
			m.Move()
			if m.ball == true {
				m.ball = false
			}
			
			// if button b is pressed, release ball
			if !btb.Get() {
				h.ball = false
			}
			
			// if button a is pressed move human player (and ball if held)
			if !bta.Get() {
				h.Move()
				if h.ball == true {
					h.Carry(b)
				}
			}
			// clear display and set pixels for both paddles and ball
			display.ClearDisplay()
			display.SetPixel(h.loc[0], h.loc[1], microbitmatrix.BrightnessFull)
			display.SetPixel(m.loc[0], m.loc[1], microbitmatrix.BrightnessFull)
			display.SetPixel(b.loc[0], b.loc[1], microbitmatrix.BrightnessFull)
		}
		// display pixel grid
		display.Display()
	}

After either player accumulates 5 points, the game ends and the second loop (which displays the scores) begins. This is an infinite loop, as the scores should be displayed until the game is reset or the device is powered off. Once again the display is cleared, and for each point that each player scored, an LED is set to full brightness. After all the point LEDs are set, they are displayed on each player’s side of the grid.

	for {
		display.ClearDisplay()
		for i := 0; i < int(h.score); i++ {
			display.SetPixel(1, int16(i), microbitmatrix.BrightnessFull)
		}
		for i := 0; i < int(m.score); i++ {
			display.SetPixel(3, int16(i), microbitmatrix.BrightnessFull)
		}
		display.Display()
	}
}

Player struct and methods Link to heading

The player struct is used for both the human and computer players. It holds the x,y coordinates of the paddle, which direction the paddle is moving, the player’s score, and whether they are holding the ball.

type Player struct {
	loc   [2]int16
	dir   int16
	score int16
	ball  bool
}

func NewPlayer(x, y int16) *Player {
	p := &Player{
		loc: [2]int16{x, y},
		dir: 1,
	}
	return p
}

Currently the player paddle moves in the direction it is already moving and only changes direction when it hits the edge of the grid. If the player is holding the ball, they will carry it (updating its location), and when the player hits the edge of the grid the ball is moved to the other side of the player and the direction it will launch is reversed.

func (p *Player) Move() {
	if p.loc[1] == 0 || p.loc[1] == 4 {
		p.dir *= -1
	}
	p.loc[1] += p.dir
}

func (p *Player) Carry(b *Ball) {
	if p.loc[1] == 0 || p.loc[1] == 4 {
		b.dir[1] = p.dir * -1
	}
	b.loc[1] += b.dir[1]
}

After a point is scored, both players’ positions are reset to the middle and one player gets the ball. The ball’s horizontal direction is set to be the same as the player’s and the ball is offset by one in that direction.

func (p *Player) Get(b *Ball) {
	p.ball = true
	b.loc[1] = p.loc[1] + p.dir
	b.dir[1] = p.dir
}

func (p *Player) Reset() {
	p.loc[1] = 2
}

Ball struct and methods Link to heading

The Ball struct keeps track of the ball’s x,y coordinates and which directions it is moving on each of those axes. When a new ball is initialized at the beginning of the game, it always belongs to the human player (this could be randomized later if desired).

type Ball struct {
	loc [2]int16
	dir [2]int16
}

func NewBall() *Ball {
	b := &Ball{
		loc: [2]int16{1, 3},
		dir: [2]int16{1, 1},
	}
	return b
}

Movement in either direction is either 1 or -1, so when the ball hits the edge of the grid or a paddle, the direction just needs to be multiplied by -1 to be reversed.

func (b *Ball) Move() {
	if b.loc[1] == 0 || b.loc[1] == 4 {
		b.dir[1] *= -1
	}
	b.loc[0] += b.dir[0]
	b.loc[1] += b.dir[1]
}

func (b *Ball) Hit(m, h *Player) {
	if (b.loc[0] == 1 && b.loc[1] == h.loc[1]) ||
		(b.loc[0] == 3 && b.loc[1] == m.loc[1]) {
		b.dir[0] *= -1
		b.dir[1] *= -1
	}
}

The ball can only go offscreen when a point is scored, so only the y location needs to be checked.

func (b *Ball) Offscreen() bool {
	if b.loc[0] == -1 || b.loc[0] == 5 {
		return true
	}
	return false
}

Some Nice Additions Link to heading

Although the game is playable, it could be made more fun and interesting. Here are some aspects of the game I would consider changing or extending:

  • Give the Computer Opponent Strategy
    • have the computer track the ball and attempt to move where it can hit the ball back
    • have computer track human paddle and attempt to server where human cannot reach
  • Better Human Player Control
    • allow human player to change direction before hitting the edge of the grid, likely with the B button
  • Speed Increase over Time
    • start the game with a slower update speed, but increment it as the game progresses until it goes impossibly fast. Given the small grid size, this may not be that enjoyable, or speed may not increase much before the game ends.
  • 2 Player Option (with menu to select mode)
    • Incompatible with the last point, two humans could play the game by controlling each paddle with just A or B. This would probably be a terrible play experience between limited input and crowding around the device :)
  • Opening Animation (this is just a bit of gloss)

This is the first time I’ve written code for a tiny computer. It’s very satisfying to write code that makes things happen when you press a button or causes LEDs to light up! I think I may need to acquire a few more boards.