Skip to content

Add effects ​

Goal ​

Shots throw sparks off walls, fire follows an entity until stopped, and bloom and a vignette give the game a look. Start from the FPS template; ctx.vfx uses the same API in third-person systems.

Files you will edit ​

  • src/game.ts
  • src/systems/weapon.ts

Steps ​

  1. Declare the feature in src/game.ts: features: { vfx: true }, or the options form below for a base look applied before the first frame (late joiners get it too). The FPS template already declares the options form; keep its other features.

    ts
    import { defineGame } from 'gameable';
    
    export default defineGame({
      features: { vfx: { post: { bloom: 0.5, vignette: 0.2 } } },
    });

    Keep the template's other definition fields. Both templates' hosts already load the feature; a custom host needs the setup in packages/vfx/README.md.

  2. In src/systems/weapon.ts, emit sparks after the raycast succeeds, before rejecting scenery hits (hit.entity === 0). The FPS template already does this, with reusable storage beside eye and forward:

    ts
    import type { BurstOptions, Vec3 } from 'gameable';
    
    const normal: Vec3 = { x: 0, y: 1, z: 0 };
    const sparkOptions: BurstOptions = { direction: normal };

    And inside weapon:

    ts
    const hit = ctx.physics.raycast(eye, forward, range, HIT_LAYERS, player);
    if (!hit) return;
    normal.x = hit.normal.x;
    normal.y = hit.normal.y;
    normal.z = hit.normal.z;
    ctx.vfx.burst('hit-sparks', hit.point, sparkOptions);

    This is ctx.vfx.burst('hit-sparks', hit.point, { direction: hit.normal }) with preallocated options. The SDK copies vectors immediately; never retain the pooled raycast result.

  3. Add these declarations and functions to that same system file. F starts fire following the player entity, G stops it, B applies a look and N restores neutral settings:

    ts
    import type { EffectId, GameContext, PostSettings, StartOptions } from 'gameable';
    
    let fireId: EffectId = 0;
    const fireOptions: StartOptions = { entity: 0 };
    const look: PostSettings = {
      bloom: 0.5,
      vignette: 0.2,
      toneMapping: 'none',
      fogDensity: 0.015,
    };
    const neutral: PostSettings = { reset: true };
    
    export function resetEffects(): void {
      fireId = 0;
    }
    
    export function effects(ctx: GameContext): void {
      if (ctx.input.pressed('F') && fireId === 0 && ctx.player !== 0) {
        fireOptions.entity = ctx.player;
        fireId = ctx.vfx.start('fire', fireOptions);
      }
      if (ctx.input.pressed('G') && fireId !== 0) {
        ctx.vfx.stop(fireId);
        fireId = 0;
      }
      if (ctx.input.pressed('B')) ctx.vfx.post(look);
      if (ctx.input.pressed('N')) ctx.vfx.post(neutral);
    }

    ctx.vfx.start('fire', { entity }) is the shorthand outside a frame callback. Keep the returned id. at alone is a world position; with entity, it is a world-axis offset. ctx.vfx.move(id, at) changes that position or offset. Stopping ends emission; existing particles finish their lives. Reset handles in init for a fresh game, not while their emitters are running.

  4. Wire the functions into src/game.ts, keeping existing init work and systems:

    diff
    -import { resetWeapon, weapon } from './systems/weapon';
    +import { effects, resetEffects, resetWeapon, weapon } from './systems/weapon';
    
     init: (ctx) => {
    +  resetEffects();
        resetWeapon(ctx);
    
    -systems: [movePlayer, weapon, enemyAI, pickups, updateHud],
    +systems: [movePlayer, weapon, effects, enemyAI, pickups, updateHud],

No manifest entry or texture is needed for built-in presets:

PresetLookUsual lifetime
hit-sparksHot gold sparks along the surface normalOne-shot
explosionOrange flash and falling embersOne-shot, short emission tail
smokeExpanding grey puffs rising slowlyLooping
fireOrange flames rising and shrinkingLooping
magicFloating purple and cyan glintsLooping
dustBrown translucent puffs spreading and settlingOne-shot
trailShort blue stream along a moving entity's pathLooping

Use burst for one-shots, start / stop for persistent emitters. Bursting a looping preset limits emission to one second. Options: scale changes size, intensity changes count and brightness, tint overrides linear RGB.

post is partial: omitted fields keep their values. reset: true restores neutral before applying supplied fields, rather than restoring the base look.

Post fieldNeutralWhat it does
resetfalseRestore neutral before this update
bloom0Glow strength around bright pixels
bloomThreshold1Linear brightness required to glow
bloomRadius0.4Bloom spread
exposure1Brightness multiplier
toneMapping'none'HDR mapping: 'none', 'aces', 'agx', 'neutral'
contrast1Contrast around linear grey 0.18
saturation1Colour intensity; 0 is greyscale
tint{ x: 1, y: 1, z: 1 }Linear RGB multiplier
vignette0Darken screen edges
fogColor{ x: 0.5, y: 0.55, z: 0.6 }Linear RGB fog colour
fogDensity0Exponential fog thickness; 0 disables it

Gotchas:

  • Effects are presentation: not replayed into the simulation, and never affect determinism.
  • Bursts use id 0, return no handle, and cannot be stopped or moved.
  • A linear RGB tint over 1 glows under bloom with the default threshold.
  • Splats never bloom at the default threshold and are not fogged; meshes and particles are fogged.
  • Particles draw after splat worlds (splats write no depth): in front of a splat wall they show, behind one they show through.
  • toneMapping other than 'none' changes how splats look.
  • SDK effect calls allocate nothing: call every frame if needed, using preallocated Vec3s and options.
  • Neutral grading bypasses the post pass; fog alone needs no full-screen pass.

Verify ​

sh
npm run dev

Shoot a wall: gold sparks fan off the hit even though scenery has no health. Press F, look down and walk: fire follows you. Press G: remaining flames fade. Press B for glow, darkened edges and mesh fog, then N for neutral. Splat scenery stays unfogged.

See also ​