Skip to content

Repository files navigation

Tests

Ignis

Ignis is a Flutter game engine built around two primitives, nodes and signals.

What is this? See Motivation.

Features

  • 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. Preload concurrently loads assets with Loaders for images, shaders, or custom resource types.

Quick Start

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)));
}

Concepts

Concept: Nodes

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.

Concept: Scenes

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.

Concept: Signals

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 to unwatch them or the signal will leak a reference to the watching node. In practice, unwatch is almost never required.

Concept: Time

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);

Concept: Math

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 exports ivector_math; do not add it to your dependencies.

Nodes

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. -

Effects

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.

Effect Controllers

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),
]);

Sprites

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);

⚠️ SpriteNode can 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);

Palettes

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' Palette is inspired by Flame's HasPaint mixin.

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, a PaletteEntry may only belong to one Palette at 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,
  ),
);

Collision Detection

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.

⚠️ While layer and mask are 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, layer and mask are -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.

Limitations in Collision Detection

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.

Inputs

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);
});

Assets

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-hoc Preload objects based on the assets needed for the next level. In this situation, remember to dispose each Preload when finished to avoid leaking ChangeNotifier resources.

Globals

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.cache and Ignis.bundle are mutable static fields, not constants. Swap them out for tests or when running multiple, isolated games in one application.

Motivation

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.

Differences from Flame

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.

Synchronous vs. Asynchronous

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 load method, even a void one, would open it to being overridden with async and 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.

Virtual Methods vs. Signals

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? ChangeNotifier is similar to Signal, but it was made for widgets, not nodes. ChangeNotifier comes with three drawbacks: poor performance, lack of N-argument typing, and a requirement to call dispose. Signals are fast, support specific argument counts, and do not require disposal.

vector_math vs. ivector_math

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.

⚠️ While ivector_math was created specifically to make Ignis more safe and performant, it is less battle-tested and offers significantly fewer features compared to the original vector_math. However, I still think it's the better fit.

Roadmap

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

License

MIT

About

Flutter game engine.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages