Accessible Game Design
Create games that everyone can enjoy. Accessibility is not just for people with disabilities—it benefits all players.
Core Accessibility Principles for Games
POUR Framework
- •Perceivable: Information must be perceivable to at least one sense
- •Operable: Interfaces must be operable via keyboard and other inputs
- •Understandable: Content and operations must be understandable
- •Robust: Content must be robust enough to work with assistive technologies
Visual Accessibility
Color and Contrast
css
/* WCAG AA: 4.5:1 contrast ratio for normal text, 3:1 for large text */
/* WCAG AAA: 7:1 contrast ratio for normal text, 4.5:1 for large text */
body {
color: #222222; /* Dark gray on light background */
background: #ffffff;
}
/* High contrast mode support */
@media (prefers-contrast: more) {
body {
color: #000000; /* Pure black */
background: #ffffff; /* Pure white */
}
}
Not Relying on Color Alone
html
<!-- BAD: Only color shows status --> <div style="color: red;">Incorrect</div> <!-- GOOD: Color + text + icon --> <div style="color: red;"> ✗ Incorrect </div>
Text Sizing and Readability
css
/* Use relative units, not fixed pixels */
body {
font-size: 1rem; /* 16px base */
line-height: 1.5; /* Adequate spacing */
letter-spacing: 0.05em;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
/* Support user font size preferences */
@media (prefers-reduced-motion: no-preference) {
/* Use motion only when user allows it */
}
Animations and Motion
css
/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Provide static fallback for animated content */
.game-animation {
animation: slide 2s ease-in-out;
}
@media (prefers-reduced-motion: reduce) {
.game-animation {
animation: none;
transform: translateX(100px); /* End state */
}
}
Font Selection
css
/* Use readable fonts, avoid decorative fonts for body text */
body {
/* Sans-serif is generally more readable for screen text */
font-family: -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
}
/* Ensure glyphs are distinct: I, l, 1, O, 0 */
body {
font-family: 'Courier New', monospace; /* Numbers game? */
-webkit-font-smoothing: antialiased;
}
Auditory Accessibility
Captions and Transcripts
html
<!-- Provide captions for audio content --> <video> <source src="gameplay.mp4" type="video/mp4"> <track kind="captions" src="captions.vtt" srclang="en"> </video> <!-- Provide transcript for longer audio --> <audio src="tutorial.mp3"></audio> <p> <a href="tutorial-transcript.txt">Transcript of tutorial audio</a> </p>
Audio Alternatives
javascript
// Provide visual feedback for important sounds
const soundManager = {
playSuccess() {
// 1. Play audio
audio.play('success');
// 2. Show visual feedback
document.querySelector('.game-feedback')
.classList.add('success');
// 3. Haptic feedback (for supported devices)
navigator.vibrate?.(50);
}
};
Sound Design
javascript
// Use distinct sounds, not overlapping audio
const sounds = {
// Each frequency band is distinct
menuClick: 'sound-high-pitch.mp3',
gameOver: 'sound-low-pitch.mp3',
success: 'sound-mid-pitch.mp3'
};
Motor Accessibility
Keyboard Navigation
javascript
class AccessibleGame {
constructor() {
// Support standard keys
this.keyMap = {
'ArrowUp': 'move-up',
'ArrowDown': 'move-down',
'ArrowLeft': 'move-left',
'ArrowRight': 'move-right',
'w': 'move-up',
'a': 'move-left',
's': 'move-down',
'd': 'move-right',
' ': 'action',
'Enter': 'confirm',
'Escape': 'cancel',
'Tab': 'next-control'
};
document.addEventListener('keydown', (e) => {
const action = this.keyMap[e.key];
if (action) {
e.preventDefault();
this.handleAction(action);
}
});
}
handleAction(action) {
// Game logic here
}
}
Reduced Motor Requirements
javascript
// 1. Don't require rapid clicks
// Avoid: "Click 10 times as fast as possible"
// Good: "Click when ready to continue"
// 2. Large click targets
const button = document.querySelector('button');
button.style.minWidth = '44px'; // Apple standard: 44x44px
button.style.minHeight = '44px';
// 3. Support toggle/hold controls
class ToggleControl {
constructor(element) {
this.active = false;
element.addEventListener('mousedown', () => {
this.active = true;
this.onActivate?.();
});
element.addEventListener('mouseup', () => {
this.active = false;
this.onDeactivate?.();
});
// Maintain state across pointer leaving element
}
}
// 4. Allow customizable key bindings
class KeybindingManager {
constructor() {
this.bindings = new Map([
['move-up', ['ArrowUp', 'w']],
['move-down', ['ArrowDown', 's']],
['action', [' ', 'Enter']]
]);
}
remapKey(action, newKey) {
const current = this.bindings.get(action);
current.push(newKey);
}
save() {
localStorage.setItem('keybindings',
JSON.stringify(Object.fromEntries(this.bindings)));
}
}
Mouse/Pointer Input
javascript
// Don't exclusively require precise mouse control
// Provide:
// - Large UI targets
// - Forgiving hit boxes
// - Visual feedback on hover
class ForgivingHitBox {
constructor(visual, hitbox = 20) {
// Visual element is 10x10, hit box expands to 50x50
this.visual = visual;
this.expandBy = hitbox;
}
isHit(x, y) {
const rect = this.visual.getBoundingClientRect();
return (x > rect.left - this.expandBy &&
x < rect.right + this.expandBy &&
y > rect.top - this.expandBy &&
y < rect.bottom + this.expandBy);
}
}
Gamepad Support
javascript
class GamepadManager {
constructor() {
this.gamepadIndex = null;
setInterval(() => this.update(), 100);
}
update() {
const gamepads = navigator.getGamepads?.();
if (!gamepads) return;
for (let i = 0; i < gamepads.length; i++) {
const gamepad = gamepads[i];
if (!gamepad) continue;
this.gamepadIndex = i;
this.handleInput(gamepad);
break;
}
}
handleInput(gamepad) {
// Standard gamepad layout
const buttons = {
0: 'A', // Bottom button
1: 'B', // Right button
2: 'X', // Left button
3: 'Y', // Top button
4: 'LB', // Left shoulder
5: 'RB', // Right shoulder
12: 'up',
13: 'down',
14: 'left',
15: 'right'
};
gamepad.buttons.forEach((button, index) => {
if (button.pressed) {
const action = buttons[index];
if (action) this.onButtonPress?.(action);
}
});
// Analog sticks
const deadzone = 0.2;
const leftX = Math.abs(gamepad.axes[0]) > deadzone ?
gamepad.axes[0] : 0;
const leftY = Math.abs(gamepad.axes[1]) > deadzone ?
gamepad.axes[1] : 0;
if (leftX !== 0 || leftY !== 0) {
this.onAnalogStick?.(leftX, leftY);
}
}
}
Cognitive Accessibility
Clear Language
html
<!-- BAD: Jargon-heavy, unclear instructions --> <p>Utilize the interface apparatus to manipulate the digital construct.</p> <!-- GOOD: Simple, direct instructions --> <p>Click the button to move forward.</p>
Visual Clarity
css
/* Use clear visual hierarchy */
h1 { font-size: 2rem; font-weight: bold; }
h2 { font-size: 1.5rem; font-weight: bold; }
p { font-size: 1rem; }
/* Whitespace and grouping */
.game-section {
padding: 2rem;
margin-bottom: 2rem;
border: 1px solid #ccc;
}
Progressive Disclosure
html
<!-- Hide complex options initially -->
<button id="advanced-toggle">Show Advanced Options</button>
<div id="advanced-options" hidden>
<!-- Complex settings here -->
</div>
<script>
document.getElementById('advanced-toggle').addEventListener('click', (e) => {
const opts = document.getElementById('advanced-options');
opts.hidden = !opts.hidden;
e.currentTarget.textContent = opts.hidden ?
'Show Advanced Options' : 'Hide Advanced Options';
});
</script>
Consistency
javascript
// Use consistent controls throughout the game
const controlScheme = {
'confirm': 'Space or Enter',
'cancel': 'Escape',
'menu': 'M or Tab',
'help': '?' or F1'
};
// Don't change these between screens
Error Prevention & Recovery
javascript
class SafeGame {
// 1. Confirm destructive actions
deleteGame() {
if (confirm('Are you sure you want to delete this save?\n' +
'This cannot be undone.')) {
// Delete
}
}
// 2. Provide undo functionality
undo() {
if (this.history.length > 0) {
this.state = this.history.pop();
}
}
// 3. Auto-save frequently
autoSave() {
setInterval(() => {
localStorage.setItem('game-save',
JSON.stringify(this.state));
}, 60000); // Every minute
}
// 4. Show clear feedback for actions
performAction(action) {
try {
this.state = action(this.state);
this.showFeedback(`✓ ${action.name} completed`, 'success');
} catch (error) {
this.showFeedback(`✗ Error: ${error.message}`, 'error');
}
}
}
Cognitive Load Management
Tutorials and Onboarding
javascript
class Tutorial {
constructor(game) {
this.game = game;
this.steps = [];
this.currentStep = 0;
}
addStep(instruction, validation) {
this.steps.push({ instruction, validation });
}
start() {
this.showInstruction(this.steps[0].instruction);
// Wait for player to complete step
const checkCompletion = () => {
if (this.steps[this.currentStep].validation()) {
this.currentStep++;
if (this.currentStep < this.steps.length) {
this.showInstruction(this.steps[this.currentStep].instruction);
} else {
this.complete();
}
}
};
setInterval(checkCompletion, 100);
}
showInstruction(text) {
const div = document.createElement('div');
div.className = 'tutorial-box';
div.textContent = text;
div.setAttribute('role', 'status');
div.setAttribute('aria-live', 'polite');
document.body.appendChild(div);
}
}
Testing for Accessibility
Automated Testing
javascript
// Test color contrast
const getContrast = (rgb1, rgb2) => {
const getLuminance = (r, g, b) => {
const [rs, gs, bs] = [r, g, b].map(x => {
x = x / 255;
return x <= 0.03928 ?
x / 12.92 :
Math.pow((x + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
const l1 = getLuminance(...rgb1);
const l2 = getLuminance(...rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
};
// Test: contrast([0, 0, 0], [255, 255, 255]) = 21 (AA and AAA)
Manual Testing
- •Keyboard Only: Can you play with keyboard alone?
- •Screen Reader: Use NVDA or JAWS to test narration
- •Color Blind: Check with ColorOracle or similar tool
- •Zoom: Test at 200% zoom level
- •Mobile: Test on actual mobile devices
- •Slow Network: Test with network throttling
- •Old Hardware: Test on older devices
Accessibility Tools
- •axe DevTools: Automated accessibility auditing
- •WAVE: Web accessibility evaluation tool
- •Lighthouse: Chrome DevTools built-in audit
- •Pa11y: Automated accessibility testing
Competitive Game Accessibility
For 2-player and competitive games, ensure fair play and accessible controls:
Comprehensive Control Remapping for Both Players
Allow both players to configure their own controls - critical for accessibility and competitive fairness:
javascript
const CONTROL_ACTIONS = ['punch', 'kick', 'jump', 'block', 'pause'];
function renderControlsGrid(playerNum) {
const grid = document.getElementById(`p${playerNum}ControlsGrid`);
const binds = getPlayerKeybinds(playerNum);
grid.innerHTML = '';
CONTROL_ACTIONS.forEach(action => {
const binding = binds[action];
const key1 = Array.isArray(binding) ? binding[0] : binding;
const key2 = Array.isArray(binding) ? binding[1] : null;
const div = document.createElement('div');
div.className = 'controlMapping';
div.innerHTML = `
<span class="controlLabel">${action.toUpperCase()}</span>
<span class="controlKey" data-player="${playerNum}" data-action="${action}" data-slot="0">
${formatKeyName(key1)}
</span>
<span class="controlKey" data-player="${playerNum}" data-action="${action}" data-slot="1">
${formatKeyName(key2)}
</span>
`;
grid.appendChild(div);
// Click or Enter key to rebind
div.querySelectorAll('.controlKey').forEach(keyEl => {
keyEl.addEventListener('click', () => startListening(playerNum, action, keyEl));
keyEl.addEventListener('keydown', (e) => {
if (e.code === 'Enter' || e.code === 'Space') {
e.preventDefault();
startListening(playerNum, action, keyEl);
}
});
});
});
}
function startListening(playerNum, action, element) {
element.textContent = '...';
element.classList.add('listening');
const handleKey = (e) => {
e.preventDefault();
applyBinding(playerNum, action, e.code);
element.classList.remove('listening');
window.removeEventListener('keydown', handleKey);
};
window.addEventListener('keydown', handleKey);
}
Multi-Input Gamepad Support
Support both keyboard and gamepad for maximum accessibility:
javascript
function isActionPressed(playerNum, action) {
const binds = getPlayerKeybinds(playerNum);
const gamepad = navigator.getGamepads()?.[playerNum - 1];
// Check keyboard (supports primary + secondary bindings)
const binding = binds[action];
if (Array.isArray(binding)) {
if (binding[0] && keysPressed[binding[0]]) return true;
if (binding[1] && keysPressed[binding[1]]) return true;
} else if (binding && keysPressed[binding]) return true;
if (!gamepad) return false;
// Check gamepad buttons (Xbox layout: A=0, B=1, X=2, Y=3, etc.)
const buttonMap = {
'punch': 0, // A button
'kick': 2, // X button
'jump': 1, // B button
'block': 3 // Y button
};
if (buttonMap[action] !== undefined) {
if (gamepad.buttons[buttonMap[action]]?.pressed) return true;
}
// Check analog stick
const stickMap = {
'punch': null,
'kick': null,
'jump': { axis: 1, dir: -1 } // Up on left stick
};
if (stickMap[action]?.axis !== undefined) {
const val = gamepad.axes[stickMap[action].axis] || 0;
if (val * stickMap[action].dir > 0.5) return true;
}
return false;
}
Fair Pause Mechanics
For 2-player games, require both players to agree on pausing:
javascript
class CompetitiveGame {
constructor() {
this.paused = false;
this.pauseRequestedBy = null;
}
handlePausePress(playerNum) {
if (this.pauseRequestedBy === playerNum) {
// This player already requested - cancel it
this.pauseRequestedBy = null;
return;
}
if (this.pauseRequestedBy !== null && this.pauseRequestedBy !== playerNum) {
// Other player requested, this player confirms
this.doPause();
return;
}
// First request
this.pauseRequestedBy = playerNum;
this.showMessage(`Player ${playerNum} paused. Player ${3 - playerNum}: Press PAUSE to confirm.`);
// Timeout: cancel pause request if not confirmed
clearTimeout(this.pauseTimeout);
this.pauseTimeout = setTimeout(() => {
this.pauseRequestedBy = null;
this.hideMessage();
}, 3000);
}
doPause() {
this.paused = true;
this.pauseRequestedBy = null;
clearTimeout(this.pauseTimeout);
}
resume() {
this.paused = false;
}
}
Clear Visual and Audio Feedback
Provide strong feedback for all critical actions - especially important when players can't easily see or hear attacks:
javascript
// Screen shake on hit
function screenShake(intensity = 5, duration = 200) {
const start = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - start;
if (elapsed >= duration) {
clearInterval(interval);
canvas.style.transform = 'translate(0, 0)';
return;
}
const progress = 1 - (elapsed / duration);
const angle = Math.random() * Math.PI * 2;
const dist = progress * intensity;
canvas.style.transform = `translate(
${Math.cos(angle) * dist}px,
${Math.sin(angle) * dist}px
)`;
}, 16);
}
// Audio feedback for each action (accessible to deaf/hard of hearing with visual cues)
function playSoundEffect(type) {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
const soundMap = {
'punch': { freq: 100, duration: 0.1 },
'kick': { freq: 80, duration: 0.15 },
'hit': { freq: 150, duration: 0.08 },
'block': { freq: 400, duration: 0.05 }
};
const sound = soundMap[type] || soundMap.hit;
osc.frequency.value = sound.freq;
gain.gain.setValueAtTime(0.2, audioCtx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + sound.duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + sound.duration);
// Also provide visual effect
screenShake(3, 50);
}
// Combo counter display (visual feedback for players with hearing impairment)
function displayCombo(count) {
const comboDisplay = document.getElementById('comboCounter');
if (comboDisplay) {
comboDisplay.textContent = `COMBO x${count}`;
comboDisplay.style.opacity = 1;
}
}
Control Configuration Persistence
Save player preferences for return visits:
javascript
function saveControlConfig(playerNum) {
const config = {
bindings: getPlayerKeybinds(playerNum),
gamepadIndex: getPlayerGamepad(playerNum),
timestamp: Date.now()
};
localStorage.setItem(`idlegames-p${playerNum}-controls`, JSON.stringify(config));
}
function loadControlConfig(playerNum) {
const saved = localStorage.getItem(`idlegames-p${playerNum}-controls`);
if (saved) {
const config = JSON.parse(saved);
applyControlConfig(playerNum, config);
}
}
// Load on startup
window.addEventListener('DOMContentLoaded', () => {
loadControlConfig(1);
loadControlConfig(2);
});
Difficulty and Assistance Options
javascript
class AccessibilityOptions {
constructor() {
this.difficulty = 'normal';
this.assistanceOptions = {
// Gameplay assistance
infiniteAmmo: false,
noDamage: false,
slowerEnemies: false,
enlargedHitBoxes: false,
// Visual assistance
highContrast: false,
largerText: false,
colorBlindMode: 'none',
textOutlines: false,
// Audio assistance
captions: true,
mono: false,
// Motor assistance
autoAim: false,
tapToMove: false,
simplifiedControls: false
};
}
setDifficulty(level) {
this.difficulty = level;
// Adjust game parameters
}
setAssistance(option, enabled) {
this.assistanceOptions[option] = enabled;
this.applySettings();
}
applySettings() {
if (this.assistanceOptions.enlargedHitBoxes) {
this.game.hitBoxScale = 1.5;
}
if (this.assistanceOptions.highContrast) {
document.body.classList.add('high-contrast');
}
// ... etc
}
}
Resources
- •WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- •Game Accessibility Guidelines: https://gameaccessibilityguidelines.com/
- •WebAIM: https://webaim.org/
- •The A11Y Project: https://www.a11yproject.com/