EventLoop and Input


Web First Gamedev - Part 3

In the previous post I got the window open. The next thing I want to figure out is what the main game loop looks like, and how input is handled. The main loop is the beating heart of a game process, updating the game’s state and rendering the next frame.

The main loop also tends to be built on top however the system event loop works, so I’m going to tackle input handling here too. So the SDL_PollEvent main loop, or winit::EventLoop::run, is close to what I’m going to try to replicate here.

Update Loop

In a native game the main loop looks kinda like:

while (true) {
    handleEvents();
    updateState();
    render();
}

This doesn’t map cleanly unfortunately. Implementing the traditional infinite main loop would lock up the tab and it would become unresponsive.

Instead I use window.requestAnimationFrame. This takes a callback which will be invoked before the next repaint by the browser.

Demo (Contains Flashing Colors)

window.addEventListener('DOMContentLoaded', (event) => {
    canvas = document.getElementById('main-canvas');
    gl = canvas.getContext('webgl2');

    window.requestAnimationFrame(doFrame);
});

function doFrame() {
    gl.clearColor(Math.random(), 0.0, 0.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    window.requestAnimationFrame(doFrame);
}

This is the bones of a render loop. The most important detail is that requestAnimationFrame is invoked in the callback in order to continue the loop. Each invokation only sets the callback for the next frame. It doesn’t behave like an element’s event handler which will fire repeatedly until the handler is removed.

So that’s the render loop sorted. It’s almost enough for the update loop too - it’s just missing a way to tell how much time has passed between frames. Fortunately requestAnimationFrame also provides a high resolution timestamp as an argument to the callback.

Demo

let canvas, gl;
let animVal = 0;
let previousT = 0;

window.addEventListener('DOMContentLoaded', (event) => {
    canvas = document.getElementById('main-canvas');
    gl = canvas.getContext('webgl2');

    window.requestAnimationFrame(doFrame);
});

function doFrame(currentT) {
    let deltaT = currentT - previousT;
    previousT = currentT;
    animVal += deltaT * 0.001;

    let offsetX = Math.sin(animVal) * (canvas.width / 8);
    let offsetY = Math.cos(animVal) * (canvas.height / 8);

    gl.clearColor(1.0, 0.0, 1.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    gl.enable(gl.SCISSOR_TEST);
    gl.scissor(offsetX + canvas.width / 4, offsetY + canvas.height / 4,
        canvas.width / 2, canvas.height / 2);
    gl.clearColor(1.0, 1.0, 1.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.disable(gl.SCISSOR_TEST);

    window.requestAnimationFrame(doFrame);
}

Here there’s a rectangle smoothly circling the canvas. To drive the animation I calculate the time between frames using the milliseconds timestamp provided to the callback.

Input Handling

I now have a place to update the game state and render each frame. What’s missing are the input events. On native platforms there’s often a place in the mainloop where you process the system’s event queue. Instead here I’m going to be largely subscribing the input events on the document or the canvas.

And as per the previous post on the topics of requestFullscreen and requestPointerLock, there are actions the browser will only let the page do from inside a handler for a user initiated action. So I need to make sure however the input handling logic is structured it happens in the callstack of those handlers - I can’t just queue up the events to replicate the SDL_PollEvent code flows I’m familiar with.

Many of the demos below just use console.log to emit input event information - it’s worth keeping the dev console open when checking them out.

Mouse Events

For mouse input there are a variety of events of interest: mousedown, mouseup, mousemove, click, wheel.

Demo

canvas.addEventListener('mousedown', (event) => {
    console.log(`MouseDown: Button[${event.button}]`);
});

canvas.addEventListener('mouseup', (event) => {
    console.log(`MouseUp: Button[${event.button}]`);
});

canvas.addEventListener('mousemove', (event) => {
    console.log(`MouseMove: [${event.offsetX}, ${event.offsetY}]`);
})

canvas.addEventListener('click', (event) => {
    console.log(`Click: [${event.offsetX}, ${event.offsetY}] [${event.button}]`);
})

canvas.addEventListener('wheel', (event) => {
    console.log(`Wheel: [${event.deltaY}]`);
})

These listeners are attached to the canvas element. This means that events will only fire when the cursor is over the canvas, and the offsetX and offsetY coordinates give the location of the event relative to the top-left of the canvas.

The mousemove event also has movementX and movementY attributes which are handy. I’ve written the boiler plate code to calculate this based on delta of the mouse location many times, and it’s nice to have it handled for me.

The click event only fires for left click. mouseup and mousedown fire for any button, but context menus will still appear. There do seem to be ways to disable context menu on right-click, but it’s also a common anti-pattern such that there are extensions and browser settings to prevent disabling of context menus so it’s not something I want to rely on.

The wheel event fires on use of the scoll wheel over the canvas. The deltaY property is positive for the direction that would normally scroll the page down, and negative for the direction that would scroll the page up.

Mouse Events with Locked Cursor

The same events are available when the cursor is locked to the canvas, but behave slightly differently.

Demo

When the cursor becomes locked the position attributes on events (offsetX/Y, clientX/Y, etc) become locked. The movementX/Y attributes are still valid. So all the mouse events I’m using will fire as normal, just with the absolute position reported as fixed.

The click event will also fire for buttons other than left mouse button. When the cursor is locked context menu and other default mouse interaction behaviors are suppressed so it should be safe to rely on the right mouse button.

With wheel there’s a problem. The spec requires that wheel fire while in pointer lock, and it does on Firefox. All the versions of Chrome that I tried however wouldn’t emit the wheel event when in pointer lock. Until this is resolved I can’t use the scroll wheel as an input mechanism for games that require pointer lock - so no scroll wheel to change weapon in an FPS. Hopefully that gets fixed soon.

Key Events

The main keyboard events are keydown and keyup

Demo

document.addEventListener('keydown', (event) => {
    if (!event.repeat) {
        console.log(`KeyDown Scancode[${event.code}] VirtualKey[${event.key}]`);
    } else {
        console.log(`KeyRepeat Scancode[${event.code}] VirtualKey[${event.key}]`);
    }
});

document.addEventListener('keyup', (event) => {
    console.log(`KeyUp Scancode[${event.code}] VirtualKey[${event.key}]`);
});

These handlers are attached to the document, not the canvas. The two attributes of note are code and key:

  • code is a string representation of the scancode (broadly the physical key location) of the key pressed. The values seem to be roughly standardized across browser and platform, at least for the majority of keys.
  • key is the string value of the character pressed, with modifiers applied. If I press AltGr+E I get a keydown event with a key of AltGraph, followed by a keydown event with a key of é.

If the key is held down, eventually keydown repeat events are triggered, which can be detected via the repeat attribute on the event.

Gamepad Events

The gamepad is the odd-one-out, and the only input device we directly query the state of, instead of relying on event handlers. To get a handle to the gamepad initially and to track when the gamepad is disconnected there are the gamepadconnected and gamepaddisconnected events. The connection event will fire when a button is pressed on the controller after the page has opened, so I’m able to rely on getting a connection event even if the controller is physically connected when the page is loaded.

Demo

let gamepad;
window.addEventListener('gamepadconnected', (event) => {
    gamepad = event.gamepad;
    console.log("Gamepad Connected");
});

window.addEventListener('gamepaddisconnected', (event) => {
    gamepad = null;
    console.log("Gamepad Disconnected");
});

There is extensive information on the details of the gamepad API and how the controller is mapped on MDN here. I poll the gamepad object every frame, to see what the state of the buttons are.

function doFrame() {
    let red = 0.0;
    let green = 0.5;
    if (gamepad) {
        let padState = navigator.getGamepads()[gamepad.index];
        if (padState.buttons[0].pressed) {
            red = 1.0;
        }
        green = (padState.axes[0] / 2) + 0.5;
    }

    gl.clearColor(red, green, 0.0, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    window.requestAnimationFrame(doFrame);
}

This checks for a button press to control the red channel, and the X axis on the primary stick to control the green channel. The standard controller mapping is specified in the spec. I have to request the current gamepad state each frame, and use the index from the gamepad object grabbed in the connection handler.

Gamepad interactions also set the browser state as having registered a user interaction. The consequence of this is that I was able to put canvas.requestFullscreen in the the button pressed block and use the controller to initiate fullscreen mode.

Touch Events

Single touch events emit emulated mouse events, so basic interactions will work on touch devices without specific handling. There are events that allow for handling multi touch gestures, but I think I’ll handle that in a separate post at some point. It seems like hefty subject worth its own attention when the time comes.