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:
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()); }
ParticleSettingsis read while the emitter runs, so view code can changeRateorGravityfrom frame to frame.Capacityis fixed when the emitter is made: births past it are dropped and counted, never reallocated.- Ranges and curves. A
FloatRangedraws one value per particle, and a plain number is a range of one.SizeOverLifeandColorare 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 andzapply 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,Ratefor streams. For a one-shot stream, setLoop = falseandDuration.name:is what scenarios and live control call the emitter.
Test it with a scenario
scenarios/strike.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 }
onegame testPASS 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:
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
stepdraws nothing, so emitters move only on drawn frames: a capture, aparticles: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.Particlesfor your own picture, andSheetColumns,SheetRowsandAnimateFramesfor a sprite sheet. - In 3D, an
IView3Dentity callsscene.Particles(emitter, position)withSpace = ParticleSpace.Volume: camera-facing quads, depth-tested against the scene, withEmissionabove 1 for bloom. This wasn’t run for this page. - Running emitters allocates nothing per frame.
Every setting is in Particles.