Input Handling

Capture keyboard, mouse, and gamepad inputs and send them over channels.

The pattern

  1. Client: Capture raw input events (keydown, mousemove, gamepad)
  2. Client: Translate to semantic actions (“move_north”, “jump”, “shoot”)
  3. Client: Batch inputs and send over channel (per-tick or per-event depending on game type)
  4. Server: Queue inputs in GameServer
  5. Server: Process inputs in order during tick loop
  6. Server: Validate and apply to game state

Client: Input capture module

Create client/src/lib/input.ts:

import { writable, type Writable } from 'svelte/store';

export type GameAction =
  | { type: 'move'; direction: 'north' | 'south' | 'east' | 'west' }
  | { type: 'jump' }
  | { type: 'interact' }
  | { type: 'attack'; target: { x: number; y: number } };

export interface InputState {
  keysPressed: Set<string>;
  mousePosition: { x: number; y: number };
  mouseButtons: Set<number>;
  gamepadState: GamepadState | null;
}

export interface GamepadState {
  axes: number[];
  buttons: boolean[];
}

export const inputState: Writable<InputState> = writable({
  keysPressed: new Set(),
  mousePosition: { x: 0, y: 0 },
  mouseButtons: new Set(),
  gamepadState: null
});

// Queue of actions to send to server
const actionQueue: GameAction[] = [];

/**
 * Initialize input listeners.
 * Call this once when your game starts.
 */
export function initializeInput() {
  // Keyboard
  window.addEventListener('keydown', (e) => {
    inputState.update((state) => {
      state.keysPressed.add(e.code);
      return state;
    });

    // Translate keys to actions immediately (for event-based games)
    const action = keyToAction(e.code);
    if (action) {
      queueAction(action);
    }
  });

  window.addEventListener('keyup', (e) => {
    inputState.update((state) => {
      state.keysPressed.delete(e.code);
      return state;
    });
  });

  // Mouse
  window.addEventListener('mousemove', (e) => {
    inputState.update((state) => {
      state.mousePosition = { x: e.clientX, y: e.clientY };
      return state;
    });
  });

  window.addEventListener('mousedown', (e) => {
    inputState.update((state) => {
      state.mouseButtons.add(e.button);
      return state;
    });

    if (e.button === 0) {
      // Left click = attack
      const action: GameAction = {
        type: 'attack',
        target: { x: e.clientX, y: e.clientY }
      };
      queueAction(action);
    }
  });

  window.addEventListener('mouseup', (e) => {
    inputState.update((state) => {
      state.mouseButtons.delete(e.button);
      return state;
    });
  });

  // Gamepad (polled, not event-based)
  setInterval(pollGamepad, 16); // 60 Hz polling
}

/**
 * Cleanup input listeners.
 * Call this when your game unmounts.
 */
export function cleanupInput() {
  window.removeEventListener('keydown', () => {});
  window.removeEventListener('keyup', () => {});
  window.removeEventListener('mousemove', () => {});
  window.removeEventListener('mousedown', () => {});
  window.removeEventListener('mouseup', () => {});
}

/**
 * Poll input state and generate actions.
 * Call this every frame for continuous movement.
 */
export function pollInput(): GameAction[] {
  const state = getCurrentInputState();
  const actions: GameAction[] = [];

  // WASD movement
  if (state.keysPressed.has('KeyW') || state.keysPressed.has('ArrowUp')) {
    actions.push({ type: 'move', direction: 'north' });
  }
  if (state.keysPressed.has('KeyS') || state.keysPressed.has('ArrowDown')) {
    actions.push({ type: 'move', direction: 'south' });
  }
  if (state.keysPressed.has('KeyA') || state.keysPressed.has('ArrowLeft')) {
    actions.push({ type: 'move', direction: 'west' });
  }
  if (state.keysPressed.has('KeyD') || state.keysPressed.has('ArrowRight')) {
    actions.push({ type: 'move', direction: 'east' });
  }

  // Gamepad analog stick
  if (state.gamepadState) {
    const [axisX, axisY] = state.gamepadState.axes;
    const deadzone = 0.2;

    if (Math.abs(axisX) > deadzone || Math.abs(axisY) > deadzone) {
      const direction = analogToDirection(axisX, axisY);
      if (direction) {
        actions.push({ type: 'move', direction });
      }
    }
  }

  return actions;
}

/**
 * Get all queued actions and clear the queue.
 * Use for event-based games (jump, interact, attack).
 */
export function flushActionQueue(): GameAction[] {
  const actions = [...actionQueue];
  actionQueue.length = 0;
  return actions;
}

/**
 * Queue an action to be sent on next flush.
 */
function queueAction(action: GameAction) {
  actionQueue.push(action);
}

// Helpers

function getCurrentInputState(): InputState {
  let state: InputState = {
    keysPressed: new Set(),
    mousePosition: { x: 0, y: 0 },
    mouseButtons: new Set(),
    gamepadState: null
  };
  inputState.subscribe((s) => (state = s))();
  return state;
}

function keyToAction(keyCode: string): GameAction | null {
  switch (keyCode) {
    case 'Space':
      return { type: 'jump' };
    case 'KeyE':
      return { type: 'interact' };
    default:
      return null;
  }
}

function pollGamepad() {
  const gamepads = navigator.getGamepads();
  const gamepad = gamepads[0]; // First connected gamepad

  if (gamepad) {
    inputState.update((state) => {
      state.gamepadState = {
        axes: Array.from(gamepad.axes),
        buttons: gamepad.buttons.map((b) => b.pressed)
      };
      return state;
    });
  }
}

function analogToDirection(
  x: number,
  y: number
): 'north' | 'south' | 'east' | 'west' | null {
  const angle = Math.atan2(y, x);
  const degrees = (angle * 180) / Math.PI;

  // Convert to 4 cardinal directions
  if (degrees >= -45 && degrees < 45) return 'east';
  if (degrees >= 45 && degrees < 135) return 'south';
  if (degrees >= -135 && degrees < -45) return 'north';
  return 'west';
}

Key points:

  • Raw input events update inputState store
  • keyToAction translates raw keys to semantic actions
  • pollInput() for continuous actions (movement)
  • flushActionQueue() for discrete actions (jump, attack)
  • Gamepad support via polling (gamepad API doesn’t have events)

Client: Two input modes

Mode 1: Event-based (turn-based, discrete actions)

import { initializeInput, flushActionQueue } from '$lib/input';
import { sendActions } from '$lib/game-store';
import { onMount } from 'svelte';

onMount(() => {
  initializeInput();

  // Send actions immediately when they occur
  const interval = setInterval(() => {
    const actions = flushActionQueue();
    if (actions.length > 0) {
      sendActions(actions);
    }
  }, 16); // Check 60 times per second

  return () => {
    clearInterval(interval);
  };
});

Best for: Card games, turn-based strategy, menu navigation.

Mode 2: Polling (continuous movement)

import { useTask } from '@threlte/core';
import { initializeInput, pollInput } from '$lib/input';
import { sendActions } from '$lib/game-store';
import { onMount } from 'svelte';

onMount(() => {
  initializeInput();
});

// Send inputs at tick rate (e.g., 20 Hz = every 50ms)
let accumulator = 0;
const tickInterval = 50; // ms

useTask((delta) => {
  accumulator += delta * 1000; // delta is in seconds

  if (accumulator >= tickInterval) {
    accumulator -= tickInterval;

    const actions = pollInput();
    if (actions.length > 0) {
      sendActions(actions);
    }
  }
});

Best for: Real-time action games, FPS, racing.


Client: Send actions to server

Update client/src/lib/game-store.ts:

export function sendActions(actions: GameAction[]): void {
  if (!gameChannel || actions.length === 0) return;

  gameChannel.push('input_batch', { actions });
}

Server: Process input batch

Update server/lib/tostada_web/channels/game_channel.ex:

@impl true
def handle_in("input_batch", %{"actions" => actions}, socket) do
  user_id = socket.assigns.user_id
  room_id = socket.assigns.room_id

  # Queue all actions
  Enum.each(actions, fn action ->
    GameServer.queue_input(room_id, user_id, action)
  end)

  {:reply, :ok, socket}
end

The GameServer’s tick loop will process these inputs in order.


Advanced: Input prediction

For responsive feel, apply inputs locally before server confirms:

import { gameState } from './game-store';
import type { GameAction } from './input';

export function applyInputLocally(action: GameAction, userId: string) {
  if (action.type === 'move') {
    gameState.update((state) => {
      const player = state.players.get(userId);
      if (!player) return state;

      // Predict new position
      const predicted = computeMove(player, action.direction);

      const updatedPlayers = new Map(state.players);
      updatedPlayers.set(userId, predicted);

      return { ...state, players: updatedPlayers };
    });
  }
}

function computeMove(player: Player, direction: string): Player {
  const speed = 0.2;
  let { x, z } = player;

  switch (direction) {
    case 'north':
      z -= speed;
      break;
    case 'south':
      z += speed;
      break;
    case 'east':
      x += speed;
      break;
    case 'west':
      x -= speed;
      break;
  }

  return { ...player, x, z };
}

Then reconcile when server state arrives:

gameChannel.on('game_tick', (state: any) => {
  // Server state is authoritative
  // Replace predicted state with server state
  gameState.set(state);
});

Trade-off: Feels instant, but can cause “rubber-banding” if server rejects the input.


Input buffering on server

The GameServer already queues inputs in handle_cast({:queue_input, ...}). This prevents:

  • Race conditions (two inputs arrive simultaneously)
  • Packet loss (inputs are queued even if processing is delayed)
  • Deterministic replay (inputs are timestamped and sorted)

Gamepad button mapping

Extend keyToAction for gamepad buttons:

function gamepadButtonToAction(buttonIndex: number): GameAction | null {
  switch (buttonIndex) {
    case 0: // A button (Xbox) / Cross (PS)
      return { type: 'jump' };
    case 1: // B button (Xbox) / Circle (PS)
      return { type: 'interact' };
    case 2: // X button (Xbox) / Square (PS)
      return { type: 'attack', target: { x: 0, y: 0 } }; // Default target
    default:
      return null;
  }
}

Then poll button state in pollInput():

if (state.gamepadState) {
  state.gamepadState.buttons.forEach((pressed, index) => {
    if (pressed) {
      const action = gamepadButtonToAction(index);
      if (action && !wasPressed(index)) {
        actions.push(action);
      }
    }
  });
}

Touch input (mobile)

For mobile games, add touch event listeners:

let touchStart: { x: number; y: number } | null = null;

window.addEventListener('touchstart', (e) => {
  const touch = e.touches[0];
  touchStart = { x: touch.clientX, y: touch.clientY };
});

window.addEventListener('touchmove', (e) => {
  if (!touchStart) return;
  const touch = e.touches[0];

  const dx = touch.clientX - touchStart.x;
  const dy = touch.clientY - touchStart.y;

  // Swipe gesture -> movement
  if (Math.abs(dx) > 50 || Math.abs(dy) > 50) {
    const direction = swipeToDirection(dx, dy);
    queueAction({ type: 'move', direction });
    touchStart = { x: touch.clientX, y: touch.clientY };
  }
});

window.addEventListener('touchend', () => {
  touchStart = null;
});

function swipeToDirection(dx: number, dy: number): 'north' | 'south' | 'east' | 'west' {
  if (Math.abs(dx) > Math.abs(dy)) {
    return dx > 0 ? 'east' : 'west';
  } else {
    return dy > 0 ? 'south' : 'north';
  }
}

Testing input

Browser console:

import { queueAction } from '$lib/input';

// Simulate jump
queueAction({ type: 'jump' });

// Simulate attack
queueAction({ type: 'attack', target: { x: 100, y: 200 } });

Or use a test harness:

export function simulateInput(action: GameAction) {
  queueAction(action);
}

Input lag mitigation

Problem: At 20 tick rate, up to 50ms delay between input and server response.

Solutions:

  1. Client-side prediction: Apply input locally, reconcile on server response (shown above)
  2. Higher tick rate: 60 Hz reduces delay to ~16ms (at cost of server resources)
  3. Input buffering: Send inputs slightly ahead of time (advanced)
  4. Lag compensation: Server rewinds time for hit detection (advanced, FPS games)

For most games, 20 Hz + prediction feels responsive enough.


Next steps

  • Scene management: Render game state in Threlte scenes
  • Putting it together: Build a complete game using all patterns
  • Advanced: Rollback netcode, snapshot interpolation, input prediction with reconciliation

Clean input handling is the foundation of good game feel.