3 Vanilla JS Tricks for Juicier Browser Games #
A browser game can be technically correct and still feel strangely dead.
You click.
The enemy loses health.
The score changes.
The sprite moves.
Everything works.
And yet the interaction feels like pressing buttons in a spreadsheet.
That gap is usually what people mean when they talk about game feel.
Game feel is not one feature. It is the collection of small visual, timing, audio, and motion responses that make an action feel acknowledged.
A hit should not merely change a number.
It should feel like something happened.
And for a small web game, you do not necessarily need Unity, Godot, Box2D, Matter.js, or a full physics simulation to get there.
Three surprisingly cheap techniques already go a long way:
- screen shake;
- hit-stop;
- non-linear easing.
None of them requires real physics.
All three can be implemented with ordinary CSS and vanilla JavaScript.
How Do You Add Game Feel to a Browser Game Without an Engine? #
The simplest way is to exaggerate the player’s input with short, controlled feedback:
- Screen shake adds spatial impact after hits, explosions, or landings.
- Hit-stop briefly freezes or slows the action at the exact moment of impact.
- Easing curves make movement accelerate, overshoot, or settle instead of travelling at a constant robotic speed.
The key is not adding more animation.
It is adding the right response at the right moment.
Game Feel Is Mostly About Timing #
Imagine two identical attacks.
Version A:
player swings
enemy HP -10
animation continues
Version B:
player swings
contact
↓
18 ms pause
↓
small screen shake
↓
enemy recoils quickly
↓
recoil eases back
The underlying game rule is identical:
damage = 10
But the second version feels heavier because several cues agree about the same event.
That is the pattern worth remembering:
INPUT
↓
GAME EVENT
↓
VISUAL RESPONSE
↓
TIMING RESPONSE
↓
RECOVERY
You are not making the simulation more realistic.
You are making the event easier to perceive.
Technique 1: Screen Shake with CSS Transform + requestAnimationFrame #
Screen shake is one of the oldest game-feel tricks because it is incredibly cheap.
The idea is simple:
impact
↓
move camera/container a few pixels randomly
↓
rapidly reduce amplitude
↓
return to zero
For a DOM-based browser game, the “camera” can just be a wrapper.
HTML #
<div class="game-shell">
<div class="game-camera" data-game-camera>
<div class="enemy" data-enemy>
ENEMY
</div>
<button
type="button"
class="attack-button"
data-attack
>
Attack
</button>
</div>
</div>
CSS #
.game-shell {
width: min(900px, 100%);
margin-inline: auto;
overflow: hidden;
border-radius: 18px;
background: #101218;
}
.game-camera {
min-height: 420px;
display: grid;
place-items: center;
gap: 30px;
padding: 40px;
transform: translate3d(0, 0, 0);
will-change: transform;
}
.enemy {
display: grid;
place-items: center;
width: 160px;
aspect-ratio: 1;
border-radius: 20px;
background: #ff4f5e;
color: white;
font: 700 18px/1 system-ui;
}
.attack-button {
padding: 12px 20px;
border: 0;
border-radius: 999px;
background: white;
color: #111;
font: 600 15px/1 system-ui;
cursor: pointer;
}
JavaScript #
const camera = document.querySelector(
'[data-game-camera]'
);
let shakeFrame = null;
function screenShake({
intensity = 10,
duration = 180
} = {}) {
if (!camera) {
return;
}
const start = performance.now();
if (shakeFrame !== null) {
cancelAnimationFrame(shakeFrame);
}
function update(now) {
const elapsed = now - start;
const progress = Math.min(
elapsed / duration,
1
);
const strength =
intensity * (1 - progress);
const x =
(Math.random() * 2 - 1) *
strength;
const y =
(Math.random() * 2 - 1) *
strength;
camera.style.transform =
`translate3d(${x}px, ${y}px, 0)`;
if (progress < 1) {
shakeFrame =
requestAnimationFrame(update);
} else {
camera.style.transform =
'translate3d(0, 0, 0)';
shakeFrame = null;
}
}
shakeFrame =
requestAnimationFrame(update);
}
The important thing is that amplitude decays:
const strength =
intensity * (1 - progress);
Without decay, the camera just vibrates.
With decay, the event feels like:
impact
████████
██████
████
██
.
That creates a clear attack and recovery phase.
Don’t Shake the Whole Page #
For a web game embedded in a site, I would almost never shake:
document.body
That tends to feel less like game feedback and more like the browser is malfunctioning.
Shake the game camera, canvas wrapper, battlefield, or local scene.
The motion should belong to the game world.
Not to the browser chrome, article layout, navigation, footer, and everything else.
This also makes the effect much easier to disable independently.
Technique 2: Hit-Stop Is the Cheapest Way to Add Weight #
If I had to choose only one trick for making a hit feel heavier, I would probably choose hit-stop.
The idea comes from fighting games and action games.
At the exact moment an attack connects, the game briefly pauses or nearly pauses.
Not for a second.
Not even usually for a tenth of a second.
Just long enough for the brain to register:
contact happened here.
Visually:
attack
↓
movement
↓
CONTACT
████████ freeze ████████
↓
release
That pause creates contrast.
Without it:
attack → contact → recovery
With it:
attack → CONTACT | pause | → recovery
The same animation suddenly feels more deliberate.
A Simple DOM-Based Hit-Stop #
For a tiny browser game, you do not need to stop the browser.
You only need to stop your game updates.
let pausedUntil = 0;
function hitStop(duration = 40) {
pausedUntil =
performance.now() + duration;
}
function gameLoop(now) {
if (now >= pausedUntil) {
updateGame();
}
renderGame();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);
The simulation pauses.
Rendering can continue.
Or, if your project is simpler and mostly CSS/DOM animation, you can temporarily apply a state class.
async function hitStop(duration = 40) {
document.documentElement.classList.add(
'is-hit-stop'
);
await new Promise(resolve => {
setTimeout(resolve, duration);
});
document.documentElement.classList.remove(
'is-hit-stop'
);
}
Then:
.is-hit-stop .game-animated {
animation-play-state: paused;
}
That is less suitable for a sophisticated game loop but perfectly reasonable for a controlled experiment.
Why Hit-Stop Works So Well #
A heavy hit is not necessarily a large animation.
It is often a timing interruption.
Consider:
weak hit:
0 ms pause
normal hit:
20 ms pause
heavy hit:
45 ms pause
Those numbers are not universal recommendations.
They are tuning values.
The point is that very small timing differences can produce surprisingly large perceptual differences.
This is why I like hit-stop for browser-game experiments.
It costs almost nothing in rendering complexity.
You are not adding particles, shaders, physics, or more assets.
You are adding:
time
Combine Hit-Stop with Screen Shake #
Now the two techniques can agree:
function registerHit() {
hitStop(35);
screenShake({
intensity: 9,
duration: 160
});
}
The event becomes:
impact
├── simulation pauses
└── camera receives impulse
That is already enough to make a basic combat interaction feel dramatically less flat.
Technique 3: Stop Using Linear Motion for Everything #
Linear animation is mathematically clean:
0%
25%
50%
75%
100%
Equal distance in equal time.
Unfortunately, very little in games feels good when everything moves like an elevator indicator.
Imagine enemy recoil.
Linear:
0px
5px
10px
15px
20px
then:
20px
15px
10px
5px
0px
It works.
But it feels mechanical.
A more convincing recoil often needs:
FAST OUT
↓
SLOW RETURN
That is an easing problem.
CSS Easing for Recoil #
.enemy {
transform: translateX(0);
transition:
transform 260ms
cubic-bezier(.16, 1, .3, 1);
}
.enemy.is-hit {
transform: translateX(22px);
}
Then JavaScript:
const enemy = document.querySelector(
'[data-enemy]'
);
function recoilEnemy() {
enemy.classList.add('is-hit');
requestAnimationFrame(() => {
requestAnimationFrame(() => {
enemy.classList.remove(
'is-hit'
);
});
});
}
For more control, split the outward and return phases.
.enemy {
--recoil: 0px;
transform: translateX(
var(--recoil)
);
}
async function recoilEnemy() {
enemy.animate(
[
{
transform:
'translateX(0)'
},
{
transform:
'translateX(24px)',
offset: 0.18
},
{
transform:
'translateX(-4px)',
offset: 0.55
},
{
transform:
'translateX(0)'
}
],
{
duration: 280,
easing:
'cubic-bezier(.2,.8,.2,1)'
}
);
}
Now the response can overshoot slightly.
That little negative rebound:
+24px
↓
-4px
↓
0px
makes the target feel less like a rectangle being translated and more like something that absorbed momentum.
Easing Is Really About Energy #
I find it useful to think of easing curves as describing energy.
Linear #
constant speed
Good for:
- timers;
- progress bars;
- conveyor-like movement;
- deliberately mechanical motion.
Ease-out #
fast → slow
Good for:
- recoil settling;
- projectiles arriving;
- UI elements entering;
- camera corrections.
Ease-in #
slow → fast
Useful when something is accelerating into an action.
Overshoot / back easing #
target
↓
past target
↓
settle
Good for:
- impacts;
- elastic UI;
- pickups;
- punchy scale changes.
Game feel is partly choosing curves that match the perceived energy of the event.
Put the Three Techniques Together #
Now we can make a tiny impact sequence.
const attackButton = document.querySelector(
'[data-attack]'
);
attackButton?.addEventListener(
'click',
() => {
performAttack();
}
);
function performAttack() {
recoilEnemy();
hitStop(35);
screenShake({
intensity: 8,
duration: 150
});
}
Conceptually:
CLICK
↓
HIT EVENT
├── 35 ms hit-stop
├── 8 px camera impulse
└── eased recoil
Nothing here requires a physics engine.
There is no collision solver.
No rigid body.
No scene graph.
No engine-specific timing system.
But the interaction suddenly has:
weight
rhythm
response
recovery
And those are four very large pieces of game feel.
A Slightly More Structured Version #
Once the prototype grows, I would separate effects from game rules.
Instead of:
button.addEventListener(
'click',
() => {
enemyHP -= 10;
screenShake();
recoilEnemy();
hitStop();
}
);
I prefer:
function resolveHit({
damage,
strength = 'normal'
}) {
enemyHP -= damage;
feedback.hit(strength);
}
Then:
const feedback = {
hit(strength) {
const presets = {
light: {
stop: 18,
shake: 3,
duration: 90
},
normal: {
stop: 32,
shake: 7,
duration: 140
},
heavy: {
stop: 48,
shake: 12,
duration: 190
}
};
const preset =
presets[strength] ??
presets.normal;
hitStop(preset.stop);
screenShake({
intensity:
preset.shake,
duration:
preset.duration
});
recoilEnemy();
}
};
Now the game rule says:
heavy hit
and the feedback layer decides what “heavy” currently means.
That separation makes tuning much easier.
The Cheapest Game-Feel Parameter Is Often Contrast #
There is a trap in game juice.
Once something feels better with:
shake = 8
you try:
shake = 16
Then:
shake = 30
Soon every sword swing looks like the building has been struck by artillery.
More feedback does not automatically mean better feedback.
What matters is contrast.
A normal action needs to feel smaller so that a special action can feel larger.
For example:
| Event | Hit-stop | Shake | Recoil |
|---|---|---|---|
| Normal click | 0 ms | 0 px | 2 px |
| Light hit | 15 ms | 2 px | 6 px |
| Normal hit | 30 ms | 6 px | 16 px |
| Heavy hit | 45 ms | 10 px | 26 px |
| Boss impact | 60 ms | 14 px | custom |
These are illustration values, not a universal tuning table.
The useful idea is relative hierarchy.
Game feel works when the feedback tells you something about the event.
If every event shakes the screen equally, screen shake stops carrying information.
Where This Breaks Accessibility #
This is the part people often skip when demonstrating game juice.
Screen shake can be unpleasant.
Fast scale changes can be unpleasant.
Large parallax movement can be unpleasant.
Repeated camera displacement can be genuinely difficult for motion-sensitive users.
So a good browser-game feedback layer should not assume:
everybody wants maximum juice.
The browser already gives us a useful signal:
@media (
prefers-reduced-motion: reduce
) {
/* reduce non-essential motion */
}
And JavaScript can read the same preference:
const reducedMotion =
window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
A Reduced-Motion Screen Shake #
function screenShake({
intensity = 10,
duration = 180
} = {}) {
if (reducedMotion.matches) {
return;
}
// normal shake implementation...
}
For hit-stop, I would usually be more nuanced.
A tiny timing pause may not create the same vestibular problem as camera shake.
So reduced motion does not necessarily mean:
remove all feedback
It can mean:
screen shake
→ off
large recoil
→ smaller
scale punch
→ smaller
hit-stop
→ keep or reduce
color flash
→ keep if accessible
sound
→ unchanged / separately controlled
The feedback system should still communicate impact.
Just not through intense motion.
Provide Another Channel for the Same Event #
If you remove screen shake, the hit can still communicate through:
sound
color
brief brightness change
damage number
small outline flash
hit marker
controller vibration
That is good interaction design beyond games too.
Do not make one sensory channel carry all the information.
For a reduced-motion mode, the sequence might become:
hit
↓
short hit-stop
↓
color flash
↓
damage number
instead of:
hit
↓
screen shake
↓
large recoil
↓
scale pulse
↓
camera zoom
The event is still clear.
A Small Accessibility-Aware Feedback Manager #
const motionPreference =
window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
const feedback = {
hit(strength = 'normal') {
const reduced =
motionPreference.matches;
const presets = {
light: {
stop: 15,
shake: 3,
recoil: 8
},
normal: {
stop: 30,
shake: 7,
recoil: 18
},
heavy: {
stop: 45,
shake: 12,
recoil: 28
}
};
const preset =
presets[strength] ??
presets.normal;
hitStop(
reduced
? preset.stop * 0.75
: preset.stop
);
if (!reduced) {
screenShake({
intensity:
preset.shake,
duration: 150
});
}
recoilEnemy({
distance:
reduced
? preset.recoil * 0.25
: preset.recoil
});
flashEnemy();
}
};
That is the architecture I would keep.
Accessibility is part of the feedback system.
Not a patch added after the game is finished.
Why requestAnimationFrame Is Enough Here #
For screen shake, requestAnimationFrame() gives us the only timing loop we really need.
We are not running a full simulation.
We are just asking:
How far through this 180 ms effect are we?
Then:
progress
↓
remaining amplitude
↓
random offset
↓
CSS transform
Once the effect reaches 100%:
if (progress < 1) {
requestAnimationFrame(update);
}
the loop stops.
That is a nice pattern for lightweight game effects:
event happens
↓
temporary RAF loop
↓
effect settles
↓
RAF stops
No permanent animation infrastructure required.
When I Would Actually Reach for a Game Engine #
None of this is an argument against Unity, Godot, Phaser, PixiJS, Matter.js, or other game tooling.
Once the project needs:
collision systems
physics
large sprite scenes
entity management
camera systems
animation state machines
complex input
level tooling
asset pipelines
a proper engine or framework can save enormous amounts of work.
But there is a large space between:
button with score counter
and:
full game engine
That middle ground includes:
- browser mini-games;
- campaign interactions;
- product games;
- educational experiences;
- portfolio experiments;
- playable ads;
- Web Components with game-like feedback;
- interactive storytelling.
For those, three small feedback primitives can be enough to make something feel much more intentional.
A Good Lab Exercise #
If I were testing this in a Lab section, I would build one target and one attack button.
Start with no feedback.
Then add effects one at a time.
Version A #
damage only
Version B #
damage
+
eased recoil
Version C #
damage
+
eased recoil
+
hit-stop
Version D #
damage
+
eased recoil
+
hit-stop
+
screen shake
That experiment is useful because you can feel exactly what each layer contributes.
It also makes over-animation easier to detect.
If version C already feels excellent, version D may not need a 14-pixel shake.
Frequently Asked Questions #
What is game feel in JavaScript? #
Game feel is the perceived responsiveness and physicality of an interaction. In a JavaScript browser game, it can be improved with timing changes, recoil, screen shake, easing, sound, particles, hit flashes, and other feedback techniques.
Can I make a juicy browser game without Unity or Godot? #
Yes.
Small browser games and interactive experiments can achieve convincing feedback with HTML, CSS, Canvas, and vanilla JavaScript. A full game engine becomes more valuable as simulation, content, and tooling complexity grows.
How do I add screen shake in vanilla JavaScript? #
Apply small temporary translate3d() offsets to the game camera or scene wrapper. Update the offset with requestAnimationFrame() and decay the shake amplitude over a short duration.
What is hit-stop? #
Hit-stop is a very brief pause or slowdown at the moment an attack connects. It emphasizes impact by interrupting motion for a few milliseconds before the game continues.
How long should hit-stop last? #
There is no universal duration. Light attacks may need only a very small pause, while heavier events can use more. Tune it relative to the game’s animation speed and overall rhythm rather than treating a specific millisecond value as a rule.
Why does easing improve game feel? #
Easing makes motion change speed over time instead of moving linearly. Fast attack and slow recovery, overshoot, recoil, and spring-like settling can all communicate energy more clearly than constant-speed movement.
Should screen shake respect prefers-reduced-motion? #
Yes.
Screen shake and large camera motion are non-essential effects that may be uncomfortable for motion-sensitive users. A reduced-motion mode should remove or significantly reduce these effects while preserving important feedback through other channels.
Do I need a physics engine for recoil? #
No.
Visual recoil can be an ordinary transform animation. You only need a physics engine if the recoil must participate in an actual physical simulation.
Conclusion #
The most useful thing about game feel in vanilla JavaScript is how little machinery it can require.
You can start with a perfectly ordinary interaction:
click
↓
damage
and add three small ideas:
screen shake
hit-stop
easing
Now the same event has:
impact
weight
timing
recovery
No rigid-body solver is involved.
No Unity project.
No Godot scene.
No physics engine.
Just carefully timed feedback.
For browser games, portfolio experiments, playable campaigns, and Lab projects, that is often enough.
And the general rule extends beyond games:
when an interaction feels dead, do not immediately add more animation. First ask whether the event has a clear moment of impact, a readable response, and a satisfying recovery.
That is where the juice usually starts.