Making a 2D Browser Sports Game Feel Good: Input, Physics and Juice
Arcade sports games live or die on feel. A match might only last a couple of minutes, but if the player feels a hundred milliseconds of lag on a tackle or loses a key press during a sprint, they notice straight away. This post collects the techniques I keep coming back to when I prototype small 2D sports games for the browser with plain JavaScript and Canvas: input handling, a stable simulation loop, simple hitboxes, and the "juice" that makes contact feel physical.
The genre, from a player's point of view
A good reference point is Football Bros, an arcade American football game by Blue Wizard Digital (official site: footballbros.io). Disclosure: I run the fan site linked here. I didn't make the game and I know nothing about its internals. Everything below is my own approach, not a description of how that game works.
As a player, though, it shows what the genre needs. Matches are short 1v1 arcade games. On desktop you move with WASD or the arrow keys and act with the spacebar. On mobile you use touch controls, and two people can play on one keyboard. There are power-ups and upgrades, difficulty levels for practice, and both a Quick Play mode and a longer Franchise Mode. Short matches and shared keyboards make every input count, so that's where we start.
1. Keyboard input for two players on one keyboard
Use KeyboardEvent.code rather than key. code describes the physical key, so WASD stays in the same place on AZERTY or QWERTZ layouts. Prevent the default action for keys you own, otherwise arrows and space will scroll the page mid-match.
// Example mapping for my own prototype (not any particular game's)
const BINDINGS = {
p1: { up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD', action: 'Space' },
p2: { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight', action: 'Enter' },
};
const held = new Set();
const pressedAt = new Map(); // code -> simulation tick of the last press
let tick = 0;
const owned = new Set(Object.values(BINDINGS).flatMap(Object.values));
addEventListener('keydown', (e) => {
if (!owned.has(e.code)) return;
e.preventDefault();
if (!e.repeat) pressedAt.set(e.code, tick); // ignore auto-repeat
held.add(e.code);
});
addEventListener('keyup', (e) => held.delete(e.code));
addEventListener('blur', () => held.clear()); // avoid "stuck" keys after alt-tab
Ghosting and rollover caveats. Many cheap membrane and laptop keyboards can't report every combination of keys held at once. When two players each hold a direction plus an action key, some combinations simply never reach the browser. That's a hardware limit, so no JavaScript can fix it. What you can do:
- Keep each player's keys physically far apart (left cluster vs. arrow cluster).
- Avoid designs that need three or more simultaneous keys per player.
- Offer rebinding, and add a small "press all your keys" test screen so players can spot ghosting before the match.
- Treat a missing key-up as a normal case: clear state on
blurandvisibilitychange.
2. Input buffering
Players press buttons slightly early. If a tackle is only accepted on the exact frame the player becomes able to act, the game feels unresponsive. A buffer keeps a press "alive" for a few simulation ticks.
const BUFFER_TICKS = 6; // ~100 ms at 60 Hz
function consumePress(code) {
const t = pressedAt.get(code);
if (t !== undefined && tick - t <= BUFFER_TICKS) {
pressedAt.delete(code); // one press, one action
return true;
}
return false;
}
// inside the player update
if (player.canAct && consumePress(BINDINGS.p1.action)) startTackle(player);
Store ticks, not performance.now() timestamps, so the buffer behaves the same at any frame rate.
3. A fixed-timestep loop with requestAnimationFrame
requestAnimationFrame fires at the display's refresh rate: 60, 120 or 144 Hz, and irregularly when the tab is under load. If you move objects by speed * dt with a variable dt, collisions and jump arcs change with the hardware. The classic fix is a fixed simulation step with an accumulator, plus interpolation when rendering.
const STEP = 1000 / 60;
let last = performance.now();
let acc = 0;
function frame(now) {
let dt = now - last;
last = now;
if (dt > 250) dt = 250; // clamp: avoid a "spiral of death" after a stall
acc += dt;
while (acc >= STEP) {
update(STEP / 1000); // deterministic step
tick++;
acc -= STEP;
}
render(acc / STEP); // alpha in [0, 1) for interpolation
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
In render(alpha), draw each object at prev + (curr - prev) * alpha, saving prev at the start of every update. Movement then stays smooth on 120 Hz screens without changing the simulation.
4. Simple collision and tackle hitboxes
You rarely need a physics engine for a 2D arcade sports game. Axis-aligned boxes are enough, and they're easy to reason about. Use a hurtbox a little smaller than the sprite (so near misses feel fair) and a short-lived hitbox in front of the tackler.
function overlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
}
const tackleBox = { x: 0, y: 0, w: 18, h: 20 }; // reused, not reallocated
function updateTackle(p, target) {
if (p.tackleTicks <= 0) return;
p.tackleTicks--;
const active = p.tackleTicks > 4 && p.tackleTicks < 12; // active frames only
if (!active) return;
tackleBox.x = p.facing > 0 ? p.x + p.w : p.x - tackleBox.w;
tackleBox.y = p.y + 4;
if (overlap(tackleBox, target.hurtbox)) onTackleHit(p, target);
}
Splitting a move into startup, active and recovery frames gives you a dial for balance. Longer recovery punishes whiffed tackles, and a shorter startup makes a move feel snappy.
5. Juice: easing, hit-stop and screen shake
"Juice" is the feedback that sells an impact. Three cheap techniques do most of the work.
Easing. Animate UI and camera moves with easing curves instead of linear interpolation.
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
Hit-stop. Freeze the simulation for 3–5 ticks on a big hit. Skip update but keep rendering, so the moment reads clearly.
Trauma-based screen shake. Add "trauma" on impact, let it decay, and shake by trauma squared so small hits stay subtle.
let trauma = 0;
function addTrauma(v) { trauma = Math.min(1, trauma + v); }
// call between ctx.save() and ctx.restore() each frame
function applyShake(ctx, dt) {
trauma = Math.max(0, trauma - dt * 1.5);
const s = trauma * trauma;
const ox = (Math.random() * 2 - 1) * 8 * s;
const oy = (Math.random() * 2 - 1) * 8 * s;
ctx.translate(ox, oy);
}
Provide an option to reduce shake, and respect the prefers-reduced-motion media query.
6. Touch controls as a fallback
On phones, use Pointer Events and track each pointer by pointerId, so a thumb on the virtual stick and a tap on the action button don't interfere. Set touch-action: none on the canvas to stop the browser from scrolling or zooming.
const stick = { id: null, ox: 0, oy: 0, dx: 0, dy: 0 };
canvas.addEventListener('pointerdown', (e) => {
if (e.clientX < innerWidth / 2 && stick.id === null) {
stick.id = e.pointerId; stick.ox = e.clientX; stick.oy = e.clientY;
} else {
pressedAt.set('Space', tick); // right half acts as the action button
}
canvas.setPointerCapture(e.pointerId);
});
canvas.addEventListener('pointermove', (e) => {
if (e.pointerId !== stick.id) return;
stick.dx = Math.max(-1, Math.min(1, (e.clientX - stick.ox) / 40));
stick.dy = Math.max(-1, Math.min(1, (e.clientY - stick.oy) / 40));
});
canvas.addEventListener('pointerup', (e) => {
if (e.pointerId === stick.id) { stick.id = null; stick.dx = stick.dy = 0; }
});
Because touch feeds the same pressedAt buffer, the rest of the game doesn't care where input came from.
7. Performance: crisp pixels and no GC hitches
Handle devicePixelRatio. Size the backing store to physical pixels, then scale the context, otherwise the game looks blurry on high-DPI screens. Capping the ratio at 2 keeps fill-rate under control on phones.
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const { width, height } = canvas.getBoundingClientRect();
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
addEventListener('resize', resize);
resize();
Avoid garbage in the loop. Small allocations every frame (new vectors, arrays from map or filter, closures, template strings for fonts) eventually trigger garbage collection pauses, which show up as stutters right when the action peaks. Reuse objects like the tackleBox above, pool particles, and keep hot loops as plain for loops over preallocated arrays.
A few more habits:
- Pre-render static things (field lines, the scoreboard frame) to an offscreen canvas and draw that once per frame.
- Set
ctx.fontandfillStyleonly when they change. - Pause the loop on
visibilitychange, and resetlastwhen you resume so the accumulator doesn't explode.
Wrapping up
None of these techniques is complicated on its own. Players notice the combination: a buffered press, a fixed-step simulation that behaves the same on every screen, honest hitboxes, and a few frames of hit-stop and shake. Prototype with these from the first day and your small browser sports game will feel good long before it looks finished.
All rights reserved