Handmade Web Games

Chapter 7: Mouse and keyboard input

JavaScript uses an event system with callback functions to handle user inputs.

("keydown", () => {
  . = `key: "${.}"`;
});

begin typing...

This creates a bit of a challenge for the game developer. If our game loop is something like this...

function loop() {
  update();
  draw();
  requestAnimationFrame(loop);
}

loop();

...then where in that loop is the event firing? What is the order of operations when user inputs can come in at any time?

Reveal the answer

The answer is that they don't fire inside the loop at all. Event listeners run in the background, firing between frames whenever the player does something. The loop's job is just to read whatever state those handlers have written by the time fixedUpdate() runs.

Before we begin...

The following examples expect a game loop with draw and fixedUpdate functions, as well as a DPI-scaled canvas and its boundingClientRect and CanvasRenderingContext2D. If that all sounds unfamiliar to you, take a look at Chapter 3 which defines all of these.

Keyboard events

Events update state

To bring keyboard events into the game loop, we need to change them from momentary actions into state that the loop can poll.

Some states span many frames. For example, this demo reads which keys are currently pressed. The state only updates when events fire.

Aside--where did { signal } in the code example come from?

Because many of the demos on this page register events on the global window object, they'll interfere with one another if we don't tear down the event listeners when each preview unmounts.

One way to do this is with removeEventListener:

const  = (: KeyboardEvent) => {
  const { ,  } = ;
  .();
  if ( &&  === "c") {
    ("keydown", );
  }
};
("keydown", );

But because it requires holding onto a reference to the callback function, removeEventListener always feels a bit clunky to use. And in our particular case for these demos, coming up with a way to figure out which event each callback in every demo is tied to sounds awful.

Thankfully, a much nicer alternative is provided by AbortController:

const {
  , // a function you can call anywhere
  , // one signal can abort many things
} = new ();

// log keydown events until "ctrl+c"
(
  "keydown",
  () => {
    const { ,  } = ;
    .();
    if ( &&  === "c") {
      ();
    }
  },
  {  },
);

You can pass the same abort signal to as many event listeners as you want, then call abort() from anywhere. This abort is totally unaware of the event types and fully decoupled from the event handlers, making it great for this use case.

A small downside we'll have to live with is that in each of the demos, you'll see a { signal } passed in to the addEventListener but you won't see where it comes from, as we need a way to call abort() on unmount outside of the demo.

For a deeper dive on this, see Don't Sleep on AbortController by Artem Zakharchenko.

Continuous vs discrete events

The previous demo showed an example of continuous, pollable state. But sometimes we need events to behave like one-shot blips that fire just when the user interacts. How can our pollable state object handle these?

The answer is to use event callbacks to set temporary event-related state (like which keys were just pressed this tick). Then reset() that state at the end of each fixedUpdate().

const state = {
keysDown: new Set<string>(),
};
addEventListener(
"keydown",
(event) => {
state.keysDown.add(event.key);
},
{ signal },
);
addEventListener(
"keyup",
(event) => {
state.keysDown.delete(event.key);
},
{ signal },
);

Mouse events

Let's look at how mouse events are usually handled for DOM elements, like an HTML <button>:

. = () => {
  . = `x: ${.}, y: ${.}`;
};

Unlike this example, the items in canvas games aren't DOM elements and they don't have event handlers. So how can we make them clickable?

Before we get there, let's make the canvas itself capable of responding to mouse pointer events.

Why not mouse events?

Because not everyone clicks stuff using a mouse! Mobile devices tend to use touch events, tablets might use a stylus, and there are also things like trackpads or assistive devices that support multi-finger gestures.

Pointer events are a hardware-agnostic way to model all of these.

There are many events for mouse-ish things the browser responds to. But if you want to focus on just a handful, the ones with links are worth knowing for browser games.

  • Legacy mouse events
    • mousedown, mouseup, mousemove, mouseenter, mouseleave, mouseover, mouseout
  • Pointer events (the "modern" api for click-like events)
    • pointerdown, pointerup, pointermove, pointerenter, pointerleave, pointerover, pointerout, pointercancel, gotpointercapture, lostpointercapture, pointerlockchange, pointerlockerror
    • click is an odd one--it's a PointerEvent in modern browsers, but a MouseEvent in old ones. It generally fires right after pointerup, but can also be triggered programmatically, or by pressing Enter or Space when an interactive HTML element is focused.
  • Specialty events
  • Scroll events
    • wheel, mousewheel, scroll
  • Touchscreen/trackpad events
    • touchstart, touchmove, touchend, touchcancel
  • Drag events
    • dragstart, drag, dragend, dragenter, dragover, dragleave, drop
  • Non-standard Apple-only gesture events
    • gesturestart, gesturechange, gestureend

Reading mouse state in the game loop

Just like with key presses in the previous section, our fixedUpdate() and draw() code never directly listens to pointer events. Instead there is a single global event listener for each event which sets the state that our fixedUpdate() and draw() can read.

Following the cursor position

The cursor position is continuous, pollable state that persists across many frames, just like keysDown. To set it, listen for pointermove and set (x, y) coordinates offset by the canvas' actual location on the screen. This way (0, 0) is the top left corner.

Hitbox tests

To detect if the user is clicking a specific in-game item, check if the cursor x/y position is within the bounds of that item at the time of the click.

This is the same type of logic you used to detect collisions in chapter 5.

Hitbox tests with Path2D

For a path-based hitbox, you need to not only draw the target and test clicks against the same Path2D, but also reset the transform before you execute the hit test so that you can compare CSS pixels from the event to in-canvas pixels.

Cursor types

Browsers support dozens of built-in cursors [MDN], including an invisible one, and they support custom cursors. Set canvas.style.cursor to change it.

Using right-click

The event that shows the browser's native right-click menu is called contextmenu. If your game relies on right clicks, then you probably don't want that menu popping up. For this, use preventDefault() on the contextmenu event.

Avoid overriding the right-click behavior without reason, though, since blocking the right-click menu is invasive to the user experience.

Customizing right-click behavior

Every PointerEvent also supports an event.button property. To specifically detect right clicks, listen for pointerdown events where event.button === 2.

("pointerdown", () => {
  if (. === 0) {
    // left click occurred
  }
  if (. === 1) {
    // middle/wheel click occurred
  }
  if (. === 2) {
    // right click occurred
  }
});
const state = {
isPressed: false,
};
canvas.addEventListener(
"pointerup",
() => {
state.isPressed = false;
},
{ signal },
);
canvas.addEventListener(
"pointerdown",
() => {
state.isPressed = true;
},
{ signal },
);

Hitbox test cheatsheet

Rectangles:

type  = { : number; : number };
type  = { : number; : number; : number; : number };

function (: , : ) {
  return (
    . >= . &&
    . <= . + . &&
    . >= . &&
    . <= . + .
  );
}

An odd shape defined by a canvas path:

To account for any current transforms, such as the DPI scaling, we need to map CSS pixel coordinates into the canvas' current transformed coordinate space before testing isPointInPath.

type  = { : number; : number };

function (: number, : number) {
  const  = new ();

  .(,  - 36);
  .( + 10,  - 10);
  .( + 38,  - 10);
  .( + 16,  + 6);
  .( + 24,  + 34);
  .(,  + 18);
  .( - 24,  + 34);
  .( - 16,  + 6);
  .( - 38,  - 10);
  .( - 10,  - 10);
  .();

  return ;
}

function (: CanvasRenderingContext2D, : , : Path2D) {
  const  = .();
  const { ,  } = new (., .).();
  return .(, , );
}

Circles:

The formula for this is:

(x-h)2+(y-k)2r2

where (x,y) are the coordinates for the point and (h,k) are the coordinates for the center of the circle.

type  = { : number; : number };
type  = { : number; : number; : number };

function (: , : ) {
  const {  } = ;
  const  = . - .; // (x – h)
  const  = . - .; // (y – k)
  return  ** 2 +  ** 2 <=  ** 2;
}

Arbitrary polygons:

If you shoot a horizontal ray to the right of the point (x, y) and count the number of intersections it has with each edge, an odd number of intersections means that the point is inside the polygon and an even number means it is outside.

A deeper look into this algorithm

Let's begin with a visualization. The polygon in question contains a point to test (the blue dot). The horizontal ray it casts to the right is also in blue. We'll be counting the number of times that blue line crosses an edge. To visualize the potentially relevant edges, we look at whether the edge's y values contain the point's y value (drawn in red).

1 / 11
Edge:irrelevantCrossings:0State:outside (even count)

(You can drag the point around to test it!)

Full credit to Harald @hg42 for this writeup with the algorithm visualization idea that I've forked above. If you'd like to know more about how the ray-casting point-in-polygon algorithm works, I recommend you take a look at that post and the related Wikipedia entry instead.

Now, I'll be honest--before making this writeup I'd never taken the time to fully grok the isPointInPolygon function below, although, just like Harald in his post, I had also used it plenty. So, rather than reexplain a concept I am not an expert in, I'd like to take this moment to encourage using unfamiliar algorithms like this directly in your code. There are many such functions I just treat as black boxes: perlin noise, seeded random number generators, hashing algorithms, and so on. I'm confident in their results through testing and usage even if I lack the background to verify the algorithms on a theoretical level. And while one might reach for an npm package for these things, I think there's something nice about having this code directly in your codebase as part of your own library of reusable "black boxes" that you know well. You can bring your helpers between projects and better understand them over time as you gain familiarity through actual usage.

We shouldn't fear relying on unfamiliar code in our own codebases, especially for well-known algorithms. The "what the fuck?" in the comments of the infamous fast inverse square root function [Wikipedia] from Quake III in the 90s is a nice reminder that programmers have been doing this kind of thing for decades.

float Q_rsqrt( float number )
{
	long i;
	float x2, y;
	const float threehalfs = 1.5F;

	x2 = number * 0.5F;
	y  = number;
	i  = * ( long * ) &y;                       // evil floating point bit level hacking
	i  = 0x5f3759df - ( i >> 1 );               // what the fuck?
	y  = * ( float * ) &i;
	y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration
//	y  = y * ( threehalfs - ( x2 * y * y ) );   // 2nd iteration, this can be removed

	return y;
}

So, I may not be able to implement a good PRNG from scratch--but I do know when and how to use one, and by manually copying something like splitmix32 (from the random numbers section of Chapter 5) into my code I intuitively feel its impact on my game's bundle size, which I can weigh against more sophisticated and heavier alternatives. And even if I don't understand the details of the algorithm, with just a quick glance I do know it's not doing anything crazy like making network requests or running a very slow O(n^4) loop that I might not want inside my own game loop. I'm also not exposed to a left-pad incident [Wikipedia] or supply-chain attack. There's a comforting level of stability with this approach that, with ~15 years working in the web space, I've rarely experienced.

Installing a package makes it way too easy to overlook the little details. But more importantly, installing packages for these small things leads to working at a level of abstraction at which you simply won't see a lot of the code that's running in your games. You certainly don't have to be a purist about this, but I would encourage you to try making your games self-contained and dependency-free as much as you reasonably can. And to that end, every now and then you might find yourself inlining someone else's code that, to you, is just a black box function that gets a job done.

Alright, back to looking at isPointInPolygon. After having read the @hg42 post about it, this actually doesn't look too bad!

type  = { : number; : number };
type  = [number, number][];

function ({ ,  }: , : ) {
  let  = false;
  let  = .(-1)!;

  for (const  of ) {
    const [, ] = ;
    const [, ] = ;

    const  =  >  !==  > ;
    const  = (( - ) * ( - )) / ( - ) + ;
    const  =  &&  < ;

    if () {
       = !;
    }

     = ;
  }

  return ;
}

Pointer lock

Pointer lock [MDN] makes the cursor disappear and allows you to move it beyond the edges of the window. It changes the purpose of the mouse so that instead of looking at its x/y position on the screen, you only read its relative movements.

Why would you want this behavior? Picture a 3D first person shooter. There's no mouse cursor moving about the page--instead the world moves around you as you rotate in place. With pointer lock, you can move your mouse infinitely in any direction rather than being forced to stay in your display's boundaries.

The pointer events we'll be using in the next demo have movementX and movementY properties that track the deltas of the cursor's x and y positions between the current event and the previous event of the same type.

Pointer lock movement

Click the canvas to lock the pointer, then move your mouse to pan around. Press Escape when you're done to release the lock.

const state = {
x: 0,
y: 0,
};

See also

On this page