Guide

Guide Recipes Game feel

Game feel

Add feedback cues that make interaction feel responsive.

Intermediate ~3 min read

Game feel

Game feel is the layer of feedback that makes actions feel tangible: a flash when the player takes damage, a shake when something explodes, a sound that clicks with each tween step, particles that trail behind a moving object. In ExoJS, these effects are compositions of primitives you already know — tweens, filters, particles, view manipulation, audio — applied at the moment of an event.

Damage flash

When the player takes damage, flash the sprite red for a fraction of a second and tween back to normal. Use a ColorFilter on the sprite and animate its color with a tween:

import { ColorFilter, Signal } from '@codexo/exojs';

init(loader) {
    this.player = new Sprite(loader.get(Texture, 'hero'));
    this.flash = new ColorFilter(new Color(255, 255, 255, 1));
    this.player.filters = [this.flash];

    this.onDamage = new Signal();
    this.onDamage.add(() => {
        // Flash red instantly
        this.flash.color.set(255, 80, 80, 1);
        // Tween back to white over 200ms
        this.app.tweens.create(this.flash.color)
            .to({ r: 255, g: 255, b: 255 }, 0.2)
            .start();
    });
}

The key insight: the ColorFilter multiplies the sprite’s rendered output by the filter color. White ([255, 255, 255, 1]) is identity — no tint. Red mutes green and blue. The tween interpolates the r, g, b components of the Color object back to 255 over 200ms, producing a smooth recovery. Since ColorFilter exposes its internal Color object as a live getter, you can tween or mutate it directly — no filter reconstruction needed.

Screen shake on explosion

View.shake(intensity, durationMs, { frequency, decay }) is the simplest feedback effect in ExoJS. Fire it on any dramatic event:

this.app.input.onPointerTap.add(pointer => {
    // Position the burst at the click location (relative to system position)
    this.burstPos.set(pointer.x - this.particles.position.x, pointer.y - this.particles.position.y);
    this.burst.reset();
    // Shake the view: 22px intensity, 280ms, 26Hz oscillation, with decay
    this.view.shake(22, 280, { frequency: 26, decay: true });
});

Combine it with a particle burst for a complete explosion feel — particles provide the visual debris, the shake provides the impact. The shake displaces the view center; the particles render inside that view, so they move with the shake. The effect is coherent: the whole scene rattles.

Tween-heavy feedback

Tweens are the workhorse of game feel. A few reusable patterns:

// Scale punch — grow on hit, settle back
this.app.tweens.create(this.sprite.scale)
    .to({ x: 1.4, y: 1.4 }, 0.08)
    .yoyo()
    .repeat(1)
    .easing(Ease.cubicOut)
    .start();

// Tilt wobble — oscillate rotation
this.app.tweens.create(this.sprite)
    .to({ rotation: 8 }, 0.06)
    .easing(Ease.cubicOut)
    .yoyo()
    .repeat(2)
    .start();

// Fade flash — full white overlay that fades out
this.overlay.alpha = 1;
this.app.tweens.create(this.overlay)
    .to({ alpha: 0 }, 0.3)
    .start();

Each pattern is fire-and-forget — call .start() inside an event handler and the tween runs to completion. No per-frame tracking, no cleanup. The existing tween manager handles all active tweens each frame.

Continuous feedback: particles + audio

For held actions — thrust, charging, spinning up — continuous feedback creates presence:

update(delta) {
    const thrustMag = Math.hypot(this.thrust.x, this.thrust.y);

    if (thrustMag > 0.05) {
        // Particles trail behind the ship
        this.rate.value = 900 * thrustMag;
        this.particles.setPosition(
            this.ship.x - Math.cos(this.angle) * 28,
            this.ship.y - Math.sin(this.angle) * 28,
        );

        // Audio hum scales with thrust
        this.engine.volume = 0.08 + thrustMag * 0.32;
    } else {
        this.rate.value = 0;
        this.engine.volume = 0;
    }

    this.particles.update(delta);
}

A RateSpawn with a Constant rate lets you dynamically change rate.value each frame — the spawn module reads the current value, not a snapshot. The engine hum uses an OscillatorSound with volume tied to thrust magnitude. The combined effect: moving produces particles and sound; stopping kills both. The player feels the connection between input and response.

Layering timing

The best feedback combines immediate and gradual responses. When an event fires:

  1. Frame 0: Screen shake starts (instant displacement). Particle burst spawns (instant visual). Filter flash fires (instant color change).
  2. Next 200ms: Tween recovers the filter back to white. Shake decays.
  3. Next 500ms: Particles fade out via AlphaFadeOverLifetime.

The player experiences a sharp impact followed by a smooth settle — contrast makes the impact feel stronger. No orchestration framework needed: just call .start() on each effect from the same event handler.

Examples

Damage Flash Pointer Open in Playground View source
import { Application, Color, ColorFilter, Scene, Signal, Sprite, Texture } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
});

class DamageFlashScene extends Scene {
    private hit!: Signal;
    private ship!: Sprite;
    private filterColor!: Color;
    private filter!: ColorFilter;
    private hud!: ReturnType<typeof mountControls>;
    private hits = 0;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { ship: 'image/ship-a.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.hit = new Signal();
        this.ship = new Sprite(loader.get(Texture, 'ship')).setAnchor(0.5).setScale(2.2).setPosition(width / 2, height / 2);
        this.filterColor = new Color(255, 255, 255, 1);
        this.filter = new ColorFilter(this.filterColor);
        this.ship.filters = [this.filter];

        this.hud = mountControls({
            title: 'Damage Flash',
            controls: [{ keys: 'Click', action: 'flash the ship' }],
            status: 'Hits: 0',
        });

        this.hit.add(() => {
            this.hits++;
            this.hud.setStatus(`Hits: ${this.hits}`);
            this.filterColor.set(255, 120, 120, 1);
            this.app.tweens.create(this.filterColor).to({ r: 255, g: 255, b: 255 }, 0.2).start();
        });
        this.app.input.onPointerTap.add(() => {
            this.hit.dispatch();
        });
    }

    override draw(context): void {
        context.backend.clear();
        context.render(this.ship);
    }
}

app.start(new DamageFlashScene());

Click to trigger a red flash on a sprite — ColorFilter animated via tween.

Screen Shake on Explosion Pointer Open in Playground View source
import { Application, Color, Scene, Texture, Vector, View } from '@codexo/exojs';
import {
    AlphaFadeOverLifetime,
    BurstSpawn,
    ConeDirection,
    Constant,
    particlesExtension,
    ParticleSystem,
} from '@codexo/exojs-particles';

const app = new Application({
    canvas: {
        width: 1280,
        height: 720,
        mount: document.body,
        sizingMode: 'fit',
    },
    clearColor: Color.black,
    loader: {
        basePath: 'assets/',
    },
    extensions: [particlesExtension],
});

class ScreenShakeOnExplosionScene extends Scene {
    private view!: View;
    private ps!: ParticleSystem;
    private burstPos!: Vector;
    private burst!: BurstSpawn;

    override async load(loader): Promise<void> {
        await loader.load(Texture, { particle: 'image/particle-light.png' });
    }

    override init(loader): void {
        const { width, height } = this.app.canvas;

        this.view = new View(width / 2, height / 2, width, height);
        this.ps = new ParticleSystem(loader.get(Texture, 'particle'), { capacity: 5000 });
        this.ps.setPosition(width / 2, height / 2);
        this.burstPos = new Vector(0, 0);
        this.burst = new BurstSpawn({
            schedule: [{ time: 0, count: 160 }],
            lifetime: new Constant(0.9),
            position: new Constant(this.burstPos),
            velocity: ConeDirection.omni(100, 360),
            scale: new Constant(new Vector(0.22, 0.22)),
        });
        this.ps.addSpawnModule(this.burst);
        this.ps.addUpdateModule(new AlphaFadeOverLifetime());
        this.app.input.onPointerTap.add(p => {
            this.burstPos.set(p.x - this.ps.position.x, p.y - this.ps.position.y);
            this.burst.reset();
            this.view.shake(22, 280, { frequency: 26, decay: true });
        });
    }

    override update(delta): void {
        this.ps.update(delta);
    }

    override draw(context): void {
        context.backend.clear();
        context.backend.setView(this.view);
        context.render(this.ps);
        context.backend.setView(null);
    }
}

app.start(new ScreenShakeOnExplosionScene());

Click anywhere to spawn particles and shake the view — the one-two punch of visual debris and camera impact.

Where to go next

The next recipe, UI patterns, covers in-canvas UI construction — dialog systems, typewriter text, progress indicators, and when to use DOM instead.