Skip to content
2026Team project

Astrocat

A real-time multiplayer vertical platformer where players race through generated levels to become the first cat to reach the moon.

GitHub

Role
iOS game development
Contribution
Procedural level generation, multiplayer prototyping, race flow
Key challenges
Playable procedural generation, real-time synchronization, game physics
Tech stack
SpriteKit, GameplayKit, GameKit, SwiftUI

Overview

Astrocat is a real-time multiplayer vertical platformer we built at Apple Developer Academy. Players race upward through a generated level, trying to become the first cat to reach the moon.

Our team started with a challenge to build a game using Apple's ecosystem. We agreed early that we wanted something competitive and fast-paced, which eventually became Astrocat.

My first time building a game

Before Astrocat, most of my work had been in web and application development. This was my first game project, and it introduced me to problems I had not needed to think about this directly before.

Movement had to feel right. A randomly generated level still had to be possible to finish. Other players had to move smoothly even though their state only arrived over the network at intervals.

My main contribution centered on the level generator, alongside multiplayer prototyping and parts of the race flow. We wanted each race to feel different without hand-building every level, so I started by randomizing where the platforms could appear.

Making random levels playable

Procedural generation turned out to be harder than I expected.

My first approach was much closer to placing platforms randomly within a set of ranges. That gave us variation quickly, but it also exposed the problem with treating randomness as the main goal. Platforms could stack awkwardly, the route could stay in one part of the map for too long, or a jump could become difficult enough that the level was no longer realistically finishable.

I changed the generation logic several times as we tested it. The system gradually moved away from arbitrary placement and toward generating randomness inside a set of gameplay constraints.

The final generator ended up working in a few stages:

  1. Playable backbone
  2. Distraction platforms
  3. Trap placement
  4. Generated level

Building a route before adding variation

After a few iterations, I found that it worked better to start with a reliable route first, then add randomness around it. That became the backbone: a path that moves upward through the level one row at a time.

For each next platform, the generator chooses a possible direction but still checks whether the new position is reachable from the previous platform. If the preferred placement does not work, it tries other directions before falling back to a safe position.

It also keeps track of how the route has been moving. Staying in the same column too often is discouraged, reaching the edge forces the route back inward, and the level is divided into rough left, center, and right zones so the path does not stay in one area indefinitely.

The randomness is still there, but it operates inside rules that protect the route through the level.

Adding distractions without breaking the route

Once there was a guaranteed path upward, the next problem was that following one obvious route every time could become predictable.

I added additional platforms around the backbone as distractions. These are not necessary to complete the level, but they give the player other possible directions to take during a race.

Those platforms still have constraints of their own. They need enough spacing from nearby platforms, cannot occupy an existing cell, should not stack awkwardly above or below another platform, and still need to be reachable from somewhere nearby.

Some of them can also form short chains that move away from the backbone. That gave the level more variation without allowing those optional branches to replace the route that guarantees the level can still be completed.

Placing traps under different constraints

Traps made the generation problem more complicated because not every obstacle belongs in the same kind of position.

Some traps sit directly on platforms, while others make more sense in open space. Their placement therefore uses different candidate positions rather than treating every trap the same way.

I also had to keep traps from appearing too close to one another and avoid placements that would make sections of the route unreasonable to traverse.

That was one of the things I learned from working on the generator: procedural generation became less about asking "where should I put something randomly?" and more about defining which positions were valid for each kind of object.

Reproducing the same level in multiplayer

The generator uses a seeded random source, so the same seed can reproduce the same sequence of generation decisions.

That became useful for multiplayer too.

Instead of sending the generated position of every platform and trap to every player, the match can share the same seed and let each device construct the level locally.

A value that originally existed to control randomness therefore became part of the synchronized race state.

Exploring real-time multiplayer

Before settling on the final multiplayer approach, our team explored its technical feasibility through a few prototypes. I worked on the GameKit prototype, using it to test how real-time multiplayer could work for Astrocat.

GameKit handled things such as player authentication, matchmaking, and establishing a GKMatch. What I still had to figure out was what the game actually needed to send between devices, how often to send it, and what should happen when those updates arrived.

Sending movement without sending every frame

One of the first questions I had was how often player movement actually needed to be sent.

My first instinct was that smoother multiplayer would require sending positions as frequently as possible. In practice, that was unnecessary. I experimented with sending the player's position and velocity roughly every 0.05 seconds, or around 20 updates per second, while each device continued rendering the game normally.

The trade-off showed up on the receiving side. Those updates arrive as separate positions rather than continuous movement, so applying each new position directly could make the remote player visibly jump between them.

Instead, I moved the remote player toward the latest received position over a short duration. It was a simple form of smoothing, but it made the movement appear much more continuous without increasing how often movement had to be sent.

That experiment helped me separate two things I had initially thought of as the same problem: how often the game renders and how often player state needs to travel over the network. Smooth remote movement did not require sending every possible position. It required enough updates to follow the player, together with a way to handle the gaps between them.

Treating movement and race events differently

Once movement was working, I found that different kinds of multiplayer data needed different delivery guarantees.

Player position changes constantly. If one update is lost, a newer position will arrive shortly afterward, so receiving the older update late is not especially useful. Movement updates therefore use unreliable delivery.

Race state is different. Missing something such as the start of a race, a player's finish, or the beginning of the next round can leave devices in different states, so those messages use reliable delivery instead.

We also needed to make sure players had actually reached the lobby before starting a race. Each device sends a small reliable playerReady heartbeat once per second, and the host keeps track of which players have reported ready. Once everyone is ready, the host can start the match and broadcast the shared level seed.

There is still a timeout fallback so the host does not wait indefinitely if the readiness handshake does not complete normally.

Working through this made me realize that multiplayer synchronization is not about sending everything as often or as reliably as possible. Movement becomes outdated quickly, while race events need to arrive consistently. The important part was deciding what kind of state I was sending and what guarantees that state actually needed.

Learning how game systems fit together

Astrocat also introduced me to engineering ideas that were quite different from what I had encountered in my previous application work.

Two of the biggest ones for me were how directly physics affected the experience and how GameplayKit organized game behavior using ECS.

Physics that affected how the game felt

Before this project, I mostly associated physics with what I had learned in school rather than software development.

Building a platformer changed that quickly. Horizontal movement depends on velocity, jumping changes vertical motion, gravity continuously pulls the player back down, and collisions determine how the character interacts with platforms and obstacles.

What surprised me was how visible small changes became. Adjusting movement speed or jump strength could make the same character feel responsive, heavy, floaty, or frustrating.

The movement could be technically correct and still not feel good to play. That made me see physics as part of the player experience rather than just something happening underneath it.

Learning ECS through GameplayKit

Astrocat was also my first introduction to Entity-Component-System architecture through GameplayKit.

The easiest way for me to understand ECS was through the player itself. The entity is the player, or the cat participating in the race. Instead of putting everything the player can do inside one large object, its responsibilities are attached as smaller components.

For example, the player has components for different parts of its behavior:

  • InputComponent keeps track of joystick and jump input.
  • MovementComponent holds values such as movement speed and jump impulse.
  • LocomotionComponent keeps track of movement states such as idle, running, and jumping.
  • StatusComponent handles temporary states such as being slowed or stunned.
  • CameraComponent describes how the camera should follow the player.

The systems are the part that act on those pieces during the game loop. The movement system, for example, reads the player's input, movement values, and current status, then updates the SpriteKit physics body by changing its horizontal velocity or applying a jump impulse. The camera system can update the camera separately without movement needing to know how the camera works.

So instead of thinking of the player as:

Player = input + movement + camera + status + every behavior

I started thinking of it more as a game object assembled from smaller pieces that could be updated independently.

That made ECS much easier for me to understand. It was also different from the application architecture I was used to, and showed me why composition becomes useful when many game behaviors need to interact.

Bringing the race together

The individual systems eventually had to behave like one race.

I worked on parts of that flow including the shared race start, countdown, finish timing, support for more than two players, and moving players into another round. These pieces depended on the multiplayer state staying consistent across devices, especially when starting and ending a race.

This was also my first time using TestFlight to distribute and test a game on real devices. Testing with multiple devices made issues around timing, synchronization, controls, and the overall feel of the race much easier to notice.

Reflection

Astrocat changed how I think about game development more than I expected.

The biggest shift came from procedural generation. My first instinct was to think about randomness as the feature, but most of the actual work became defining constraints around that randomness. The final generator is still unpredictable, but only after establishing what needs to remain playable.

I would keep that general approach: generate a safe structure first, then introduce variation around it.

The project also made several concepts that had previously felt abstract much more practical. Physics directly changed how the controls felt. ECS gave me a different way to organize interacting behaviors. Multiplayer forced me to distinguish between local state, shared state, and state that only needed to be approximated on another device.

Testing generated levels more systematically

Most of our evaluation of the generator came from repeatedly playing the levels it produced and adjusting the rules when something felt wrong.

If I continued the project, I would make that more systematic.

I would generate a much larger set of levels and automatically inspect properties such as whether a complete backbone exists, the horizontal distance between successive jumps, platform distribution across the map, trap density, and how often similar patterns repeat.

That would give me a way to evaluate changes to the generator beyond the handful of seeds we happened to encounter while testing.

Testing multiplayer under worse conditions

I would approach the multiplayer prototype similarly.

The movement synchronization was enough for us to explore the idea, but I did not benchmark it under controlled latency, jitter, or packet loss.

If I continued working on it, I would simulate those conditions and compare the simple movement smoothing I used with approaches such as snapshot interpolation or prediction. I would also measure how changing the network update rate affects bandwidth and how remote movement feels rather than choosing that value mostly by observation.

The biggest thing I took away from Astrocat is that game systems become visible very quickly when their assumptions are wrong. A generator can produce an impossible jump, a physics value can make the character feel wrong, and a networking decision can make another player look broken even when the underlying state is technically correct.