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.tssrc/systems/weapon.ts
Steps
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.tsimport { 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.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 besideeyeandforward:tsimport type { BurstOptions, Vec3 } from 'gameable'; const normal: Vec3 = { x: 0, y: 1, z: 0 }; const sparkOptions: BurstOptions = { direction: normal };And inside
weapon:tsconst 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.Add these declarations and functions to that same system file.
Fstarts fire following the player entity,Gstops it,Bapplies a look andNrestores neutral settings:tsimport 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.atalone is a world position; withentity, 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.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:
| Preset | Look | Usual lifetime |
|---|---|---|
hit-sparks | Hot gold sparks along the surface normal | One-shot |
explosion | Orange flash and falling embers | One-shot, short emission tail |
smoke | Expanding grey puffs rising slowly | Looping |
fire | Orange flames rising and shrinking | Looping |
magic | Floating purple and cyan glints | Looping |
dust | Brown translucent puffs spreading and settling | One-shot |
trail | Short blue stream along a moving entity's path | Looping |
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 field | Neutral | What it does |
|---|---|---|
reset | false | Restore neutral before this update |
bloom | 0 | Glow strength around bright pixels |
bloomThreshold | 1 | Linear brightness required to glow |
bloomRadius | 0.4 | Bloom spread |
exposure | 1 | Brightness multiplier |
toneMapping | 'none' | HDR mapping: 'none', 'aces', 'agx', 'neutral' |
contrast | 1 | Contrast around linear grey 0.18 |
saturation | 1 | Colour intensity; 0 is greyscale |
tint | { x: 1, y: 1, z: 1 } | Linear RGB multiplier |
vignette | 0 | Darken screen edges |
fogColor | { x: 0.5, y: 0.55, z: 0.6 } | Linear RGB fog colour |
fogDensity | 0 | Exponential 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.
toneMappingother 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 devShoot 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
- Turn on a feature
- Add a weapon
- The wasm boundary
packages/vfx/README.md— VFX host API