This week we have been working on the local multiplayer of Breaking Fast. The first version of Breaking Fast, which was playtested several months ago, had an ad-hoc art design for only two players, and therefore neither the art nor the code were scalable for moving easily from 1 to 4 players. Therefore, the main work carried out during this week has consisted of refactoring the code so that it accepts a unique set of assets, and that it scales this set according to the number of players. Also, different viewports are created according to the number of players, as shown later.
As every programmer that is reading this post can figure, even for a medium-sized codebase the aformentioned task entails lots of work, but going step by step is the only way to go. In the following sections, I briefly explain the most important code changes that were performed:
1) Substituting scalar values by arrays of values: in the first version of the post, we had something like:
local Camera = require "Camera" local camera1 = Camera() local camera2 = Camera() local Player = require "Player" local player1 = Player.new(...) local player2 = Player.new(...)
Of course, if we want to achieve scalability, this is not the way to go. Therefore, we changed the aforementioned scalar variables into arrays, as follows:
local Camera = require "Camera" for i = 1, numPlayers do cameras[i] = Camera() end local Player = require "Player" for i = 1, numPlayers do players[i] = Player.new(...) end
This applies of course to every element that needs to be replicated according to the number of players.
2) Re-designing assets for one player scenario: in the single player mode, there are no viewports (there are no any other player), and therefore the assets need to be as large as they can be. Under any other circumstances (more than one player), the assets need to be scaled down proportionally according to the resolution and aspect ratio for which the game is being developed, 1920x1080 and 16:9, respectively.
In the first prototype of the game, the assets were designed ad-hoc for two players. For example, the background was designed with a resolution of 1920 x 540, and I would place two different backgrounds in different positions:
background1 = love.graphics.newImage("background.png") love.graphics.draw(background1, 0, 0) background2 = love.graphics.newImage("background.png") love.graphics.draw(background2, 0, 540)
(This is an oversimplification for several reasons; first, we actually add the background to a layer of a camera, because we want it to scroll a little bit. Read this series of posts to get more information on parallax scrolling. Also, what we actually draw is not the background itself, but a batch of backgrounds that we paste once after another in order minimize draw calls and provide an illusion of infinite scenario).
After the changes, we have only one background with a resolution of 1920 x 1080*, and therefore we don't need to place background for different players in different positions; the viewports will take care of this according to the number of players.
*(Again, although this would be technically possible, we use a larger resolution to support scrolling in the vertical axis and for fixing some problems with different aspect ratios, as discussed later).
3) Designing viewports: if we want that more than one player can play on the same machine, we need to provide each player with a fragment of the screen. Each fragment is called a viewport. In the first prototype, we didn't need to worry about viewports, because everything was drawn ad-hoc for two players. However, now we have a unique set of assets, and depending on the number of players, these assets must be replicated for the different players and must be scaled down appropriately.
For example, for two players, we want that the first player is allocated the upper half of the screen, whereas the second player should be provided with the lower half. In the case of four players, each player should have a quarter of the screen.
In order to implement viewports in Löve, I saw two alternatives: using scissors or canvases. I didn't get to really grasp how to use scissors for this purpose in my first attempts (see video below), so I switched my attention quickly to the second option, which turned out to work great.
Fail with using scissor. The second player viewport was rendered onto the first player one.
Canvases represent an off-screen rendering target. Internally, it creates an OpenGL framebuffer object to which the contents are drawn, instead of drawing the contents to the screen. The process from a high-level perspective is as follows:
- Create as many canvases as the number of players (one canvas per camera).
- Depending on the number of players, scale the assets that will be drawn to the canvases.
- Place each canvas in its correct position according to the number of players.
- Draw all the stuff that we used to draw on the screen on the canvases instead, and then, draw the the canvases.
if numPlayers == 3 then cameras:setScale(2, 2) cameras:createCanvas(0.5 * intendedWidth, 0.5 * intentedHeight, 0, 0) cameras:setScale(2, 2) cameras:createCanvas(0.5 * intendedWidth, 0.5 * intentedHeight, 0.5 * intendedWidth, 0) cameras:setScale(2, 2) cameras:createCanvas(0.5 * intendedWidth, 0.5 * intentedHeight, 0.25 * intendedWidth, 0.5 * intentedHeight) ...
The createCanvas() function, which is added to my camera module, takes the width and height of the canvas, and its top left position. This would yield the following layout for the screen:
As part of this viewport design, we also needed to change the camera code for drawing. However, modifying this code to support drawing on canvases was surprisingly easy.
function camera:draw() love.graphics.setCanvas(self.canvas) love.graphics.clear() local bx, by = self.x, self.y for _, v in ipairs(self.layers) do self.x = bx * v.scale self.y = by * v.scale self:set() v.draw() self:unset() end self.x, self.y = bx, by love.graphics.setCanvas() love.graphics.draw(self.canvas, self.canvasLeft, self.canvasTop) end
The lines in bold text are the only additions we needed to add in order to make the function work with canvases.
You can watch the result of our work in the following video. Challenge 1: By the way, do you recognize the musical masterpiece that goes with the video? :P
4) Managing aspect ratios: ensuring that Breaking Fast can be played on most aspect ratios is vital, but it is also tricky. The solution requires twofold work from the artistic and code perspectives.
First, let's see what happens when we execute the code for two players for a 16:9 aspect ratio (the aspect ratio for which the game is being developed):
Now, consider the same code but for a resolution of 1280 x 800, which is a 16:10 aspect ratio and very common for a Macbook Pro, for example:
In order to solve this, the trick is to scale all the assets down to adjust them to the height of the screen, and given that the assets (e.g. the background) are bigger than the screen, the player will have the impression that the adjustement has been perfectly done in both dimensions, as shown next:
There is only one inconvenience. As we are losing the perfect adjustment to the width, the HUD elements like the energy bar or the countdown are not perfectly in the middle or at the end of the screen, respectively, but they have a small offset to the left.
Assuming that we have a perfect adjustment to the width, the middle of the screen is calculated as:
screenMiddleX = 0.5 * intendedWidth
, where intendedWidth is the width of the design resolution (1920). If we want to correct the position, we need to multiply this value by the following proportion:
screenMiddleX = 0.5 * intendedWidth * (scaleForAdjustToHeight / scaleForAdjustToWidth)
The result would be as follows:
Note that now the HUD elements are now properly placed. The result for many different aspect ratios is shown in the next video. Challenge 2: again, do you recognize the composition that accompanies the video?
And that's all for now. We'll keep you posted on the advances of Breaking Fast, so stay tuned!