How to build a shmup for Apple Watch

A guide to building a shmup for Apple Watch: virtual resolution, Digital Crown controls, rectangle collisions, pooling, timed level tables, a wave generator.

Pixel scene on a green display: a ship on the left shooting at comets, rockets and a boss on the right

A shmup (shoot 'em up, a shooter where your ship flies against waves of enemies) on Apple Watch runs into three problems: a tiny display, a finger that covers half the game, and watches that come in different sizes. Three decisions solve them: the whole game runs on a fixed 84 × 48 grid, collisions are checked by hand with rectangles, and levels are tables of timestamps rather than code. The ship is steered with the Digital Crown and fires on its own.

The examples come from Space Impact Watch, a remake of Space Impact, the shooter that shipped on the Nokia 3310 in 2000. It runs on the watch as well as on iPhone and iPad. The code is built on SpriteKit, Apple's 2D game framework, but the principles apply to any shmup for a small screen.

A fixed 84 × 48 virtual resolution

First decide which units the game works in. If you use screen points, the world has a different size on every watch. It is better to pick a small virtual resolution and do everything in it. Our game uses 84 × 48, exactly the size of the Nokia 3310 display. Positions, speeds, sprite sizes and enemy paths are all in these units. When the scene starts, it calculates once how many screen points one virtual pixel spans (scene width divided by 84), and everything is simply multiplied by that.

Game scene with a visible grid of 84 × 48 virtual pixels
A level on the 84 × 48 grid. Each square is one virtual pixel, which the watch draws as a block of several real points.

What you get:

  • The same look on every device. The game looks identical on every watch size and on the iPad; only the block size changes.
  • Level design in small whole numbers. An enemy flies at height 13, a rocket turns at x = 20.
  • Simple graphics. Sprites are single-colour and drawn at ten times their size, so one virtual pixel is a 10 × 10 block in the source image. They get their colour in the game: black on a green background in light levels, the other way round in dark ones.

Crown controls and automatic fire

On a watch, a finger covers half the game, so the ship is not steered by touch. The Digital Crown handles movement: turning it moves the ship up or down. Add a sensitivity setting and an option to invert the direction.

Firing is automatic; in our game every 0.4 seconds, and every 0.3 from level four on. The display is used for a single gesture: a swipe to the right fires one of three special weapons.

Three special weapons: a vertical line, a homing missile and a laser hitting a boss
Three special weapons: a line that covers the full height of the playfield, a homing missile and a laser.

On a watch, a vibration on hit matters as much as the animation; a small explosion on the display is easy to miss. Trigger haptics through a single interface (hit, enemy killed, boss defeated) that each platform implements separately: the watch answers with a vibration, the iPhone in its own way. The phone version has no crown; it has on-screen buttons instead, using the same sensitivity and direction settings.

Screenshot of the game in the Apple Watch simulator, the ship facing a boss
The game in the Apple Watch simulator: the ship facing a boss.

A game loop in a single scene

The whole game can be one scene with one loop:

every frame:
    move all objects
    check collisions
    spawn new enemies based on the time in the level

Add features in the order that lets you test as early as possible:

  • Shooting and player movement. Nothing can be tested without them.
  • Enemies and collisions. The first enemy only needs to fly straight left and die after one hit.
  • Vibration and hit animations. On a small display, the player cannot tell a hit landed without them.
  • Background and HUD. Lives, score, number of special weapons. If the HUD gets in the way on some level, move it to the bottom.

At the end you have a playable loop with lives, score, explosions, a background and player respawn. If you keep the game logic separate from rendering and input, the watch and phone versions can share it.

Rectangle collisions instead of a physics engine

SpriteKit has a physics engine, and it is tempting to use it for collisions too. Every object gets a physics body built from the outline of its sprite, precise collision detection and a category (player, enemy, bullet, special weapon), and the engine reports the pairs that touched on its own. Apple's documentation on physics bodies says that bodies built from a texture's alpha channel are the most accurate and also the most expensive to compute. We tried this in our game and went back to checking collisions by hand.

The manual check is one function that goes through pairs of groups and compares rectangles:

for every pair of groups:
    (player and enemies, player and bullets, bullets and enemies, bullets and bullets)
    for every object A in the first group and object B in the second:
        if rectangle A overlaps rectangle B:
            resolve the hit
Two pairs of sprites with their collision rectangles marked; on the left only the corners of the rectangles overlap
Left: the rectangles overlap, the pixels do not, and the game counts it as a hit. Right: a real hit.

The benefits: you control the order of the checks, and the whole "what happens on a hit" rule lives in one place. The inaccuracy is small, because on an 84 × 48 grid the sprites are so tiny that the rectangle and the real outline differ by only a few virtual pixels.

Pooling only where it pays off

Pooling means that when an object dies you do not delete it, you hide it and reuse it next time. It is tempting to pool everything, explosions and power-ups included. That has a cost: every collision and every update first has to check whether the object is alive at all. One forgotten check and a hidden enemy kills the player, or a hidden bullet shoots down a rocket. When we removed pooling from everything in our game, most of the deleted code was these checks.

A simple rule: pool what there is a lot of and what is always created the same way. The player creates 21 bullets at the start and then only recycles them. Enemies and their bullets are reused too; explosions and power-ups are created and destroyed normally.

Levels as timed tables

A level does not have to be code; a list of records is enough. Each record says what appears, at which second, at what height and how it moves. Movement is a list of points the enemy flies through at constant speed. Optionally there are shot times, a boss charge attack or a spawner of smaller enemies.

every frame:
    time = now - level start - time spent paused
    for every record in the level table:
        if the record is unused and time > record.time:
            create an object from the record
            mark the record as used
Timeline of the first level: seconds from left to right, icon height matches the height at which the enemy appears
Level one: 39 records over 67 seconds. Comets, then waves of rockets, power-ups (red) and a boss at the end.

A level written this way is easy to tune: moving a wave by half a second means changing one number. The timeline also shows the rhythm you are after: a short wave, a pause, a different enemy type, a power-up before a harder stretch.

A rocket flying a zigzag across the playfield, with the turning points of its path marked
A rocket's path from level one: six points it flies through at constant speed.

The downside shows with bigger changes. The densest level in our game has 71 rows, and a table like that is hard to read without an editor. It works better to write levels as waves (a group of enemies sharing one movement pattern) rather than as individual ships.

Endless mode from one pseudo-random number

A leaderboard (Game Center in our case) needs a mode that never ends. There, a generator replaces the hand-written table and builds it while you play. It produces a 20-digit pseudo-random number, and individual pairs of digits decide one wave:

number = next pseudo-random 20-digit number
type = power-up if digits 4 to 5 > 90, otherwise enemy
sprite = digits 1 to 2 modulo number of enemy kinds
pause before the wave = digit 3 / 2 seconds (at least 1)
height = digit 7, scaled to the playfield height
movement pattern = digits 10 to 11 modulo 10
ships in the wave = digit 17 / 2 + 1
speed = digit 13 × (1 + wave number / 10), clamped to 20 to 50
every thirtieth wave = boss, generation stops while the boss is alive

Keep the generator's seed (starting value) fixed in the game. Every player then gets the same sequence of waves, and leaderboard scores are comparable.

Ten small playfields, each with one enemy movement pattern drawn as a dashed line
Ten movement patterns from the generator. The exact shape depends on the height at which the wave appears.

A simulation of the generator outside the game (300 waves, the same seed as in the game) shows how the mode develops. The first boss comes after 29 waves, that is after 74 enemies and 3 power-ups, at roughly 130 seconds. The first 30 waves fly at the minimum speed of 20, because a single digit is still below the lower limit. Average speed then rises to 28.5, 37.5 and 41; from wave 120 on, most enemies are at the maximum. Roughly every eleventh wave is a power-up.

Timeline of the first 135 seconds of endless mode with enemy icons and a boss at the end
The first 135 seconds of endless mode according to the generator simulation. Waves are longer and less regular than in a hand-written level.

The weakness is that the generator knows nothing about rhythm. A hand-written level alternates pressure and rest; the generator does not. The pause during bosses and the rising speed help a bit, but the hand-written levels play better.

Limits of this approach

  • No automated tests. Everything is checked by hand in the simulator and on the watch.
  • Global state. Most of the game state is global variables of a single scene. That is convenient for a watch-only game and inconvenient once you add an iPhone version.
  • No level editor. A timed table is easy to tune in small steps, but bigger changes would call for an editor.

If you are making a game for a watch or another small screen and want help with something, get in touch. How we generate puzzles in another of our apps is covered in the post about our sudoku generator.

← More in Space Impact Watch