DEV Community

loading...
Cover image for How I built an "Asteroids" game using Jetpack Compose for Desktop
Kotlin

How I built an "Asteroids" game using Jetpack Compose for Desktop

sebastianaigner profile image Sebastian Aigner ・7 min read

A while ago, I tweeted about a small game I had created on top of Jetpack Compose for Desktop: A small clone of the classic arcade game Asteroids, in which you control a space ship with your mouse, and navigate the vastness of space, avoiding and breaking asteroids in the process.

Today, it's time to take a look under the hood and understand how I built a basic version of this game, and how Compose for Desktop helped me achieve it in just one evening!

We will take a look at parts and structures in the code that I find the most interesting. To see how it all fits together, I suggest exploring the whole code on GitHub. The whole implementation is only 300 lines of code, which I hope makes studying and understanding it easy.

The Game

If you're not caught up on your 80s arcade trivia, Asteroids was a popular arcade game where you try to steer your space ship through space, avoiding and destroying asteroids with your ship.

Because of the limitations of the hardware at the time, the game is quite simplistic in appearance: a triangular spaceship moves across a plain background and avoids simple displays of asteroids on a 2D surface.

What makes this a challenge is the interia: Just like a real space ship, your spaceship moves along its course in a straight line at constant speed, and you need to make corrective maneuvers by turning your ship and directing your thrust.

Asteroids has achieved cult status in the arcade game scene. Because of that, I wanted to see what it would take to recreate this experience using Jetpack Compose for Desktop!

The Building Blocks

I have roughly divided the project into a few building blocks that make up the project, and that we will talk about. Namely, those are:

In the second part of this series on building a game with Compose for Desktop, we will also look at additional rendering details, the geometry and linear algebra behind the game, and frame-independent movement.

Let's dive right in!

The Game Loop

At the center of most games stands the game loop. It acts as the entry point that calls the game logic code. This is a fundamental difference between implementing typical declarative user interfaces and building games:

  • Declarative UI is usually mostly static, and reacts to user actions (clicking, dragging) or other events (new data, computation progress...)
  • Games run their logic many times per second, simulating the game world and its entities one frame at a time.

That is not to say that these two approaches are incompatible! All we need to run a main "game loop" is to get our function to execute once per frame. In Jetpack Compose, we have the withFrame family of functions (withFrameMillis, withFrameNanos), which can help us achieve exactly that.

Let's assume we already have a game object – we will talk about state management shortly. We can then create a LaunchedEffect which asks Jetpack Compose for Desktop to call our update function whenever a new frame is rendered:

LaunchedEffect(Unit) {
    while (true) {
        withFrameNanos {
            game.update(it)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

withFrameNanos is a suspending method. Its exact implementation is described in the documentation:

withFrameNanos suspends until a new frame is requested, immediately invokes onFrame with the frame time in nanoseconds in the calling context of frame dispatch, then resumes with the result from onFrame.

The frame time, which it provides us with, will also come in handy, as we will see in the second part of this blog post series, when we talk about frame-independent movement.

Game State Management

Jetpack Compose is excellent at managing state, and when building a game like Asteroids, we can use the same mechanisms to keep track of the data attached to game objects or the current play session, to name just two examples.

As suggested in the previous section, my Asteroids game has a Game class, an instance of which is wrapped in a remember call in the main composition.

val game = remember { Game() }
Enter fullscreen mode Exit fullscreen mode

It acts as a container for all game-related data. For example:

  • Game object information (in a mutableStateListOf)
  • The current game phase (RUNNING / STOPPED)
  • The size of the playing field (based on window dimensions)

Inside the Game object, we treat the state data as mutable, and make any state changes as we see fit.

Game Objects

Individual game objects once again group the state belonging to an individual game entity: a spaceship, an asteroid, or a bullet, and provide methods to modify their state, spawn new game objects, or check their relation to other game objects.

In my implementation of Asteroids, all game objects share a lot of behavior, from the way they move through the environment to how they check their collision – we'll talk about the geometry and linear algebra that goes into that a bit later.

The GameObject class provides implementations for these shared behaviors:

sealed class GameObject(speed: Double = 0.0, angle: Double = 0.0, position: Vector2 = Vector2.ZERO) {
    var speed by mutableStateOf(speed)
    var angle by mutableStateOf(angle)
    var position by mutableStateOf(position)
    var movementVector /* ... */
    abstract val size: Double

    fun update(realDelta: Float, game: Game) {
        val velocity = movementVector * realDelta.toDouble()
        position += velocity
        position = position.mod(Vector2(game.width.value.toDouble(), game.height.value.toDouble()))
    }

    fun overlapsWith(other: GameObject): Boolean {
        return this.position.distanceTo(other.position) < (this.size / 2 + other.size / 2)
    }
}
Enter fullscreen mode Exit fullscreen mode

For example, the ShipData class inherits speed, angle, position and its update method from GameObject, but defines its own size, angle, and a function to fire a bullet:

class ShipData : GameObject() {
    override var size: Double = 40.0
    var visualAngle: Double = 0.0

    fun fire(game: Game) {
        val ship = this
        game.gameObjects.add(BulletData(ship.speed * 4.0, ship.visualAngle, ship.position))
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that the ShipData (or even a GameObject in general) does not include any logic on how to render this item to the display – with Jetpack Compose, keeping state and presentation separated is quite easy.

Because a lot of behavior is shared between all types of entities in the game, our main game loop can treat them as the supertype GameObject for the most part, and only specific interactions between certain types of objects, like bullet-asteroid or asteroid-player collisions, need to be handled specifically.

Rendering to the Screen

I found that in Jetpack Compose, separating game data from the visual representation comes quite naturally. Game objects like a ship, an asteroid, or a bullet are all represented in two parts:

  • A class holding the state associated with the game object (in terms of "Compose state" – via mutableStateOf and friends) – We briefly talked about this in the previous section.
  • A @Composable, defining the rendering based on the game object's data.

To illustrate the latter, here's the minimal visual representation of the Asteroid composable. It receives asteroidData, which is the container for all information regarding the state of this particular game object:

@Composable
fun Asteroid(asteroidData: AsteroidData) {
    val asteroidSize = asteroidData.size.dp
    Box(
        Modifier
            .offset(asteroidData.xOffset, asteroidData.yOffset)
            .size(asteroidSize)
            .rotate(asteroidData.angle.toFloat())
            .clip(CircleShape)
            .background(Color(102, 102, 153))
    )
}
Enter fullscreen mode Exit fullscreen mode

This code snippet is enough to describe the whole visual representation of an asteroid.

We start with a Box – one of Compose's most basic layout primitives, which allows us to have entities overlap (which is useful since we manually take care of placing the individual entities). We then use Jetpack Compose's Modifiers to specify the position of the asteroid in the form of an offset, its size, rotation angle, shape (by clipping a CircleShape), and background color.

Note that Compose offers quite high-level APIs even for these basic shapes – for example, we can use .rotate directly, without having to manually do geometry work to figure out how to get our entities facing the right way.

To keep this snippet as concise as possible, I've also introduced some extension functions on GameObject that make it possible to reuse the logic of computing the offset of a game object based on its position and size, called xOffset and yOffset, which I've snuck into the previous code snippet already. Their implementation is relatively straightforward:

val GameObject.xOffset: Dp get() = position.x.dp - (size.dp / 2)
val GameObject.yOffset: Dp get() = position.y.dp - (size.dp / 2)
Enter fullscreen mode Exit fullscreen mode

A slightly more complicated composable would be the Ship component, which combines the shapes of a triangle and circle to create a minimalistic spaceship:

@Composable
fun Ship(shipData: ShipData) {
    val shipSize = shipData.size.dp
    Box(
        Modifier
            .offset(shipData.xOffset, shipData.yOffset)
            .size(shipSize)
            .rotate(shipData.visualAngle.toFloat())
            .clip(CircleShape)
            .background(Color.Black)
    ) {
        Canvas(modifier = Modifier.fillMaxSize(), onDraw = {
            drawPath(
                color = Color.White,
                path = Path().apply {
                    val size = shipSize.toPx()
                    moveTo(0f, 0f) // Top-left corner...
                    lineTo(size, size / 2f) // ...to right-center...
                    lineTo(0f, size) // ... to bottom-left corner.
                }
            )
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

The Box defining the ship is quite similar to the one we saw for an Asteroid, but we additionally add a Canvas to draw some additional shapes on top of our spaceship – in this case, a triangle path. In typical Compose fashion, we just add this Canvas in the lambda block following our Box, meaning the Canvas will inhert the coordinate system of its parent, including its offset and rotation.

These composables are then just rendered to a play surface – nothing more than a Box with a locked aspect ratio of 1.0f (to keep it quadratic). Of course, applying some artistic talent to these visual representations of the game is also possible, but we're keeping it minimal for now.

Continued in Part 2

There's still a bit more work to do until we can call our game done. In part 2 of this blog post series, we will look at additional rendering details, the geometry and linear algebra behind the game's simple physics simulation, as well as frame-independent movement. Read on and find out!

Discussion (0)

Forem Open with the Forem app