Definition
A chord is input made by pressing several keys together to trigger one action, like a chord on a piano where several notes are played at once. The chord collection window in this document is a method where you don't decide the result as soon as the first key event arrives. Instead, you collect the keys that come in together over a short time and settle them all at once.
Why it matters
Browsers have no concept of "pressed at the same time." Even if you press W and D together, the keydown events arrive separately for each key, in order. In a structure where the loop that draws the screen (requestAnimationFrame) and the fixed-interval computation that advances the rules by one tick (a simulation step) run separately, like in a game, a frame or a step can slip in between those events.
That is how this bug happens. Take a control where tapping the same direction key twice quickly makes you dash in that direction. If you settle the direction at the first key event and use it on the next step, the second key that arrives right after (the one that makes the diagonal) can't be merged. If you press the two keys alternately, one key is already held down, so you get the diagonal by accident. To the eye it looks like the same input, but the result differs.
How it works
time → t0: keydown W (double tap confirmed) t0+Δ: keydown D
├──────── collection window (e.g. tens of ms, not extended) ────────┤
└→ consumed once as a W+D diagonal in the simulation step
- Don't settle on the first event. Open the collection window at the first key where the double tap is confirmed.
- Fill in the other-axis key within the window. If a vertical (W/S) key and a horizontal (A/D) key land in the same window, combine the directions.
- Don't extend the window. If you lengthen the window every time a key arrives, the delay has no upper bound. Start it only on the first key and let it end after a fixed length.
- Consume only once, in the simulation step. If you consume in the render frame, the input can run twice or be skipped when rendering and simulation drift apart. Clear the buffer when you consume it.
- Clear it when state changes. When the window loses focus (blur), or on pause or restart, clear the buffer and the record of "the first tap that was pressed."
In practice
const CHORD_WINDOW_MS = 50; // a short upper bound with no perceptible delay. Fixed length.
let pending: { firstKey: string; startedAt: number; axes: Set<"x" | "z"> } | null = null;
window.addEventListener("keydown", (event) => {
if (!isDoubleTap(event.code)) return;
pending ??= { firstKey: event.code, startedAt: performance.now(), axes: new Set() };
pending.axes.add(axisOf(event.code)); // collect the axes of keys that arrive inside the window
});
function onSimulationStep(now: number) {
if (pending && now - pending.startedAt >= CHORD_WINDOW_MS) {
dash(directionFrom(pending.axes)); // consume only once
pending = null;
}
}
function resetInput() {
pending = null; // call on blur, pause, restart
}
Write the test so it covers the same combination in both key orders, and inserts a frame between the two keydown events. If you only verify "alternate pressing," you can't catch this regression.
Trade-offs
| Choice | Benefit | Cost |
|---|---|---|
| Settle on the first event immediately | Zero delay | You can miss keys pressed together |
| Short collection window | Merges simultaneous input reliably | Even a single-key input runs later by the length of the window |
| Long collection window | Catches slow simultaneous input | Controls feel sluggish |
When not to use it
- An action that needs only one key doesn't need a collection window. It only adds delay.
- Fighting-game-style input that needs precision calls for a separately designed, frame-level input buffer policy. The approach in this document is hard to use as it is.
- There is no basis for generalizing this approach as is to other input policies (gamepad, touch).
Common mistakes
- Assuming "pressed at the same time" is one event. Each key gets its own event.
- Extending the window every time a key arrives. The delay then has no upper bound.
- Leaving out the reset path. Stale input left over after a blur or pause contaminates the next action.
- Testing only alternate pressing. Even for the same input, a different event order is a different code path.
Related concepts
- ime-composition-commit-boundary — the same family of problem: deferring the moment of settling when several inputs come together to form one meaning
- pointer-events-touch-gesture-handling — the principle of clearing state on cancel and reset paths
- test-oracle — the view that expected values should come from correct behavior, not from the implementation