Guide

GuideRecipesGame 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. For a single drawable this is a tint, not a filter:

examples/guides/game-feel/damage-flash.ts
override init(): void {
  this.player = new Sprite(this.loader.get('image/hero.png'));
  this.flashColor = Color.white.clone();

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

override update(): void {
  // The tween moves the Color; handing it to setTint is what tells the
  // renderer about it - mutating a live Color in place notifies nobody.
  this.player.setTint(this.flashColor);
}

The key insight: the tint multiplies the sprite’s rendered output. 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.

Screen shake on explosion

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

examples/guides/game-feel/screen-shake.ts
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, 0.28s, 26Hz oscillation, with decay
  this.view.shake(22, Time.seconds(0.28), { 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:

examples/guides/game-feel/damage-flash.ts
// 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.tint.a = 1;
this.app.tweens.create(this.overlay.tint).to({ a: 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 system handles all active tweens each frame.

Continuous feedback: particles + audio

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

examples/guides/game-feel/thrust.ts
override update(delta: Seconds): void {
  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 is a Voice from this.app.audio.play(new AudioGenerator({ type: 'sawtooth', frequency: 90 }), { volume: 0 }), with this.engine.volume tied to thrust magnitude each frame. 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). Tint flash fires (instant colour change).
  2. Next 200ms: Tween recovers the tint 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 FlashPointerOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Signal, Sprite } from '@codexo/exojs';
import { mountControls } from '@examples/runtime';

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

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

    this.hit = new Signal();
    this.ship = new Sprite(this.loader.get('image/ship-a.png'))
      .setAnchor(0.5)
      .setScale(2.2)
      .setPosition(width / 2, height / 2);
    // A single drawable's flash is a tint, not a filter: it multiplies in
    // the sprite shader and costs no render target.
    this.flashColor = new Color(255, 255, 255, 1);

    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.flashColor.set(255, 120, 120, 1);
      app.tweens.create(this.flashColor).to({ r: 255, g: 255, b: 255 }, 0.2).start();
    });
    app.input.onPointerTap.add(() => {
      this.hit.dispatch();
    });
  }

  override update(): void {
    // The tween moves the Color; handing it to setTint is what tells the
    // renderer about it.
    this.ship.setTint(this.flashColor);
  }

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

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

await app.start(DamageFlashScene);

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

Screen Shake on ExplosionPointerOpen in PlaygroundView source

Preview is paused until you click Play.

import { Application, Color, FixedResolutionCanvasSizing, type RenderingContext, Scene, Time, Vector, View } from '@codexo/exojs';
import { AlphaFadeOverLifetime, BurstSpawn, ConeDirection, Constant, particlesExtension, ParticleSystem } from '@codexo/exojs-particles';

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

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

    this.view = new View(width / 2, height / 2, width, height);
    this.ps = new ParticleSystem(this.loader.get('image/particle-light.png'), { capacity: 5000 });
    this.systems.add(this.ps);
    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());
    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, Time.seconds(0.28), { frequency: 26, decay: true });
    });
  }

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

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

await app.start(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.