hey!
after a sultry move to california I am now ready to emerge from my chrysalis a plump and boisterous game developer once more. if you'll lend me your eyes, I decided I'd like to talk a bit about how the camera works in game. I've been tweaking it a bit more recently and it's returned me to the right headspace. so!
camera cameraderie
essentially from the moment we decided to focus on non-fixed-screen multiplayer I was very conscious of how the camera should behave to accommodate players in different stages of the race. the whole respawn system didn’t come for a while but I knew that I was invariably going to have to deal with players in very different regions of the screen at any given point in time. so ignoring the dynamic tag-team respawns, the problem in essence has always been: how do we keep the relevant aspects of gameplay visible on screen at all times in spite of players having freedom of movement over the entire level?
dynamic zooming + velocity lookahead the camera smoothly interpolates from its current position to the “true” (goal) position, which is determined from the players’ positions. the naïve way to get a goal position ignoring all positional information is just to take the average of player positions. this doesn’t make sense in a racing context however, as somebody at the back of the pack has the same amount of power as anybody else to drag the camera towards them.
// Naïve positional averaging
if (EqualPlayerPriority) {
// Regular averaging of positions
tx = 0;
ty = 0;
foreach (Player player in GameManager.ActivePlayers) {
tx += player.transform.position.x;
ty += player.transform.position.y;
}
tx /= GameManager.NumPlayersActive;
ty /= GameManager.NumPlayersActive;
}
this way, if we have 4 players in game, they each get 0.25 or 25% of the positional influence. I actually use this just for the spawn room before races begin, since there’s not really a concept of a “leader” in that context and it’s nice to have a bit of motion alongside player movement from the get-go.
in the race itself, we don’t want somebody who’s ultra-fast to entirely eclipse the other players, nor somebody lagging behind to feel overly penalised too early. I store the players in a list sorted by their position in the race (I’ll make another post about how this is figured out since it actually turned out to be less straightforward than anticipated due to the room generation algorithm detailed in a previous post). the goal position is then weighted towards players in the lead, ensuring that the camera’s progressively following the “action” without straying too far from the pack.
the weighted camera positional update then looks a bit more like this:
// Weight camera priority towards players in the lead
float total = 0;
tx = 0; // goal x
ty = 0; // goal y
// playerOrderList is sorted from the leader at i = 0 to the furthest behind at i = totalActivePlayers
for (int i = 0; i < GameManager.playerOrderList.Count; i++) {
Player player = GameManager.playerOrderList[i];
// 'inverse' weight based on position - higher val for further forward players
float contribution = GameManager.playerOrderList.Count - i;
if (i == 0) contribution *= 2f; // Give double the standard weight to the leader
total += contribution;
// Update from position alone
Vector2 pos = player.transform.position;
tx += contribution * pos.x;
ty += contribution * pos.y;
}
tx /= total;
ty /= total;
this works great except for in situations where players have very high velocities, in the case of e.g. slippy surfaces – since the camera’s position is smoothed out over time, in rare occasions the leader is able to accidentally run offscreen and get killed by a trigger volume (more on this below). so I just added a similarly weighted velocity offset to the positional update, smoothing this over the course of a few frames, to make sure it doesn’t end up looking absurdly shaky when players jump around.
the final ingredient paving the way to the current version is dynamic zooming. muddledash’s camera has a bunch of triggers associated with it:
to make a bit more sense of things, here’s a colour coded version of it:
the key areas to look at are the innermost 2, the teal and green.
if we detect a player in the teal area it implies that they’re significantly away from the goal camera position, which is roughly the center point of the screen (aside from the positional weighting). so this will tell the camera to zoom further out as long as at least one octopus is occupying a teal trigger’s zone. there’s a limit on this of course, just to prevent the camera zooming out indefinitely.
the green area reacts in essentially the opposite way, but it’s a bit more forgiving towards the far-behind players: a zooming in will only occur when all currently active octopodes are within its trigger volume. this means that the camera is a lot more likely in an average game to be zoomed quite far out, but it has the added benefit of really emphasising how close the race is getting when players are all clustered near each other. I do this just by keeping a list of players currently within the zoom-in area, and compare it against the number of players currently active – that is, initialized in-game and in the race.
the red area is a death trigger for players just offscreen, which in turn adds them a list to be respawned at a later date by another player.
and finally, the purple outline is specifically for the gift – it’s got a lot more yield on how far off-screen it can go before disappearing. this comes into play in places where the gift isn’t in anybody’s hands, and it can roll offscreen – it’s very jarring to run after a gift and find that it’s disappeared into thin air!
¿where did it go? the speed at which the camera zooms out is also quite context dependent. when players enter a down-transitioning room or hit an up-transitional booster I signal the camera to zoom way out, which allows people to plan their route ahead a little and overall just feels fairer. this needs to happen quite rapidly just due to the speed of the falling / soaring podes involved. after a transition the zoom rate slows back down for regular play.
I’m accomplishing these smoothing transitions using the absolute classic:
// smoothVal in range (0, 1)
value += (target – value) * smoothVal
which you should also be multiplying by Time.deltaTime if you’re running outside of FixedUpdate (ie in framerate-dependent code, if using anything other than Unity). for the rapid booster / down room transitions I go as far as smoothing the ‘smoothVal’ variable itself! when there’s a lot of frenetic motion it helps to ease these transitions out a bit so people’s eyes don’t have to jerk around.
and there you have it! that’s more or less how it’s currently implemented. through more playtesting some constants might change here and there but it’s more or less what I’d call the final version.
player in "front" has more of an influence on the camera (indicated by rectangular trigger areas) when jumping at the start we’ve got a couple of deadlines coming up where Kieryn (aka SafetySnail – it’s time he gets unmasked) will be showing the game off at some events, we’ll have more concrete info on that soon. it’s a great development motivator! hopefully these tech posts are interesting, and if anyone has any specific implementation questions about what we’ve shown so far I’m happy to delve into it more. right now I’m mostly working on getting some UI elements in place and multi-controller support, so I will jump on any excuse to interact with other humans.