I recently spent a long weekend experimenting with browser-based game development. After sinking way too many hours into animating a super simple space ship and solving smaller problems like reliably handling input, animating projectiles, and procedurally genrating a nice space-themed starry background, the very basic game still wasn’t very fun because the player had nowhere to go.
These examples don’t really work on phones or tablets because you control the ship with arrow/WASD keys. If the demos get into a weird state, you can reset them by pressing the R
key or by reloading the page.
In the simplest version of the game, the camera is fixed in a single spot. The ship can float through space forever, which feels natural, but it’s floating through the same small viewport over and over. In a space setting, that’s pretty limiting. If I want the ship to travel around a map larger than my monitor, I need to implement a camera that can track the ship as it moves around space.
Initially, I created a simple camera that just tracks the ship’s position. Each frame, this camera updates to ensure the ship, wherever it might be, is at the center of the viewport. This has a couple important benefits: it decouples the size of the playable area from the size of the screen, and it maintains equal visibility in all directions around the ship.
From a technical perspective, this camera works really well. It’s effective because the ship can never reach the edge of the screen or travel off camera. It’s efficient becuase there’s no additional math needed to animate the camera independently.
By my standards, though, a camera that tracks the ship exactly isn’t as fun as it could be. There’s no feedback from how the ship moves relative to the camera, so it’s hard to discern velocity or acceleration while you’re thrusting around space. It doesn’t feel dynamic, and without visual cues like animating the ship’s engines and a moving background, it would be hard to know whether the game’s working at all.
For this game, I wanted a camera that tracks the ship, but not exactly. I’m willing to sacrifice a bit of visibility and a bit of efficiency for a dynamic camera that feels more natural, more cinematic, and, ultimately, a bit more exciting. There are plenty of resources online discussing ways to achieve this, but instead of learning something new, I decided to apply half-remembered technique inspired by a control systems class I took in college: a PID controller.
A PID controller defines a relationship between an input and output using three terms:
Each particular controller, depending on the use case and desired behavior, then multiplies each term by a constant (cp
, ci
, and cd
) to determine the control output.
A PID-controlled game camera defines a system that reacts to input (the position of the ship) and an output (the position of the camera). This a two-dimensional game, so I’m effectively creating two PID controllers that work in tandem, one for each dimension. In Typescript, it looks like this:
|
|
When it’s time to calculate the camera’s position for a new frame, advance()
accepts two paramters: dt
is the amount of time that’s passed since the last frame, and focus
is the vector describing the location and motion of the focus object. In this case, that’s always the player’s ship.
The exact behavior defined by this system depends entirely on the chosen cp
, ci
, and cd
coefficients. I want the camera to generally track the ship, but if the user is accelerating, it should pull away a bit in that direction. When the ship changes direction, the camera should be slightly slow to react, giving the user that additional bit of visual feedback.
The relationship betwene the coefficients and the resulting behavior of the controller can be complex. The wrong blend of coefficients might cause the camera that reacts too slowly, allowing the ship to accelerate off the screen before it begins moving. Other coefficients could cause the camera to oscillate around the ship forever, or to never quite catch the ship as it drifts off. While a player might benefit from some subtle camera movement, they certainly shouldn’t be annoyed by it. They should never really have to think about it.
When it comes to for choosing the right values to provide the behavior I’m looking for, there’s really nothing better than lots of experimentation. Here, we have three examples of different coefficient blends and the camera systems they define.
In the first example, I test the constants cp = 0.0001
, ci = 0.0001
, and cd = 0.005
. The camera is underdamped and oscillates wildly before eventually settling when the ship stops accelerating. It’s fun for a demo, but it’s hard to think about anything other than the camera’s movement. Let’s try again.
In the next example, I test cp = 0.002
, ci = 0.01
, and cd = 0.05
. Here, the camera is way overdamped and tracks the ship too well. This camera feels basically the same as the simple ship-tracking camera shown earlier, but with a lot of unnecessary math and complexity thrown in.
In this final example, I test cp = 0.0001
, ci = 0
, and cd = 0.12
. This camera doesn’t oscillate, but it does lag behind the ship in a dynamic way. The camera’s movement is constrained to a small area around the ship to maintain visibility and playability, but it moves enough to provide some feedback to the player. The movement feels natural, and if I was playing a game with this level of camera elasticity, it wouldn’t steal my attention from everything else happening on the screen.
Interestingly, I found the camera behaved best with an integral coefficient, ci
, of zero. This means that the best controller for this job, in control systems terms, is actually a PD controller. It’s pretty redundant to do all the math to calculate the integral term before ignoring it, so I’d probably remove that code before releasing to production if this were a serious project.
If you want to check out the code that drives the demos on this page and play with it, improve it, or roast it, it’s all available, including the Webpack config I used to compile to Javascript, in one gigantic Typescript file here.