Introduction

The goal is to build a simplified version of the dinosaur game from Chrome but in the terminal using Go and the Bubbletea CLI library.

Here is the repo: https://github.com/ddahon/bubblesaur

1) Getting started

Let’s start with the skeleton of our Bubbletea program:

package main

import (
	"log"

	tea "github.com/charmbracelet/bubbletea"
)

type model struct{}

func initialModel() model {
	return model{}
}

func (m model) Init() tea.Cmd {
	return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		case "q", "ctrl+c":
			return m, tea.Quit
		}
	}

	return m, nil
}

func (m model) View() string {
	return ""
}

func main() {
	p := tea.NewProgram(initialModel(), tea.WithAltScreen())
	if _, err := p.Run(); err != nil {
		log.Fatal(err)
	}
}

Now we can create our player and add it to the model:

type model struct {
	player player
}

type player struct {
	spriteWidth  int
	spriteHeight int
	spriteChar   rune
}

func initialModel() model {
	player := player{
		spriteWidth:  4,
		spriteHeight: 5,
		spriteChar:   '*',
	}
	return model{
		player: player,
	}
}

And finally display it:

func (p player) view() string {
	row := strings.Repeat(
		string(p.spriteChar),
		p.spriteWidth,
	)
	return strings.Repeat(row+"\n", p.spriteHeight)
}

func (m model) View() string {
	return m.player.view()
}

Great, but our player should be placed on the bottom left. To do that, we must first figure out the size of the terminal window we are in. We can use the “golang.org/x/term” library:

import (
	...
	"golang.org/x/term"
)

type model struct {
	...
	screenHeight int
	screenWidth  int
}

func initialModel() model {
	screenWidth, screenHeight, err := term.GetSize(int(os.Stdin.Fd()))
	if err != nil {
		log.Fatalf("Failed to get terminal size: %s", err)
	}
	...
	
	return model {
		...
		screenHeight: screenHeight,
		screenWidth:  screenWidth,
	}
}

Edit: I found out later that Bubbletea sends a tea.WindowSizeMsg at the start of the program and after each resize, and it contains the width and height of the window.

Now that we have the screen height, let’s use it to display the player at the bottom by adding ‘\n’ padding:

type player struct {
	...
	y float32
}

func initialModel() model {
	...
	player := player {
		...
		y: int(screenHeight),
	}
}

func (m model) View() string {
	y_padding := strings.Repeat("\n", int(m.player.y)-m.player.spriteHeight-1)
	return y_padding + m.player.view()
}

2) Adding movement

The next step is to make the player jump. A jump is composed of the following:

  • Jump key is pressed
  • Player accelerates upwards
  • After reaching top of the jump, player accelerates downwards
  • Player touches the ground

Therefore we need to add:

  • Event handling for jump key
  • Player vertical speed
  • Player position and speed calculation
  • Ground check

Event handling for jump key

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		switch msg.String() {
		...
		case " ":
		  m.player.jump()
		}
	}
}

func (p player) jump() {
	//TODO
}

Player vertical speed

type player struct {
	...
	ySpeed       float32
	jumpSpeed    float32
}

ySpeed keeps track of the player’s current speed and jumpSpeed is the speed applied to the player to actually perform the jump. Let’s use it in our jump function as well:

func (p *player) jump() {
	p.ySpeed = p.jumpSpeed
}

Player position, speed calculation and ground check Now we have to actually use the speed to update the player’s position. To do that, we have to update y as follows:

\(y = y + ySpeed \times \Delta T\) where \(\Delta T = now - lastUpdate\)

Where do we put this update? The thing is, there is no main loop: Bubbletea only updates the view when the model changes or when a message is received.

The simplest way to solve this is to create a goroutine that sends a message to the Bubbletea program periodically:


type tickMsg struct{}

func tick(p *tea.Program, fps int) {
	time.Sleep(time.Duration(1/fps) * time.Second)
	p.Send(tickMsg{})
}

func main() {
	p := tea.NewProgram(initialModel(), tea.WithAltScreen())
	go func() {
		for {
			tick(p, 30)
		}
	}()
	...
}

And then handle the tick message:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	...
	case tickMsg:
		m.mainLoop()
	}
}


func (m *model) mainLoop() {
	deltaT := float32(time.Now().Sub(m.lastTick).Seconds())
	m.lastTick = time.Now()

	// Update player position
	m.player.y += m.player.ySpeed * deltaT * -1
	m.player.y = min(m.player.y, float32(m.screenHeight-1))
	if !m.player.isGrounded(float32(m.screenHeight - 1)) {
		m.player.ySpeed += m.player.gravity * deltaT * -1
	}
}

Great, if we relaunch our game and press space our player should now take off.

Now let’s add gravity in our main loop:

func (m *model) mainLoop() {
	...
	m.player.y = min(m.player.y, float32(m.screenHeight)) // make sure player stops at the ground
	if !m.player.isGrounded(float32(m.screenHeight)) {
		// apply gravity only if not grounded
		m.player.ySpeed += m.player.gravity * deltaT * -1
	}
func (p player) isGrounded(floorHeight float32) bool {
	return p.y == float32(floorHeight)
}

Only jump if on the ground to prevent multiple jumps:

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {
	case tea.KeyMsg:
		...
		case " ":
			if m.player.isGrounded(float32(m.screenHeight)) {
				m.player.jump()
			}
		}
		...
}

And finally add gravity to our player:

type player struct {
	...
	gravity      float32
}

func initialModel() model {
	...
	player := player{
		...
		gravity:      30,
	}

3) Enemies

It’s time to add some enemies to our game. Let’s start by extracting sprite information into a dedicated struct and creating our enemy struct:

type sprite struct {
	width  int
	height int
	char   rune
}

type player struct {
	sprite
	...
}

type enemy struct {
	sprite
	xSpeed int
	x float32
	y float32
}

So, how can we manage our enemies? We need to:

  • Spawn and keep track of the enemies
  • Make them move
  • Delete them when they leave the screen

Spawn and keep track of the enemies Add fields to keep track of spawning and a list of enemies:

type model struct {
	...
	lastTick         time.Time
	lastSpawn        time.Time
	enemies      []enemy
}

Make a spawn function that is called periodically by the main loop:

func (m *model) spawnEnemy() {
	e := enemy{
		sprite: sprite{
			height: 2,
			width:  4,
			char:   'X',
		},
		x: float32(m.screenWidth - 4 - 1),
		xSpeed: -25,
		y:      float32(m.screenHeight),
	}
	m.enemies = append(m.enemies, e)
}

func (m *model) mainLoop() {
	...
	if float32(time.Now().Sub(m.lastSpawn).Seconds()) > m.spawnRateSeconds {
		m.spawnEnemy()
		m.lastSpawn = time.Now()
	}
	...
}

Then, to display the enemies inside the main view function we need a more flexible way to render things. Let’s change our code so we use a 2D array for rendering the screen:

type model struct {
	...
	screen           [][]string
}

func (m *model) resetScreen() {
	for i := 0; i < m.screenHeight; i++ {
		m.screen[i] = make([]string, m.screenWidth)
		for j := 0; j < m.screenWidth; j++ {
			m.screen[i][j] = " "
		}
	}
}

func (s sprite) render(screen [][]string, x int, y int) {
	for i := 0; i < s.width; i++ {
		for j := 0; j < s.height; j++ {
			screen[y-j][x+i] = string(s.char)
		}
	}
}

func (m model) View() string {
	m.resetScreen()
	m.player.sprite.render(m.screen, 0, int(m.player.y))
	res := ""
	for i := 0; i < m.screenHeight; i++ {
		res += strings.Join(m.screen[i], "") + "\n"
	}
	return res
}

Note that the order of indices of the array is inverted regarding to coordinates, screen[y][x] contains the symbol at (x, y). That way, screen contains the list of lines, which is easier to render than the list of columns.

Finally, we can render enemies:

func (m model) View() string {
	...
	for i := 0; i < len(m.enemies); i++ {
		m.enemies[i].sprite.render(m.screen, int(m.enemies[i].x), m.screenHeight-1)
	}
	...
}

Make them move Add enemy position update in the main loop, same as for the player:

func (m *model) mainLoop() {
	...
	// Update enemies position
	for i := 0; i < len(m.enemies); i++ {
		m.enemies[i].x += m.enemies[i].xSpeed * deltaT
	}
}

Now we see enemies coming towards our player: todo gif

Delete them when they leave the screen Add a check before updating the position to see if it will go beyond the screen:

func (m *model) mainLoop() {
	deltaT := float32(time.Now().Sub(m.lastTick).Seconds())
	m.lastTick = time.Now()

	// Update player position
	m.player.y += m.player.ySpeed * deltaT * -1
	m.player.y = min(m.player.y, float32(m.screenHeight-1))
	if !m.player.isGrounded(float32(m.screenHeight - 1)) {
		m.player.ySpeed += m.player.gravity * deltaT * -1
	}

	// Update enemies position
	for i := 0; i < len(m.enemies); i++ {
		if m.enemies[i].x < m.enemies[i].xSpeed*deltaT {
			m.enemies = slices.Delete(m.enemies, i, i+1)
			continue
		}
		m.enemies[i].x += m.enemies[i].xSpeed * deltaT
	}
}

4) Score

Score Add score to our model:

type model struct {
	...
	score        float
}

Update score in main loop:

func (m *model) mainLoop() {
	...
	m.score += deltaT * 10
}

And finally display it:

func (m model) View() string {
	...
	scoreDisplay := fmt.Sprintf("Score: %v", int(m.score))
	for i, v := range strings.Split(scoreDisplay, "") {
		m.screen[1][i] = v
	}
	...	
}

Gameover First let’s detect collisions between player and enemy in our game loop:

func (e enemy) collidesWith(p player) bool {
	return e.x <= float32(p.width) && p.y >= e.y-float32(e.height)
}

func (m *model) mainLoop() {
	...
	for i := 0; i < len(m.enemies); i++ {
		if m.enemies[i].x < m.enemies[i].xSpeed*deltaT {
			m.enemies = slices.Delete(m.enemies, i, i+1)
			continue
		}
		m.enemies[i].x += m.enemies[i].xSpeed * deltaT
		if m.enemies[i].collidesWith(m.player) {
			m.gameOver = true
		}
	}

Create a gameOver field for the model and stop increasing score when game is over:

type model struct {
	...
	gameOver bool
}

func (m *model) mainLoop() {
	...
	if !gameOver {
		m.score += deltaT * 10
	}
}

Finally display game over message:

func (m model) View() string {
	m.resetScreen()
	if m.gameOver {
		return fmt.Sprintf("Game Over :( Your score: %v.\nPress q to quit", int(m.score))
	}
	...
}

5) Polishing

The game is currently not challenging, to fix that let’s increase the horizontal speed over time:

type model struct {
	...
	xSpeed       float32
}

func (m *model) spawnEnemy() {
	e := enemy{
		...
		xSpeed: m.xSpeed,
	}
	...
}

func (m *model) mainLoop() {
	...
	m.xSpeed += -0.1 * deltaT
}

And finally let’s do the same for the spawn rate:

func (m *model) mainLoop() {
	...
	m.spawnRateSeconds += -0.05 * deltaT
}

6) Conclusion

It needs some (a lot) more polishing but we have the basic functionality: