DEV Community

Bijan Boustani
Bijan Boustani

Posted on • Edited on

Recreating Pong for the Web with Elm

Let's take a tour of the process for creating a web version of the classic Atari game, Pong! In case you missed it, here is the introductory post for this series. The goal is to rebuild classic interactive games and learn a little bit along the way.

🏓 Pong

Before we dive in, let's set the context for what Pong looks like.

Pong Sketch

Pong is an abstract representation of table tennis, consisting of these basic shapes:

  • a ball that moves and bounces around the screen
  • a left paddle that moves up and down, controlled by the player
  • a right paddle that moves up and down, controlled by the computer
  • a score indicator for each paddle
  • a window rectangle with a black background and a "net" in the middle

There have been many versions of Pong released over the years, but we'll use these elements as a model for our version. But let's start with some fun history.

📖 History

Pong Marketing Material

One of the fascinating things about Pong is that it was released in the early 1970s to a public that had little context for the idea of a digital video game.

Atari would need to "productize" arcade games at a time when coin-operated pinball games were probably the closest corollary.

Games like Spacewar! predate the release of Pong by a decade, but they were only available to academics and too complicated for the public. So Atari would have to use skills from several disciplines like engineering, design, and entrepreneurship to create something accessible.

The result was the game cabinet pictured in the marketing material above. And below we can see how straightforward they made the instructions and affordances.

Pong Arcade Cabinet UI

In our version, we'll use the arrow keys on a keyboard to control the paddles. But it's easy to see how people could take to the simple controls on the cabinet.

The story goes that Atari put their prototype in a local bar named Andy Capp's Tavern in the heart of Silicon Valley. And the machine was so popular that it would overflow with quarters.

As I was doing "research" for this project, I bought an Atari 2600 emulator called RetroN 77 thinking it would be a good way to simulate the original. But it turns out Pong may have been considered passé by the time the Atari 2600 was released. After the arcade cabinet, there was a dedicated home console version that predated the cartridge-based Atari 2600.

The emulator was still fun to share with my daughter:

Playing the Atari 2600 Emulator

Due to its cultural relevance, Pong is even part of the Smithsonian museum. If you're interested in learning more about the history, the Pong Wikipedia page is a good place to start. And there's a fun documentary called World 1-1 you might like too.

🕹 Rebuilding Pong for the Web

My original intention for this series was to rebuild classic games, and then post step-by-step walkthroughs of how to do the same.

After taking the time to work out the mechanics of Pong, I realized that approach would be exhausting to read 😅. It would take a whole course to do justice to the content. So this post will be a tour of the process and the interesting bits, and I'll share all the source code on GitHub at the end.

Plus, I'm still learning game development, and I don't want to steer you in the wrong direction. I'll try to stick with clear explanations in plain terms without relying on jargon that can be scary when you're first getting started.

My goal is still to create faithful reproductions of these classic games. So I worked hard to figure out what made Pong so fun to play. But I'll also add little features and interesting bits where it makes sense.

🎯 Why Pong is Perfect

It turns out Pong is a perfect place to get started with game development. Like most software projects, the first 90% is straightforward. And then the last 10% that makes all the difference.

👏 Simple But Amazing

Pong Elements with Labels

We can start by sketching all the elements that make up Pong to get an idea of what we want to put on the screen. It involves all the basics, and has everything you need to make for a surprisingly fun game:

  • Rules: Clear rules for controlling paddles and scoring make the game accessible.
  • Game Loop: A loop allows us to continuously:
    • listen for input
    • adjust objects over time
  • Input Handling: We'll use the keyboard to handle player input and move the left paddle.
  • Competition: Player scoring adds some fun and a win state.
  • Rendering: We'll use SVG to create a game window and render all the elements.
  • Game States: We'll transition between different states:
    • StartingScreen
    • PlayingScreen
    • EndingScreen
  • AI: We'll simulate simple AI for the computer-controlled right paddle.
  • Basic Physics: We'll use basic physics to keep the ball and paddles moving with position and velocity.
  • Collisions: When the ball collides with a paddle or the edge of the screen, we can handle the collisions and responding accordingly.
  • Assets: Most games will incorporate external assets like sprites and sounds. We won't need any sprites since we're using simple SVG shapes. But we'll use an external sound library to incorporate simple sound effects.

Some of these can be tricky for those of us with a background in web development. When we're building web applications, our app is doing nothing most of the time. We occasionally need to load a new page or make a request to the database, but for the most part the content is static. Even now, you're reading this post but all the hard work of loading the text on the page is already done.

With game development, there is a constant stream of activity. We use a game loop to handle input from players. And, even when there's no input, we still move objects around the screen over time.

When I started learning some of these concepts, I was scared off by terms like "delta time" and "velocity" and "vector." And it's understandable if you feel that way too. It can also get confusing to think about the coordinates and which way is up and down.

One helpful technique is to think of words you're more comfortable with. For instance, you could think of:

  • horizontalPosition instead of an x position
  • verticalSpeed instead of vy velocity
  • changeInTime instead of deltaTime

Once we get a working mental model, we can work towards using common terms and find out why they're more fitting.

💊 Deeper Levels

After getting the basic game elements worked out, it turns out there's a much deeper level to games. Beyond getting things to show up on the screen and move around, we can start to take an interest in:

  • Game Design: The major takeaway from this project is how little changes can make a big difference in game-play. The game elements mentioned above will get things up and running. Then, you can tinker with all kinds of little settings like ball speed and angles and scoring to make it fun.
  • Risk and Reward: Hitting the ball closer to the edge of your paddle is a "risky" move, because you're more likely to miss it. A "safe" approach would be to hit the ball with the center of the paddle. But we can change the ball angle coming off the paddle, so your risky move turns into reward because the other paddle will have a much more difficult time returning it. Without this, Pong turns out to be surprisingly boring and predictable.
  • Speed: We can start by tinkering to set a fast ball speed that's fun to play, but not so fast that it's too difficult. But we also speed up the ball slightly every time it hits a paddle. The result is a feeling that the game speed increases with volleys and adds much more fun to the game.
  • Randomness: We start with a simple serve from the same location and in the same direction. Then, we can add randomness to the ball location and direction so you have to keep paying attention and build your skills to consistently return the serve.
  • Fairness: It turns out some changes will feel unfair. Between the ball speed and the randomness, the serves can feel unfair and it gets too easy to get scored on. And without capping the ball speed to an upper bound, it starts to feel unfair if a successful return is impossible.
  • Playtesting and Tinkering: I didn't know all this before building the game. It was a long process of adding features and playing the game to see the (often unintended) consequences. Tinkering with the values can lead to a lot of fun and interesting ideas.
  • Aesthetics and UI: I spent some time trying to recreate the feel of the arcade cabinet. I ended up throwing out some of the work, but adding a little color and polish adds a lot to the experience.
  • Learning and Skill Development: Games can be so compelling because we're learning as we play. We're wired to recognize patterns, and it's fun to feel the sense of improvement and progress as we get better. There's a book called A Theory of Fun for Game Design you might like if you're interested in reading more about this topic.

🌳 Working with Elm

If you haven't worked with the Elm programming language before, An Introduction to Elm is a good place to get started. It's a short book, and the examples give you everything you need to start building front-end web applications with Elm.

The Elm Architecture is also well-suited for building games too. At a glance, you can see the similar pattern between The Elm Architecture functions (init, update, view) and a simple game development platform like PICO-8 (init, update, draw).

The Elm Archtecture

PICO-8

  • init: We can model our game using types and provide initial values. For example, where should the ball and paddles be located when we start the game?
  • update: This allows us to continuously change the model values over time. We'll update player scores, move the ball, and transition between game states.
  • view: This will continuously draw the updated model on the screen using SVG.

I'll dive into Elm code throughout the rest of this post. But if you don't know Elm yet, feel free to skim through the content to see the process. And hopefully it will inspire you to go learn more about Elm and functional programming!

🤔 Modeling the Game Components

When you're coming up with an initial version of the data model, it doesn't have to be perfect. We can start with a handful of elements we know we'll need. Then we can pull those into the view function so we have something to look at:

  • Window
  • Ball
  • Left Paddle
  • Right Paddle

⬛️ Window

Here is a Window type alias we can use to create a SVG rectangle element that will hold the game. Keep in mind that SVG uses the top left as the starting point. When we start with 0 values for x and y we're starting at the top left.

  • As the x value increases, we move right ➡️
  • As the y value increases, we move down ⬇️

Using values for x, y, height, and width we can draw the rectangle for our game window.

SVG Rectangle Sketch

type alias Window =
    { backgroundColor : String
    , x : Float
    , y : Float
    , width : Float
    , height : Float
    }
Enter fullscreen mode Exit fullscreen mode

And we'll set some initial values:

initialWindow : Window
initialWindow =
    { backgroundColor = "black"
    , x = 0.0
    , y = 0.0
    , width = 800.0
    , height = 600.0
    }
Enter fullscreen mode Exit fullscreen mode

These values will correspond to an SVG rect element, and Elm has an Svg package that contains all the same elements and attributes we'll need.

Here is the corresponding view function that calls Svg.rect. We can pass our initialWindow into this function and it produces the rectangle we need.

viewGameWindow : Window -> Svg.Svg msg
viewGameWindow window =
    Svg.rect
        [ Svg.Attributes.fill <| window.backgroundColor
        , Svg.Attributes.x <| String.fromFloat window.x
        , Svg.Attributes.y <| String.fromFloat window.y
        , Svg.Attributes.width <| String.fromFloat window.width
        , Svg.Attributes.height <| String.fromFloat window.height
        ]
        []
Enter fullscreen mode Exit fullscreen mode

And we can even add another view function for the "net" in the middle of the screen. Some Googling turned up this stroke-dasharray doc from MDN, which worked well for placing a dashed line in the middle of the game window.

viewNet : Window -> Svg msg
viewNet window =
    Svg.line
        [ Svg.Attributes.stroke "white"
        , Svg.Attributes.strokeDasharray "14, 14"
        , Svg.Attributes.strokeWidth "4"
        , Svg.Attributes.x1 <| String.fromFloat <| (window.width / 2)
        , Svg.Attributes.x2 <| String.fromFloat <| (window.width / 2)
        , Svg.Attributes.y1 <| String.fromFloat <| window.y
        , Svg.Attributes.y2 <| String.fromFloat <| window.height
        ]
        []
Enter fullscreen mode Exit fullscreen mode

It's not much to look at so far, but it works!

Game Window Rectangle

🎾 Ball

The ball is like the window because we're still using Svg.rect, but in this case it'll be much smaller. We add a vx and vy that we'll use to indicate the horizontal and vertical velocity. We could just push the position of the ball around over time using x and y. But these velocity values allow us to do things like change the speed and negate the values to get the ball moving the other way when it strikes a paddle.

type alias Ball =
    { color : String
    , x : Float
    , y : Float
    , vx : Float
    , vy : Float
    , width : Float
    , height : Float
    }
Enter fullscreen mode Exit fullscreen mode

Here are the initial values I ended up with for the initialBall. It was basically a matter of tinkering until it was somewhere near the middle of the screen.

initialBall : Ball
initialBall =
    { color = "white"
    , x = 395.0
    , y = 310.0
    , vx = 350.0
    , vy = 350.0
    , width = 10.0
    , height = 10.0
    }
Enter fullscreen mode Exit fullscreen mode

We also could have represented the same data with a position value to hold the x and y. And a velocity to hold the vx and vy. For this first game, I wanted to stick with a "flat" model that didn't involve any nesting so that updating values would be easy. But I'll likely change that approach in the next game I build.

I should also mention that I used Int values when I started. I later switched to using Float values to avoid having to perform conversions and round the integers. I'm not sure, but I think the precision of Float values also makes the animation look a little smoother too.

And here's the view function to render the ball on the page:

viewBall : Ball -> Svg msg
viewBall ball =
    Svg.rect
        [ Svg.Attributes.fill <| ball.color
        , Svg.Attributes.x <| String.fromFloat ball.x
        , Svg.Attributes.y <| String.fromFloat ball.y
        , Svg.Attributes.width <| String.fromFloat ball.width
        , Svg.Attributes.height <| String.fromFloat ball.height
        ]
        []
Enter fullscreen mode Exit fullscreen mode

🏓 Paddles

The paddles are rectangles like the window and ball. Both paddles have the same fields, but we can add a PaddleId type to differentiate between them.

We add a score for each paddle that we can increment. Also note that we only need a vy for the vertical speed since the paddle doesn't need to move horizontally.

type PaddleId
    = Left
    | Right

type alias Paddle =
    { color : String
    , id : PaddleId
    , score : Int
    , x : Float
    , y : Float
    , vy : Float
    , width : Float
    , height : Float
    }
Enter fullscreen mode Exit fullscreen mode

Then, we can set initial values to locate the paddles near the left and right edges of the game window:

initialLeftPaddle : Paddle
initialLeftPaddle =
    { color = "lightblue"
    , id = Left
    , score = 0
    , x = 48.0
    , y = 200.0
    , vy = 600.0
    , width = 10.0
    , height = 60.0
    }

initialRightPaddle : Paddle
initialRightPaddle =
    { color = "lightpink"
    , id = Right
    , score = 0
    , x = 740.0
    , y = 300.0
    , vy = 475.0
    , width = 10.0
    , height = 60.0
    }
Enter fullscreen mode Exit fullscreen mode

Here are the view functions for the paddles and paddle scoring:

viewPaddle : Paddle -> Svg msg
viewPaddle paddle =
    Svg.rect
        [ Svg.Attributes.fill <| paddle.color
        , Svg.Attributes.x <| String.fromFloat paddle.x
        , Svg.Attributes.y <| String.fromFloat paddle.y
        , Svg.Attributes.width <| String.fromFloat paddle.width
        , Svg.Attributes.height <| String.fromFloat paddle.height
        ]
        []

viewPaddleScore : Int -> Window -> Float -> Svg msg
viewPaddleScore score window positionOffset =
    Svg.text_
        [ Svg.Attributes.fill "white"
        , Svg.Attributes.fontFamily "monospace"
        , Svg.Attributes.fontSize "80"
        , Svg.Attributes.fontWeight "bold"
        , Svg.Attributes.x <| String.fromFloat <| (window.width / 2) + positionOffset
        , Svg.Attributes.y "100"
        ]
        [ Svg.text <| String.fromInt score ]
Enter fullscreen mode Exit fullscreen mode

When I first started, I hard-coded some of these values to keep things simple. For instance, the positionOffset for the scores is something I added in later as a way to use this same function to display both scores and pass in a number to move the score over within the SVG window.

We pull all that together into the model and we have all the basic elements for our game!

type alias Model =
    { ball : Ball
    , leftPaddle : Paddle
    , rightPaddle : Paddle
    , window : Window
    }

initialModel : Model
initialModel =
    { ball = initialBall
    , leftPaddle = initialLeftPaddle
    , rightPaddle = initialRightPaddle
    , window = initialWindow
    }
Enter fullscreen mode Exit fullscreen mode

Pong Elements in the Browser

The game isn't "alive" yet, but we have everything we need and it's all in roughly the correct location.

🔁 Update

The update function is where a lot of the magic happens for our game. This is how we update the ball position, move the paddles around, update player scores, etc. It's also where things get a little more complicated. The source code for this project is available at the bottom of this post, so I'll try to keep this section at a high level without diving into the details.

⏱ Subscriptions

The subscriptions function is a good place to start, and we'll handle these in our update. We use subscriptions for two major features:

  • ⌨️ handling keyboard input
  • ✨ adding animation

In the code snippet below, we use the Browser.Events module from Elm's Browser package to handle browser animation and key events from the player.

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ Browser.Events.onAnimationFrameDelta BrowserAdvancedAnimationFrame
        , Browser.Events.onKeyDown <| Json.Decode.map PlayerPressedKeyDown <| keyDecoder
        , Browser.Events.onKeyUp <| Json.Decode.map PlayerPressedKeyUp <| keyDecoder
        ]
Enter fullscreen mode Exit fullscreen mode

This produces a steady stream of data we can use for our game. And a Msg type allows us to indicate how we want to handle them in our update function.

type Msg
    = BrowserAdvancedAnimationFrame Time
    | PlayerPressedKeyDown String
    | PlayerReleasedKey String
Enter fullscreen mode Exit fullscreen mode

I'm admittedly glossing over a lot of details as this blog post grows much larger than I intended. But the important part is that we'll use BrowserAdvancedAnimationFrame to animate the ball and paddles so they move over time. PlayerPressedKeyDown will give us a way to know when a player is pressing an arrow key to move the left paddle.

✏️ Sketching Updates with Pseudocode

When I started writing the update function, I mapped out a quick plan with the pseudocode below.

Thinking this way with conditionals turned out to be a huge mistake 👎. Every time I wanted to add a feature, it meant adding yet another condition. And the order of the conditions ended up being important too.

if nothing is happening
    move ball
    move right AI paddle
if player input
    move left paddle
if ball hits right paddle
    negate ball.vx
    update ball position
if ball hits left paddle
    negate ball.vx
    update ball position
if ball hits left window edge
    increment right player score
    reset ball to initial position
    set ball in motion
if ball hits right window edge
    increment left player score
    reset ball to initial position
    set ball in motion
if ball hits top or bottom window edge
    negate ball.vy
Enter fullscreen mode Exit fullscreen mode

At least it gave me a rough idea of the events in the game of Pong. But for the next game I work on I'll sketch this out on paper before I dive into the code. And instead of thinking about if-this-then-that, I'll try to come up with union types for different states.

🚀 Getting the Ball Moving

At this point, it's fun to set the ball in motion and see what happens. We can use something like this to adjust the position based on the time we're getting from BrowserAdvancedAnimationFrame. In other words, as time advances, the ball's x and y position will increase, which means the ball will move down ⬇️ and to the right ➡️.

{ ball
    | x = ball.x + ball.vx * time
    , y = ball.y + ball.vy * time
}
Enter fullscreen mode Exit fullscreen mode

It's fun to see the ball start moving, but we quickly realize it won't know to stop at the bottom of the window.

Working Ball Movement

You'll need to check out the source code at the bottom of this post to see the complete example. But the basic idea at this point is to invert the ball's vertical direction when it hits the bottom of the window. The ball has a positive vy value as it's moving downward. Hitting the bottom edge of the window (which we know because it's the value of the window.height) means we take the vy value and turn it into a negative value.

-- ball hits bottom edge of window
case edge of
    Window.Bottom ->
        { ball | vy = negate ball.vy }
Enter fullscreen mode Exit fullscreen mode

Ball Collides with Bottom Edge of Window

From here, the development starts to flow a bit. When the ball collides with the top or bottom of the window, we negate the vy value. When the ball hits the left or right edge of the window, we increment the player score by 1 and we reset the ball back to its original position.

💥 Paddle Collisions

Handling the ball collisions with the edges of the window is somewhat straightforward since we can use the window's x, y, width, and height values. Paddles are a little more difficult, because we have to compare the ball's location and the paddle's location to see if they intersect. Here are the boolean checks I used to determine if the ball had collided with one of the paddles:

ballHitLeftPaddle : Ball -> Paddle -> Bool
ballHitLeftPaddle ball paddle =
    (paddle.y <= ball.y && ball.y <= paddle.y + paddle.height)
        && (paddle.x <= ball.x && ball.x <= paddle.x + paddle.width)
        && (ball.vx < 0)

ballHitRightPaddle : Ball -> Paddle -> Bool
ballHitRightPaddle ball paddle =
    (paddle.y <= ball.y && ball.y <= paddle.y + paddle.height)
        && (paddle.x <= ball.x + ball.width && ball.x <= paddle.x + paddle.width)
        && (ball.vx > 0)
Enter fullscreen mode Exit fullscreen mode

🚦 Game States

After getting some of the initial features up and running, we realize that games aren't always in a "playing" state. Super Mario Bros. is a memorable example where there is an initial screen and you have to press the start button to begin playing the game.

Mario Start Screen

So we can begin with a StartingScreen state, and transition to a PlayingScreen state after a player is ready. For Pong, I added brief instructions below the game window (which are visible at the bottom of this post) and used the spacebar key to start the game.

Super Mario Bros. also has a memorable "Game Over" screen. When one of the players reaches the winning score, we'll transition from PlayingScreen to an EndingScreen state where we can display the winner.

Mario Game Over Screen

type GameState
    = StartingScreen
    | PlayingScreen
    | EndingScreen
Enter fullscreen mode Exit fullscreen mode

We can create a custom type like this to handle the different game states and transition between them. It also ensures that players are only in one state at a time, since we shouldn't still be playing the game in the "game over" screen. We can disable to movement of the ball and paddles in the start and end states. And we also handle keyboard input to start or restart the game from those states.

🦉 Draw the Owl

Take a look at the source code because there's actually a lot going on in the update function.

Draw the Owl

I hate to say "draw the owl" here. But I wanted to make sure I cover the more interesting aspects of what sets this implementation of Pong apart from many others.

😍 The Interesting Bits

Once you get a working version of the game up and running, it's time to figure out what makes it interesting, compelling, and fun.

⚡️ Changing the Ball Angle

If I had to pick one feature that makes a big difference in the game, it's this one. When the ball collides with a paddle, it initially makes sense to invert the ball's vx value and send it back the other way. It would look something like this:

-- ball hits paddle
{ ball | vx = negate ball.vx }
Enter fullscreen mode Exit fullscreen mode

That works well for the ball to hit the paddle and change the horizontal direction. As it turns out, having the ball follow a slow, predictable path can make the game so boring.

The solution is to give players a way to control the angle of the ball off the paddle.

This is better explained visually, so here are a couple of quick sketches. Instead of having the ball follow a predictable path, the location where the ball hits the paddle determines the angle.

Ball Angle Change

Another Ball Angle Change

That might not seem like a big difference, but the result is that one paddle can make it way harder for the other paddle to return the ball.

Result of Ball Angle Change
We can also add a small amount to the ball speed whenever it hits a paddle to make the game feel quicker. Whenever the ball collides with a paddle, add to the vx value to increase the horizontal speed. Elm's clamp function is useful to set an upper bound, because the ball can end up moving so fast that it becomes difficult to play.

Adjusting things like the ball angle and speed has a huge impact on playability. It also adds an element of strategy to the game that gives the player options:

  • Conservative Option: Be conservative and hit the ball with the middle of the paddle. The speed will increase with each hit and eventually the right paddle will struggle to keep up.
  • Risky Option: Or, take on the risk of hitting the ball closer to the edge of the paddle, which returns it at a harder angle for the AI paddle.

🤖 "AI" for the Right Paddle

My first attempt at getting a computer-controlled paddle working was to just have the paddle.y position match the ball.y position. It works well as a starting point, because the ball and right paddle end up in sync with one another and allows the right paddle to consistently return the ball.

The only problem is that makes it impossible for the left paddle to score, because the right paddle will always meet the ball.

I ended up finding a solution that seems to work pretty well where the right paddle tries to "catch up" with the ball's location:

updateRightPaddle : Ball -> Float -> Paddle -> Paddle
updateRightPaddle ball time paddle =
    if ball.y > paddle.y then
        { paddle | y = paddle.y + paddle.vy * time }

    else if ball.y < paddle.y then
        { paddle | y = paddle.y - paddle.vy * time }

    else
        paddle
Enter fullscreen mode Exit fullscreen mode

If the ball is higher on the screen, then the paddle will "chase" upward. If the ball is lower, then the paddle will "chase" downward. Then, you can tinker with the right paddle's vy value to increase or decrease the difficulty. I ended up finding a value that works well in that it's difficult to score but not impossible. So it feels rewarding to score points against the "AI" paddle.

🏆 Winning

Speaking of scoring, it was surprisingly difficult to figure out what the winning score was supposed to be for the arcade game. Was it 10? 11? 15? Or whoever scored the most within a certain time frame?

Then I found this sticker:

Winning Score Sticker on Arcade Cabinet

First player to score 15 points WINS

But I was curious about why the score on this arcade cabinet was on a sticker instead of located with the rest of the instructions.

It turns out there was a physical switch arcade owners could use to adjust the winning score.

Arcade Cabinet Instructions

This felt like low-hanging fruit, so I added it as an option to the game.

💯 Ball Path History

This felt like the "killer app" for this version of Pong. I saw Bret Victor's Inventing on Principle talk years ago and loved the idea that game elements have a past and future. But I never thought about how something like that works.

With Elm, it's a little more obvious because the Elm debugger gives a glimpse of what the stream of values looks like over time.

To visualize the ball history, we can start by creating an empty list. And then every time the ball changes position, we prepend the ball to the list.

updateBallPath : Ball -> BallPath -> Maybe WindowEdge -> Model -> Model
updateBallPath ball ballPath maybeWindowEdge model =
    case model.showBallPath of
        Pong.Ball.Off ->
            model

        Pong.Ball.On ->
            case maybeWindowEdge of
                Just Pong.Window.Left ->
                    { model | ballPath = [] }

                Just Pong.Window.Right ->
                    { model | ballPath = [] }

                _ ->
                    { model | ballPath = List.take 99 <| ball :: ballPath }
Enter fullscreen mode Exit fullscreen mode

When the ball hits the left or right edge of the game window, we clear the history by setting ballPath = []. The rest of the time, we're collecting new positions by prepending the ball to ballPath. Instead of "prepend" the :: operator is usually called "cons" in languages like LISP and Elm.

Lastly, we can pipe to List.take 99 to limit the amount of history and avoid potential performance pitfalls. Having said that, I'd be curious how much history it could handle before slowing down, and you might even be able to build a whole new game around that idea.

Show Ball Path History

📈 Performance

Game development can be pretty tricky. If I write an inefficient function for most applications, it's only going to run here and there and probably won't be noticeable. If I write an inefficient function for a game, it will run about 60 times per second. The whole time 😅.

As I continue to make more games, I'd like to find better ways to profile performance and benchmark. I recorded some performance profiles using my browser's DevTools, but it's difficult to see where inefficiencies are.

It's also interesting to try different machines and browsers. I wrote this on my relatively new MacBook Pro laptop, but it likely wouldn't run as smoothly on my older laptop.

For now, I added a viewFps function in the Util module that takes a list of times and allows you to use a toggle to display and hide the current frames per second.

🎨 Polish and Sound

As some final polish, I added some color to the background to pay homage to the original game cabinet. And I used SVG to recreate the Pong lettering at the top of the screen. I had other fun ideas to create more cabinet affordances in the UI, but it would've taken some time to get right.

There's also a great JavaScript library called howler.js for adding audio to the game. Some of the howler demos are really amazing. Elm provides "ports" to integrate with JavaScript libraries in a safe way. So I added simple beep.wav and boop.wav sounds for when the ball hits the paddles and edges. When the collisions occur, it triggers the port and plays the sound in the browser. Check out the source code if you want to see an example of how this works.

💡 Ideas

One of the most fun parts of game development is stumbling on little ideas that would be intriguing to implement.

Sometimes you have an idea you want to see come to life, and sometimes you accidentally write some bad code that produces an interesting result. For instance, I made a mistake where the ball's motion was stopped unless I was moving the paddle. But that got me thinking of a game like Superhot, where "time moves only when you move".

The ball path history feature I mentioned above is a good example of this kind of "+1" feature that's not core to the game but adds a lot to it.

Here are a few more ideas of varying complexity that I would've loved to implement given the time:

  • Change the ball's color.
  • Add particle effects for collisions.
  • Add a pause state.
  • Dynamically change the ball's width and height.
  • Multiball mode.
  • Multipaddle mode.
  • "Superhot" mode, where the ball moves only when you move.
  • Replay winning shots in the ending screen state.
  • Local multiplayer where one paddle uses the keyboard and one paddle uses the mouse.
  • Online multiplayer (if you build something like this I'd love to see it!).

🎓 Lessons Learned

  • Little things make all the difference. Adjusting settings like the ball speed and path has a huge impact on playability. Polish!
  • Think in states (with union types) instead of conditionals. You can start small with moving away from Bool values to an On and Off switch, then add additional custom states.
  • Refactoring has its ups and downs. It's great to clean up the code and add clarity. But I split the application code into separate files and modules, which didn't improve the development experience.
  • Find balance.
    • Gently resist the urge to move on and work on something else that's new and different. It's easy to get the first 90% done, but the interesting bits are in the final stretch. Adding polish can move it from a lark to a genuinely interesting project.
    • Having said that, find a good stopping point. I could probably keep building different versions of Pong with fancy features for a year. And it's tempting to implement those fun ideas in the list above. But it's important to find a good place to stop and ship it if we want to keep moving and growing. 🚀

📈 What's Next?

That's Just Pong

While rebuilding Pong, there were a bunch of times where I wanted to bail and work on something more complex and impressive. And other times where I just wanted to "quickly" build other games like Tetris and Snake. But I'm going to try sticking with the approach I outlined in the intro post for this series.

Breakout was always my favorite Atari game when I was a kid, so it'll be fun to figure it out. Plus I have all the basic mechanics built for this project, so it'll give me an opportunity to try different data structures and add some more interesting features like particle effects that I didn't get to do for Pong.

🏫 Resources

If you're learning functional programming and game development, feel free to get in touch with me and I'd be happy to hear from you. You can also find me on Twitter.

As promised, here are links to the game and the source code!

Top comments (0)