Control Schemes
by Peter N. Wood
Previous Step 2: The Game Loop
Next Step 4: Collisions
Last Updated: 11 Jan, 2018
by Peter N. Wood
Previous Step 2: The Game Loop
Next Step 4: Collisions
Last Updated: 11 Jan, 2018
The one key feature of a video game is interactivity. Whether it’s a visual novel, puzzle game, or open-world sandbox we need to provide the user a way to influence the game we make. Front-end web developers will be familiar with event listeners, maybe in the form of attaching a listener for a click event to a link: that’s how we’ll handle user input to our game.
A really common way to attach event listeners is to use jQuery, but that’s a big library with lots of functionality we won’t need for a video game. It’ll increase the load time for our page as well. Instead, we’ll be using raw JavaScript methods like getElementById() and addEventListener().
First, a note on browser compatibility: in older browsers (namely Internet Explorer and Safari) some properties on the various event objects we’re using won’t be available. Some browsers won’t support the Canvas API at all, and we shouldn’t bother accounting for them except to include a message like “Sorry, your browser doesn’t meet the requirements for this game”; however, it’s worth including some measure of compatibility, and I’ll discuss that in a supplemental section at the end of this tutorial. For the purposes of this step in the tutorial, we’ll assume our users are using the latest version of Chrome, Firefox, Opera, or another browser with basic Canvas support.
Let’s take a bigger step towards making Asteroids and create a player object that looks and moves like the ship from the arcade classic. We define the player object with x/y coordinates, x and y speed, a flag to indicate whether to apply acceleration in the ship’s facing direction “thrust”, a property for it’s direction in radians ( in the range 0 — Τ, otherwise known as 2π ), and radian acceleration to track whether the ship is rotating. We define the points that shape the ship and apply a rotation transform using its radians; this is all easy to do using our vector graphics rendering methods.
Just quick note on the rotation transform: we keep a fixed set of render points that represent the ship at a rotation of 0 radians, and every frame calculate where those points should be based on the ship’s current rotation. We set the points relative to the ship’s center, so we can apply a little trigonometry to rotate each point very easily: calculate sine and cosine for the ship’s rotation, each x-coordinate is playerX + ( pointX × cosine + pointY × sine ) and each y-coordinate is playerY + ( pointX × sine - pointY × cosine ).
const CANVAS_W = 800; const CANVAS_H = 450; const TAU = 2 * Math.PI; const DELTA_TIME = 5; const MAX_SPEED = 5; const TURN_SPEED = 0.05; // Render points relative to the ship's center const POINTS = [ { x: 16, y: 0 }, { x: -12, y: -9 }, { x: -8, y: 0 }, { x: -12, y: 9 } ]; var player = { x: CANVAS_W / 2, y: CANVAS_H / 2, xVel: 0, yVel: 0, thrust: false, radians: 0, radAcc: 0 }; var context = document.getElementById('canvas'); var lastTimestamp = Date.now(); function frameStep(timestamp) { // Update var now = Date.now(); accumulator += now - lastTimestamp; lastTimestamp = now; // Fill the background context.fillStyle = '#000'; context.fillRect(0, 0, CANVAS_W, CANVAS_H); // Calculate these now to save computing time var sin = Math.sin(player.radians); var cos = Math.cos(player.radians); // Apply rotation transform for each point context.beginPath(); for (var i = 0; i < 4; i++) { var pointX = POINTS[i].x * cos + POINTS[i].y * sin; var pointY = POINTS[i].x * sin - POINTS[i].y * cos; // We need to "moveTo" the first point and "lineTo" the rest. context[i === 0 ? 'moveTo' : 'lineTo'](player.x + pointX, player.y + pointY); } context.closePath(); context.lineWidth = 2; context.strokeStyle = '#FFF'; context.stroke(); if (!start) { var start = timestamp; } if (timestamp - start < 2000) { window.requestAnimationFrame(frameStep); } } window.requestAnimationFrame(frameStep);
Now that we’ve got a ship object and a way to render it, let’s start with the easiest controls to code: keyboard controls. First, we attach a keydown event listener to the document, and check a few values on the event object. We should call preventDefault() to prevent scrolling with the arrow keys and other behaviors, but still allow keyboard shortcuts (like ctrl/command + R for reloading the page); it would be really awkward to start scrolling up the page when you’re just trying to play the game. The event object provides flags for each of these modifier keys.
Let’s use arrow keys to move the ship. Note that if a key is held down, it’ll keep triggering the keydown event at a rapid pace. That’s no good, so we check the repeat flag and skip any further steps if it’s true. To move the ship, we set the ship’s thrust to true if the up arrow is pressed. For the left and right arrows, we either increment the player’s radian acceleration up by one to turn right, or down by one to turn left. Then we add a keyup event that reverses the effect of it’s associated keydown event.
This is a very simple way to ensure that neither the left or right arrow overrides the other: if they’re both pressed, they cancel out; if both are pressed and one is released, it’s as if we only pressed the other key. E.G. Press the left arrow and radAcc = -1; press the right arrow and we add 1 so radAcc = 0; release the left arrow and radAcc = 1 (as if we’re turning right); the right arrow is still held down so this feels normal and intuitive.
function handleKeydown(e) { // ctrlKey refers to control on Apple computers, ctrl on Windows // metaKey refers to command on Apple, the Windows key on Windows if (!e.altKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); } if (!e.repeat) { switch (e.key) { case 'ArrowUp' : player.thrust = true; break; case 'ArrowLeft' : player.radAcc--; break; case 'ArrowRight' : player.radAcc++; break; } } } function handleKeyup(e) { switch (e.key) { case 'ArrowUp' : player.thrust = false; break; case 'ArrowLeft' : player.radAcc++; break; case 'ArrowRight' : player.radAcc--; break; } } document.addEventListener('keydown', handleKeydown); document.addEventListener('keyup', handleKeyup);
For the update method, we need to do a few things: rotate the ship (unless the radian acceleration is 0), update the ship’s velocity if the player is applying thrust, limit the ship’s speed so it can’t keep increasing indefinitely, and move the ship to the opposite edge of the screen if it passes an edge.
To rotate the ship, we add the current radian acceleration (multiplied by our rotation speed) to the current radians. Then, we limit our radians in the range of 0 - Τ by either adding Τ if it’s less than 0, or subtracting Τ if it’s greater than Τ. Don’t clamp the value, or you’ll prevent your player from rotating left beyond 0 radians or right beyond Τ radians.
In some programming languages, a value will simply overflow if it gets too big or small: meaning, the value will loop around to the smallest or largest value it can be. In JavaScript, numbers get clamped at the maximum or minimum allowed value: Number.MAX_VALUE or Number.MIN_VALUE. You’re unlikely to encounter these types of values in our game, but if you did the ship would stop rotating in one direction. Better to just limit the range of our radians.
To update the ship’s speed, we have to use a little trigonometry and determine how much to move in the x and y directions; using the player’s radians, cosine will give us the x value, sine the y value. To make sure we don’t exceed our maximum speed, we have to calculate the combined velocity in the x and y directions; we can treat the two velocities as if they’re sides of a right triangle and calculate the hypoteneuse to get the combined speed (this value will always be a positive number).
Finally, if the player moves past one of the canvas edges, we either add or subtract the distance to the opposite edge from the player’s current position. Then the ship will appear to loop around the level. E.G. If the ship would be at x: -8, we add the canvas width so that it ends up 8 pixels to the left of the right edge of the canvas.
// Get hypoteneuse of a right triangle given the two shorter sides function getHypoteneuse(x, y) { return Math.sqrt(x * x + y * y); } // Restrict a value to a specified range function clamp(value, min, max) { return value < min ? min : value > max ? max : value; } function update() { if (player.radAcc) { var radians = player.radians + player.radAcc * TURN_SPEED; if (radians < 0) { radians += TAU; } else if (radians >= TAU) { radians -= TAU; } player.radians = radians; } // Update the player's speed in the x and y directions if (player.thrust) { var cos = Math.cos(player.radians); var sin = Math.sin(player.radians); // Calculate the combined speed of both directions; we divide by MAX_SPEED to keep from accelerating too quickly player.xVel += cos / MAX_SPEED; player.yVel += sin / MAX_SPEED; var speed = getHypoteneuse(player.xVel, player.yVel); if (speed < -MAX_SPEED || speed > MAX_SPEED) { speed = clamp(speed, -MAX_SPEED, MAX_SPEED); player.xVel = speed * cos; player.yVel = speed * sin; } } player.x += player.xVel; player.y += player.yVel; // If the player passes an edge of the canvas, move it to the opposite edge if (player.x < 0) { player.x += CANVAS_W; } else if (player.x > CANVAS_W) { player.x -= CANVAS_W; } if (player.y < 0) { player.y += CANVAS_H; } else if (player.y > CANVAS_H) { player.y -= CANVAS_H; } }
Now that our keyboard controls are complete, let’s try something more difficult: mouse and touch controls. Mouse controls aren’t too bad, but touch controls have all sorts of default behaviors built into the browser. The biggest issue for a game like this is the zoom feature, where a quick double-tap or long touch will change the zoom level on the webpage. The simplest solution, though a little extreme, is to include the following meta tag in the document head; this will totally prevent any zooming anywhere on the page, so this is only really suitable if your game is the only thing on, or primary focus of, the page.
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0">
Our first step is to move our logic for updating the player’s thrust and rotation to a function that doesn’t rely on a specific type of input. That way, we keep the details of every input command in one place, while each event listener is only responsible for converting its input into the appropriate command. In fact, since buttons mostly behave the same way whether they receive a mouse or touch event, we can use one callback function to handle both touch and mouse events.
We could track mouse input on the canvas element, and rotate the ship towards the cursor, but if we do the same with touch input the user’s finger will cover part of the screen, making it harder to play. We can keep things simple with some html buttons that emulate keyboard input. An easy way to do this is to wrap the buttons in an element and attach the event listener to that wrapper. We can add the desired action as a data attribute to each button, and we’re almost all set.
Touch events are tricky, because certain gestures and long presses will change the page zoom, highlight the selected element, or something else we don’t want. We need to preventDefault() for touch events, but not for mouse events: if we prevent the default action on a mouse event, the button element won’t respond to styles using the :active pseudo selector, which we should use for a good user experience.
const KEY_MAP = { 'ArrowUp' : 'up', 'ArrowLeft' : 'left', 'ArrowRight' : 'right' }; function handleInput(action, isActivated) { switch (action) { case 'up' : player.thrust = isActivated; break; case 'left' : player.radAcc += isActivated ? -1 : 1; break; case 'right' : player.radAcc += isActivated ? 1 : -1; break; } } function handleKeydown(e) { // Extra step to check whether the canvas is visible var rect = context.canvas.getBoundingClientRect(); // ctrlKey refers to control on Apple computers, ctrl on Windows // metaKey refers to command on Apple, the Windows key on Windows if (rect.top < window.innerHeight && !e.altKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); } var key = KEY_MAP[e.key]; if (!e.repeat && key) { handleInput(key, true); } } function handleKeyup(e) { var key = KEY_MAP[e.key]; if (key) { handleInput(key); } } // Track whether the mouse button is down for mouseout events var mouseDown = false; function handleButtonInput(e) { // Prevents unwanted touch event behavior if (e.type === 'touchstart') { e.preventDefault(); } e.stopPropagation(); // Ensure the mouse or touch event came from a button, is not a right click, and that the button has an "action" data attribute if (e.target.tagName === 'BUTTON' && e.button !== 2 && e.target.dataset.action) { mouseDown = e.type === 'mousedown'; // The touchstart event is the closest equivalent to mousedown among touch events var isActivated = mouseDown || e.type === 'touchstart'; handleInput(e.target.dataset.action, isActivated); } } // Our buttons' wrapping element. Events triggered by the buttons will bubble up to this element. var buttonControls = document.getElementById('button-wrapper'); buttonControls.addEventListener('mousedown', handleButtonInput); buttonControls.addEventListener('mouseup', handleButtonInput); // buttonControls.addEventListener('mouseout', handleButtonInput); function handleButtonMouseout(e) { // We only need to handleButtonInput if the mouse button was down when the mouse moved out if (e.target.tagName === 'BUTTON' && mouseDown) { mouseDown = false; handleButtonInput(e); } } buttonControls.addEventListener('mouseout', handleButtonMouseout); buttonControls.addEventListener('touchstart', handleButtonInput); buttonControls.addEventListener('touchend', handleButtonInput); function preventEverything(e) { e.preventDefault(); e.stopPropagation(); } buttonControls.addEventListener('touchmove', preventEverything); buttonControls = null;
And now we have options for keyboard, mouse, and touch controls to move our player object around the game space. Note our special mouseout callback; if the user moves the mouse away from the button we need to treat that like a mouseup event, but only if the mouse button was pressed down when leaving the button element. To do this, we track whether the mouse is currently pressed down with our own variable mouseDown.
Now that we have player input, we’re that much closer to a completed game. Next up: collision detection.
— Peter N. Wood
Previous Step 2: The Game Loop
Next Step 4: Collisions
MDN Canvas API documentation
MDN article on optimizing canvas rendering
Old versions of Safari don’t support the key property of key events, and every version of Internet Explorer from IE 9 up through the latest Edge use non-standard values (as if to intentionally make things harder). Versions of IE older than 8 won’t support the canvas anyway, so at least we can avoid that headache. Firefox, Chrome, and Opera have supported this properly for a long time, and Safari has supported it since version 10.1, but if you want to ensure your game works on as many platforms as possible, you’ll need to adapt. Here’s a more comprehensive compatibility list.
My solution makes use of an intermediary method between the key event listener and the handleInput() method I introduced in the last code sample. Instead of running the key through a Map object, we write a function to return a valid “action” value for the handleInput() method. The first step is easy: if the key property is present, we run that through our KEY_MAP. Otherwise, we can run the keyCode property through a KEYCODE_MAP first.
Building the keymap is the hard part, but even if you setup key rebinding options in your game (something I’ll cover in a later post), you aren’t going to need to include all 200 or so key codes. If it’s something as simple as the Asteroids game we’re making, you’ll only need to look up a handful keyCodes: just be sure to actually test them in IE. MDN provides a comprehensive list of keyCodes here. Here’s a compatibility keycode map that’ll work for the game we’re building in this tutorial.
const KEYCODE_MAP = { '32': 'fire', // Spacebar '38': 'up', '37': 'left', '39': 'right', '40': 'down' }