Pew pew! Making a Game with Compose Canvas on Wear OS đź‘ľ

Ataul Munim
Android Developers
6 min readDec 14, 2022

--

A few weeks ago, Sara Hamilton and I took a slight detour from our usual areas of interest by making a Wear OS game using Compose for Wear OS.

Our goals included:

  • Using our existing knowledge of Jetpack Compose (on mobile) with Compose for Wear OS
  • Trying inputs unique to Wear OS devices (like rotary input via rotating bezels and side buttons)
  • Understanding what other considerations needed to be made for gaming on Wear OS
Pixel Watch showing an arrow-shaped spaceship drawn with 4 white lines in the center of a black screen.

We started by building a clone of the Chrome Dino game in which a T-Rex jumps over cacti (except ours was a duck that jumped over desserts — it makes just as much sense, folks!), and ended up with a demo of a laser-shooting spaceship so that we’d have a bit more… space to explore.

Create a game loop

The foundation of this game is powered by an infinite loop which intermittently calls a function to process game state and produce UI state. Since GameEngineViewModel is the screen-level state holder, this is where the game loop (the business logic) will live.

Diagram showing user input events flowing from the SpaceCanvas to the GameEngineViewModel and UiState flowing from the ViewModel to the SpaceCanvas. Both the update() function and user input modify GameState which is internal to the ViewModel.

update() is a private function in the ViewModel where we can process changes to GameState, and use it to produce a new UiState.

Our composable function, SpaceCanvas, renders the UiState whenever a new one is emitted, and notifies the ViewModel about user input events.

Render the UI with Compose Canvas

Rendering our game world with Compose Canvas is ideal because it has a simple coordinate system and includes APIs for drawing, which work on Wear OS too.

The Canvas composable’s onDraw parameter provides access to a DrawScope, letting us draw each element separately, using extension functions to keep our SpaceCanvas composable short and manageable.

Whenever the parameters of SpaceCanvas change, it recomposes and a new frame is drawn. Lasers were drawn with drawCircle():

And the spaceship itself was drawn with drawPath().

Using the rotate() transformation lets us orient the ship without having to deal with lots of math.

Avoid unnecessary work

We’re creating the spaceship path each time we draw because it’s currently dependent on the spaceship’s position and size.

Since the size is only expected to change once (when the `SpaceCanvas` size changes) in the game’s lifetime, we can avoid a costly initialization per frame by instead remembering the path, and using the translate() transformation to position it correctly.

We write a function to create the spaceship path at position (0, 0). This removes the dependency on the spaceship’s position, which updates frequently:

which we can then use in the SpaceCanvas composable:

remember(spaceship.width, spaceship.length) means that spaceshipPath will only update when the spaceship’s width or length changes, instead of every frame.

Detect rotary input for turning

There are three actions that we need to perform as a user: turn, thrust and fire. Rotary input seems perfect for the first use case: turning.

Wear OS devices, such as the Pixel Watch, can feature a physical rotating side button or a rotating bezel around the watch face. The bezel can be a part of the physical hardware or just a part of the software, like the Samsung Galaxy Watch5. The rotating side button and bezels send information about the input event to the focused view.

We can acquire focus on the Canvas so that these rotary scroll events are sent there. The modifier ordering matters: both the focusRequester and onRotaryScrollEvent need to be applied on something that’s focusable. This means they should come before the focusable() modifier, either in the same modifier chain (as in this case), or in one of the parent composables:

Now, we can modify the ship state in the ViewModel:

and when a new UiState is generated on the next call to update(), the Canvas will use the rotationDegrees to transform the spaceship’s path.

Spaceship, shaped like a white arrow, rotates both clockwise and counter-clockwise on its center, rendered on a black Pixel Watch

Detecting touch input for thrusting

We can think of thrusting as a boolean state; while the spaceship’s thrusters are engaged, we want to apply a forward force, and when they’re disengaged, we want to apply “friction” so the spaceship slows down. For this, we detect presses on the screen using the pointerInput modifier with the detectTapGestures function:

In the previous snippet, onPress isn’t a simple callback — it’s a suspending function with a PressGestureScope receiver. Here, we can use tryAwaitRelease() to differentiate the down and up events.

While the user presses the screen (thrusting == true), the ViewModel will apply the forward force, which will either cause the ship to accelerate or decelerate, depending on the ship’s bearing and momentum.

Spaceship accelerates towards the right of the screen, disappearing at the edge and reappearing from the opposite side. A flashing jet appears while the user is thrusting.

detectTapGestures receives another useful parameter, the onDoubleTap lambda, that we can use to detect when the user double-taps to make the ship fire a laser, as shown below.

A double tap on the screen triggers a white single-pixel laser to emit from the nose of the spaceship and move away

Handle different device shapes

One of the game’s mechanics includes “teleporting” the spaceship so that when it flies outside the viewport, it reappears on the other side.

On a rectangular device, when the spaceship’s x-coordinate is less than 0 or greater than the viewport width, we set it to the opposite value (and similar logic for the y-coordinate and viewport height), e.g. from A to B below.

A dotted square representing the canvas with two circles labeled “A” and “B”. The “A” is on the inside of the square at the right edge, and the “B” is on the outside of the square at the left edge.
“A” indicates the current position of the spaceship as it flies off towards the right edge of the screen, and “B” indicates its next position

This doesn’t work on a round device for two reasons:

  • The corners of the Canvas are clipped by the device frame so they’re invisible to the user but still exist virtually. This means if the spaceship is flying towards the corners, it disappears but takes longer to reappear on the other side because it’s still traversing the “invisible” space
  • The “opposite” side is conceptually different for circles
Left: shows the same behavior with a circle canvas where the Y-coordinate does not change, Right: shows the desired behavior with a circle canvas, where “B” is on the opposite side of the circle to “A”.

To fix this, we apply different logic for teleporting depending on the screen type, which we can check from a composable function:

A right-angled triangle is used to represent the offset of the spaceship from the center of the circle. The sides are labeled “a” (x-direction), “b” (y-direction) and “c” (distance from spaceship to center of the circle). The second image shows that we can use the same triangle to calculate the opposite side of the circle.

We know “a” and “b” because it’s the absolute difference between the position of the spaceship and the center of the circle. Using Pythagoras’ Theorem, we can calculate “c”.

If “c” is greater than the circle’s radius, it’s outside of the circle and we need to teleport the spaceship to the opposite side, using “a” and “b” to calculate the new position.

Demo of the spaceship flying and shooting. As it disappears from one side of the round Pixel Watch, it reappears on the opposite.

What’s next?

Using Canvas on Wear OS is no different from using Canvas on mobile. This makes sense because it’s from the compose-foundation artifact, which works on Wear OS, mobile, and other surfaces that support Jetpack Compose.

We added rotary input support to a custom component, and took device shape into account with a single boolean check! We can use the same techniques for building apps on Wear OS, not just games, so what else do we need to consider?

Preserving the battery is a priority on wearables. Using dark or muted colors (as we have) can help the display use less energy. If you’re making an app that requires internet access, be considerate of network usage, and prefer to sync when the device is connected to WiFi and a charger.

When you’re using device features like rotary input, make sure they’re enhancements for devices which support them, but not required for ones that don’t. In our case, we should add another way for the spaceship to rotate, for example, onPressDownEvent() could change the spaceship’s bearing as well as controlling the thrust.

Do you have an idea for a Wear OS app, game or demo? Show us in the #wear-compose Slack channel, on Mastodon, Twitter or below!

The code shown in this demo is available here.

--

--

Ataul Munim
Android Developers

Android Developer Relations Engineer, focusing on Wear OS.