---
title: "Particle effects in 2D"
url: https://recursiveai.org/onegame/cookbook/particles/
description: "Sparks, smoke, trails and explosions as view-only emitters in 2D and 3D: settings, bursts, sprite sheets, and counting particles from tests."
version: "1.5.1"
---

# Particle effects in 2D

Add fire and sparks to a C# game with onegame’s particle emitters: a steady stream, a burst when something happens, additive glow, and scenarios that count live particles without touching the simulation.

New in 1.4.0, in the `RecursiveAI.OneGame.Particles` package, which the SDK brings with the rest of the engine. An emitter makes short-lived particles and moves them. It is view-only: it lives in a field of the view, reads state, never writes it, and uses its own random stream, so replays, snapshots and drift hashes are the same with or without it.

## The game

An anvil beside a fire. Each hammer strike throws a burst of sparks. Create the game with `onegame new forge`, delete `art/` and everything in `scenarios/`, and replace `Game.cs`:

```csharp
using System;
using System.Numerics;
using OneGame.Core;
using OneGame.Host;
using OneGame.Particles;
using OneGame.Platform;
using OneGame.UI;

namespace Forge;

/// <summary>An anvil. The sim counts hammer strikes; the view throws sparks for each one and keeps a fire going.</summary>
[Entity]
public partial class Anvil : Entity, IView2D
{
    [Synced] public partial int Strikes { get; private set; }

    [Command]
    public void Strike() => Strikes++;

    // Emitters are view fields: they never go in synced state.
    private ParticleEmitter? _fire;
    private ParticleEmitter? _sparks;
    private int _seen;

    [View]
    public void Draw(Draw2D d)
    {
        _fire ??= new ParticleEmitter(new ParticleSettings
        {
            Rate = 60,                                    // particles per second
            Prewarm = 1f,                                 // already burning on the first frame
            Lifetime = new FloatRange(0.5f, 0.9f),        // seconds
            Speed = new FloatRange(40, 90),               // world units per second
            Direction = -Vector3.UnitY,                   // up on screen
            Spread = 0.3f,                                // half-angle in radians
            SpawnRadius = 20,
            Size = new FloatRange(18, 28),
            SizeOverLife = ParticleCurve.Linear(1f, 0.2f),
            Color = ParticleGradient.Of(
                (0f, ParticleGradient.Rgb(0xFFE08A)),
                (0.5f, ParticleGradient.Rgb(0xFF8A2A)),
                (1f, ParticleGradient.Rgb(0xB0200A, alpha: 0f))),
            Blend = ParticleBlend.Additive,               // glows add up instead of covering
        }, seed: 1, name: "fire");

        _sparks ??= new ParticleEmitter(new ParticleSettings
        {
            Rate = 0,                                     // bursts only
            Lifetime = new FloatRange(0.3f, 0.6f),
            Speed = new FloatRange(150, 300),
            Direction = -Vector3.UnitY,
            Spread = 1.2f,
            Gravity = new Vector3(0, 600, 0),             // +Y is down in 2D
            Size = 6f,
            Color = ParticleGradient.FadeOut(ParticleGradient.Rgb(0xFFF2B0)),
            Blend = ParticleBlend.Additive,
        }, seed: 2, name: "sparks");

        if (Strikes != _seen)                             // the view notices a strike and reacts; it never writes state
        {
            _seen = Strikes;
            _sparks.Burst(40, Vector3.Zero);
        }

        d.FillRect(new Rect(-60, 0, 160, 40), Color.Rgb(0x3A3F47));    // the anvil
        d.Particles(_fire, new Vector2(-200, 40), z: 1); // moves the emitter, steps it by this frame’s time, draws it
        d.Particles(_sparks, new Vector2(20, 0), z: 2);
    }
}

public sealed class ForgeGame : GameDefinition
{
    public override string Name => "forge";

    public override void RegisterEntities(EntityRegistry registry) => OneGame.Generated.Forge.EntityTypes.Register(registry);

    public override void Setup(World world) => world.Add(new Anvil());

    public override void DefineActions(ActionMap actions) =>
        actions.Context("play", c => c.Button("strike", Bind.Key(Key.Space), Bind.Pad(GamepadButton.South)));

    public override void OnEachFrame(GameInstance game)
    {
        if (game.Actions.Pressed("strike"))
        {
            ((Anvil)game.World.Entities[0]).Send.Strike();
        }
    }

    public override void DefineScreens(ScreenSet screens) => screens.Add("main", _ => Ui.Column());
}
```

- **`ParticleSettings`** is read while the emitter runs, so view code can change `Rate` or `Gravity` from frame to frame. `Capacity` is fixed when the emitter is made: births past it are dropped and counted, never reallocated.
- **Ranges and curves.** A `FloatRange` draws one value per particle, and a plain number is a range of one. `SizeOverLife` and `Color` are straight lines between stops over each particle’s life, from 0 to 1.
- **`d.Particles(emitter, position)`** moves the emitter, steps it by the frame’s view time and draws its particles as sprites on the current layer, so the 2D camera and `z` apply as for any sprite. An emitter you don’t pass in a frame neither moves nor draws.
- **`Burst(count, at)`** is for one-off effects, `Rate` for streams. For a one-shot stream, set `Loop = false` and `Duration`.
- **`name:`** is what scenarios and live control call the emitter.

## Test it with a scenario

**`scenarios/strike.yaml`**

```yaml
name: strike
description: The fire burns from the first frame, and a strike throws a burst of sparks that dies out.
steps:
  - step: 30
  - expect: { particles: { emitter: fire, min: 30, max: 90 } }
  - expect: { particles: { emitter: sparks, count: 0 } }
  - action: strike
  - step: 2
  - expect: { particles: { emitter: sparks, count: 40 } }
  - step: 60
expect:
  - particles: { emitter: sparks, count: 0 }
  - state: { entity: Anvil, property: Strikes, equals: 1 }
```

```shell
onegame test
```

```text
PASS strike (300 ms, tick 92, hash ea1db98c3a4fe420)
1 passed, 0 failed
```

The `particles:` expectation counts the live particles of one emitter (or `all`) in the frame it draws: `count`, `min` and `max`. Moving the fire and the anvil while writing this page changed every frame’s picture but not that hash: particles aren’t part of the world.

## Look at them live

The `particles` tool reports each emitter in the last drawn frame. With the MCP client from [Drive a running game over MCP](https://recursiveai.org/onegame/cookbook/live-control-mcp/), with the 200-character cut taken out:

```text
step {"frames": 30} -> {"frame":30,"tick":30,"hash":"00923da20355ec31"}
actions {"name": "strike"} -> {"contexts":[{"name":"play","active":true,"screens":[]}],"actions":[{"name":"strike","kind":"button","context":"play","active":true,"held":false,"pressed":false,"released":false,"x":0,"y":0,"default":true,"bindings":["key:Space","pad:South"]}]}
step {"frames": 1} -> {"frame":31,"tick":31,"hash":"fc78029970b5f06f"}
capture_frame {"path": "shots/burst.png", "size": "360p"} -> {"path":"/tmp/onegame/forge/artifacts/shots/burst.png","width":640,"height":360,"scale":1,"sha256":"ed71b3c34cda2a4b5d50e35561bf3c75474e876ab822fc365777ce0f4af8d8e2","renderer":"NVIDIA GeForce RTX 4070","tick":31,"hash":"fc78029970b5f06f"}
step {"frames": 9} -> {"frame":40,"tick":40,"hash":"86b11354f295189d"}
capture_frame {"path": "shots/strike.png", "size": "360p"} -> {"path":"/tmp/onegame/forge/artifacts/shots/strike.png","width":640,"height":360,"scale":1,"sha256":"ac08082aca9869d7b0a8ac5d611a2ee3f8ab2b55752d66fad2f5eeb26bacdbda","renderer":"NVIDIA GeForce RTX 4070","tick":40,"hash":"86b11354f295189d"}
particles {} -> {"stage":"render2d","emitters":2,"live":83,"items":[{"name":"fire","live":43,"capacity":256,"emitting":true,"spawned":68,"dropped":0,"blend":"additive","space":"plane","position":[-200,40,0]},{"name":"sparks","live":40,"capacity":256,"emitting":true,"spawned":40,"dropped":0,"blend":"additive","space":"plane","position":[20,0,0]}]}
quit {} -> {"frame":40,"tick":40,"hash":"86b11354f295189d"}
```

`actions` with a `name` taps the action, and the press shows after the next step. `burst.png` shows the sparks at the moment they were born, all at one point on the anvil’s top edge. Nine ticks later, `strike.png` shows the fire on the left and forty sparks fanned out above the anvil, falling back under gravity.

## Good to know

- A headless `step` draws nothing, so emitters move only on drawn frames: a capture, a `particles:` expectation, or every frame of a windowed game. Each drawn frame steps them by the view time since the last one, so the result doesn’t depend on how many frames were drawn in between.
- An emitter draws a soft round spot by default. Pass a texture or an asset path to `d.Particles` for your own picture, and `SheetColumns`, `SheetRows` and `AnimateFrames` for a sprite sheet.
- In 3D, an `IView3D` entity calls `scene.Particles(emitter, position)` with `Space = ParticleSpace.Volume`: camera-facing quads, depth-tested against the scene, with `Emission` above 1 for bloom. This wasn’t run for this page.
- Running emitters allocates nothing per frame.

Every setting is in [Particles](https://recursiveai.org/onegame/docs/particles/).
