Skip to content
AKAVA
AKAVA / navigation
open console
← All notes

Game Feel in Vanilla JavaScript Without a Game Engine

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:

  1. screen shake;
  2. hit-stop;
  3. 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.