Ignis is a Flutter game engine built around two primitives, nodes and signals.
What is this? See Motivation.
- Features
- Installation
- Quick start
- Concepts
- Nodes
- Effects
- Sprites
- Palettes
- Collision Detection
- Inputs
- Assets
- Globals
- Motivation
- Differences from Flame
- Roadmap
- License
- Embrace composition. Everything - sprites, shapes, text, colliders, effects - is a
Node. Compose behavior and graphics by building trees. - Completely synchronous. Nodes are instantiated, updated, and rendered in a completely synchronous loop. Errors are reported at the source.
- Signals, not callbacks. The
Signal, a lightweight event emitter, powers everything from animations to collisions. - Embedded in Flutter. Any node can be rendered in the widget tree via
SceneWidget. Ignis runs wherever Flutter runs (I think). - Asset preloading.
Preloadconcurrently loads assets withLoaders for images, shaders, or custom resource types.
Mount any Node to a Scene, then pass it to a SceneWidget.
import 'package:flutter/material.dart';
import 'package:ignis/ignis.dart';
class GameNode extends TransformNode {
final player = ShapeNode(
shape: Shape.circle(16),
anchor: Anchor.center(),
paint: Paint()..color = Colors.orange,
);
GameNode() {
add(player);
}
@override
void tick(double dt) {
player.position.mutate().x += 40 * dt;
}
}
void main() {
final game = GameNode();
final scene = game.mount();
runApp(MaterialApp(home: SceneWidget(scene)));
}Node is the primitive of Ignis.
A node is set up just once, in its constructor. Node constructors can wire up children, subscribe to signals, retrieve cached assets - there are no restrictions.
Nodes can override tick(dt) to run per-frame logic and render(canvas) to use the canvas. These are called in separate passes, once per frame, by the game loop.
Node comes with two signals, onMount and onUnmount, which are emitted when that instance enters and exits a scene. Other nodes expose additional signals based on their specific purpose.
Any node can have children, which are sorted by priority. Priority dictates the order in which they are updated and rendered.
class Ship extends TransformNode {
final sprite = SpriteNode(sheet: Spritesheet.asset('ship.png'));
final thruster = ShipThrusterNode(); // Your own `Node` subclass.
final velocity = Vector2(10, 0);
Ship() {
addAll([sprite, thruster]);
}
@override
void tick(double dt) {
position.mutate().addScaled(velocity, dt);
}
}For a complete list of available nodes, see Nodes.
Any node can be mounted to create a Scene. Scenes wrap a tree of nodes with a size, and offer methods to manipulate the entire node tree effectively. A scene is also required when using a SceneWidget, which lets Flutter drive the engine.
final game = GameNode();
final scene = game.mount();
final widget = SceneWidget(scene); // Embed the scene in Flutter.SceneWidget acts as a controller for the scene in Flutter. You can control rendering and the underlying game loop using its parameters:
final widget = SceneWidget(
scene,
paused: true, // Start the scene paused.
debug: true, // Enable debug rendering.
);Scenes can also be driven completely manually. In fact, this is how much of Ignis is tested internally.
scene.update(1 / 60); // Manual update at 60 FPS.
scene.render(canvas); // Manual render (e.g. to a `PictureRecorder`).
scene.render(canvas, debug: true); // Manual render with debug rendering.Nodes communicate time-sensitive events through Signal, a lightweight message emitter.
🤖 Why "signal"? The name is taken from the parallel concept in Godot.
By convention, signals are prefixed with on so subscriptions read naturally in node constructors.
// Declare a signal with 1 parameter. There are Signal0, Signal1, ...
final onCollision = Signal1<ColliderNode>();
// Call a signal with a function argument to watch it.
final unwatch = onCollision((other) => print('Hit $other!'));
// Sends a type-safe message to all watchers.
onCollision.emit(someCollider);
// Stop watching the signal.
unwatch();Although nodes are driven by signals, Signal is a standalone utility class and may be used anywhere. Notably, signals can easily be used to implement communication between your Flutter app and your Ignis game. Here's an example integration using flutter_hooks.
/// Calls [handle] whenever [signal] is emitted.
void useSignal0(Signal0 signal, void Function() handle) {
useEffect(() {
return signal(handle);
}, [signal, handle]);
}
⚠️ When using signals in nodes, prefer to listen to signals that belong to your children. By doing so, the signals will not leak when the node goes out of use. However, when watching global signals or signals belonging to parent and sibling nodes, remember tounwatchthem or the signal will leak a reference to the watching node. In practice,unwatchis almost never required.
In Ignis, the unit of time is seconds.
For example, nodes receive a tick each frame of the game loop, along with the amount of time that passed as dt:
void tick(double dt) {
// `dt` seconds elapsed this frame.
// This is usually quite small, e.g. 0.01666 at 60 FPS.
}All objects that accept an interval or duration are also expressed in seconds.
// Triggers after 200 milliseconds.
final timer = TimerNode(interval: 0.2);
// Progresses an effect over the course of 1.5 seconds.
final controller = EffectController.duration(1.5);Math types such as Vector2, Matrix3, and Aabb2 come from Ignis' companion ivector_math package. ivector_math is a re-implementation of vector_math with additional semantics for controlled mutability.
Unlike vector_math, types in ivector_math are immutable by default. In order to modify one, call mutate() to obtain a scoped, mutable view for in-place updates.
final position = Vector2.zero();
position.mutate().addScaled(velocity, dt);Although it was developed with Ignis in mind, ivector_math is otherwise generally applicable.
⚠️ Ignis exportsivector_math; do not add it to yourdependencies.
Ignis comes with the following nodes.
| Node | Purpose | Signals |
|---|---|---|
Node |
Base node specifying enabled, priority, and children. |
onMount, onUnmount |
CollisionDetectionNode |
Holds a CollisionDetection arena. |
- |
ColliderNode |
Registers its Shape with the nearest CollisionDetectionNode. |
onCollisionStart, onCollisionEnd |
EffectNode |
Base node for time-driven effects. See Effects. | onFinish |
FpsNode |
Tracks a rolling-window average frame rate in fps. |
onUpdate |
InputNode |
Base hit area for gestures. See Inputs. | - |
PaintedNode |
Base node using Paint. See Palettes. |
- |
ShapeNode |
Draws a Shape. |
- |
SizedNode |
Base node with a size, used for shapes, sprites, and more. | - |
SpriteNode |
Animates a Spritesheet. See Sprites. |
onFrame, onLoop, onFinish |
TextNode |
Draws text with TextPainter. |
- |
TimerNode |
Tracks time to power its signal. | onTrigger |
TransformNode |
Base spatial node with a position, scale, and angle. |
- |
EffectNode, called simply an effect in the documentation, is a node with a notion of being "finished".
Most effects extend ControlledEffect, exposing a proper onProgress signal from 0 (start) to 1 (finish) as their EffectController advances. Meanwhile, higher-order effects tend to extend EffectNode directly.
Ignis comes with the following effects.
| Effect | Purpose |
|---|---|
AnchorEffect |
Mutates the anchor of a SizedNode over time. |
ColorFilterOpacityEffect |
Given a Paint, mutates a ColorFilter's alpha over time. |
ColorOpacityEffect |
Given a Paint, mutates the color's alpha channel over time. |
CombinedEffect |
An effect that finished when all its sub-effects finish. |
ControlledEffect |
Base effect for effects that use an EffectController. |
MoveEffect |
Mutates the position of a TransformNode over time. |
RotateEffect |
Mutates the angle of a TransformNode over time. |
ScaleEffect |
Mutates the scale of a TransformNode over time. |
SequentialEffect |
An effect that advances its sub-effects serially. |
Specific effects drop their *Node suffix and are simply called *Effect.
Effects that operate on Paint always indicate the specific property of Paint they operate on. For example, ColorOpacityEffect and ColorFilterOpacityEffect both animate the opacity of a Paint, but the mechanism used differs: one modifies color, the other sets the colorFilter property.
The progress over time of any particular ControlledEffect is determined by its EffectController.
🔥 The concept of an effect controller, like many ideas in Ignis, comes directly from Flame. If you've used Flame's
EffectController, you should be quite comfortable!
EffectController is composable: there's no single monolithic constructor, only a handful of small controllers you chain together with a SequenceEffectController. The code below creates a controller that waits 500 milliseconds, then advances progress linearly from 0 to 1 over the next 1500 milliseconds.
final controller = SequenceEffectController([
OnceEffectController(WaitEffectController(0.5)),
DurationEffectController(1.5),
]);Ignis comes with the following effect controllers.
| Effect Controller | Purpose |
|---|---|
DurationEffectController |
Progresses over a duration, shaped by a Curve. |
InfiniteEffectController |
Repeats its child forever. |
OnceEffectController |
Runs its child once; ignored after that. |
RepeatEffectController |
Repeats its child a fixed number of times. |
RoundtripEffectController |
Runs its child forward, then back down to its start. |
SequenceEffectController |
Runs controllers one after another. |
WaitEffectController |
Holds progress at 1 for a duration. |
Common controllers have a dot-shorthand constructor. Use it to make controller trees legible at a glance. The example from earlier could more succinctly be written:
EffectController controller = .sequence([
.once(.wait(0.5)),
.duration(1.5),
]);SpriteNode draws frames from one or more Spritesheets, optionally animating between them over time.
The code below creates a sprite from a spritesheet of 32x32 pixel tiles, animated at 12 frames per second (FPS).
final sprite = SpriteNode(
sheet: Spritesheet.asset('assets/ship.png', size: .all(32)),
fps: 12,
loop: true,
);Call play to begin animating from a specific row and column in the spritesheet.
// Animates the second row.
sprite.play(row: 1);
⚠️ SpriteNodecan only play animations on the same row.
It's also possible to combine multiple spritesheets via SpriteNode.split. This is particularly useful for swapping animation sets, like an idle sheet and a running sheet, while keeping assets modular.
final sprite = SpriteNode.split(
sheets: [
.asset('assets/player/idle.png', size: .all(32)),
.asset('assets/player/running.png', size: .all(32)),
],
fps: 12,
loop: true,
);
// Animates the third row of the running spritesheet.
sprite.play(sheet: 1, row: 2);Sprites and shapes implement PaintedNode, giving them access to a Palette. A palette is an ordered collection of named Paints, letting a single node draw several times each render without additional code.
🔥 Ignis'
Paletteis inspired by Flame'sHasPaintmixin.
Every palette starts with one default paint, accessible via paint on either the palette or its owning node.
final shape = ShapeNode(
shape: .circle(16),
paint: Paint()..color = Colors.orange,
);
// Painted nodes expose the default paint via their palette.
assert(identical(shape.paint, shape.palette.paint));Palettes begin with one paint by default, but you can easily register additional paints. The code below registers a shadow paint that draws behind the default paint at a slight offset.
// Register a new paint by name.
palette.add(
PaletteEntry(
// The paint's unique name.
'shadow',
// The actual paint to draw with.
Paint()..color = Colors.black54,
// The offset at which to draw with this paint.
// Defaults to 0.
offset: .all(4),
// The order in which to draw with this paint.
// Defaults to 0.
priority: -1,
// Whether or not to draw with this paint.
// Defaults to true.
enabled: true,
),
);
// Retrieve the newly added paint.
final shadowPaint = palette['shadow'];
// Or retrieve the entire entry to update enabled, priority, etc.
final shadowEntry = palette.entry('shadow');
shadowEntry.enabled = false; // No shadow for now.
// When you're done with the paint, remove it by name as well.
palette.remove('shadow');
⚠️ Entry names must be unique within a palette. Additionally, aPaletteEntrymay only belong to onePaletteat a time.
It's quite common to access the palette when creating effects. The code below fades out the shadow paint linearly over 500 milliseconds.
add(
ColorOpacityEffect.fadeOut(
paint: palette['shadow'],
controller: .duration(0.5),
cleanup: true,
),
);Collisions work using two nodes, CollisionDetectionNode and ColliderNode.
CollisionDetectionNode sets up an actual collision detection arena. Whenever a ColliderNode is added to a scene, it finds the closest CollisionDetectionNode and registers itself.
final cd = CollisionDetectionNode();
final player = ColliderNode(shape: .circle(16));
final wall = ColliderNode(shape: .rectangle(.new(32, 200)));
cd.addAll([player, wall]);
player.onCollisionStart((other) {
print('Hit $other!');
});ColliderNode also supports specifying two bitmasks, layer and mask, to exclude certain collisions from consideration. layer indicates the physics layers the collider exists on, while mask indicates which physics layers it collides with. A pair only reports a collision to a side whose mask intersects the other's layer.
⚠️ Whilelayerandmaskare defined as integers, they should be treated as bitmasks. Each position in the integer is a separate physics layer. That also means Ignis only supports up to 32 collision detection layers. By default,layerandmaskare -1 (all bits are 1), letting all colliders interact.
The code below adjusts the previous example to exclude wall-wall collisions altogether, greatly improving collision detection performance and removing the need to handle those cases in your code.
const TERRAIN_LAYER = 1 << 0;
const UNIT_LAYER = 1 << 1;
wall
..layer = TERRAIN_LAYER;
player
..layer = UNIT_LAYER
..mask = TERRAIN_LAYER | UNIT_LAYER;🤖 Why "layer" and "mask"? These names come from Godot's collision system.
At this time, the collision detection implementation only supports Shape and does not return collision points or depth. In exchange, it is quite fast!
Expanding the capabilities and usefulness of collision detection in Ignis while maintaining solid performance is a focus of ongoing development. Specifically, a general Polygon shape is well within the engine's scope.
InputNode is a hit area that recognizes pointer gestures by delegating to Flutter's own gesture recognizers. It has its own shape too, so it's free to cover an area larger or smaller than whatever it's representing.
A node wanting more than one gesture just adds more input nodes. When multiple input nodes overlap, priority decides who's tried first. An event a node doesn't apply to, such as HoverInput receiving a tap, always falls through to the next input node. Once a node does claim an event, the search stops there unless its behavior is HitBehavior.translucent.
Ignis comes with the following inputs.
| Input | Purpose | Signals |
|---|---|---|
TapInput |
Recognizes taps. | onTapDown, onTapUp, onTap, onTapCancel |
DragInput |
Recognizes drags. | onDragStart, onDragUpdate, onDragEnd, onDragCancel |
HoverInput |
Tracks mouse hover. | onHoverEnter, onHoverExit |
The code below is a simple way to implement the common drag-and-drop pattern.
final piece = ShapeNode(shape: .circle(16));
final drag = DragInput(shape: piece.shape);
piece.add(drag);
drag.onDragUpdate((event) {
piece.position.mutate().add(event.delta);
});In Ignis, all assets must be loaded to the Cache in order to be accessible in nodes. The entrypoint for loading is Preload.
A preload loads assets into a cache in parallel, driven by pluggable Loaders. Ignis ships with a few loaders for common asset types like images and shaders, but it's easy to write your own, too.
// Set up a new preload.
final preload = Preload();
// Load assets from the root bundle's `AssetManifest`.
preload.manifest(
// Try each of these loaders for every asset found.
Loader.multiple([
// Load images, detected by extension.
Loader.image()
..extensions(['png', 'jpg', 'gif']),
// Load the game's JSON level files, detected by prefix and extension.
Loader.json()
..prefix('assets/levels/')
..extensions(['json'])
])
);
// Returns a future that resolves when this preload is done.
await preload.run();
// Retrieve assets by looking into the cache.
final level1 = Ignis.cache.retrieve<Map>('assets/levels/level1.json');Preload is also a ChangeNotifier with fields that track loading progress. A typical implementation pattern is to declare a single Preload, then animate its loading in a widget.
Below is a common pattern using riverpod and flutter_hooks which sets up a game's one-time Preload, then watches it from a loading page:
final preloadPod = Provider((ref) {
return Preload().manifest(
.multiple([
.image()
..extensions(['png', 'jpg', 'gif']),
.json()
..prefix('assets/levels/')
..extensions(['json']),
]),
);
});
class LoadingPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final preload = useListenable(ref.watch(preloadPod));
final progress = preload.progress;
useEffect(() {
if (progress >= 1) {
// Launch the game!
}
}, [progress]);
return Scaffold(
body: Column(
children: [
Text('Loading...'),
LinearProgressIndicator(value: progress),
]
),
);
}
}❓ Why preload at all? Since nodes are synchronous, they cannot retrieve assets asynchronously. But even if they could, what do you render while the asset is loading? Rather than resolving this concern with a complex API, Ignis protects the visual fidelity of your game by simply requiring preloading, and making it easy to configure and animate, too.
⚠️ For games with many assets, you may want to set up ad-hocPreloadobjects based on the assets needed for the next level. In this situation, remember todisposeeachPreloadwhen finished to avoid leakingChangeNotifierresources.
The Ignis namespace holds the global instances of the AssetBundle and Cache. Most games will not need to modify them directly.
By default, Preload and Spritesheet automatically access Ignis.cache when storing and retrieving assets.
// The following preloads are equivalent:
final preload1 = Preload();
final preload2 = Preload(cache: Ignis.cache);
// The following spritesheets are equivalent:
final sheet1 = Spritesheet.asset('assets/ship.png');
final sheet2 = Spritesheet(Ignis.cache.retrieve('assets/ship.png'));
⚠️ Ignis.cacheandIgnis.bundleare mutable static fields, not constants. Swap them out for tests or when running multiple, isolated games in one application.
Why does Ignis exist?
I, @misha, have been making games using Flame for many years. In late 2025, I decided to do that full time as an indie game developer.
Flame has been my engine of choice for three reasons.
- Dart. I am very picky about programming languages, but I love writing Dart. I know no language that comes even close. Flame uses Dart. Fantastic.
- Flutter. I have a strong, personal conviction that most games are more UI/UX than game graphics - it's the menus that determine ratings (*cough*, looking at you, Civilization 7). But UI/UX is fundamentally different from game graphics, and deserves a separate framework. Flame lives alongside Flutter, a UI/UX framework with which I have extensive professional experience shipping dozens of apps over the last decade.
- Unopinionated. Look through Flame's documentation. You'll find that each part of Flame solves a different, specific problem. If you don't have the problem, that solution just gets out of your way. Have you ever tried Unity? Yeah. This is the opposite of that.
On a whim, I began experimenting with Godot last year. I was incredibly surprised at how intuitive the node hierarchy, signals, and asset management were. Unfortunately, I quickly fell out of the honeymoon phase trying to develop user interfaces and safe, ergonomic abstractions in GDScript. The programming experience provided by Dart and Flutter is worlds apart.
When I eventually came home to Flame, I realized I missed Godot's mental model of Nodes and Signals. Initially, I wrote Ignis on top of Flame's low-level Game class, with a Node hierarchy completely replacing Component. But soon I noticed there wasn't that much I needed from Flame, and simply adopted the remaining classes into the codebase. Anchor, RenderLoop, SceneRenderBox, SceneWidget, some of TransformNode, and a plethora of bits and bobs throughout Ignis can trace their lineage directly to Flame.
Ignis literally would not exist without Flame. Meanwhile, the new abstractions are my (flexible!) interpretation of Godot's primitives. I chose the name "ignis" because it means "flame" in Latin, yet has the same foreign-sounding mouthfeel as "godot".
Until now, Flame has been the only reliable, unopinionated option for 2D game development in Flutter. I'm hoping Ignis can be a second.
Ignis makes several fundamentally different architectural decisions compared to Flame. This section hopes to explain these trade-offs and how they affect the usage of the engine.
In Flame, any component can declare an async loading method. While this makes it easy to load assets dynamically, in practice it creates a confusing gap: you can't safely manipulate a component until it's done loading!
In Ignis, nodes must be loaded synchronously. There isn't a load method because nodes are expected to set themselves up in their constructors. You can always use a node's methods and signals immediately after creating it, no queuing or remembering to await loaded necessary.
❓ Why load in the constructor? Constructors are the only code location that must be synchronous. Having any kind of virtual
loadmethod, even avoidone, would open it to being overridden withasyncand breaking the engine's invariants.
The drawback is that assets must be loaded ahead of time. To compensate, Ignis ships with a highly configurable preloading system.
In Flame, implementing behavior for special events (like collisions and gestures) usually requires extending a component and overriding a virtual method.
Virtual methods make it difficult to compose behavior and have no native faculty for handling multiple listeners. To resolve the issue, I wrote flame_fuse, a library that enables composable behavior in Flame. Every Flame game I wrote in the last three years works using flame_fuse. (You may even see the beginnings of Ignis in that package!)
In Ignis, aside from tick(dt) and render(canvas), nodes receive engine information using signals. Signals are explicit, accessible without subclasses, and natively support any number of listeners. If you can access the signal, you can watch it and you can emit it.
The net result is that the number of custom nodes you write in Ignis is much lower - usually just one per "thing" in your game. For example, a PlayerNode likely just uses a raw SpriteNode, ColliderNode, etc. by connecting directly to their signals.
❓ Why not
ChangeNotifier?ChangeNotifieris similar toSignal, but it was made for widgets, not nodes.ChangeNotifiercomes with three drawbacks: poor performance, lack of N-argument typing, and a requirement to calldispose. Signals are fast, support specific argument counts, and do not require disposal.
Flame uses vector_math, a popular, well-tested math library. Unfortunately, the API of vector_math is suboptimal from the perspective of control: it's very hard to tell when you are creating new objects or mutating them.
Ignis instead uses ivector_math, a reimplementation of vector_math that creates a syntactic gap between its mutable and immutable APIs. As a result, it's difficult to write code that accidentally mutates vectors and matrices - both in the engine itself, and in your game.
⚠️ Whileivector_mathwas created specifically to make Ignis more safe and performant, it is less battle-tested and offers significantly fewer features compared to the originalvector_math. However, I still think it's the better fit.
Until 1.0.0, Ignis will change frequently and dramatically.
I'm currently working on the following:
- Camera
- Collision details
- Audio
- Hit testing
- Gestures & input
- Particles
- Debugging tools
- More nodes
- More effects
- More examples