Recursive AI

Particle effects in 2D

All onegame recipes

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:

C#
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 }
Terminal
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, 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.