Input Handling
Capture keyboard, mouse, and gamepad inputs and send them over channels.
The pattern
- Client: Capture raw input events (keydown, mousemove, gamepad)
- Client: Translate to semantic actions (“move_north”, “jump”, “shoot”)
- Client: Batch inputs and send over channel (per-tick or per-event depending on game type)
- Server: Queue inputs in GameServer
- Server: Process inputs in order during tick loop
- 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
inputStatestore keyToActiontranslates raw keys to semantic actionspollInput()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:
- Client-side prediction: Apply input locally, reconcile on server response (shown above)
- Higher tick rate: 60 Hz reduces delay to ~16ms (at cost of server resources)
- Input buffering: Send inputs slightly ahead of time (advanced)
- 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.