The first version of keyboard support on Brick Buster was a keydown listener on the window and a comment that said "keyboard done". It was not done. A player arriving by Tab could not put focus anywhere near the game, and the only keys that did anything were the ones the browser already owned: Tab moved the focus straight past, and Space scrolled the page down a screen.
The second version listened on the canvas itself, which worked for a mouse user who had clicked it and for nobody else. The distance between reachable and playable turned out to be four separate problems, and only one of them is about key handlers.
They are worth doing in order, because the fourth one is invisible until the first three are finished.
A canvas is one object, and it has no name
The element has no implicit role at all. The W3C's HTML-ARIA mapping lists canvas as "no corresponding role", and the accompanying accessibility API mapping gives it a single platform object: IA2_ROLE_CANVAS on Windows, Control Type Image under UIA, ATK_ROLE_CANVAS elsewhere. However much is drawn on it, that is one thing.
The obvious fix looks like the fallback content, and HTML does define it: "the contents of the canvas element, if any, are the element's fallback content". The rendering rules then say that in non-visual media, or when scripting is off, the canvas "represents its fallback content instead". The operative word is instead. A browser that supports canvas and is executing script represents the bitmap, not the text between the tags, so a paragraph written there is a message for a browser that is not the one being used. MDN states the consequence without hedging: the element "is just a bitmap and does not provide information about any drawn objects".
So the object needs a name that is written down somewhere. HTML-ARIA permits any role on the element, which means role="img" with an aria-label is legal and is the pattern that works: one graphic, one name, and a label that says which game this is rather than describing the pixels.
Getting focus onto the canvas
A canvas without a tabindex is not in the tab order, and a listener on the window that fires while focus sits somewhere else is a listener firing for the wrong reason. tabindex="0" places the canvas in the sequence where it appears in the document; tabindex="-1" makes it reachable only from script, which is the right choice when a start button hands focus over on click.
The keyboard criterion, WCAG 2.1.1, asks that "all functionality of the content is operable through a keyboard interface", and then adds a clause that a reflex game runs straight into: "without requiring specific timings for individual keystrokes". A paddle that only moves on the exact frame of the press is a paddle a keyboard player cannot use reliably. The countermeasure is the one in the note on coyote time and input buffering — the key stays queued for a handful of frames, so an input that arrives 40 ms late still counts.
Focus also has to be visible. A default outline disappears against a dark canvas, so the page replaces it with a two-pixel ring in the HUD colour and keeps it on :focus-visible so a mouse click does not leave a ring behind. Removing the outline and putting nothing back is the fastest way to fail this section.
Not trapping the player in the canvas
Success criterion 2.1.2 is one sentence long: if keyboard focus can be moved to a component, it can be moved away from that component with the keyboard alone. A game that swallows Tab and Escape so the page stops scrolling has built a keyboard trap, and a fullscreen game is the easiest place to build one by accident.
The exit here is Escape, plus a pause panel made of ordinary focusable buttons. Escape is treated as a standard exit and needs no announcement; a custom key would have to be advertised on the page.
Which keys need preventDefault is not a matter of taste. Four of them have a default action that fights the game, and two of those must be left alone.
| Key | Default action | What the game does |
|---|---|---|
| Arrow keys | scroll the nearest scrollable ancestor | calls preventDefault() while the canvas has focus |
| Space | scrolls down by one viewport | calls preventDefault() and maps it to the primary action |
| Tab | moves focus to the next focusable element | leaves it alone, because it is the way out |
| Escape | exits fullscreen, otherwise nothing | leaves it alone and closes the pause overlay |
The rule that falls out of the table is to suppress the default only for keys the game is genuinely using, and never for the two that move focus. Swallowing Tab to stop a paddle drifting off the edge of the screen breaks the page for every other keyboard user on it.
Putting the score where it can be heard
A score painted onto the canvas is invisible to assistive technology, because it is now pixels inside a single named object. The score has to exist as text in the document — a sibling element that renders as the HUD on screen and supplies the same number to everything else.
Announcing every frame is the second mistake, and it is the loud one. A live region that changes sixty times a second becomes a stutter, and readers will either interrupt themselves continuously or give up on the region. The fix is a region with aria-live="polite" that is written to only on an event: a brick hit, a life lost, the run over. The per-frame number can still be drawn on the canvas for the eye; the region gets the events.
Two regions turned out to be one region too many. A polite region for the running score and a second assertive region for the outcome fought each other, and the assertive one interrupted the running commentary mid-word. Reusing the polite region for the final score, written once after the last brick, reads better and needs less markup.
Contrast for the HUD, not just the field
Success criterion 1.4.11 sets a floor of 3:1 against adjacent colours for "visual information required to identify user interface components and states". It applies to the HUD and to state indicators such as an empty life slot, and it does not apply to decoration, which is why the scanline overlay is exempt and the score is not.
The ratios below are arithmetic from the WCAG relative-luminance formula — linearise each channel, weight red 0.2126, green 0.7152 and blue 0.0722, then divide the lighter result plus 0.05 by the darker result plus 0.05. They are measured against the page background rather than the canvas, because that is what the text sits on.
| Colour | Contrast ratio | Clears the 3:1 floor |
|---|---|---|
#3dff8a phosphor green | 15.3:1 | yes |
#ffb000 amber | 11.0:1 | yes |
#888888 mid grey, tried for dimmed state | 5.7:1 | yes |
#555555 dark grey, tried for dimmed state | 2.7:1 | no |
Three of the four clear the floor and the fourth is the one that was actually in the stylesheet, on the reasoning that a dimmed indicator is meant to be less prominent. That is exactly the case the criterion was written for: a dimmed control that cannot be seen is not a dimmed control, it is a missing one. The grey was moved up to the 5.7:1 value and the dimming was done with size and weight instead of brightness.
Pros
- A focusable canvas plus one polite live region covers both things a screen reader needs, and both are a few lines of markup.
- Keyboard controls can be tested without any assistive software: put the mouse aside, press Tab once, and play.
- The same focus work also fixes controller and switch-device input, which reach the page as ordinary key events.
- Suppressing only the keys the game binds keeps the page's own navigation intact for everyone else.
Cons
- The canvas stays one named object for its whole life, so per-object meaning for a complicated scene needs a parallel hidden DOM that has to be kept in sync.
- A live region can report a score and an outcome and cannot describe a moving scene, so the game remains unplayable without sight.
- Getting
preventDefaultwrong on one key breaks the surrounding page for every other keyboard user on it. - Focus handling is invisible in testing unless someone deliberately navigates by keyboard, which is a step that gets skipped.
Sources
- HTML Standard: the canvas element — the definition of fallback content and the rendering rule that makes it apply only when the canvas is not being drawn.
- MDN: <canvas> — the statement that canvas content is not exposed to accessibility tools, and the permitted ARIA roles.
- W3C: Understanding SC 2.1.1 Keyboard — the operability requirement, the clause about per-keystroke timing, and the neighbouring 2.1.2 on not trapping focus.
- W3C: Understanding SC 1.4.11 Non-text Contrast — the 3:1 floor for user interface components and states, and what it excludes.