SpriteKit Recipe – Custom Scale Mode

Beginner Game iOS SpriteKit Swift Tutorial

One of the first learning hurdles encountered with SpriteKit comes when determining how to get the contents from a scene to display as expected on various devices, each with their own resolution and aspect ratios. We will review a scene’s scaleMode property as a built-in solution that may work for some projects. Then, we will build a custom solution to overcome the limitiations we were otherwise stuck with.

Project Intro
You can grab a copy of the starter project here. It was created using Xcode Version 9.2, by choosing the iOS template “Game” in the Application category. I removed much of the placeholder code and content and added a simple “Tile Map Node” with some grass and water tiles to roughly simulate an island like you might see on a world map for a retro style Role Playing Game (RPG).
The tile art comes from a collection called “Tiny 16” by Sharm. You can find the original collection here.
Because of the pixel-based art style I have selected, I set the scene size to a relatively small resolution of 640×480. This provides what I feel is a balanced view of the world. It is far enough out so that you dont feel claustrophobic, but also close enough to interact with individual tiles by tapping on them. The region that should be rendered by my scene size is enclosed by a white rectangle which you can see in the image below:

Or for a close up of just the region itself – and what I would ideally like to see when I run the game on a device, look at the next image:

Scale Modes
Because the aspect ratio and resolution of your target devices are unlikely to always exactly match what you have set in the editor, SpriteKit allows you to set a “scaleMode” property on the scene to help your content look similar across the board. Let’s go ahead and examine each of the options in turn as it appears on an iPhoneX.
Aspect Fill
If you are following along, build and run the project using an iPhoneX simulator. No changes need to be made to the code yet.

What we see above is the result of the scene being rendered to the view with a default scale mode of .aspectFill. This doesn’t look quite like what I wanted. Granted, the aspect ratio of this device’s screen is different than the one I specified in the scene editor, so it can’t match exactly.
This particular scale option causes the the render to maintain its aspect (so that it doesn’t appear stretched), and to scale itself so that the entire view area is ‘covered’. In practice, this usually means that some of the content will be cropped out of view. Because the iPhoneX is wider than the editor’s size, the top and bottom of the screen are lost.
For the sake of comparison, both versions show the same amount of content horizontally. However, the box in the editor was able to display 15 tiles vertically, while the iPhoneX is only able to show about 9 tiles. It feels like the camera is too close in my opinion.
Aspect Fit
Next, let’s try changing the scale mode to aspect fit. You can see this in the ‘GameViewController.swift’ file at line 22. Change the line to the following:

scene.scaleMode = .aspectFit

Like before, this scale option maintains the aspect so that there is no stretching. It also will resize the content to make sure that ‘all’ of it is visible – albeit at the expense of under-utilizing the available space. Instead of cropping our content, we now have wasted space which is also unacceptable in my opinion.
Fill
Next, let’s try changing the scale mode to ‘fill’.

You could say that this variant attempts to fix the problem of “aspectFill” such that I didn’t want to have my content cropped. It also attempts to fix the problem of “aspectFit” such that I didn’t want to have wasted space on the view. Unfortunately, its solution stretches the content to fill the available space. I still don’t approve.
Resize Fill
There is one last option we can try, resize fill.

This option resizes the scene’s size to match the view size. A larger screen will see more tiles, and a smaller screen will see less. It looks ‘ok’ but isn’t a reliable option for this project because I can’t consistently control the perceived “distance” of the camera to the world, but I want the zoom to feel roughly the same between devices. To help illustrate the problem, look at the result on an iPad Pro below:

Custom Recipe
Of the four options provided as a default solution to our problem, I did not feel that any of them were acceptable. What I would really like to see would be a hybrid option. I want the qualities of “.aspectFit” so that the scene resizes to make the zoom level feel the same on all devices while making sure that all of the original content is visible. I also like the idea of the “.resizeFill” because I don’t want there to be wasted space like I saw on a standard “.aspectFit”.
This hybrid solution is what we will implement now. I will actually resize the scene, but will do so based on an “aspectFill” of the view’s size against the scene’s size. Create a new file named ‘CGSizeExtensions.swift’ and add the following code:

extension CGSize {
func asepctFill(_ target: CGSize) -> CGSize {
let baseAspect = self.width / self.height
let targetAspect = target.width / target.height
if baseAspect > targetAspect {
return CGSize(width: (target.height * width) / height, height: target.height)
} else {
return CGSize(width: target.width, height: (target.width * height) / width)
}
}
}

Next, head back to the ‘GameViewController.swift’ file. I reset the scene’s scaleMode to .aspectFit:

scene.scaleMode = .aspectFit

And then I added an override of “viewDidLayoutSubviews” so that the custom resizing element would apply whenever the screen changes, such as after loading or changing orientation. Note that I used a square size that matches the height of the scene. This way, portrait orientation will look roughly the same as landscape. If I left it as 640×480 like the original setting in the scene, then the portrait version would be zoomed out a little further.

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let view = self.view as? SKView else { return }
let resize = view.frame.size.asepctFill( CGSize(width: 480, height: 480) )
view.scene?.size = resize
}

The result of using this code is seen above. This is the best version yet. None of the vertical content is cropped, there is no stretching, and there is also no wasted space on the sides. In fact, this version is so good that it has exposed my lazyness in making too small of a tilemap for my own demonstration. Hope you enjoyed it anyway!
Summary
I shared my experience with getting content from a scene onto a real device’s view where each device has different resolution and aspect ratio requirements. I developed an understanding of a scene’s “scaleMode” property and each of the four option settings available. Each has its own set of pro’s and con’s but ultimately none were quite right for my own project. This ultimately led to a fifth custom variant that was a hybrid option involving a scaleMode and an altered scene size. If you need help or got stuck along the way, feel free to check out the project in its completed form here.
Become a Patron!