In Part 1 we laid the groundwork for our game, creating a cross-platform graphics engine, a rectangular world with a moving player avatar, and a platform layer for iOS.
We will now build upon the code we created in Part 1, adding a 2D maze, user input and collision handling. If you haven't already done so, I strongly suggest you work through Part 1 and write the code yourself, but if you prefer you can just download the project from here.
Wolfenstein, at its heart, is a 2D maze game built on a 64x64 tile grid. A grid of fixed-size tiles is a little too crude to create realistic architecture, but it's great for making twisting corridors, hidden alcoves and secret chambers. It's also really easy to define in code.
Create a new file in the Engine module called Tile.swift
with the following contents:
public enum Tile: Int, Decodable {
case floor
case wall
}
Then, create another file called Tilemap.swift
:
public struct Tilemap: Decodable {
private let tiles: [Tile]
public let width: Int
}
public extension Tilemap {
var height: Int {
return tiles.count / width
}
var size: Vector {
return Vector(x: Double(width), y: Double(height))
}
subscript(x: Int, y: Int) -> Tile {
return tiles[y * width + x]
}
}
If you were thinking this looks familiar, you'd be right. Tilemap
is pretty similar to the Bitmap
struct we defined in the first part (they even both have "map" in the name). The only difference is that instead of colors, Tilemap
contains tiles[1].
In the World
struct, replace the stored size
property with:
public let map: Tilemap
Then modify the World.init()
method to look like this:
public init(map: Tilemap) {
self.map = map
self.player = Player(position: map.size / 2)
}
Since size
is now a sub-property of map
, rather than being of a property of the world itself, let's add a computed property for the size so we don't break all the existing references:
public extension World {
var size: Vector {
return map.size
}
...
}
To draw the map we will need to know if each tile is a wall or not. We could just check if the tile type is equal to .wall
, but later if we add other kinds of wall tile, such code would break silently.
Instead, add the following code to Tile.swift
:
public extension Tile {
var isWall: Bool {
switch self {
case .wall:
return true
case .floor:
return false
}
}
}
The switch
statement in the isWall
property is exhaustive, meaning it doesn't include a default:
clause, so it will fail to compile if we add another case without handling it. That means we can't accidentally introduce a bug by forgetting to update it when we add new wall types.
Repeating that switch
everywhere we access the tiles would be a nuisance, but by using the isWall
property instead, we can ensure the code is robust without being verbose.
In Renderer.draw()
add the following block of code before // Draw player
:
// Draw map
for y in 0 ..< world.map.height {
for x in 0 ..< world.map.width where world.map[x, y].isWall {
let rect = Rect(
min: Vector(x: Double(x), y: Double(y)) * scale,
max: Vector(x: Double(x + 1), y: Double(y + 1)) * scale
)
bitmap.fill(rect: rect, color: .white)
}
}
That takes care of drawing the wall tiles. But so that we can actually see the white walls against the background, in Renderer.init()
change the line:
self.bitmap = Bitmap(width: width, height: height, color: .white)
to:
self.bitmap = Bitmap(width: width, height: height, color: .black)
Now that we have code to draw the map, we need a map to draw.
Here we hit upon another natural division between architectural layers. So far we have only really dealt with two layers: the game engine and the platform layer. Gameplay specifics such as particular map layouts do not really belong in the engine, but they are also not tied to a particular platform. So where do we put them?
We could create a whole new module for game-specific code, but even better would be if we could treat our game as data to be consumed by the engine, rather than code at all.
You may have noticed that the Tile
enum has an Int
type, and conforms to Decodable
(as does Tilemap
itself). The reason for this is to facilitate defining maps in JSON rather than having to hard-code them.
Create a new empty file in the main project called Map.json
and add the following contents:
{
"width": 8,
"tiles": [
1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 1, 1, 1, 0, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1
]
}
The numbers match the case values in the Tile
enum, so 0
is a floor tile and 1
is a wall. This is a tiny and trivial map, but it will serve to test the engine. You can replace it with a different map size or layout if you prefer - it won't affect the rest of the tutorial.
In ViewController.swift
, add the following free function outside the ViewController
class[2]:
private func loadMap() -> Tilemap {
let jsonURL = Bundle.main.url(forResource: "Map", withExtension: "json")!
let jsonData = try! Data(contentsOf: jsonURL)
return try! JSONDecoder().decode(Tilemap.self, from: jsonData)
}
Then replace the line:
private var world = World()
with:
private var world = World(map: loadMap())
Run the app again and you should see the map rendered in all its glory:
The player looks a bit silly now though, drifting diagonally across the map without regard for the placement of walls. Let's fix that.
For maximum flexibility in the map design, the player's starting position should be defined in the JSON file rather than hard-coded.
We could add a new case called player
to the Tile
enum, and then place the player in the map array the same way we place walls, but if we later add different types of floor tile we'd have no way to specify the type of floor underneath the player if they both occupied the same element in the tiles
array.
Instead, let's add a new type to represent non-static objects in the map. Create a new file called Thing.swift
in the Engine module, with the following contents:
public enum Thing: Int, Decodable {
case nothing
case player
}
Then add a things
property to Tilemap
:
public struct Tilemap: Decodable {
private let tiles: [Tile]
public let things: [Thing]
public let width: Int
}
Finally, add an array of things
to the Map.json
file:
{
"width": 8,
"tiles": [
1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 1, 1, 1, 0, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 0, 0, 1, 0, 0, 0, 1,
1, 1, 1, 1, 1, 1, 1, 1
],
"things": [
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0
]
}
Unlike the map tiles, we can't just look up the player position from the things
array each time we need it, because once the player starts moving their position will no longer align to the tile grid.
Instead we'll use things
to get the initial player position, after which the Player
object will keep track of its own position. In World.init()
, replace the line:
self.player = Player(position: map.size / 2)
with:
for y in 0 ..< map.height {
for x in 0 ..< map.width {
let position = Vector(x: Double(x) + 0.5, y: Double(y) + 0.5)
let thing = map.things[y * map.width + x]
switch thing {
case .nothing:
break
case .player:
self.player = Player(position: position)
}
}
}
This code scans through the things array and adds any non-zero things (currently just the player) to the world. Note that we add 0.5
to the map X and Y coordinate when we set the player's starting position so that the player will be placed in the center of the tile instead of at the top-left corner.
If we try to run the app now the compiler will complain that we are trying to return from the initializer without initializing all properties. Although we can see that there is a 1
in the things
array, the Swift compiler isn't able to detect that statically.
To fix that, add a !
to the player
property, converting it into an Implicitly Unwrapped Optional.
public var player: Player!
Using an IUO means the game will crash if we forget to include the player in things
, but the game can't work without a player anyway.
With the player avatar in the correct starting position, we now need to fix the player's movement. We'll start by removing the hard-coded diagonal motion. In Player.swift
, change the line:
self.velocity = Vector(x: 1, y: 1)
to:
self.velocity = Vector(x: 0, y: 0)
Now, to make the player move properly we need to implement user input. User input is handled by the platform layer, as it will vary significantly between different target devices. On a Mac you'd probably use arrow keys for movement. On tvOS you'd use a joypad. For iOS, we'll need to implement touch-based movement.
The most effective form of touch-based interface is direct manipulation, but that doesn't translate well to games where player movement often extends beyond a single screen. Traditional game control schemes also don't work well on a touch screen - some early ports of classic games to iOS had truly horrible controls because they tried to replicate physical buttons and joysticks with on-screen icons[3].
The best general-purpose movement control I've found for action games is a floating joystick, where the stick position is measured relative to where the finger first touched down instead of a fixed point. The benefit of this approach is that it's much less susceptible to finger-drift than a fixed joystick, meaning that the player can keep their eyes on the action without worrying about their finger slipping off the controls.
There are a couple of different ways to implement a joystick on iOS, but the most straightforward is to use a UIPanGestureRecognizer
. Go ahead and add a panGesture
property to the ViewController
class:
class ViewController: UIViewController {
private let imageView = UIImageView()
private let panGesture = UIPanGestureRecognizer()
...
}
Then add the following line to viewDidLoad()
:
view.addGestureRecognizer(panGesture)
Conveniently, UIPanGestureRecognizer
already computes the relative finger position from the point at which the gesture started, so we don't need to store any state information beyond what the gesture recognizer already tracks for us.
In a normal app we'd use the target/action pattern to bind a method that would be called whenever the gesture was recognized, but that's not really helpful in this case because we don't want to be informed about pan gestures as they happen, we want to sample them once per frame. Instead of an action method, we'll use a computed property for the input vector.
Add the following code to ViewController
:
private var inputVector: Vector {
switch panGesture.state {
case .began, .changed:
let translation = panGesture.translation(in: view)
return Vector(x: Double(translation.x), y: Double(translation.y))
default:
return Vector(x: 0, y: 0)
}
}
This input needs to be passed to the engine. We could directly pass the vector, but we'll be adding other inputs in future (e.g. a fire button), so let's package the input up inside a new type. Create a new file in the Engine module called Input.swift
with the following contents:
public struct Input {
public var velocity: Vector
public init(velocity: Vector) {
self.velocity = velocity
}
}
Then replace the following line in ViewController.update()
:
world.update(timeStep: timeStep)
with:
let input = Input(velocity: inputVector)
world.update(timeStep: timeStep, input: input)
And in the World.swift
file, change the update()
method to:
mutating func update(timeStep: Double, input: Input) {
player.velocity = input.velocity
player.position += player.velocity * timeStep
player.position.x.formTruncatingRemainder(dividingBy: size.x)
player.position.y.formTruncatingRemainder(dividingBy: size.y)
}
If you try running the app now, and drag your finger around the screen, you'll see that the player moves way too fast. The problem here is that we are measuring the input vector in screen points, but player movement in the game is supposed to be specified in world units - a finger swipe across the screen covers far more points than tiles.
The solution is to divide the input vector by some value - but what value? We could use the relative scale factor of the screen to the world, but we don't really want the player speed to depend on the size of the world and/or screen.
What we need to do is normalize the input velocity, so that its value is independent of the input method. Regardless of the raw input values produced by the platform layer the game should always receive a value in the range 0 to 1.
In order to normalize the input, we first need to pick a maximum value. If you think about a real joystick on a gamepad, they typically have a travel distance of about half an inch. On an iPhone that's roughly 80 screen points, so a sensible joystick radius would be around 40 points (equivalent to 80 points diameter).
Add a new constant to the top of the ViewController.swift
file (not inside the ViewController
class itself):
private let joystickRadius: Double = 40
Now we have defined the maximum, we can divide the input by that value to get the normalized result. In the inputVector
getter, replace the line:
return Vector(x: Double(translation.x), y: Double(translation.y))
with:
var vector = Vector(x: Double(translation.x), y: Double(translation.y))
vector /= joystickRadius
return vector
That's almost the solution, but there's still a problem. If the user drags their finger further than 40 points, the resultant vector magnitude will be > 1, so we need to clamp it. In order to do that, we need to calculate the actual length of the vector.
Pythagoras's theorem states that the length of the hypotenuse of a right-angle triangle is equal to the square root of the sum of the squares of the other two sides. This relationship allows us to derive the length of a vector from its X and Y components.
In Vector.swift
, add a computed property for length
:
public extension Vector {
var length: Double {
return (x * x + y * y).squareRoot()
}
...
}
Then in ViewController.inputVector
, replace the line:
vector /= joystickRadius
with:
vector /= max(joystickRadius, vector.length)
With that extra check, we can be confident that the resultant magnitude will never be greater than one. Run the app again and you should find that the player now moves at reasonable speed. If anything, one world unit per second is a bit slow for the maximum speed. Add a speed
constant to Player
so we can configure the maximum rate of movement:
public struct Player {
public let speed: Double = 2
...
}
Then, in World.update()
, multiply the input velocity by the player's speed to get the final velocity:
func update(timeStep: Double, input: Vector) {
player.velocity = input.velocity * player.speed
...
}
The speed is reasonable now, but movement feels a bit laggy. The problem is, if you drag more than 40 points and then try to change direction, you have to move your finger back inside the joystick radius before it will register movement again. This was exactly the problem we wanted to mitigate by having a floating joystick, but although the joystick re-centers itself whenever you touch down, its position still remains fixed until you raise your finger.
Ideally, it should never be possible to move your finger off the joystick. Attempting to move your finger outside the joystick radius should just drag the joystick to a new location.
In the same way that we clamped the vector to prevent input values > 1, we can also clamp the cumulative translation value of the gesture itself. UIPanGestureRecognizer
has a setTranslation()
method that lets you update the distance travelled, so in the inputVector
computed property, just before the return vector
line, add the following:
panGesture.setTranslation(CGPoint(
x: vector.x * joystickRadius,
y: vector.y * joystickRadius
), in: view)
This uses the clamped vector
value multiplied by the joystickRadius
to get the clamped translation distance in screen points, then reassigns that to the gesture recognizer, effectively changing its record of where the gesture started.
Next, we need to solve the problem of the player moving through walls. To prevent this, we first need a way to detect if the player is intersecting a wall.
Because the map is a grid, we can identify the tiles that overlap the player rectangle by taking the integer parts of the min
and max
coordinates of the rectangle to get the equivalent tile coordinates, then looping through the tiles in that range to see if any of them is a wall.
We'll implement that as an isIntersecting()
method on Player
:
public extension Player {
...
func isIntersecting(map: Tilemap) -> Bool {
let minX = Int(rect.min.x), maxX = Int(rect.max.x)
let minY = Int(rect.min.y), maxY = Int(rect.max.y)
for y in minY ... maxY {
for x in minX ... maxX {
if map[x, y].isWall {
return true
}
}
}
return false
}
}
That gives us a way to detect if the player is colliding with a wall. Now we need a way to stop them from doing that. A simple approach is to wait for the collision to happen, then undo it. Replace the code in World.update()
with the following:
mutating func update(timeStep: Double, input: Input) {
let oldPosition = player.position
player.velocity = input.velocity * player.speed
player.position += player.velocity * timeStep
if player.isIntersecting(map: map) {
player.position = oldPosition
}
}
If you run the game again, you'll see that it's now not possible to walk through walls anymore. The only problem is that now if you walk into a wall, you get stuck, unless you walk directly away from the wall again.
Once you touch the wall, any attempt at lateral movement is likely to lead to interpenetration and be rejected by the update handler. In fact, it's not really possible to move down the one-unit-wide corridor because of this.
We can mitigate the problem a bit by reducing the player's radius
. The current value of 0.5
, means the player's diameter is one whole world unit - the same width as the corridor. Reducing it to 0.25
will give us a bit more freedom to move around. In Player.swift
replace the line:
public let radius: Double = 0.5
with:
public let radius: Double = 0.25
That helps with the narrow corridors, but we still stick to the walls when we hit them. It would be much nicer if you could slide along the wall when you walk into it, instead of stopping dead like you'd walked into flypaper.
To fix this, we need to go beyond collision detection, and implement collision response. Instead of just working out if the player is intersecting a wall, we'll calculate how far into the wall they have moved, and then push them out by exactly that amount.
To simplify the problem, we'll start by computing the intersection vector between two rectangles, then we can extend it to work with player/map collisions.
To get the intersection, we measure the overlaps between all four edges of the two rectangles and create an intersection vector for each. If any of these overlaps is zero or negative, it means the rectangles are not intersecting, so we return nil
. Then we sort the vectors to find the shortest.
Add the following code in Rect.swift
:
public extension Rect {
func intersection(with rect: Rect) -> Vector? {
let left = Vector(x: max.x - rect.min.x, y: 0)
if left.x <= 0 {
return nil
}
let right = Vector(x: min.x - rect.max.x, y: 0)
if right.x >= 0 {
return nil
}
let up = Vector(x: 0, y: max.y - rect.min.y)
if up.y <= 0 {
return nil
}
let down = Vector(x: 0, y: min.y - rect.max.y)
if down.y >= 0 {
return nil
}
return [left, right, up, down]
.sorted(by: { $0.length < $1.length }).first
}
}
In Player.swift
, replace the isIntersecting(map:)
method with the following:
func intersection(with map: Tilemap) -> Vector? {
let minX = Int(rect.min.x), maxX = Int(rect.max.x)
let minY = Int(rect.min.y), maxY = Int(rect.max.y)
var largestIntersection: Vector?
for y in minY ... maxY {
for x in minX ... maxX where map[x, y].isWall {
let wallRect = Rect(
min: Vector(x: Double(x), y: Double(y)),
max: Vector(x: Double(x + 1), y: Double(y + 1))
)
if let intersection = rect.intersection(with: wallRect),
intersection.length > largestIntersection?.length ?? 0 {
largestIntersection = intersection
}
}
}
return largestIntersection
}
The basic structure of the new method is the same as before: identifying the potentially overlapping tiles and looping through them. But now we actually compute the intersection vectors - rather than just a boolean to indicate that an intersection has occurred - and return the largest intersection detected.
In World.swift
, change the update()
method to use the new intersection method:
mutating func update(timeStep: Double, input: Input) {
player.velocity = input.velocity * player.speed
player.position += player.velocity * timeStep
if let intersection = player.intersection(with: map) {
player.position -= intersection
}
}
If an intersection was detected, we subtract the vector from the player's current position. Unlike the previous solution, this won't move the player back to where they were before, but instead it will move them the shortest distance necessary to push them out of the wall. If they approach the wall at an angle, their lateral movement will be preserved and they will appear to slide along the wall instead of stopping dead.
If you run the game now and try sliding against a wall you may find that the player occasionally sinks into it, or even passes right through it to the other side.
The problem is that if the player is at an intersection between two walls, pushing them out of one wall tile can potentially push them right into another one, since we only handle the first intersection we detect.
That would probably be corrected by a subsequent collision response, but that wouldn't happen until one or more frames later, and in the meantime the player may have moved further into the wall.
Instead of waiting until the next frame to apply further collision response steps, we can replace the if
statement in the update logic with a while
loop.
In World.update()
replace the following line:
if let intersection = player.intersection(with: map) {
with:
while let intersection = player.intersection(with: map) {
With that change in place, the collision logic will keep nudging the player until there are no further intersections detected.
This collision response mechanism is still a bit fragile however, because it assumes that a player can never walk more than their own diameter's depth through a wall in a single frame. If they did somehow manage to do that, they could get trapped - stuck in an infinite loop of being nudged out of one wall into another and then back again. If they managed to go even faster they might pass the center point of the wall and end up getting pushed out the other side.
So what would it take for that to happen? At the current player movement speed of 2 units-per-second, and a player diameter of 0.5 units, they will travel their own diameter's distance in 0.25 seconds (one quarter of a second). In order to prevent the player potentially getting stuck, we need to guarantee that the size of each time step remains well below that limit.
Of course, the game would be completely unplayable at 4 frames per second, and we would never ship it in that state, but there are plenty of scenarios where a game might suffer a single-frame stutter. For example, if the player answers a phone call in the middle of a game, the game will move into the background and the frame timer will be paused. When the player resumes 10 minutes later, we don't want their avatar to suddenly jump forward several thousand pixels as if they had been moving that whole time.
A brute-force solution is to cap the update timeStep
at some sensible maximum like 1/20 (i.e. a minimum of 20 FPS). If the frame period ever goes over that, we'll just pass 1/20 as the timeStep
anyway.
Add a new constant to the top of the ViewController.swift
file:
private let maximumTimeStep: Double = 1 / 20
Then in update()
, replace the line:
let timeStep = displayLink.timestamp - lastFrameTime
with:
let timeStep = min(maximumTimeStep, displayLink.timestamp - lastFrameTime)
That gives us a little bit of breathing room, but what happens later if we want to add projectiles with a radius of 0.1 units that travel 100 units per second? Well, we can't very well set a minimum frame rate of 1000 FPS, so we need a way to make the time steps as small as possible without increasing the frame rate.
The solution is to perform multiple world updates per frame. There is no rule that says world updates have to happen in lock-step with drawing[4]. For a game like this, where the frame rate will likely be limited by rendering rather than physics or AI, it makes sense to perform multiple world updates for every frame drawn.
If the actual frame time is 1/20th of a second, we could update the world twice, passing a timeStep
of 1/40th each time. The player won't see the effect of those intermediate steps on screen, so we won't bother drawing them, but by reducing the time between updates we can improve the accuracy of collisions and avoid any weirdness due to lag.
Since the frame rate will be 60 FPS on most systems, a time step of 1/120 (120 FPS) seems reasonable for now (we can always increase it later if we need to handle tiny and/or fast-moving objects). Add the following constant to the top of ViewController.swift
:
private let worldTimeStep: Double = 1 / 120
Then, in the update()
method, replace the line:
world.update(timeStep: timeStep, input: input)
with:
let worldSteps = (timeStep / worldTimeStep).rounded(.up)
for _ in 0 ..< Int(worldSteps) {
world.update(timeStep: timeStep / worldSteps, input: input)
}
Regardless of the frame rate, the world will now always be updated at a minimum of 120 FPS, ensuring consistent and reliable collision handling.
You might be thinking that with the worldTimeStep
logic, we no longer need the maximumTimeStep
check, but actually this saves us from another problem - the so-called spiral of death that can occur when your world updates take longer to execute than your frame step.
By limiting the maximum frame time, we also limit the number of iterations of the world update. 1/20 divided by 1/120 is 6, so there will never be more than six world updates performed for a single frame drawn. That means that if a frame happens to take a long time (e.g. because the game was backgrounded or paused), then when the game wakes up it won't try to execute hundreds of world updates in order to catch up with the frame time, blocking the main thread and delaying the next frame - and so on - leading to the death spiral.
That's it for Part 2. In this part we...
- Loaded and displayed a simple maze
- Added touch input for moving the player
- Added collision detection and response so the player doesn't pass through walls
- Refined the game loop and decoupled world updates from the frame rate
In Part 3 we'll take the jump into 3D and learn how to view a 2D maze from a first person perspective.
-
Try creating a larger, more intricate maze. Does the maze have to be square?
-
Add a new object type, such as pillar. Should this be a
Tile
or aThing
? How would you draw it? What about collision handling? -
Physics-based games often use a fixed frame rate to ensure deterministic behavior. Our frame rate is now guaranteed to be at least 120 steps per second, but it's not actually deterministic. For example, at a frame rate of 50 frames per second, 120/50 == 2.4 steps per frame. We round that up to 3 steps, so our total world steps would be 50 * 3 = 150 steps per second, not 120. How could you modify the update loop to perform 120 steps per second, regardless of the frame rate?
[1] We could replace both of these with a single Map
type, perhaps using generics or a protocol in place of Color
/Tile
, but despite the superficial similarity, these two structs serve significantly different roles, and are likely to diverge as we add more code, rather than benefitting from a shared implementation.
[2] You may wonder why we haven't bothered with any error handling in the loadMap()
function? Since the Map.json
file is bundled with the app at build time, any runtime errors when loading it would really be symptomatic of a programmer error, so there is no point in trying to recover gracefully.
[3] I'd name and shame some examples, but due to the 32-bit Appocalypse of iOS 11, most of them don't exist anymore.
[4] Modern games often run their game logic on a different thread from graphics rendering. This has some nice advantages - such as improved input responsiveness - but it adds a lot of complexity because you need to ensure that all the data structures shared by the game logic and the renderer are thread-safe.