Input Architecture for Web Games
Keyboard, gamepad, touch — unified, remappable, and latency-safe.
Input is messy. Keys repeat at OS level, gamepads drift, touch is analog, and
remapping demands indirection. Instead of hacks, build an **input layer** that
translates all devices into the same logical actions (jump, dash, pause).
This cookbook gives you modular recipes to drop into any game.
Recipe 1 — Keyboard State
// input/keyboard.js
export class Keyboard {
constructor(bindings){
this.keys = new Set();
this.bindings = bindings; // { jump:['Space','KeyW'], left:['ArrowLeft','KeyA'] }
addEventListener('keydown', e => this.keys.add(e.code));
addEventListener('keyup', e => this.keys.delete(e.code));
}
get(action){ return this.bindings[action]?.some(k => this.keys.has(k)); }
}
Use e.code (hardware) not e.key (layout), so “WASD” works across locales.
Recipe 2 — Gamepad Polling
// input/gamepad.js
export class GamepadInput {
constructor(){ this.dead=0.25; }
poll(){
const pads = navigator.getGamepads();
const gp = pads[0]; if(!gp) return {};
return {
left: gp.axes[0] < -this.dead,
right: gp.axes[0] > this.dead,
up: gp.axes[1] < -this.dead,
down: gp.axes[1] > this.dead,
jump: gp.buttons[0].pressed, // A / Cross
dash: gp.buttons[1].pressed // B / Circle
};
}
}
Poll once per frame. Deadzones (0.2–0.3) prevent analog drift.
Recipe 3 — Touch Joystick
// input/touch.js
export class TouchStick {
constructor(canvas){
this.axis={x:0,y:0};
let sx=0, sy=0, id=null;
canvas.addEventListener('touchstart',e=>{
if(id!==null) return;
const t=e.changedTouches[0]; id=t.identifier; sx=t.clientX; sy=t.clientY;
});
canvas.addEventListener('touchmove',e=>{
const t=[...e.changedTouches].find(t=>t.identifier===id); if(!t) return;
this.axis.x=(t.clientX-sx)/50; this.axis.y=(t.clientY-sy)/50;
this.axis.x=Math.max(-1,Math.min(1,this.axis.x));
this.axis.y=Math.max(-1,Math.min(1,this.axis.y));
});
canvas.addEventListener('touchend',e=>{ this.axis={x:0,y:0}; id=null; });
}
}
Virtual sticks map well to mobile — normalize to -1..1 for consistency.
Recipe 4 — Unified Input Layer
// input/index.js
import {Keyboard} from './keyboard.js';
import {GamepadInput} from './gamepad.js';
import {TouchStick} from './touch.js';
export class Input {
constructor(canvas){
this.kb = new Keyboard({
left:['ArrowLeft','KeyA'], right:['ArrowRight','KeyD'],
up:['ArrowUp','KeyW'], down:['ArrowDown','KeyS'],
jump:['Space'], dash:['ShiftLeft']
});
this.gp = new GamepadInput();
this.touch = new TouchStick(canvas);
}
poll(){
const g = this.gp.poll();
return {
left: this.kb.get('left') || g.left || this.touch.axis.x<-0.3,
right: this.kb.get('right') || g.right || this.touch.axis.x>0.3,
up: this.kb.get('up') || g.up || this.touch.axis.y<-0.3,
down: this.kb.get('down') || g.down || this.touch.axis.y>0.3,
jump: this.kb.get('jump') || g.jump,
dash: this.kb.get('dash') || g.dash
};
}
}
Game reads input.poll() once per frame → same API no matter the device.
Recipe 5 — Remapping UI
// remap.js
export async function waitForKey(){
return new Promise(res=>{
function handler(e){ removeEventListener('keydown',handler); res(e.code); }
addEventListener('keydown',handler);
});
}
// usage
btn.onclick = async () => {
const code = await waitForKey();
bindings.jump=[code]; // update mapping
};
Expose a “Press a key…” overlay. Update bindings JSON. Save to localStorage.