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.

Leave a Comment

Scroll to Top