Skip to main content

Particles And Effects

Hs2d particles are pooled sprite particles. A particle layer owns a fixed-capacity CompositorParticleSystem, gameplay updates emitters and bursts, and render composes the sprite layer. New gameplay code should not use draw2d particle loops.

Source References

SourceWhy it matters
../games/hosanna-ui/src/hosanna-game/particles/CompositorParticleSystem.tsFixed sprite pool, default particle atlas, native emitter kinds, update loops, active/emit/move counters, and pool-full behavior.
../games/hosanna-ui/src/hosanna-game/particles/ParticleEmitter.tsRemoved draw2d API. It throws and tells callers to use CompositorParticleSystem with sprite particles.
../games/hosanna-ui/src/hosanna-game/particles/CompositorParticleOverlay.tsStandalone overlay wrapper for simple compositor-backed particle effects.
../games/hosanna-ui/src/hosanna-game/hosanna2d/Hs2dParticleLayer.tsHs2d layer wrapper that updates emitters, applies camera projection scale, clears particles, and exposes stats.
../games/hosanna-ui/src/hosanna-game/hosanna2d/Hs2dWorld.tsaddParticleLayer creates a CompositorParticleSystem on an existing sprite layer and registers it for snapshots.
../hosanna-ui-game-samples-public/src/hosanna-game-examples/native-shoot-em-up/VerticalShooterLevelScene.tsReference gameplay usage: add particle layer during world build, update rocket emitters, emit bounded bursts, reset counters.
../hosanna-ui-game-samples-public/src/hosanna-game-examples/diagnostics/ParticleRigController.tsDiagnostic usage: custom particle regions, emitter modes, counters, and HUD reporting.

Current API

Use:

  • CompositorParticleSystem for the underlying fixed sprite pool
  • Hs2dParticleLayer for Hs2d world integration
  • Hs2dWorld.addParticleLayer for normal gameplay scenes
  • CompositorParticleOverlay only for simple non-world overlays or diagnostics

Do not use:

  • ParticleEmitter
  • per-frame screen.DrawRect particle loops
  • per-frame screen.DrawObject loops for particle sprites
  • dynamically created particle sprites during gameplay

ParticleEmitter remains as a failing compatibility shell so old code breaks loudly instead of silently returning to the removed draw2d path.

Fixed-Capacity Model

CompositorParticleSystem allocates all particle sprites in its constructor:

Fixed-capacity particle sprites being requested, activated from inactive slots, updated and projected, expired, parked, and reused while overflow emissions are droppedFixed-capacity particle sprites being requested, activated from inactive slots, updated and projected, expired, parked, and reused while overflow emissions are dropped

Each particle stores:

  • active flag
  • sprite handle
  • world position
  • velocity
  • age and lifetime
  • frame base and current frame

When an emitter fires, the system finds an inactive particle. If the pool is full, the emission is dropped. That is intentional: a frame must degrade by skipping excess particles, not by allocating.

Default particle regions are a small 16-frame atlas:

  • sparkle frames at indices 0-3
  • fire/explosion frames at indices 4-7
  • laser/missile frames at indices 8-11
  • dust frames at indices 12-15

You can pass custom regions when the game needs different art. Create bitmaps and regions during build/setup, not during the frame loop.

Adding A Particle Layer

Add particles after the world and sprite layer exist:

import { createDefaultCompositorParticleRegions, type NativeRigParticleEmitter } from '@hs-src/hosanna-game/particles';
import { Hs2dParticleLayer, type Hs2dWorld } from '@hs-src/hosanna-game/hosanna2d';

const FOREGROUND_LAYER_ID = 'my-game:foreground';
const PARTICLE_POOL_SIZE = 160;

class MyLevelScene extends Hs2dLevelScene {
protected override buildWorld(): Hs2dWorld | undefined {
const world = Hs2dWorld.create({
viewportWidth: this.screenWidth,
viewportHeight: this.screenHeight,
worldWidth: this.worldWidth,
worldHeight: this.worldHeight,
});

world.addSpriteLayer({ id: FOREGROUND_LAYER_ID });

this.particles = world.addParticleLayer({
id: 'my-game:particles',
layer: FOREGROUND_LAYER_ID,
regions: createDefaultCompositorParticleRegions(),
poolSize: PARTICLE_POOL_SIZE,
projectionScaleRatio: 1,
});

this.particleEmitters.push({
kind: 'rocketFire',
x: this.shipX,
y: this.shipY + 58,
rateMs: 24,
burstSize: 1,
accumulatorMs: 0,
});

return world;
}
}

Hs2dWorld.addParticleLayer attaches the particle system to an existing sprite layer's compositor. Particles then render as sprites inside that layer's normal render/compose path.

Emitters

An emitter is plain data:

type NativeRigParticleEmitter = {
kind: 'cameraSparkle' | 'sparkle' | 'fire' | 'firework' | 'explosion' | 'rocketFire' | 'laser' | 'missile' | 'dust';
x: number;
y: number;
rateMs: number;
burstSize: number;
accumulatorMs: number;
};

Use persistent emitters for continuous effects:

  • engine trails
  • fires
  • camera sparkle
  • magic fountains
  • ambient dust

Update their positions before the particle layer tick:

protected override updateParticles(deltaMs: number): void {
if (!this.particles) return;

Hs2dParticleLayer.updateRocketEmitters(this.particleEmitters, {
x: this.shipX,
y: this.shipY,
});

super.updateParticles(deltaMs);
this.activeParticles = this.particles.getStats().activeParticles;
}

The base Hs2dLevelScene.updateParticles feeds particleEmitters and the active camera into the layer.

Bursts

Use burst emissions for one-shot effects:

private emitBurst(
kind: NativeRigParticleEmitter['kind'],
x: number,
y: number,
burstSize: number,
): void {
this.particles?.emitForEmitter(
{ kind, x, y, rateMs: 1, burstSize, accumulatorMs: 1 },
this.activeCamera,
);
}

Keep burst sizes small and named:

const ENEMY_HIT_SPARK_PARTICLES = 2;
const ENEMY_DESTROY_EXPLOSION_PARTICLES = 8;
const ENEMY_DESTROY_FIRE_PARTICLES = 4;

Large burst constants are easier to review than scattered numeric literals. They also make performance tests and feature flags easier to reason about.

Diagnostics And Stats

Hs2dParticleLayer.getStats() returns:

StatMeaning
activeParticlesCurrently live particle sprites.
emittedParticlesEmissions since the last counter reset.
movedParticlesParticle move operations since the last counter reset.

Use a stats window:

protected override onStatsWindow(): void {
const stats = this.particles?.getStats();
this.statusText = `particles ${stats?.activeParticles ?? 0}`;
this.particles?.resetCounters();
}

Hs2dLevelScene.onStatsWindow() runs only when __TELEMETRY__ is enabled. Using it to publish and reset optional diagnostic counters is appropriate, but required particle cleanup or gameplay state changes must live in normal update/reset paths.

The particle diagnostic displays active, emitted, and moved counters while cycling emitter configurations. Use it to validate pool sizes and emitter rates before moving an effect into a game.

Camera Projection

Particles are world-space by default. CompositorParticleSystem.update projects each particle through:

screenX = (particle.x - camera.x) * camera.scale
screenY = (particle.y - camera.y) * camera.scale

Hs2dParticleLayer can adjust scale through projectionScaleRatio. Keep this at 1 for gameplay particles that should track the world camera. Use a lower ratio only when an effect intentionally behaves closer to a screen-space overlay.

Effects Gating

Effects should be controlled by measured feature flags while tuning:

const ENABLE_ENEMY_HIT_PARTICLES = true;
const ENABLE_PROJECTILE_TILE_HIT_PARTICLES = true;

if (ENABLE_ENEMY_HIT_PARTICLES) {
this.emitBurst('sparkle', projectile.x, projectile.y, ENEMY_HIT_SPARK_PARTICLES);
}

Remove or keep flags based on data. Do not leave a slow effect enabled just because it looks small in code; use particleUpdate telemetry, active particle counts, and moved particle counts.

Do And Don't

DoDon't
Allocate particle sprites when the world/layer is built.Create particle sprites in response to every explosion.
Use Hs2dWorld.addParticleLayer for gameplay.Draw gameplay particles directly to GameScreen.
Use fixed pool sizes and bounded burst constants.Let effects scale with enemy count without a cap.
Update emitter positions before super.updateParticles(deltaMs).Move emitters from render code.
Read activeParticles, emittedParticles, and movedParticles on a stats window.Log per-particle state every frame.
Drop emissions when the pool is full.Allocate a bigger pool dynamically during a spike.
Create custom regions during build/setup.Build particle atlases in the frame loop.
Gate expensive effects during profiling.Optimize by guesswork without particle counters.
Talk to us