Building Pong in Your Terminal: Part Two

35 minute read     Updated:

Josh Alletto %
Josh Alletto

In this Series

Discover how to create a Pong game in a terminal using Golang. Earthly guarantees consistent builds for your Go-powered Pong game across any development environment. Learn more about Earthly.

I’ve been trying to learn Golang recently. As a side project I’ve been building a version of Pong that you can play in your terminal. In Part One I showed how to use the tcell library to get a bouncing ball working in our terminal. In this second part, I’ll finish up the game by adding paddles, players, a score and some game logic.

The complete code for part one and two..

Paddles

Create a file called paddle.go. Our paddle will be similar to our ball in that it will need an X and a Y position, and also a speed. We’ll only be moving the paddles along the Y axis so for now we’ll just set up a Yspeed.


package main

type Paddle struct {
    X      int
    Y      int
    Yspeed int
}

Then we need to update our Game struct to use the paddles. To make things easier to keep track of, we’ll call these two paddles Player1 and Player2, even though they will be instances of Paddle.


type Game struct {
    Screen tcell.Screen
    Ball   Ball
    Player1 Paddle
    Player2 Paddle
}

And then we can create paddles and add them to our instance of Game inside our main.go file. Note we need to get the screen width in order to place the right paddle. Remember screen is a tcell.Screen


    width, _ := screen.Size()

    player1 := Paddle {
        width:  1,
        height: 6,
        Y:      3,
        X:      5,
        Yspeed: 3,
    }

    player2 := Paddle {
        width:  1,
        height: 6,
        Y:      3,
        X:      width - 5,
        Yspeed: 3,
    }

    game := Game{
        Screen:  screen,
        Ball:    ball,
        Player1: player1,
        Player2: player2,
    }

Also similar to our ball, we’ll need a way to display the paddle. But this presents a problem. Moving forward, let’s choose to get excited by problems. Deal? Good, because this actually presents a bunch of problems. We’ll tackle them one at a time, but first, a quick review of how our ball object works.

Displaying Multiple Characters

Here is the code to display the ball:


func (b *Ball) Display() rune {
    return "\u25CF"
}

It returns the unicode for a round dot that looks like the ball from Pong. Then, in our event loop in our game.go file, we can use the SetContent function to display it.


    screen.SetContent(g.Ball.X, g.Ball.Y, g.Ball.Display(), nil, defStyle)

Remember that SetContent can only display one character at a time. No problem for our ball. But for our paddle, yes problem, because the paddle isn’t going to be one character. It needs to be several characters stacked on top of each other. Specifically, we will use a space " " with a different background color. But in order to make it look like a paddle, we’ll need multiple spaces. Further complicating things, those spaces will need to be stacked on top of each other rather than right next to each other. With our current setup, that would look something like this:


screen.SetContent(g.Paddle.X, g.Paddle.Y, g.Paddle.Display(), nil, paddleStyle)
screen.SetContent(g.Paddle.X, g.Paddle.Y + 1, g.Paddle.Display(), nil, paddleStyle)
screen.SetContent(g.Paddle.X, g.Paddle.Y + 2, g.Paddle.Display(), nil, paddleStyle)
screen.SetContent(g.Paddle.X, g.Paddle.Y + 3, g.Paddle.Display(), nil, paddleStyle)
screen.SetContent(g.Paddle.X, g.Paddle.Y + 3, g.Paddle.Display(), nil, paddleStyle)

No thank you.

To help us with this, and really anything we want to display that is larger than one character, we’ll use a function. Luckily, the tcell tutorial offers a solution. The function below is copied directly from the tcell getting started tutorial. All I did was change the name.


func drawSprite(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
    row := y1
    col := x1

    for _, r := range []rune(text) {
        s.SetContent(col, row, r, nil, style)
        col++
        if col >= x2 {
            row++
            col = x1
        }
        if row > y2 {
            break
        }
    }
}

At its core, all this does is loop through a string and call SetContent on each character. But I want to point out that we have two sets of coordinates we need to pass it. The first, x1 and y1, are like our starting position. The next set, x2 and y2, are where we want to end up. You can think of this as our width and our height for whatever we want to print to the screen. So with this in place, I can tell this function “Draw my paddle at position (2,3) and make it 1 character long and 7 characters high.” This will make more sense as we go.

This new setup isn’t perfect, we’ll need to do a couple of weird things to get it to work for us, but it’s a huge improvement over calling SetContent() a bunch of times.

A Paddle, For Real This Time

So back to paddle.go:


import strings

type Paddle struct {
    width  int
    height int
    X      int
    Y      int
    Yspeed int
}

func (p *Paddle) Display() string {
    return strings.Repeat(" ", p.height)
}

We’ve added a width and a height attribute to our struct, which we will use to determine how tall our paddle should be. Note that this alone will just return a long row of spaces. We’ll need to tell drawSprite how to display it properly. This is where that width and height come in. We also need to set up a different tcell.Style to make sure the paddles are a different color than the black background of our terminal. You can check out tcell’s color constants, but I just went with white for now.

    
    paddleStyle := tcell.StyleDefault.Background(tcell.ColorWhite).Foreground(tcell.ColorWhite)

    // repeat this for Player 2
    drawSprite(s,
            g.Player1.X,
            g.Player1.Y,
            g.Player1.X+g.Player1.width,
            g.Player1.Y+g.Player1.height,
            paddleStyle,
            g.Player1.Display())

A Paddle

Looks good. To stay consistent, we can use the same function to display our ball.


drawSprite(s,
    g.Ball.X,
    g.Ball.Y,
    g.Ball.X,
    g.Ball.Y,
    defStyle,
    g.Ball.Display())

We also need a way to move our paddle. For this, we’ll use the same principle we did with our bouncing ball; adding speed to position. We don’t want the player to be able to move the paddle off the screen, so before we actually move it, we need to check that it isn’t at the top or bottom of the screen first.


func (p *Paddle) MoveUp() {
    if p.Y > 0 {
        p.Y -= p.Yspeed
    }
}

func (p *Paddle) MoveDown(windowHeight int) {
    if p.Y < windowHeight-p.height {
        p.Y += p.Yspeed
    }
}

Getting Control

Now we have a paddle, but we have no way to move it. In the first part we used Screen.PollEvent to listen for input. We can expand on what we already built to give us control over our paddle. We’ll have the left paddle controlled by the w and s keys and for the right paddle we’ll use the arrow keys.


    //we can update this line to grab that height value.
    width, height := s.Size() 

    switch event := game.Screen.PollEvent().(type) {
        case *tcell.EventResize:
            game.Screen.Sync()
        case *tcell.EventKey:
            if event.Key() == tcell.KeyEscape || event.Key() == tcell.KeyCtrlC {
                game.Screen.Fini()
                os.Exit(0)
            } else if event.Key() == tcell.KeyUp {
                game.Player2.MoveUp()
            } else if event.Key() == tcell.KeyDown {
                game.Player2.MoveDown(height)
            } else if event.Rune() == 'w' {
                game.Player1.MoveUp()
            } else if event.Rune() == 's' {
                game.Player1.MoveDown(height)
            }

Now we have movable paddles, but if you try to use them to hit the ball, the ball will pass right through. We need to write some code that will check to see if the coordinates of the paddle ever collide with the coordinate of the ball.

Collision

For now, we can probably get away with just writing a function connected to the Ball that checks if it has intersected with one of the paddles. Remember we need to keep in mind the width and height of the paddle.

Better Collision

If we were going to add any other objects to this game we’d probably want to create a parent class for anything that moves or collides. Then we could just inherit from this in our ball and our paddle and anything else we wanted to create and have one function that could check for collision between any moving Body. We could also use an interface for this.

I actually originally broke the code up this way, but it was sort of overkill for our final product and I wanted to just get the basics across in this article. If I expand on this further, or build another game, I’d bring that code back in. For now, I think what we have illustrates the point and, most important, it works.


func (b *Ball) intersects(p Paddle) bool {
    return b.X >= p.X && b.X <= p.X+p.width && b.Y >= p.Y && b.Y <= p.Y+p.height
}

This confusing looking function took me a long time to write and is probably not the best way to go about this and also I barely understand how it works, but let me try to give you the basic idea.

Basically, we don’t just need to check if the x and y overlap, we need to see if the ball touches any coordinate along the edge of the paddle. So really, we need to check if the ball’s X or Y overlaps any X or Y position between the paddles X and Y and the paddles X + width and Y + height values.

When the ball hits the paddle, we’ll want to change its direction, so let’s write a couple functions to make that easier for us. We can also update checkEdges function to use them.


func (b *Ball) reverseX() {
    b.Xspeed *= -1
}

func (b *Ball) reverseY() {
    b.Yspeed *= -1
}

func (b *Ball) CheckEdges(maxWidth int, maxHeight int) {
    if b.X <= 0 || b.X >= maxWidth {
        b.reverseX()
    }

    if b.Y <= 0 || b.Y >= maxHeight {
        b.reverseY()
    }
}

And then we can use them in our main event loop.


    if g.Ball.intersects(g.Player1) || g.Ball.intersects(g.Player2) {
        g.Ball.reverseX()
        g.Ball.reverseY()
    }

Now if we run the code we should be able to use both paddles to hit the ball.

You Can’t Lose

In Pong, if the ball ends up behind your opponents paddle, you score a point. This usually means the ball disappears and then reappears in the middle of the screen for the next round of play. We can make this update pretty easily. First, let’s get rid of the code that tells the ball to bounce whenever it reaches the left or the right of the screen. That will leave our checkEdges method looking like this:


func (b *Ball) CheckEdges(maxWidth int, maxHeight int) {

    // We delete the code that checks if
    // the ball is on the left or the right.

    if b.Y <= 0 || b.Y >= maxHeight {
        b.reverseY()
    }
}

Let’s then write another function that let’s us reset the balls position and direction.


func (b *Ball) Reset(x int, y int, xSpeed int, ySpeed int) {
    b.X = x
    b.Y = y
    b.Xspeed = xSpeed
    b.Yspeed = ySpeed

}

Then, in our event loop, we can make the update as needed.


    if g.Ball.Body.X <= 0 {
        g.Ball.Reset(width/2, height/2, -1, 1)
    }

    if g.Ball.Body.X >= width {
        g.Ball.Reset(width/2, height/2, 1, 1)
    }

Run the code and let the ball get by one of the paddles.

Declaring a Loser

I feel like the score should be something that’s attached to the player, but right now we don’t really have any player objects. Let’s create a Player struct. This way a player can have both a score and a paddle, and maybe in a later version, a user name or some other identifier.1


package main

type Player struct {
    Score  int
    Paddle Paddle
}

Then update our game struct:


type Game struct {
    Screen  tcell.Screen
    Ball    Ball
    Player1 Player
    Player2 Player
}

And also in our main.go


    player1 := Player{
        Score: 0,
        Paddle: Paddle{
            width:  1,
            height: 6,
            Y:      3,
            X:      5,
            Yspeed: 3,
        },
    }

    player2 := Player{
        Score: 0,
        Paddle: Paddle{
            width:  1,
            height: 6,
            Y:      3,
            X:      width - 5,
            Yspeed: 3,
        },
    }   

With our code updated to use the Player struct, we can now increase the score every time a player gets the ball past their opponent.


    if g.Ball.X <= 0 {
        g.Player2.Score++
        g.Ball.Reset(width/2, height/2, -1, 1)
    }

    if g.Ball.X >= width {
        g.Player1.Score++
        g.Ball.Reset(width/2, height/2, 1, 1)
    }

We should also display the score. Make sure to remember to import strconv.


    drawSprite(s, (width/2)-5, 1, 1, 1, defStyle, strconv.Itoa(g.Player1.Score))
    drawSprite(s, (width/2)+5, 1, 1, 1, defStyle, strconv.Itoa(g.Player2.Score))

Game Over, Man

Last thing we need to do is allow the game to end. You can set the max score to be whatever you want, but to make it easier to test and show in a quick video, I made it so that first player to 2 wins.


func (g *Game) GameOver() bool {
    return g.Player1.Score == 2 || g.Player2.Score == 2
}

func (g *Game) DeclareWinner() string {
    if !g.GameOver() {
        return "Game Not Over. No Winner"
    }

    if g.Player1.Score > g.Player2.Score {
        return "Left Player"
    } else {
        return "Right Player"
    }
}

Then in our event loop inside of Run:


    if g.GameOver() {
        drawSprite(s, (width/2)-4, 7, (width/2)+5, 7, defStyle, "Game Over")
        drawSprite(s, (width/2)-8, 11, (width/2)+10, 11, defStyle, g.DeclareWinner()+" Wins!")
        s.Show()
    }

Conclusion

There’s more to be done to make the game look a little better. I might increase the size of the ball, not sure how to do that and make it round but you could give it some width and height and just make it a cube. I’d like to make the score easier to see and play with some more fun colors than just whatever the default terminal pallet is which is what we are using right now.

Something that would really be fun would be to try to make it multiplayer online, but that’s for another article.

Earthly Cloud: Consistent, Fast Builds, Any CI
Consistent, repeatable builds across all environments. Advanced caching for faster builds. Easy integration with any CI. 6,000 build minutes per month included.

Get Started Free


  1. Yes, you’re going to have to go update every single place in the code where you had g.Player1 to g.Player1.Paddle and you’ll have to do it for both players. Will it be tedious and annoying? Yes. Will it be everything programming every promised it would be? Yes. Is this me being too lazy to go back and rewrite this tutorial to account for this earlier? No. I am whatever the opposite of lazy is. Can’t be bothered to look it up.↩︎

Josh Alletto %
Josh is a writer and former devops engineer. He's passionate about coding, learning, and sharing knowledge.
✉Email Josh✉

Updated:

Published:

Get notified about new articles!
We won't send you spam. Unsubscribe at any time.