Pew pew! Making a Game with Compose Canvas on Wear OS 👾
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
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.
update() is a private function in the ViewModel where we can process changes to
GameState, and use it to produce a new
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
And the spaceship itself was drawn with
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
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
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.
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
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.
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.
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.
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
To fix this, we apply different logic for teleporting depending on the screen type, which we can check from a composable function:
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.
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.