From 8e2dcb2b330c7597111e82e212c1d8932fcb6070 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 09:53:32 +0200 Subject: [PATCH 01/26] perf!: Replace OrderedSet children container with flat sorted-array ComponentSet --- .../lib/src/components/core/component.dart | 74 +-- .../src/components/core/component_set.dart | 506 ++++++++++++++++++ packages/flame/pubspec.yaml | 1 - .../flame/test/components/component_test.dart | 12 +- .../flame/test/components/priority_test.dart | 12 +- packages/flame_3d/pubspec.yaml | 1 - 6 files changed, 553 insertions(+), 53 deletions(-) create mode 100644 packages/flame/lib/src/components/core/component_set.dart diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index c8198d496d5..600c1e00ae0 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -10,8 +10,8 @@ import 'package:flame/src/components/core/component_tree_root.dart'; import 'package:flame/src/effects/provider_interfaces.dart'; import 'package:flutter/painting.dart'; import 'package:meta/meta.dart'; -import 'package:ordered_set/ordered_set.dart'; -import 'package:ordered_set/read_only_ordered_set.dart'; + +part 'component_set.dart'; /// [Component]s are the basic building blocks for a [FlameGame]. /// @@ -291,26 +291,31 @@ class Component { /// /// This makes it possible to have lighter components that don't have any /// children. - OrderedSet? _children; + ComponentSet? _children; + + /// The [ComponentSet] that currently stores this component, if any. + /// Maintained by [ComponentSet]. + ComponentSet? _containerSet; + + /// This component's slot index within [_containerSet]'s backing array, or + /// -1 when the component is not in any container. Maintained by + /// [ComponentSet]. + int _containerIndex = -1; /// This field should be used internally for functionality when you need to /// make sure that the component set is created if it doesn't already exist. - OrderedSet get _internalChildren => - _children ??= createComponentSet(); + ComponentSet get _internalChildren => _children ??= createComponentSet(); - void rebalanceChildren() { - if (_children != null) { - _children!.rebalanceAll(); - } - } + /// Restores the priority ordering of the [children], after one or more of + /// them have changed their [priority]. + void rebalanceChildren() => _children?.rebalance(); /// The children components of this component. /// - /// This getter will automatically create the [OrderedSet] container within + /// This getter will automatically create the [ComponentSet] container within /// the current object if it didn't exist before. Check the [hasChildren] /// property in order to avoid instantiating the children container. - ReadOnlyOrderedSet get children => - _children ??= createComponentSet(); + ComponentSet get children => _children ??= createComponentSet(); /// Whether this component has any children. /// Avoids the creation of the children container if not necessary. @@ -318,26 +323,17 @@ class Component { /// `Component.childrenFactory` is the default method for creating children /// containers within all components. Replace this method if you want to have - /// customized (non-default) [OrderedSet] instances in your project. - static OrderedSet Function() childrenFactory = () { - return OrderedSet.mapping( - _componentPriorityMapper, - strictMode: strictQueryMode, - ); - }; + /// customized (non-default) [ComponentSet] instances in your project. + static ComponentSet Function() childrenFactory = ComponentSet.new; - /// Whether OrderedSet's strict mode mode should be enabled for all children - /// sets. + /// Whether the strict query mode should be enabled for all children sets; + /// see [ComponentSet.strictMode]. static bool strictQueryMode = false; - static num _componentPriorityMapper(Component component) { - return component.priority; - } - /// This method creates the children container for the current component. - /// Override this method if you need to have a custom [OrderedSet] within + /// Override this method if you need to have a custom [ComponentSet] within /// a particular class. - OrderedSet createComponentSet() => childrenFactory(); + ComponentSet createComponentSet() => childrenFactory(); /// Returns the closest parent further up the hierarchy that satisfies type=T, /// or null if no such parent can be found. @@ -566,8 +562,12 @@ class Component { update(dt); final children = _children; if (children != null) { - for (final child in children) { - child.updateTree(dt); + // The update pass doubles as the safe point where tombstones left by + // removals since the last tick are compacted away. + children._compact(); + final elements = children._elements; + for (var i = 0; i < elements.length; i++) { + elements[i]?.updateTree(dt); } } } @@ -618,8 +618,12 @@ class Component { render(canvas); final children = _children; if (children != null) { - for (final child in children) { - renderChild(canvas, child); + final elements = children._elements; + for (var i = 0; i < elements.length; i++) { + final child = elements[i]; + if (child != null) { + renderChild(canvas, child); + } } afterChildrenRendered(canvas); } @@ -856,8 +860,10 @@ class Component { ) sync* { nestedContexts?.add(locationContext); if (_children != null) { - for (final child in _children!.reversed()) { - if (child is IgnoreEvents && child.ignoreEvents) { + final elements = _children!._elements; + for (var i = elements.length - 1; i >= 0; i--) { + final child = elements[i]; + if (child == null || (child is IgnoreEvents && child.ignoreEvents)) { continue; } T? childPoint = locationContext; diff --git a/packages/flame/lib/src/components/core/component_set.dart b/packages/flame/lib/src/components/core/component_set.dart new file mode 100644 index 00000000000..d78a0933f97 --- /dev/null +++ b/packages/flame/lib/src/components/core/component_set.dart @@ -0,0 +1,506 @@ +part of 'component.dart'; + +/// A fast, ordered container for a [Component]'s children. +/// +/// The children are stored in a single flat [List], sorted by +/// [Component.priority]; components with equal priority keep the order in +/// which they were added. This is the same ordering that the old +/// `OrderedSet`-based container provided, but backed by a contiguous array +/// instead of a splay tree of hash sets, which makes iteration, additions, +/// removals and reorders significantly faster and allocation-free. +/// +/// Performance characteristics: +/// - iteration: O(n) over a contiguous array, without any allocations on the +/// internal engine paths; +/// - [add]: O(1) when the component sorts at or after the end, which is the +/// common case, and O(n) for a mid-list insertion; +/// - [remove] and [contains]: O(1), using the slot index that is stored on +/// the component itself; +/// - [rebalance] (after priority changes): O(n) when the list turns out to +/// still be sorted, otherwise O(n log n) with a stable sort. +/// +/// A removal does not shift the array, it leaves a `null` "tombstone" in +/// place. Tombstones are invisible to iteration and are compacted away at the +/// start of the next update tick, or earlier if they grow to dominate the +/// array. +/// +/// Mutating the container while iterating it is allowed in the ways that the +/// component lifecycle needs: removals take effect immediately (the removed +/// component is simply not visited), and additions at the end may or may not +/// be visited by an ongoing iteration. Operations that shift the positions of +/// existing elements (a mid-list insertion, [rebalance], or tombstone +/// compaction) invalidate live iterators, which will then throw a +/// [ConcurrentModificationError]. +/// +/// The contents of this container are managed by the [Component] lifecycle: +/// use [Component.add], [Component.remove] and their related methods to +/// modify which components are in it. +class ComponentSet extends Iterable { + ComponentSet({bool? strictMode}) + : strictMode = strictMode ?? Component.strictQueryMode; + + /// Whether calling [query] for an unregistered type throws an error + /// (`true`), or transparently registers the type on first use (`false`). + final bool strictMode; + + /// The backing store; `null` entries are tombstones left by removals. + final List _elements = []; + + /// The number of live (non-tombstone) elements. + int _length = 0; + + /// The number of tombstones currently present in [_elements]. + int _tombstones = 0; + + /// Incremented whenever existing elements change their position within + /// [_elements] (mid-list insertion, sorting, or compaction). Iterators use + /// this to detect concurrent structural modification. + int _shiftCount = 0; + + /// Compaction is deferred until this many tombstones have accumulated + /// (unless the whole set empties out, or a structural operation needs a + /// dense array anyway). + static const int _tombstoneCompactionThreshold = 16; + + /// The per-type query caches, created by [register]. + Map>? _queries; + + @override + int get length => _length; + + @override + bool get isEmpty => _length == 0; + + @override + bool get isNotEmpty => _length != 0; + + @override + Iterator get iterator => _ComponentSetIterator(this); + + /// The elements of this set in reverse order. + /// + /// Unlike the rest of the [Iterable] interface, this is a method and not a + /// getter, for historical reasons. The returned iterable is a lazy view: it + /// costs nothing to create and always reflects the current contents. + Iterable reversed() => _ReversedComponentSetView(this); + + @override + bool contains(Object? element) { + return element is Component && identical(element._containerSet, this); + } + + @override + Component get first { + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + return element; + } + } + throw StateError('No element'); + } + + @override + Component get last { + final elements = _elements; + for (var i = elements.length - 1; i >= 0; i--) { + final element = elements[i]; + if (element != null) { + return element; + } + } + throw StateError('No element'); + } + + @override + Component elementAt(int index) { + RangeError.checkNotNegative(index, 'index'); + final elements = _elements; + if (_tombstones == 0) { + if (index >= elements.length) { + throw IndexError.withLength(index, _length, indexable: this); + } + return elements[index]!; + } + var live = 0; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null && live++ == index) { + return element; + } + } + throw IndexError.withLength(index, _length, indexable: this); + } + + @override + void forEach(void Function(Component element) action) { + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + action(element); + } + } + } + + /// Adds [component] to this set, keeping the priority ordering; returns + /// whether the component was added (`false` if it already was in the set). + /// + /// This is internal machinery: adding a component here does not make it go + /// through the component lifecycle. Use [Component.add] instead. + @internal + bool add(Component component) { + if (identical(component._containerSet, this)) { + return false; + } + assert( + component._containerSet == null, + 'A component cannot be contained by two children containers at once', + ); + final elements = _elements; + if (_length == 0 && elements.isNotEmpty) { + // The array contains only tombstones; reset it. + elements.clear(); + _tombstones = 0; + } + final priority = component._priority; + var lastIndex = elements.length - 1; + while (lastIndex >= 0 && elements[lastIndex] == null) { + lastIndex--; + } + if (lastIndex >= 0 && elements[lastIndex]!._priority > priority) { + _insertSorted(component, priority); + } else { + component._containerIndex = elements.length; + elements.add(component); + } + component._containerSet = this; + _length++; + final queries = _queries; + if (queries != null) { + for (final cache in queries.values) { + if (cache.check(component)) { + cache.insertSorted(component); + } + } + } + return true; + } + + /// Inserts [component] before all existing elements with a higher priority, + /// and after all elements with the same or a lower priority. + void _insertSorted(Component component, int priority) { + _compact(); + final elements = _elements; + var lo = 0; + var hi = elements.length; + while (lo < hi) { + final mid = (lo + hi) >>> 1; + if (elements[mid]!._priority <= priority) { + lo = mid + 1; + } else { + hi = mid; + } + } + elements.insert(lo, component); + component._containerIndex = lo; + for (var i = lo + 1; i < elements.length; i++) { + elements[i]!._containerIndex = i; + } + _shiftCount++; + } + + /// Removes [component] from this set; returns whether it was present. + /// + /// This is internal machinery: removing a component here does not make it + /// go through the component lifecycle. Use [Component.remove] instead. + @internal + bool remove(Component component) { + if (!identical(component._containerSet, this)) { + return false; + } + final index = component._containerIndex; + assert(identical(_elements[index], component)); + _elements[index] = null; + component._containerSet = null; + component._containerIndex = -1; + _length--; + _tombstones++; + final queries = _queries; + if (queries != null) { + for (final cache in queries.values) { + if (cache.check(component)) { + cache.data.remove(component); + } + } + } + if (_length == 0) { + _elements.clear(); + _tombstones = 0; + } else if (_tombstones >= _tombstoneCompactionThreshold && + _tombstones * 2 >= _elements.length) { + _compact(); + } + return true; + } + + /// Removes all elements from this set. + /// + /// This is internal machinery: clearing this set does not make the + /// components go through the component lifecycle. Use [Component.removeAll] + /// instead. + @internal + void clear() { + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + element._containerSet = null; + element._containerIndex = -1; + } + } + elements.clear(); + _length = 0; + _tombstones = 0; + _shiftCount++; + _queries?.forEach((_, cache) => cache.data.clear()); + } + + /// Restores the priority ordering after one or more elements have changed + /// their [Component.priority]. + /// + /// This is invoked automatically, at most once per parent per game tick, + /// when priorities of mounted components change. The sort is stable: + /// components with equal priority keep their relative order. + void rebalance() { + _compact(); + final elements = _elements; + var isSorted = true; + for (var i = 1; i < elements.length; i++) { + if (elements[i - 1]!._priority > elements[i]!._priority) { + isSorted = false; + break; + } + } + if (isSorted) { + return; + } + _shiftCount++; + // Ties are broken by the pre-sort index, which makes the (unstable) + // built-in sort behave as a stable sort. + elements.sort((a, b) { + final byPriority = a!._priority.compareTo(b!._priority); + return byPriority != 0 + ? byPriority + : a._containerIndex - b._containerIndex; + }); + for (var i = 0; i < elements.length; i++) { + elements[i]!._containerIndex = i; + } + _queries?.forEach((_, cache) => cache.resort()); + } + + /// Rewrites the backing array without its tombstones, restoring exact + /// element indices. + void _compact() { + if (_tombstones == 0) { + return; + } + final elements = _elements; + var write = 0; + for (var read = 0; read < elements.length; read++) { + final element = elements[read]; + if (element != null) { + if (write != read) { + elements[write] = element; + element._containerIndex = write; + } + write++; + } + } + elements.length = write; + _tombstones = 0; + _shiftCount++; + } + + /// Whether type [C] has been registered as a queryable type. + bool isRegistered() { + return _queries?.containsKey(C) ?? false; + } + + /// Registers [C] as a queryable type, so that [query] can answer in O(1). + /// + /// If the type is already registered this is a no-op. Registering a type on + /// a non-empty set costs one pass over the existing elements, so it is + /// recommended to register the desired types as early as possible. + void register() { + final queries = _queries ??= {}; + if (queries.containsKey(C)) { + return; + } + final data = []; + final elements = _elements; + for (var i = 0; i < elements.length; i++) { + final element = elements[i]; + if (element is C) { + data.add(element); + } + } + queries[C] = _QueryCache(data); + } + + /// All elements of type [C], in priority order, in O(1). + /// + /// The type [C] must have been [register]ed beforehand, unless [strictMode] + /// is false, in which case the registration happens on the first query. + Iterable query() { + final cache = _queries?[C]; + if (cache == null) { + if (strictMode) { + throw StateError('Cannot query unregistered query $C'); + } + register(); + return query(); + } + // The cached list itself is returned, but typed as an Iterable to prevent + // accidental modification of the cache from the outside. + return cache.data as Iterable; + } + + @override + Iterable whereType() { + final cache = _queries?[C]; + if (cache != null) { + return cache.data as Iterable; + } + return super.whereType(); + } +} + +class _ComponentSetIterator implements Iterator { + _ComponentSetIterator(this._set) : _shiftCount = _set._shiftCount; + + final ComponentSet _set; + final int _shiftCount; + int _index = -1; + Component? _current; + + @override + Component get current => _current!; + + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + @override + bool moveNext() { + final set = _set; + if (set._shiftCount != _shiftCount) { + throw ConcurrentModificationError(set); + } + final elements = set._elements; + for (var i = _index + 1; i < elements.length; i++) { + final element = elements[i]; + if (element != null) { + _index = i; + _current = element; + return true; + } + } + _index = elements.length; + _current = null; + return false; + } +} + +class _ReversedComponentSetView extends Iterable { + _ReversedComponentSetView(this._set); + + final ComponentSet _set; + + @override + int get length => _set._length; + + @override + bool get isEmpty => _set._length == 0; + + @override + bool get isNotEmpty => _set._length != 0; + + @override + Iterator get iterator => _ReversedComponentSetIterator(_set); +} + +class _ReversedComponentSetIterator implements Iterator { + _ReversedComponentSetIterator(ComponentSet set) + : _set = set, + _shiftCount = set._shiftCount, + _index = set._elements.length; + + final ComponentSet _set; + final int _shiftCount; + int _index; + Component? _current; + + @override + Component get current => _current!; + + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + @override + bool moveNext() { + final set = _set; + if (set._shiftCount != _shiftCount) { + throw ConcurrentModificationError(set); + } + final elements = set._elements; + for (var i = _index - 1; i >= 0; i--) { + final element = elements[i]; + if (element != null) { + _index = i; + _current = element; + return true; + } + } + _index = -1; + _current = null; + return false; + } +} + +/// A cached, always up-to-date result of `query()`: the subset of the +/// elements that are of type [C], in the same order as the main array. +class _QueryCache { + _QueryCache(this.data); + + final List data; + + bool check(Component component) => component is C; + + /// Inserts [component] into [data], keeping it ordered consistently with + /// the main backing array (which orders by priority). + void insertSorted(Component component) { + final list = data; + final index = component._containerIndex; + if (list.isEmpty || list.last._containerIndex < index) { + list.add(component as C); + return; + } + var lo = 0; + var hi = list.length; + while (lo < hi) { + final mid = (lo + hi) >>> 1; + if (list[mid]._containerIndex < index) { + lo = mid + 1; + } else { + hi = mid; + } + } + list.insert(lo, component as C); + } + + /// Re-sorts the cache after the main array has been re-sorted (at which + /// point every element's index is up to date again). + void resort() { + data.sort((a, b) => a._containerIndex - b._containerIndex); + } +} diff --git a/packages/flame/pubspec.yaml b/packages/flame/pubspec.yaml index cc6e3c086df..7894212d75f 100644 --- a/packages/flame/pubspec.yaml +++ b/packages/flame/pubspec.yaml @@ -22,7 +22,6 @@ dependencies: flutter: sdk: flutter meta: ^1.12.0 - ordered_set: ^8.0.0 vector_math: ^2.1.4 dev_dependencies: diff --git a/packages/flame/test/components/component_test.dart b/packages/flame/test/components/component_test.dart index 619aeee3ea2..4af7830b5a7 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -6,8 +6,6 @@ import 'package:flame/src/components/core/component_tree_root.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:ordered_set/mapping_ordered_set.dart'; -import 'package:ordered_set/ordered_set.dart'; import '../custom_component.dart'; @@ -1665,18 +1663,14 @@ void main() { final component0 = Component(); expect(component0.children.strictMode, false); - Component.childrenFactory = () => OrderedSet.mapping( - (e) => e.priority, - // ignore: avoid_redundant_argument_values - strictMode: true, - ); + Component.childrenFactory = () => ComponentSet(strictMode: true); final component1 = Component(); final component2 = Component(); component1.add(component2); component2.add(Component()); - expect(component1.children, isInstanceOf()); + expect(component1.children, isInstanceOf()); expect(component1.children.strictMode, isTrue); - expect(component2.children, isInstanceOf()); + expect(component2.children, isInstanceOf()); expect(component2.children.strictMode, isTrue); }); diff --git a/packages/flame/test/components/priority_test.dart b/packages/flame/test/components/priority_test.dart index 0828eb16f9c..429332574f4 100644 --- a/packages/flame/test/components/priority_test.dart +++ b/packages/flame/test/components/priority_test.dart @@ -2,8 +2,6 @@ import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; -import 'package:ordered_set/mapping_ordered_set.dart'; -import 'package:ordered_set/ordered_set.dart'; import 'package:test/test.dart'; import '../custom_component.dart'; @@ -261,15 +259,13 @@ void main() { }); } -class _SpyComponentSet extends MappingOrderedSet { +class _SpyComponentSet extends ComponentSet { int callCount = 0; - _SpyComponentSet() : super((e) => e.priority); - @override - void rebalanceAll() { + void rebalance() { callCount++; - super.rebalanceAll(); + super.rebalance(); } } @@ -281,7 +277,7 @@ class _ParentWithReorderSpy extends Component { _ParentWithReorderSpy(int priority) : super(priority: priority); @override - OrderedSet createComponentSet() => _SpyComponentSet(); + ComponentSet createComponentSet() => _SpyComponentSet(); void assertCalled(int n) { final componentSet = children as _SpyComponentSet; diff --git a/packages/flame_3d/pubspec.yaml b/packages/flame_3d/pubspec.yaml index d94626f09bb..518c97e827e 100644 --- a/packages/flame_3d/pubspec.yaml +++ b/packages/flame_3d/pubspec.yaml @@ -20,7 +20,6 @@ dependencies: flutter_gpu: sdk: flutter meta: ^1.12.0 - ordered_set: ^8.0.0 vector_math: ^2.1.4 dev_dependencies: From e38a2b42106bc1de7d6388ad2fd824c141f70ff6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 10:03:02 +0200 Subject: [PATCH 02/26] docs: Update children container docs after OrderedSet removal --- doc/flame/components/components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index f02935f0a4c..b9c6a3d57aa 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -331,7 +331,7 @@ flameGame.findByKeyName('player'); ### Querying child components -The children that have been added to a component live in a `QueryableOrderedSet` called +The children that have been added to a component live in a `ComponentSet` called `children`. To query for a specific type of components in the set, the `query()` function can be used. By default `strictMode` is `false` in the children set, but if you set it to true, then the queries will have to be registered with `children.register` before a query can be used. From de59d60adcdce51d07f68253d472fed5355d9158 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 10:26:20 +0200 Subject: [PATCH 03/26] refactor!: Rename ComponentSet to ComponentList, replace childrenFactory with per-component comparator support --- doc/flame/components/components.md | 2 +- .../lib/src/components/core/component.dart | 44 +++++---- ...component_set.dart => component_list.dart} | 93 ++++++++++++------- .../flame/test/components/component_test.dart | 41 ++++++-- .../flame/test/components/priority_test.dart | 10 +- 5 files changed, 123 insertions(+), 67 deletions(-) rename packages/flame/lib/src/components/core/{component_set.dart => component_list.dart} (83%) diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index b9c6a3d57aa..3590ca1da0f 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -331,7 +331,7 @@ flameGame.findByKeyName('player'); ### Querying child components -The children that have been added to a component live in a `ComponentSet` called +The children that have been added to a component live in a `ComponentList` called `children`. To query for a specific type of components in the set, the `query()` function can be used. By default `strictMode` is `false` in the children set, but if you set it to true, then the queries will have to be registered with `children.register` before a query can be used. diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 600c1e00ae0..492069877fe 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -11,7 +11,7 @@ import 'package:flame/src/effects/provider_interfaces.dart'; import 'package:flutter/painting.dart'; import 'package:meta/meta.dart'; -part 'component_set.dart'; +part 'component_list.dart'; /// [Component]s are the basic building blocks for a [FlameGame]. /// @@ -291,20 +291,20 @@ class Component { /// /// This makes it possible to have lighter components that don't have any /// children. - ComponentSet? _children; + ComponentList? _children; - /// The [ComponentSet] that currently stores this component, if any. - /// Maintained by [ComponentSet]. - ComponentSet? _containerSet; + /// The [ComponentList] that currently stores this component, if any. + /// Maintained by [ComponentList]. + ComponentList? _containerList; - /// This component's slot index within [_containerSet]'s backing array, or + /// This component's slot index within [_containerList]'s backing array, or /// -1 when the component is not in any container. Maintained by - /// [ComponentSet]. + /// [ComponentList]. int _containerIndex = -1; /// This field should be used internally for functionality when you need to /// make sure that the component set is created if it doesn't already exist. - ComponentSet get _internalChildren => _children ??= createComponentSet(); + ComponentList get _internalChildren => _children ??= createComponentList(); /// Restores the priority ordering of the [children], after one or more of /// them have changed their [priority]. @@ -312,28 +312,32 @@ class Component { /// The children components of this component. /// - /// This getter will automatically create the [ComponentSet] container within + /// This getter will automatically create the [ComponentList] container within /// the current object if it didn't exist before. Check the [hasChildren] /// property in order to avoid instantiating the children container. - ComponentSet get children => _children ??= createComponentSet(); + ComponentList get children => _children ??= createComponentList(); /// Whether this component has any children. /// Avoids the creation of the children container if not necessary. bool get hasChildren => _children?.isNotEmpty ?? false; - /// `Component.childrenFactory` is the default method for creating children - /// containers within all components. Replace this method if you want to have - /// customized (non-default) [ComponentSet] instances in your project. - static ComponentSet Function() childrenFactory = ComponentSet.new; - - /// Whether the strict query mode should be enabled for all children sets; - /// see [ComponentSet.strictMode]. + /// Whether the strict query mode should be enabled for all children lists; + /// see [ComponentList.strictMode]. static bool strictQueryMode = false; /// This method creates the children container for the current component. - /// Override this method if you need to have a custom [ComponentSet] within - /// a particular class. - ComponentSet createComponentSet() => childrenFactory(); + /// Override this method if you need a customized [ComponentList] for a + /// particular class, for example one with a custom ordering: + /// + /// ```dart + /// @override + /// ComponentList createComponentList() { + /// return ComponentList( + /// comparator: (a, b) => a.someValue.compareTo(b.someValue), + /// ); + /// } + /// ``` + ComponentList createComponentList() => ComponentList(); /// Returns the closest parent further up the hierarchy that satisfies type=T, /// or null if no such parent can be found. diff --git a/packages/flame/lib/src/components/core/component_set.dart b/packages/flame/lib/src/components/core/component_list.dart similarity index 83% rename from packages/flame/lib/src/components/core/component_set.dart rename to packages/flame/lib/src/components/core/component_list.dart index d78a0933f97..46729b0b228 100644 --- a/packages/flame/lib/src/components/core/component_set.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -3,8 +3,9 @@ part of 'component.dart'; /// A fast, ordered container for a [Component]'s children. /// /// The children are stored in a single flat [List], sorted by -/// [Component.priority]; components with equal priority keep the order in -/// which they were added. This is the same ordering that the old +/// [Component.priority] (or by a custom comparator, see +/// [Component.createComponentList]); components that compare equal keep the +/// order in which they were added. This is the same ordering that the old /// `OrderedSet`-based container provided, but backed by a contiguous array /// instead of a splay tree of hash sets, which makes iteration, additions, /// removals and reorders significantly faster and allocation-free. @@ -35,14 +36,39 @@ part of 'component.dart'; /// The contents of this container are managed by the [Component] lifecycle: /// use [Component.add], [Component.remove] and their related methods to /// modify which components are in it. -class ComponentSet extends Iterable { - ComponentSet({bool? strictMode}) +class ComponentList extends Iterable { + ComponentList({bool? strictMode, this.comparator}) : strictMode = strictMode ?? Component.strictQueryMode; /// Whether calling [query] for an unregistered type throws an error /// (`true`), or transparently registers the type on first use (`false`). final bool strictMode; + /// An optional custom ordering, replacing the default ordering by + /// [Component.priority]. Supply one via [Component.createComponentList]. + /// + /// The ordering is stable in both cases: elements that compare equal keep + /// their insertion order. If the values that the comparator reads change + /// after insertion, call [Component.rebalanceChildren] to restore the + /// ordering (priority changes on mounted components do this automatically). + final Comparator? comparator; + + /// The relative order of [a] and [b]: by the custom [comparator] if one + /// was supplied, otherwise by [Component.priority]. + int _compareOrder(Component a, Component b) { + final comparator = this.comparator; + if (comparator == null) { + final ap = a._priority; + final bp = b._priority; + return ap < bp + ? -1 + : ap > bp + ? 1 + : 0; + } + return comparator(a, b); + } + /// The backing store; `null` entries are tombstones left by removals. final List _elements = []; @@ -75,18 +101,18 @@ class ComponentSet extends Iterable { bool get isNotEmpty => _length != 0; @override - Iterator get iterator => _ComponentSetIterator(this); + Iterator get iterator => _ComponentListIterator(this); /// The elements of this set in reverse order. /// /// Unlike the rest of the [Iterable] interface, this is a method and not a /// getter, for historical reasons. The returned iterable is a lazy view: it /// costs nothing to create and always reflects the current contents. - Iterable reversed() => _ReversedComponentSetView(this); + Iterable reversed() => _ReversedComponentListView(this); @override bool contains(Object? element) { - return element is Component && identical(element._containerSet, this); + return element is Component && identical(element._containerList, this); } @override @@ -151,11 +177,11 @@ class ComponentSet extends Iterable { /// through the component lifecycle. Use [Component.add] instead. @internal bool add(Component component) { - if (identical(component._containerSet, this)) { + if (identical(component._containerList, this)) { return false; } assert( - component._containerSet == null, + component._containerList == null, 'A component cannot be contained by two children containers at once', ); final elements = _elements; @@ -164,18 +190,17 @@ class ComponentSet extends Iterable { elements.clear(); _tombstones = 0; } - final priority = component._priority; var lastIndex = elements.length - 1; while (lastIndex >= 0 && elements[lastIndex] == null) { lastIndex--; } - if (lastIndex >= 0 && elements[lastIndex]!._priority > priority) { - _insertSorted(component, priority); + if (lastIndex >= 0 && _compareOrder(elements[lastIndex]!, component) > 0) { + _insertSorted(component); } else { component._containerIndex = elements.length; elements.add(component); } - component._containerSet = this; + component._containerList = this; _length++; final queries = _queries; if (queries != null) { @@ -188,16 +213,16 @@ class ComponentSet extends Iterable { return true; } - /// Inserts [component] before all existing elements with a higher priority, - /// and after all elements with the same or a lower priority. - void _insertSorted(Component component, int priority) { + /// Inserts [component] before all existing elements that sort after it, + /// and after all elements that sort the same or before it. + void _insertSorted(Component component) { _compact(); final elements = _elements; var lo = 0; var hi = elements.length; while (lo < hi) { final mid = (lo + hi) >>> 1; - if (elements[mid]!._priority <= priority) { + if (_compareOrder(elements[mid]!, component) <= 0) { lo = mid + 1; } else { hi = mid; @@ -217,13 +242,13 @@ class ComponentSet extends Iterable { /// go through the component lifecycle. Use [Component.remove] instead. @internal bool remove(Component component) { - if (!identical(component._containerSet, this)) { + if (!identical(component._containerList, this)) { return false; } final index = component._containerIndex; assert(identical(_elements[index], component)); _elements[index] = null; - component._containerSet = null; + component._containerList = null; component._containerIndex = -1; _length--; _tombstones++; @@ -256,7 +281,7 @@ class ComponentSet extends Iterable { for (var i = 0; i < elements.length; i++) { final element = elements[i]; if (element != null) { - element._containerSet = null; + element._containerList = null; element._containerIndex = -1; } } @@ -278,7 +303,7 @@ class ComponentSet extends Iterable { final elements = _elements; var isSorted = true; for (var i = 1; i < elements.length; i++) { - if (elements[i - 1]!._priority > elements[i]!._priority) { + if (_compareOrder(elements[i - 1]!, elements[i]!) > 0) { isSorted = false; break; } @@ -290,10 +315,8 @@ class ComponentSet extends Iterable { // Ties are broken by the pre-sort index, which makes the (unstable) // built-in sort behave as a stable sort. elements.sort((a, b) { - final byPriority = a!._priority.compareTo(b!._priority); - return byPriority != 0 - ? byPriority - : a._containerIndex - b._containerIndex; + final byOrder = _compareOrder(a!, b!); + return byOrder != 0 ? byOrder : a._containerIndex - b._containerIndex; }); for (var i = 0; i < elements.length; i++) { elements[i]!._containerIndex = i; @@ -378,10 +401,10 @@ class ComponentSet extends Iterable { } } -class _ComponentSetIterator implements Iterator { - _ComponentSetIterator(this._set) : _shiftCount = _set._shiftCount; +class _ComponentListIterator implements Iterator { + _ComponentListIterator(this._set) : _shiftCount = _set._shiftCount; - final ComponentSet _set; + final ComponentList _set; final int _shiftCount; int _index = -1; Component? _current; @@ -412,10 +435,10 @@ class _ComponentSetIterator implements Iterator { } } -class _ReversedComponentSetView extends Iterable { - _ReversedComponentSetView(this._set); +class _ReversedComponentListView extends Iterable { + _ReversedComponentListView(this._set); - final ComponentSet _set; + final ComponentList _set; @override int get length => _set._length; @@ -427,16 +450,16 @@ class _ReversedComponentSetView extends Iterable { bool get isNotEmpty => _set._length != 0; @override - Iterator get iterator => _ReversedComponentSetIterator(_set); + Iterator get iterator => _ReversedComponentListIterator(_set); } -class _ReversedComponentSetIterator implements Iterator { - _ReversedComponentSetIterator(ComponentSet set) +class _ReversedComponentListIterator implements Iterator { + _ReversedComponentListIterator(ComponentList set) : _set = set, _shiftCount = set._shiftCount, _index = set._elements.length; - final ComponentSet _set; + final ComponentList _set; final int _shiftCount; int _index; Component? _current; diff --git a/packages/flame/test/components/component_test.dart b/packages/flame/test/components/component_test.dart index 4af7830b5a7..0c52fd90177 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -1659,19 +1659,34 @@ void main() { }); group('miscellaneous', () { - testWithFlameGame('childrenFactory', (game) async { + testWithFlameGame('createComponentList override', (game) async { final component0 = Component(); expect(component0.children.strictMode, false); - Component.childrenFactory = () => ComponentSet(strictMode: true); - final component1 = Component(); + final component1 = _CustomListComponent(); final component2 = Component(); component1.add(component2); component2.add(Component()); - expect(component1.children, isInstanceOf()); + expect(component1.children, isInstanceOf()); expect(component1.children.strictMode, isTrue); - expect(component2.children, isInstanceOf()); - expect(component2.children.strictMode, isTrue); + expect(component2.children.strictMode, isFalse); + }); + + testWithFlameGame('custom children comparator', (game) async { + final parent = _ReverseOrderedComponent(); + final children = List.generate(5, (i) => Component(priority: i)); + parent.addAll(children); + await game.ensureAdd(parent); + + expect( + parent.children.toList(), + equals(children.reversed.toList()), + ); + + // Reordering after a priority change keeps following the comparator. + children.first.priority = 10; + game.update(0); + expect(parent.children.first, children.first); }); testWithFlameGame('initially same debugMode as parent', (game) async { @@ -2182,3 +2197,17 @@ class _RemoveAllChildrenComponent extends Component { removeAll(children); } } + +class _CustomListComponent extends Component { + @override + ComponentList createComponentList() => ComponentList(strictMode: true); +} + +class _ReverseOrderedComponent extends Component { + @override + ComponentList createComponentList() { + return ComponentList( + comparator: (a, b) => b.priority.compareTo(a.priority), + ); + } +} diff --git a/packages/flame/test/components/priority_test.dart b/packages/flame/test/components/priority_test.dart index 429332574f4..8e6ed5ad323 100644 --- a/packages/flame/test/components/priority_test.dart +++ b/packages/flame/test/components/priority_test.dart @@ -259,7 +259,7 @@ void main() { }); } -class _SpyComponentSet extends ComponentSet { +class _SpyComponentList extends ComponentList { int callCount = 0; @override @@ -277,11 +277,11 @@ class _ParentWithReorderSpy extends Component { _ParentWithReorderSpy(int priority) : super(priority: priority); @override - ComponentSet createComponentSet() => _SpyComponentSet(); + ComponentList createComponentList() => _SpyComponentList(); void assertCalled(int n) { - final componentSet = children as _SpyComponentSet; - expect(componentSet.callCount, n); - componentSet.callCount = 0; + final componentList = children as _SpyComponentList; + expect(componentList.callCount, n); + componentList.callCount = 0; } } From b25dcf41079ba7114adba94289622d821fb866e3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:00:17 +0200 Subject: [PATCH 04/26] test: Add golden tests for lifecycle ordering, hit-test order, and equal-priority ordering --- .../component_tree_golden_test.dart | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 packages/flame/test/components/component_tree_golden_test.dart diff --git a/packages/flame/test/components/component_tree_golden_test.dart b/packages/flame/test/components/component_tree_golden_test.dart new file mode 100644 index 00000000000..7e71a913724 --- /dev/null +++ b/packages/flame/test/components/component_tree_golden_test.dart @@ -0,0 +1,223 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +/// Golden tests that pin the observable behavior of the component tree: +/// lifecycle event ordering in same-tick edge cases, hit-test order, and +/// the ordering guarantees for children with equal priorities. +/// +/// These behaviors must survive any change to the children container or the +/// traversal machinery. +void main() { + group('Lifecycle ordering', () { + testWithFlameGame('add to a new parent during remove, same tick', ( + game, + ) async { + final parent1 = Component(); + final parent2 = Component(); + await game.world.ensureAdd(parent1); + await game.world.ensureAdd(parent2); + final child = _EventLogComponent(); + await parent1.ensureAdd(child); + expect(child.events, ['onMount']); + + parent1.remove(child); + parent2.add(child); + game.update(0); + await game.ready(); + + expect(child.parent, parent2); + expect(child.isMounted, isTrue); + expect(parent1.children, isEmpty); + expect(parent2.children, [child]); + expect(child.events, ['onMount', 'onRemove', 'onMount']); + }); + + testWithFlameGame('move to a new parent while old parent is removed', ( + game, + ) async { + final parent1 = Component(); + final parent2 = Component(); + await game.world.ensureAdd(parent1); + await game.world.ensureAdd(parent2); + final child = _EventLogComponent(); + await parent1.ensureAdd(child); + + child.parent = parent2; + parent1.removeFromParent(); + game.update(0); + await game.ready(); + + expect(parent1.isMounted, isFalse); + expect(child.parent, parent2); + expect(child.isMounted, isTrue); + expect(parent2.children, [child]); + expect(child.events, ['onMount', 'onRemove', 'onMount']); + }); + + testWithFlameGame('remove and re-add to the same parent, same tick', ( + game, + ) async { + final parent = Component(); + await game.world.ensureAdd(parent); + final child = _EventLogComponent(); + await parent.ensureAdd(child); + + parent.remove(child); + parent.add(child); + game.update(0); + await game.ready(); + + expect(child.parent, parent); + expect(child.isMounted, isTrue); + expect(parent.children, [child]); + }); + }); + + group('Hit-test order', () { + testWithFlameGame('componentsAtPoint is front-to-back by priority', ( + game, + ) async { + final bottom = _HittablePositionComponent(priority: 0); + final middle = _HittablePositionComponent(priority: 1); + final top = _HittablePositionComponent(priority: 2); + // Added in an order that differs from the priority order. + await game.world.ensureAddAll([middle, top, bottom]); + + final hits = game.componentsAtPoint(Vector2(405, 305)).toList(); + final hitComponents = hits + .whereType<_HittablePositionComponent>() + .toList(); + expect(hitComponents, [top, middle, bottom]); + }); + + testWithFlameGame('children hit before their parents', (game) async { + final parent = _HittablePositionComponent(priority: 0); + final child = _HittablePositionComponent(priority: 0); + parent.add(child); + await game.world.ensureAdd(parent); + + final hits = game + .componentsAtPoint(Vector2(405, 305)) + .whereType<_HittablePositionComponent>() + .toList(); + expect(hits, [child, parent]); + }); + + testWithFlameGame('equal-priority siblings hit in reverse add order', ( + game, + ) async { + final first = _HittablePositionComponent(priority: 0); + final second = _HittablePositionComponent(priority: 0); + final third = _HittablePositionComponent(priority: 0); + await game.world.ensureAddAll([first, second, third]); + + final hits = game + .componentsAtPoint(Vector2(405, 305)) + .whereType<_HittablePositionComponent>() + .toList(); + expect(hits, [third, second, first]); + }); + }); + + group('Equal-priority ordering', () { + testWithFlameGame('insertion order is kept through mounting', ( + game, + ) async { + final children = List.generate(10, (i) => Component()); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + expect(parent.children.toList(), children); + }); + + testWithFlameGame('insertion order survives a sibling priority change', ( + game, + ) async { + final children = List.generate(10, (i) => Component()); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + + // Push the first child to the back; everyone else keeps their order. + children.first.priority = 1; + game.update(0); + await game.ready(); + + expect( + parent.children.toList(), + [...children.skip(1), children.first], + ); + + // Return it to the same priority as the others: it must now sort after + // them (a fresh sort key, not its original insertion position). + children.first.priority = 0; + game.update(0); + await game.ready(); + + expect( + parent.children.toList(), + [...children.skip(1), children.first], + ); + }); + + testWithFlameGame('insertion order is kept when the parent remounts', ( + game, + ) async { + final children = List.generate(10, (i) => Component()); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + + parent.removeFromParent(); + game.update(0); + await game.ready(); + expect(parent.isMounted, isFalse); + + await game.world.ensureAdd(parent); + expect(parent.children.toList(), children); + }); + + testWithFlameGame('update and render visit equal priorities in add order', ( + game, + ) async { + final order = []; + final children = List.generate( + 5, + (i) => _UpdateLogComponent(i, order), + ); + final parent = Component(children: children); + await game.world.ensureAdd(parent); + + game.update(0); + expect(order, [0, 1, 2, 3, 4]); + }); + }); +} + +class _EventLogComponent extends Component { + final List events = []; + + @override + void onMount() { + events.add('onMount'); + } + + @override + void onRemove() { + events.add('onRemove'); + } +} + +class _HittablePositionComponent extends PositionComponent { + _HittablePositionComponent({super.priority}) : super(size: Vector2.all(10)); +} + +class _UpdateLogComponent extends Component { + _UpdateLogComponent(this.id, this.order); + + final int id; + final List order; + + @override + void update(double dt) { + order.add(id); + } +} From 0b71600225992d0bc64cf2f81a4f0301949d2b80 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:01:06 +0200 Subject: [PATCH 05/26] perf: Early-return from processLifecycleEvents when the queue is empty --- .../src/components/core/component_tree_root.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index f5b0b44e913..c2edccab780 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -137,10 +137,16 @@ class ComponentTreeRoot extends Component { } void processLifecycleEvents() { + if (!hasLifecycleEvents) { + // The completer is only ever created while events are queued, so there + // is nothing to complete here either. + assert(_lifecycleEventsCompleter == null); + return; + } // reorder events to process later grouped by parent - final reorderParents = {}; + Set? reorderParents; LifecycleEventStatus handleReorderEvent(Component parent) { - reorderParents.add(parent); + (reorderParents ??= {}).add(parent); return LifecycleEventStatus.done; } @@ -176,8 +182,10 @@ class ComponentTreeRoot extends Component { _blocked.clear(); } - for (final parent in reorderParents) { - parent.rebalanceChildren(); + if (reorderParents != null) { + for (final parent in reorderParents!) { + parent.rebalanceChildren(); + } } if (!hasLifecycleEvents && _lifecycleEventsCompleter != null) { From 764af8eeb37338f7a1613045ddeedb237bb15547 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:04:02 +0200 Subject: [PATCH 06/26] perf: Cache decorator render closures instead of allocating per frame --- .../src/components/mixins/has_decorator.dart | 6 +++- .../src/components/position_component.dart | 6 +++- .../lib/src/components/router/route.dart | 6 +++- .../post_process/post_process_component.dart | 29 ++++++++++--------- .../flame/lib/src/rendering/decorator.dart | 22 ++++++++++---- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/flame/lib/src/components/mixins/has_decorator.dart b/packages/flame/lib/src/components/mixins/has_decorator.dart index b3da7be9104..9617ad1a773 100644 --- a/packages/flame/lib/src/components/mixins/has_decorator.dart +++ b/packages/flame/lib/src/components/mixins/has_decorator.dart @@ -16,12 +16,16 @@ import 'package:flame/src/rendering/decorator.dart'; mixin HasDecorator on Component { Decorator? decorator; + /// Cached `super.renderTree` tear-off, so that the render pass does not + /// allocate a fresh closure for [Decorator.applyChain] on every frame. + void Function(Canvas)? _superRenderTree; + @override void renderTree(Canvas canvas) { if (decorator == null) { super.renderTree(canvas); } else { - decorator!.applyChain(super.renderTree, canvas); + decorator!.applyChain(_superRenderTree ??= super.renderTree, canvas); } } } diff --git a/packages/flame/lib/src/components/position_component.dart b/packages/flame/lib/src/components/position_component.dart index 4eb3c2fa08c..80f74c1ca41 100644 --- a/packages/flame/lib/src/components/position_component.dart +++ b/packages/flame/lib/src/components/position_component.dart @@ -518,9 +518,13 @@ class PositionComponent extends Component } } + /// Cached `super.renderTree` tear-off, so that the render pass does not + /// allocate a fresh closure for [Decorator.applyChain] on every frame. + void Function(Canvas)? _superRenderTree; + @override void renderTree(Canvas canvas) { - decorator.applyChain(super.renderTree, canvas); + decorator.applyChain(_superRenderTree ??= super.renderTree, canvas); } @internal diff --git a/packages/flame/lib/src/components/router/route.dart b/packages/flame/lib/src/components/router/route.dart index 76beebde07d..b1e3aa7ae87 100644 --- a/packages/flame/lib/src/components/router/route.dart +++ b/packages/flame/lib/src/components/router/route.dart @@ -162,10 +162,14 @@ class Route extends PositionComponent } } + /// Cached `super.renderTree` tear-off, so that the render pass does not + /// allocate a fresh closure for [Decorator.applyChain] on every frame. + void Function(Canvas)? _superRenderTree; + @override void renderTree(Canvas canvas) { if (isRendered) { - _renderEffect.applyChain(super.renderTree, canvas); + _renderEffect.applyChain(_superRenderTree ??= super.renderTree, canvas); } } diff --git a/packages/flame/lib/src/post_process/post_process_component.dart b/packages/flame/lib/src/post_process/post_process_component.dart index cfa597b261a..eb8712fba3a 100644 --- a/packages/flame/lib/src/post_process/post_process_component.dart +++ b/packages/flame/lib/src/post_process/post_process_component.dart @@ -105,22 +105,25 @@ class PostProcessComponent extends PositionComponent { return superSize; } + /// Cached render chain, so that the render pass does not allocate fresh + /// closures for `Decorator.applyChain` on every frame. + void Function(Canvas)? _renderChain; + @override @mustCallSuper void renderTree(Canvas canvas) { - decorator.applyChain( - (canvas) { - postProcess.render( - canvas, - size, - super.renderTreeWithoutDecorator, - (context) { - _renderContext.postProcess = postProcess; - }, - ); - }, - canvas, - ); + decorator.applyChain(_renderChain ??= _buildRenderChain(), canvas); + } + + void Function(Canvas) _buildRenderChain() { + final renderTree = super.renderTreeWithoutDecorator; + void updateContext(PostProcess? context) { + _renderContext.postProcess = postProcess; + } + + return (canvas) { + postProcess.render(canvas, size, renderTree, updateContext); + }; } } diff --git a/packages/flame/lib/src/rendering/decorator.dart b/packages/flame/lib/src/rendering/decorator.dart index b9457223ef1..a5f3190b6ab 100644 --- a/packages/flame/lib/src/rendering/decorator.dart +++ b/packages/flame/lib/src/rendering/decorator.dart @@ -31,16 +31,26 @@ class Decorator { /// The next decorator in the chain, or null if there is none. Decorator? _next; + /// Cached closure that forwards the draw call to the rest of the chain, + /// so that no closure needs to be allocated per frame. It is keyed by the + /// identity of the [_chainedDrawSource] it wraps: callers that pass the + /// same (cached) draw callback every frame reuse the same chain closure. + late void Function(Canvas) _chainedDraw; + void Function(Canvas)? _chainedDrawSource; + /// Applies this and all subsequent decorators if any. /// /// This method is the main method through which the decorator is applied. void applyChain(void Function(Canvas) draw, Canvas canvas) { - apply( - _next == null - ? draw - : (nextCanvas) => _next!.applyChain(draw, nextCanvas), - canvas, - ); + if (_next == null) { + apply(draw, canvas); + } else { + if (!identical(_chainedDrawSource, draw)) { + _chainedDrawSource = draw; + _chainedDraw = (nextCanvas) => _next!.applyChain(draw, nextCanvas); + } + apply(_chainedDraw, canvas); + } } /// Applies visual effect while [draw]ing on the [canvas]. From 51e20e859d3e3d17e019ee2a7749184bfa86dff6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:05:48 +0200 Subject: [PATCH 07/26] perf: Make render contexts and debug caches lazily allocated --- .../lib/src/components/core/component.dart | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 492069877fe..23fc61be113 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -591,19 +591,16 @@ class Component { /// cleans them up afterwards. @protected void renderChild(Canvas canvas, Component child) { - int? originalLength; - final hasContext = _renderContexts.isNotEmpty; - if (hasContext) { - originalLength = child._renderContexts.length; - child._renderContexts.addAll(_renderContexts); + final contexts = _renderContexts; + if (contexts == null || contexts.isEmpty) { + child.renderTree(canvas); + return; } + final childContexts = child._renderContexts ??= QueueList(); + final originalLength = childContexts.length; + childContexts.addAll(contexts); child.renderTree(canvas); - if (hasContext) { - child._renderContexts.removeRange( - originalLength!, - child._renderContexts.length, - ); - } + childContexts.removeRange(originalLength, childContexts.length); } /// Called once after all children have been rendered in [renderTree]. @@ -616,7 +613,7 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; if (context != null) { - _renderContexts.add(context); + (_renderContexts ??= QueueList()).add(context); } render(canvas); @@ -638,7 +635,7 @@ class Component { } if (context != null) { - _renderContexts.removeLast(); + _renderContexts!.removeLast(); } } @@ -1163,14 +1160,16 @@ class Component { //#region Context - final QueueList _renderContexts = QueueList(); + /// The stack of render contexts inherited from ancestors during the render + /// pass. Created lazily: most components never provide or receive one. + QueueList? _renderContexts; /// Override this method if you want your component to provide a custom /// render context to all its children (recursively). ComponentRenderContext? get renderContext => null; T? findRenderContext() { - return _renderContexts.whereType().lastOrNull; + return _renderContexts?.whereType().lastOrNull; } //#endregion @@ -1201,8 +1200,9 @@ class Component { /// The color that the debug output should be rendered with. Color debugColor = const Color(0xFFFF00FF); - final ValueCache _debugPaintCache = ValueCache(); - final ValueCache _debugTextPaintCache = ValueCache(); + late final ValueCache _debugPaintCache = ValueCache(); + late final ValueCache _debugTextPaintCache = + ValueCache(); /// The [debugColor] represented as a [Paint] object. Paint get debugPaint { From 8c5b31cba900c48f9cd7720d580dce1b5b5c4fdf Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:08:29 +0200 Subject: [PATCH 08/26] perf: Replace generator-based removal teardown with an explicit collection pass --- .../lib/src/components/core/component.dart | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 23fc61be113..8d74942b319 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -1127,26 +1127,46 @@ class Component { _removeCompleter = null; } + /// Reusable buffer for [_remove]. A fresh list is used in the rare case of + /// a re-entrant teardown (e.g. a nested game disposed from `onRemove`). + static final List _teardownBuffer = []; + void _remove(Component parent) { parent._internalChildren.remove(this); - propagateToChildren( - (Component component) { - component - ..onRemove() - .._unregisterKey() - .._clearMountedBit() - .._clearRemovingBit() - .._setRemovedBit() - .._removeCompleter?.complete() - .._removeCompleter = null - .._parent!.onChildrenChanged(component, ChildrenChangeType.removed); - return true; - }, - includeSelf: true, - ); + final buffer = _teardownBuffer.isEmpty ? _teardownBuffer : []; + _collectTeardown(buffer); + for (var i = 0; i < buffer.length; i++) { + final component = buffer[i]; + component + ..onRemove() + .._unregisterKey() + .._clearMountedBit() + .._clearRemovingBit() + .._setRemovedBit() + .._removeCompleter?.complete() + .._removeCompleter = null + .._parent!.onChildrenChanged(component, ChildrenChangeType.removed); + } + buffer.clear(); _parent = null; } + /// Collects this component and all its descendants into [out] in teardown + /// order: leaves first, siblings in reverse order, ancestors after their + /// subtrees. This matches the order that + /// `descendants(reversed: true, includeSelf: true)` would produce, without + /// allocating generator frames on every removal. + void _collectTeardown(List out) { + final children = _children; + if (children != null) { + final elements = children._elements; + for (var i = elements.length - 1; i >= 0; i--) { + elements[i]?._collectTeardown(out); + } + } + out.add(this); + } + void _unregisterKey() { if (key != null) { final game = findGame(); From d74ba8129ffefedbfda2954a16e1d5977f61a802 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:12:26 +0200 Subject: [PATCH 09/26] perf: Track mounted pointer-event handlers so hit tests can early-out --- .../events/callbacks/double_tap_callbacks.dart | 10 ++++++++++ .../src/events/callbacks/drag_callbacks.dart | 2 ++ .../src/events/callbacks/scale_callbacks.dart | 2 ++ .../callbacks/secondary_tap_callbacks.dart | 8 ++++++++ .../lib/src/events/callbacks/tap_callbacks.dart | 8 ++++++++ packages/flame/lib/src/game/flame_game.dart | 17 +++++++++++++++++ 6 files changed, 47 insertions(+) diff --git a/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart index 6489374760f..3f465f35837 100644 --- a/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/double_tap_callbacks.dart @@ -1,5 +1,6 @@ import 'package:flame/components.dart'; import 'package:flame/events.dart'; +import 'package:meta/meta.dart'; /// [DoubleTapCallbacks] adds the ability to receive double-tap events in a /// component. @@ -25,8 +26,17 @@ mixin DoubleTapCallbacks on Component { void onDoubleTapCancel(DoubleTapCancelEvent event) {} @override + @mustCallSuper void onMount() { super.onMount(); DoubleTapDispatcher.addDispatcher(this); + findRootGame()?.adjustPointerEventHandlerCount(1); + } + + @override + @mustCallSuper + void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); + super.onRemove(); } } diff --git a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart index bb05312471f..c8b15a865b1 100644 --- a/packages/flame/lib/src/events/callbacks/drag_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/drag_callbacks.dart @@ -68,11 +68,13 @@ mixin DragCallbacks on Component { hasDrag: true, hasScale: false, ); + findRootGame()?.adjustPointerEventHandlerCount(1); } @override @mustCallSuper void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); MultiDragScaleDispatcher.removeDispatcher( this, hasDrag: true, diff --git a/packages/flame/lib/src/events/callbacks/scale_callbacks.dart b/packages/flame/lib/src/events/callbacks/scale_callbacks.dart index 3481232abf0..92f95912cfb 100644 --- a/packages/flame/lib/src/events/callbacks/scale_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/scale_callbacks.dart @@ -30,11 +30,13 @@ mixin ScaleCallbacks on Component { hasDrag: false, hasScale: true, ); + findRootGame()?.adjustPointerEventHandlerCount(1); } @override @mustCallSuper void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); MultiDragScaleDispatcher.removeDispatcher( this, hasDrag: false, diff --git a/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart index 5cbeaea0ad7..bf26fd890e5 100644 --- a/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/secondary_tap_callbacks.dart @@ -23,5 +23,13 @@ mixin SecondaryTapCallbacks on Component { void onMount() { super.onMount(); NonPrimaryTapDispatcher.addDispatcher(this); + findRootGame()?.adjustPointerEventHandlerCount(1); + } + + @override + @mustCallSuper + void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); + super.onRemove(); } } diff --git a/packages/flame/lib/src/events/callbacks/tap_callbacks.dart b/packages/flame/lib/src/events/callbacks/tap_callbacks.dart index 4c898ed3658..cf28c818fea 100644 --- a/packages/flame/lib/src/events/callbacks/tap_callbacks.dart +++ b/packages/flame/lib/src/events/callbacks/tap_callbacks.dart @@ -23,5 +23,13 @@ mixin TapCallbacks on Component { void onMount() { super.onMount(); MultiTapDispatcher.addDispatcher(this); + findRootGame()?.adjustPointerEventHandlerCount(1); + } + + @override + @mustCallSuper + void onRemove() { + findRootGame()?.adjustPointerEventHandlerCount(-1); + super.onRemove(); } } diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index dd5eecba7d5..398b316fba0 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -243,6 +243,20 @@ class FlameGame extends ComponentTreeRoot point.y < canvasSize.y; } + /// The number of currently mounted components (in this game or any nested + /// game) that can receive pointer events. Maintained by the event callback + /// mixins ([TapCallbacks], [DragCallbacks], [DoubleTapCallbacks], + /// [ScaleCallbacks], [SecondaryTapCallbacks]) on the root game, so that + /// [containsEventHandlerAt] can skip hit testing entirely for games + /// without any pointer-event handlers. + int _pointerEventHandlerCount = 0; + + @internal + void adjustPointerEventHandlerCount(int delta) { + _pointerEventHandlerCount += delta; + assert(_pointerEventHandlerCount >= 0); + } + @override bool containsEventHandlerAt(Vector2 position) { // Deprecated game-level detector mixins handle events for the entire @@ -262,6 +276,9 @@ class FlameGame extends ComponentTreeRoot this is MultiTouchDragDetector) { return true; } + if (_pointerEventHandlerCount == 0) { + return false; + } for (final component in super.componentsAtPoint(position)) { if (component is TapCallbacks || component is DragCallbacks || From bcdc1b7cc89bd6ff282e6651d70e528d1c32a452 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:18:38 +0200 Subject: [PATCH 10/26] feat!: Make updateTree non-virtual behind a CustomTraversal seam --- .../stories/router/router_world_example.dart | 2 +- .../children_traversal_benchmark.dart | 2 +- packages/flame/lib/components.dart | 1 + .../lib/src/components/core/component.dart | 23 +++++ .../src/components/core/custom_traversal.dart | 33 +++++++ .../src/components/mixins/has_time_scale.dart | 13 +-- .../lib/src/components/router/route.dart | 6 +- .../lib/src/effects/combined_effect.dart | 5 +- .../lib/src/effects/sequence_effect.dart | 5 +- packages/flame/lib/src/game/flame_game.dart | 4 +- .../core/custom_traversal_test.dart | 98 +++++++++++++++++++ .../mixins/has_time_scale_test.dart | 3 +- 12 files changed, 174 insertions(+), 21 deletions(-) create mode 100644 packages/flame/lib/src/components/core/custom_traversal.dart create mode 100644 packages/flame/test/components/core/custom_traversal_test.dart diff --git a/examples/lib/stories/router/router_world_example.dart b/examples/lib/stories/router/router_world_example.dart index f908a721e53..a6f69c3aecb 100644 --- a/examples/lib/stories/router/router_world_example.dart +++ b/examples/lib/stories/router/router_world_example.dart @@ -433,7 +433,7 @@ class PausePage extends Component void onTapUp(TapUpEvent event) => game.router.pop(); } -class DecoratedWorld extends World with HasTimeScale { +class DecoratedWorld extends World with CustomTraversal, HasTimeScale { PaintDecorator? decorator; @override diff --git a/packages/flame/benchmark/children_traversal_benchmark.dart b/packages/flame/benchmark/children_traversal_benchmark.dart index b8f7badff1d..c7d91640a03 100644 --- a/packages/flame/benchmark/children_traversal_benchmark.dart +++ b/packages/flame/benchmark/children_traversal_benchmark.dart @@ -185,7 +185,7 @@ class BarrierTreeUpdateBenchmark extends _TraversalBenchmark { } } -class _TimeScaledParent extends Component with HasTimeScale { +class _TimeScaledParent extends Component with CustomTraversal, HasTimeScale { _TimeScaledParent({super.children}); } diff --git a/packages/flame/lib/components.dart b/packages/flame/lib/components.dart index 134b3554c2c..5f090af6bdc 100644 --- a/packages/flame/lib/components.dart +++ b/packages/flame/lib/components.dart @@ -12,6 +12,7 @@ export 'src/components/components_notifier.dart'; export 'src/components/core/component.dart'; export 'src/components/core/component_key.dart'; export 'src/components/core/component_render_context.dart'; +export 'src/components/core/custom_traversal.dart'; export 'src/components/custom_painter_component.dart'; export 'src/components/fps_component.dart'; export 'src/components/fps_text_component.dart'; diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 8d74942b319..d34de80386a 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -562,7 +562,30 @@ class Component { /// This method traverses the component tree and calls [update] on all its /// children according to their [priority] order, relative to the /// priority of the direct siblings, not the children or the ancestors. + /// + /// This method is non-virtual: components that need to customize how their + /// subtree is traversed (changing the effective [dt], skipping children, + /// or updating them manually) should mix in `CustomTraversal` and override + /// its `updateSubtree` method instead. The marker mixin lets the engine's + /// flattened update pass treat such components as traversal barriers + /// instead of silently skipping their custom logic. + @nonVirtual void updateTree(double dt) { + final self = this; + if (self is CustomTraversal) { + self.updateSubtree(dt); + } else { + defaultUpdateSubtree(dt); + } + } + + /// The engine's standard update traversal: update this component, then + /// update the children in priority order. + /// + /// This is the default behavior of `CustomTraversal.updateSubtree`; + /// custom traversals can call it to delegate to the standard behavior. + @protected + void defaultUpdateSubtree(double dt) { update(dt); final children = _children; if (children != null) { diff --git a/packages/flame/lib/src/components/core/custom_traversal.dart b/packages/flame/lib/src/components/core/custom_traversal.dart new file mode 100644 index 00000000000..5e8f82de52a --- /dev/null +++ b/packages/flame/lib/src/components/core/custom_traversal.dart @@ -0,0 +1,33 @@ +import 'package:flame/src/components/core/component.dart'; + +/// A mixin for components that manage the update traversal of their own +/// subtree, instead of the engine's standard "update self, then update every +/// child in priority order" recursion. +/// +/// Mixing this in is both the capability and the barrier marker: the engine's +/// flattened update pass treats every [CustomTraversal] component as a +/// barrier, calls its [updateSubtree], and lets that method drive the +/// subtree. This is also why [Component.updateTree] is non-virtual: an +/// updateTree override without a marker would be silently skipped by the +/// flattened traversal, whereas overriding [updateSubtree] cannot be. +/// +/// Override [updateSubtree] to customize the traversal, and call +/// `super.updateSubtree` to run the standard traversal, possibly with a +/// modified time delta: +/// ```dart +/// class SlowMotionArea extends Component with CustomTraversal { +/// @override +/// void updateSubtree(double dt) => super.updateSubtree(dt / 2); +/// } +/// ``` +/// +/// Mixins that want to compose with other custom traversals (like +/// `HasTimeScale`) should be declared `on CustomTraversal` and call +/// `super.updateSubtree`, so that they chain instead of replacing each other. +mixin CustomTraversal on Component { + /// Updates this component and its subtree. + /// + /// The default implementation performs the engine's standard traversal: + /// update this component, then update the children in priority order. + void updateSubtree(double dt) => defaultUpdateSubtree(dt); +} diff --git a/packages/flame/lib/src/components/mixins/has_time_scale.dart b/packages/flame/lib/src/components/mixins/has_time_scale.dart index 7d3ab9a49a6..21da01361d9 100644 --- a/packages/flame/lib/src/components/mixins/has_time_scale.dart +++ b/packages/flame/lib/src/components/mixins/has_time_scale.dart @@ -1,11 +1,11 @@ -import 'package:flame/src/components/core/component.dart'; +import 'package:flame/src/components/core/custom_traversal.dart'; /// This mixin allows components to control their speed as compared to the /// normal speed. Only framerate independent logic will benefit from [timeScale] /// changes. /// /// Note: Modified [timeScale] will be applied to all children as well. -mixin HasTimeScale on Component { +mixin HasTimeScale on CustomTraversal { /// The ratio of components tick speed and normal tick speed. /// It defaults to 1.0, which means the component moves normally. /// A value of 0.5 means the component moves half the normal speed @@ -26,13 +26,8 @@ mixin HasTimeScale on Component { } @override - void update(double dt) { - super.update(dt * (parent == null ? _timeScale : 1.0)); - } - - @override - void updateTree(double dt) { - super.updateTree(dt * (parent != null ? _timeScale : 1.0)); + void updateSubtree(double dt) { + super.updateSubtree(dt * _timeScale); } /// Pauses the component by setting the time scale to 0.0. diff --git a/packages/flame/lib/src/components/router/route.dart b/packages/flame/lib/src/components/router/route.dart index b1e3aa7ae87..13ecf6c52f5 100644 --- a/packages/flame/lib/src/components/router/route.dart +++ b/packages/flame/lib/src/components/router/route.dart @@ -21,7 +21,7 @@ import 'package:meta/meta.dart'; /// /// Routes are managed by the [RouterComponent] component. class Route extends PositionComponent - with ParentIsA, HasTimeScale { + with ParentIsA, CustomTraversal, HasTimeScale { Route( Component Function()? builder, { this._loadingBuilder, @@ -174,9 +174,9 @@ class Route extends PositionComponent } @override - void updateTree(double dt) { + void updateSubtree(double dt) { if (timeScale > 0) { - super.updateTree(dt); + super.updateSubtree(dt); } } diff --git a/packages/flame/lib/src/effects/combined_effect.dart b/packages/flame/lib/src/effects/combined_effect.dart index f5555c5fce5..a21f0c12e25 100644 --- a/packages/flame/lib/src/effects/combined_effect.dart +++ b/packages/flame/lib/src/effects/combined_effect.dart @@ -1,3 +1,4 @@ +import 'package:flame/components.dart'; import 'package:flame/effects.dart'; /// An effect that runs multiple effects simultaneously. @@ -16,7 +17,7 @@ import 'package:flame/effects.dart'; /// infinitely. This is equivalent to setting `repeatCount = infinity`. If both /// the `infinite` and the `repeatCount` parameters are given, then `infinite` /// takes precedence. -class CombinedEffect extends Effect { +class CombinedEffect extends Effect with CustomTraversal { CombinedEffect( List effects, { bool alternate = false, @@ -53,7 +54,7 @@ class CombinedEffect extends Effect { void apply(double progress) {} @override - void updateTree(double dt) { + void updateSubtree(double dt) { update(dt); // Do not update children: the controller will take care of it } diff --git a/packages/flame/lib/src/effects/sequence_effect.dart b/packages/flame/lib/src/effects/sequence_effect.dart index 28bd1486338..0d11eebce0a 100644 --- a/packages/flame/lib/src/effects/sequence_effect.dart +++ b/packages/flame/lib/src/effects/sequence_effect.dart @@ -1,3 +1,4 @@ +import 'package:flame/src/components/core/custom_traversal.dart'; import 'package:flame/src/effects/controllers/effect_controller.dart'; import 'package:flame/src/effects/controllers/infinite_effect_controller.dart'; import 'package:flame/src/effects/controllers/repeated_effect_controller.dart'; @@ -45,7 +46,7 @@ EffectController _createController({ /// [EffectController] as a parameter. This is because the timing of a sequence /// effect depends on the timings of individual effects, and cannot be /// represented as a regular effect controller. -class SequenceEffect extends Effect { +class SequenceEffect extends Effect with CustomTraversal { SequenceEffect( List effects, { bool alternate = false, @@ -74,7 +75,7 @@ class SequenceEffect extends Effect { void apply(double progress) {} @override - void updateTree(double dt) { + void updateSubtree(double dt) { update(dt); // Do not update children: the controller will take care of it } diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index 398b316fba0..391d73b2cd1 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -38,7 +38,7 @@ import 'package:meta/meta.dart'; /// When [W] is specified, a matching world instance **must** be passed to the /// constructor; otherwise, a runtime assertion error is thrown. class FlameGame extends ComponentTreeRoot - with Game + with Game, CustomTraversal implements ReadOnlySizeProvider { FlameGame({ super.children, @@ -178,7 +178,7 @@ class FlameGame extends ComponentTreeRoot } @override - void updateTree(double dt) { + void updateSubtree(double dt) { processLifecycleEvents(); if (parent != null) { update(dt); diff --git a/packages/flame/test/components/core/custom_traversal_test.dart b/packages/flame/test/components/core/custom_traversal_test.dart new file mode 100644 index 00000000000..d4e60b209a9 --- /dev/null +++ b/packages/flame/test/components/core/custom_traversal_test.dart @@ -0,0 +1,98 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('CustomTraversal', () { + testWithFlameGame('default updateSubtree matches standard traversal', ( + game, + ) async { + final child = _DtRecorder(); + final parent = _PlainBarrier(children: [child]); + await game.world.ensureAdd(parent); + + game.update(0.5); + expect(child.recordedDts, [0.5]); + }); + + testWithFlameGame('updateSubtree can modify the dt for the subtree', ( + game, + ) async { + final child = _DtRecorder(); + final grandChild = _DtRecorder(); + child.add(grandChild); + final parent = _HalfSpeedBarrier(children: [child]); + await game.world.ensureAdd(parent); + + game.update(1.0); + expect(child.recordedDts, [0.5]); + expect(grandChild.recordedDts, [0.5]); + }); + + testWithFlameGame('updateSubtree can skip the subtree entirely', ( + game, + ) async { + final child = _DtRecorder(); + final parent = _FrozenBarrier(children: [child]); + await game.world.ensureAdd(parent); + + game.update(1.0); + expect(parent.recordedDts, isEmpty); + expect(child.recordedDts, isEmpty); + + parent.frozen = false; + game.update(1.0); + expect(parent.recordedDts, [1.0]); + expect(child.recordedDts, [1.0]); + }); + + testWithFlameGame('barrier subtrees still process nested barriers', ( + game, + ) async { + final inner = _DtRecorder(); + final innerBarrier = _HalfSpeedBarrier(children: [inner]); + final outerBarrier = _HalfSpeedBarrier(children: [innerBarrier]); + await game.world.ensureAdd(outerBarrier); + + game.update(1.0); + expect(inner.recordedDts, [0.25]); + }); + }); +} + +class _DtRecorder extends Component { + final List recordedDts = []; + + @override + void update(double dt) { + recordedDts.add(dt); + } +} + +class _PlainBarrier extends Component with CustomTraversal { + _PlainBarrier({super.children}); +} + +class _HalfSpeedBarrier extends Component with CustomTraversal { + _HalfSpeedBarrier({super.children}); + + @override + void updateSubtree(double dt) => super.updateSubtree(dt / 2); +} + +class _FrozenBarrier extends _DtRecorder with CustomTraversal { + _FrozenBarrier({List? children}) { + if (children != null) { + addAll(children); + } + } + + bool frozen = true; + + @override + void updateSubtree(double dt) { + if (!frozen) { + super.updateSubtree(dt); + } + } +} diff --git a/packages/flame/test/components/mixins/has_time_scale_test.dart b/packages/flame/test/components/mixins/has_time_scale_test.dart index 44e2596b061..04e3b2f4e6b 100644 --- a/packages/flame/test/components/mixins/has_time_scale_test.dart +++ b/packages/flame/test/components/mixins/has_time_scale_test.dart @@ -147,7 +147,8 @@ void main() { class _GameWithTimeScale extends FlameGame with HasTimeScale {} -class _ComponentWithTimeScale extends Component with HasTimeScale {} +class _ComponentWithTimeScale extends Component + with CustomTraversal, HasTimeScale {} class _MovingComponent extends PositionComponent { final speed = 1.0; From d62a993c37c525764fbb9fd3684e234a1346a4d2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:21:09 +0200 Subject: [PATCH 11/26] perf!: Drive the update pass from a root-owned flattened traversal list --- .../src/components/core/component_list.dart | 18 +++++++ .../components/core/component_tree_root.dart | 49 +++++++++++++++++++ packages/flame/lib/src/game/flame_game.dart | 4 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 46729b0b228..d74810370ca 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -91,6 +91,20 @@ class ComponentList extends Iterable { /// The per-type query caches, created by [register]. Map>? _queries; + /// A monotonically increasing counter, bumped on every membership or order + /// change of any [ComponentList] (adds, removes, clears, reorders). The + /// root's flattened update list compares against this to know when it must + /// be rebuilt. Note that this is bumped by structural changes anywhere, + /// including in other games or detached trees, so a bump means "possibly + /// changed", never the reverse. + @internal + static int structureVersion = 0; + + /// Compacts the tombstones out of the backing array, restoring exact + /// element indices. Safe to call only when no iteration is in progress. + @internal + void compact() => _compact(); + @override int get length => _length; @@ -202,6 +216,7 @@ class ComponentList extends Iterable { } component._containerList = this; _length++; + structureVersion++; final queries = _queries; if (queries != null) { for (final cache in queries.values) { @@ -252,6 +267,7 @@ class ComponentList extends Iterable { component._containerIndex = -1; _length--; _tombstones++; + structureVersion++; final queries = _queries; if (queries != null) { for (final cache in queries.values) { @@ -289,6 +305,7 @@ class ComponentList extends Iterable { _length = 0; _tombstones = 0; _shiftCount++; + structureVersion++; _queries?.forEach((_, cache) => cache.data.clear()); } @@ -312,6 +329,7 @@ class ComponentList extends Iterable { return; } _shiftCount++; + structureVersion++; // Ties are broken by the pre-sort index, which makes the (unstable) // built-in sort behave as a stable sort. elements.sort((a, b) { diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index c2edccab780..e45c1f554ce 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -109,6 +109,55 @@ class ComponentTreeRoot extends Component { bool get hasLifecycleEvents => queue.isNotEmpty; + /// The flattened pre-order list of all components below this root, stopping + /// at (and including) `CustomTraversal` barriers, whose subtrees are + /// traversed by their own `updateSubtree` implementations. + final List _flatUpdateList = []; + + /// The [ComponentList.structureVersion] that [_flatUpdateList] was built + /// against. + int _flatVersion = -1; + + /// Updates every component below this root using the flattened traversal + /// list, rebuilding the list first when the tree structure has possibly + /// changed since the previous tick. + /// + /// The visit order is identical to the recursive + /// `Component.defaultUpdateSubtree` traversal: pre-order, children in + /// priority order. Components mixing in `CustomTraversal` are treated as + /// barriers: they appear in the list themselves, and their `updateSubtree` + /// drives their subtree. + @internal + void updateChildrenFlat(double dt) { + if (_flatVersion != ComponentList.structureVersion) { + _flatVersion = ComponentList.structureVersion; + _flatUpdateList.clear(); + _flattenChildrenOf(this); + } + final list = _flatUpdateList; + for (var i = 0; i < list.length; i++) { + final component = list[i]; + if (component is CustomTraversal) { + component.updateSubtree(dt); + } else { + component.update(dt); + } + } + } + + void _flattenChildrenOf(Component component) { + if (!component.hasChildren) { + return; + } + final children = component.children..compact(); + for (final child in children) { + _flatUpdateList.add(child); + if (child is! CustomTraversal) { + _flattenChildrenOf(child); + } + } + } + /// A future that will complete once all lifecycle events have been /// processed. /// diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index 391d73b2cd1..5d722a88f6f 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -183,9 +183,7 @@ class FlameGame extends ComponentTreeRoot if (parent != null) { update(dt); } - for (final component in children) { - component.updateTree(dt); - } + updateChildrenFlat(dt); } /// This passes the new size along to every component in the tree via their From 088430729a488744995db8c6b5b80ec022e07ee3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:24:38 +0200 Subject: [PATCH 12/26] feat: Add updatePaused for pausing the update pass of a subtree --- doc/flame/examples/lib/router.dart | 2 +- .../collision_detection/quadtree_example.dart | 10 +- .../components/time_scale_example.dart | 9 +- .../layout_component_example_1.dart | 6 +- .../layout_component_example_2.dart | 4 +- .../lib/src/components/core/component.dart | 28 ++++- .../components/core/component_tree_root.dart | 3 + .../components/core/update_paused_test.dart | 100 ++++++++++++++++++ 8 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 packages/flame/test/components/core/update_paused_test.dart diff --git a/doc/flame/examples/lib/router.dart b/doc/flame/examples/lib/router.dart index c72141101a8..ecbf2e2f0e1 100644 --- a/doc/flame/examples/lib/router.dart +++ b/doc/flame/examples/lib/router.dart @@ -422,7 +422,7 @@ class PausePage extends Component void onTapUp(TapUpEvent event) => game.router.pop(); } -class DecoratedWorld extends World with HasTimeScale { +class DecoratedWorld extends World with CustomTraversal, HasTimeScale { PaintDecorator? decorator; @override diff --git a/examples/lib/stories/collision_detection/quadtree_example.dart b/examples/lib/stories/collision_detection/quadtree_example.dart index aacc93e352d..85d9d4b3a50 100644 --- a/examples/lib/stories/collision_detection/quadtree_example.dart +++ b/examples/lib/stories/collision_detection/quadtree_example.dart @@ -310,7 +310,7 @@ class Bullet extends PositionComponent with CollisionCallbacks, HasPaint { //#region Environment class Brick extends SpriteComponent - with CollisionCallbacks, GameCollidable, UpdateOnce { + with CollisionCallbacks, GameCollidable, CustomTraversal, UpdateOnce { Brick({ required super.position, required super.size, @@ -332,7 +332,7 @@ class Brick extends SpriteComponent } class Water extends SpriteComponent - with CollisionCallbacks, GameCollidable, UpdateOnce { + with CollisionCallbacks, GameCollidable, CustomTraversal, UpdateOnce { Water({ required super.position, required super.size, @@ -363,13 +363,13 @@ mixin GameCollidable on PositionComponent { //#region Utils -mixin UpdateOnce on PositionComponent { +mixin UpdateOnce on PositionComponent, CustomTraversal { bool updateOnce = true; @override - void updateTree(double dt) { + void updateSubtree(double dt) { if (updateOnce) { - super.updateTree(dt); + super.updateSubtree(dt); updateOnce = false; } } diff --git a/examples/lib/stories/components/time_scale_example.dart b/examples/lib/stories/components/time_scale_example.dart index 567ffcbd957..39d66c90ef3 100644 --- a/examples/lib/stories/components/time_scale_example.dart +++ b/examples/lib/stories/components/time_scale_example.dart @@ -75,7 +75,10 @@ class TimeScaleExample extends FlameGame } class _Chopper extends SpriteAnimationComponent - with HasGameReference, CollisionCallbacks { + with + HasGameReference, + CollisionCallbacks, + CustomTraversal { _Chopper({ super.animation, super.position, @@ -102,9 +105,9 @@ class _Chopper extends SpriteAnimationComponent } @override - void updateTree(double dt) { + void updateSubtree(double dt) { position.setFrom(position + _moveDirection * _speed * dt); - super.updateTree(dt); + super.updateSubtree(dt); } @override diff --git a/examples/lib/stories/experimental/layout_component_example_1.dart b/examples/lib/stories/experimental/layout_component_example_1.dart index 90e288d9583..f7ad4de8361 100644 --- a/examples/lib/stories/experimental/layout_component_example_1.dart +++ b/examples/lib/stories/experimental/layout_component_example_1.dart @@ -99,7 +99,7 @@ class LayoutDemo1 extends LinearLayoutComponent { _expandedMode = value; removeAll(children.toList()); addAll( - createComponentList( + createLayoutChildren( expandedMode: expandedMode, padding: padding, inflateChild: paddingInflateChild, @@ -122,7 +122,7 @@ class LayoutDemo1 extends LinearLayoutComponent { FutureOr onLoad() { super.onLoad(); addAll( - createComponentList( + createLayoutChildren( expandedMode: expandedMode, padding: padding, inflateChild: paddingInflateChild, @@ -137,7 +137,7 @@ class LayoutDemo1 extends LinearLayoutComponent { /// This needs to be a method rather than a static list /// because each of these components needs to be recreated. /// Otherwise, they'll be operated on by reference and re-parented. - static List createComponentList({ + static List createLayoutChildren({ required bool expandedMode, required EdgeInsets padding, required bool inflateChild, diff --git a/examples/lib/stories/experimental/layout_component_example_2.dart b/examples/lib/stories/experimental/layout_component_example_2.dart index 8b75ec7f331..15a5ebba3f7 100644 --- a/examples/lib/stories/experimental/layout_component_example_2.dart +++ b/examples/lib/stories/experimental/layout_component_example_2.dart @@ -82,7 +82,7 @@ class LayoutDemo2 extends LinearLayoutComponent { FutureOr onLoad() { super.onLoad(); addAll( - createComponentList( + createLayoutChildren( direction: direction, ), ); @@ -95,7 +95,7 @@ class LayoutDemo2 extends LinearLayoutComponent { /// This needs to be a method rather than a static list /// because each of these components needs to be recreated. /// Otherwise, they'll be operated on by reference and re-parented. - static List createComponentList({ + static List createLayoutChildren({ required Direction direction, }) { return [ diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index d34de80386a..e026c1b62da 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -571,6 +571,9 @@ class Component { /// instead of silently skipping their custom logic. @nonVirtual void updateTree(double dt) { + if (_updatePaused) { + return; + } final self = this; if (self is CustomTraversal) { self.updateSubtree(dt); @@ -579,6 +582,26 @@ class Component { } } + /// Whether the update pass is paused for this component and its entire + /// subtree. + /// + /// While `true`, neither this component's [update] nor any update of its + /// descendants will run; rendering and event handling continue as usual. + /// This is a lighter-weight alternative to removing the subtree, and is + /// unrelated to `Game.paused`, which stops the whole game loop. + /// + /// Toggling this takes effect from the next update pass. + bool get updatePaused => _updatePaused; + bool _updatePaused = false; + set updatePaused(bool value) { + if (_updatePaused != value) { + _updatePaused = value; + // The flattened update list excludes paused subtrees, so a toggle must + // invalidate it. + ComponentList.structureVersion++; + } + } + /// The engine's standard update traversal: update this component, then /// update the children in priority order. /// @@ -594,7 +617,10 @@ class Component { children._compact(); final elements = children._elements; for (var i = 0; i < elements.length; i++) { - elements[i]?.updateTree(dt); + final child = elements[i]; + if (child != null && !child._updatePaused) { + child.updateTree(dt); + } } } } diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index e45c1f554ce..1af4bdca9b5 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -151,6 +151,9 @@ class ComponentTreeRoot extends Component { } final children = component.children..compact(); for (final child in children) { + if (child.updatePaused) { + continue; + } _flatUpdateList.add(child); if (child is! CustomTraversal) { _flattenChildrenOf(child); diff --git a/packages/flame/test/components/core/update_paused_test.dart b/packages/flame/test/components/core/update_paused_test.dart new file mode 100644 index 00000000000..99715f93df3 --- /dev/null +++ b/packages/flame/test/components/core/update_paused_test.dart @@ -0,0 +1,100 @@ +import 'dart:ui'; + +import 'package:canvas_test/canvas_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('updatePaused', () { + testWithFlameGame('pauses the component and its subtree', (game) async { + final child = _Counter(); + final parent = _Counter(children: [child]); + final sibling = _Counter(); + await game.world.ensureAddAll([parent, sibling]); + + game.update(0); + expect(parent.updates, 1); + expect(child.updates, 1); + expect(sibling.updates, 1); + + parent.updatePaused = true; + game.update(0); + game.update(0); + expect(parent.updates, 1); + expect(child.updates, 1); + expect(sibling.updates, 3); + + parent.updatePaused = false; + game.update(0); + expect(parent.updates, 2); + expect(child.updates, 2); + expect(sibling.updates, 4); + }); + + testWithFlameGame('paused components still render', (game) async { + final component = _Counter(); + await game.world.ensureAdd(component); + component.updatePaused = true; + + game.update(0); + game.render(MockCanvas()); + expect(component.updates, 0); + expect(component.renders, 1); + }); + + testWithFlameGame('pause works inside a custom traversal subtree', ( + game, + ) async { + final pausable = _Counter(); + final running = _Counter(); + final barrier = _ScaledBarrier(children: [pausable, running]); + await game.world.ensureAdd(barrier); + + pausable.updatePaused = true; + game.update(0); + expect(pausable.updates, 0); + expect(running.updates, 1); + }); + + testWithFlameGame('lifecycle events still process while paused', ( + game, + ) async { + final parent = _Counter(); + await game.world.ensureAdd(parent); + parent.updatePaused = true; + + final child = _Counter(); + parent.add(child); + game.update(0); + await game.ready(); + + expect(child.isMounted, isTrue); + expect(child.updates, 0); + }); + }); +} + +class _Counter extends Component { + _Counter({super.children}); + + int updates = 0; + int renders = 0; + + @override + void update(double dt) { + updates++; + } + + @override + void render(Canvas canvas) { + renders++; + } +} + +class _ScaledBarrier extends Component with CustomTraversal { + _ScaledBarrier({super.children}); + + @override + void updateSubtree(double dt) => super.updateSubtree(dt * 2); +} From 8fabe0825fe22033903d793d021d0b1b03e233f0 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:25:19 +0200 Subject: [PATCH 13/26] docs: Document CustomTraversal, updatePaused, and the HasTimeScale requirement --- doc/flame/components/components.md | 31 ++++++++++++++++++++++++++++++ doc/flame/other/util.md | 4 +++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index 3590ca1da0f..c85a102e320 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -146,6 +146,37 @@ class MyComponent extends PositionComponent with TapCallbacks { ``` +### Custom update traversal and pausing + +The engine drives the update pass through a flattened traversal list owned by the game, so +`updateTree` is non-virtual and cannot be overridden. Components that need to control how their +subtree is updated (changing the effective `dt`, skipping children, or updating them manually) +should mix in `CustomTraversal` and override its `updateSubtree` method: + +```dart +class SlowMotionArea extends Component with CustomTraversal { + @override + void updateSubtree(double dt) => super.updateSubtree(dt / 2); +} +``` + +The engine treats every `CustomTraversal` component as a traversal barrier: it appears in the +flattened list itself and its `updateSubtree` drives its subtree. Mixins that compose with other +custom traversals (like `HasTimeScale`) are declared `on CustomTraversal` and chain via +`super.updateSubtree`. + +To temporarily stop updating a component and its whole subtree, set `updatePaused` to true. While +paused, no `update` calls happen in the subtree, but rendering and event handling continue, and +pending lifecycle events (adds and removes) are still processed: + +```dart +enemySquad.updatePaused = true; // freeze the squad +enemySquad.updatePaused = false; // resume it +``` + +This is unrelated to `Game.paused`, which stops the whole game loop including rendering. + + ### Composability of components Sometimes it is useful to wrap other components inside of your component. For example by grouping diff --git a/doc/flame/other/util.md b/doc/flame/other/util.md index aa7111dd722..f0d8da2afcd 100644 --- a/doc/flame/other/util.md +++ b/doc/flame/other/util.md @@ -165,7 +165,9 @@ game events. A very common approach to achieve these results is to manipulate th tick rate. To make this manipulation easier, Flame provides a `HasTimeScale` mixin. This mixin can be attached -to any Flame `Component` and exposes a simple get/set API for `timeScale`. The default value of +to any Flame `Component` that has the `CustomTraversal` mixin (so a component declares +`with CustomTraversal, HasTimeScale`; `FlameGame` already has `CustomTraversal` built in) and +exposes a simple get/set API for `timeScale`. The default value of `timeScale` is `1`, implying in-game time of the component is running at the same speed as real life time. Setting it to `2` will make the component tick twice as fast and setting it to `0.5` will make it tick at half the speed as compared to real life time. This mixin also provides `pause` and `resume` From 800beda65e97a796937c54eeb8e5601952b7d20a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:29:12 +0200 Subject: [PATCH 14/26] perf: Rebuild the flat update list through internal arrays and cache barrier flags --- .../lib/src/components/core/component.dart | 43 +++++++++++++++++++ .../src/components/core/component_list.dart | 5 --- .../components/core/component_tree_root.dart | 28 +----------- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index e026c1b62da..90d72ed0ff0 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -602,6 +602,49 @@ class Component { } } + /// Whether this component manages its own subtree traversal. Evaluated + /// once, so that the per-frame traversal loops pay a field load instead of + /// a type check. + late final bool _isTraversalBarrier = this is CustomTraversal; + + /// Appends this component's subtree to [out] in pre-order (children in + /// priority order), stopping at (but including) `CustomTraversal` barriers + /// and skipping paused subtrees. Used by the root to build its flattened + /// update list. + @internal + void flattenUpdateSubtreeInto(List out) { + final children = _children; + if (children == null) { + return; + } + children._compact(); + final elements = children._elements; + for (var i = 0; i < elements.length; i++) { + final child = elements[i]; + if (child == null || child._updatePaused) { + continue; + } + out.add(child); + if (!child._isTraversalBarrier) { + child.flattenUpdateSubtreeInto(out); + } + } + } + + /// Runs one update pass over a flattened traversal list produced by + /// [flattenUpdateSubtreeInto]. + @internal + static void updateFlatList(List list, double dt) { + for (var i = 0; i < list.length; i++) { + final component = list[i]; + if (component._isTraversalBarrier) { + (component as CustomTraversal).updateSubtree(dt); + } else { + component.update(dt); + } + } + } + /// The engine's standard update traversal: update this component, then /// update the children in priority order. /// diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index d74810370ca..3ba29dfdd5a 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -100,11 +100,6 @@ class ComponentList extends Iterable { @internal static int structureVersion = 0; - /// Compacts the tombstones out of the backing array, restoring exact - /// element indices. Safe to call only when no iteration is in progress. - @internal - void compact() => _compact(); - @override int get length => _length; diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index 1af4bdca9b5..a847b0e912f 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -132,33 +132,9 @@ class ComponentTreeRoot extends Component { if (_flatVersion != ComponentList.structureVersion) { _flatVersion = ComponentList.structureVersion; _flatUpdateList.clear(); - _flattenChildrenOf(this); - } - final list = _flatUpdateList; - for (var i = 0; i < list.length; i++) { - final component = list[i]; - if (component is CustomTraversal) { - component.updateSubtree(dt); - } else { - component.update(dt); - } - } - } - - void _flattenChildrenOf(Component component) { - if (!component.hasChildren) { - return; - } - final children = component.children..compact(); - for (final child in children) { - if (child.updatePaused) { - continue; - } - _flatUpdateList.add(child); - if (child is! CustomTraversal) { - _flattenChildrenOf(child); - } + flattenUpdateSubtreeInto(_flatUpdateList); } + Component.updateFlatList(_flatUpdateList, dt); } /// A future that will complete once all lifecycle events have been From 9fcefd17628a6b4cb60f4f4a306ab9233ad4b484 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:39:24 +0200 Subject: [PATCH 15/26] perf: Fuse the flat-list rebuild into the update pass and remove hot-path allocations --- .../lib/src/components/core/component.dart | 59 ++++++++++--------- .../src/components/core/component_list.dart | 25 +++++--- .../components/core/component_tree_root.dart | 8 ++- 3 files changed, 55 insertions(+), 37 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 90d72ed0ff0..e73f8167c2c 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -73,6 +73,7 @@ class Component { int? priority, this.key, }) : _priority = priority ?? 0 { + _isTraversalBarrier = this is CustomTraversal; if (children != null) { addAll(children); } @@ -574,9 +575,8 @@ class Component { if (_updatePaused) { return; } - final self = this; - if (self is CustomTraversal) { - self.updateSubtree(dt); + if (_isTraversalBarrier) { + (this as CustomTraversal).updateSubtree(dt); } else { defaultUpdateSubtree(dt); } @@ -603,16 +603,32 @@ class Component { } /// Whether this component manages its own subtree traversal. Evaluated - /// once, so that the per-frame traversal loops pay a field load instead of - /// a type check. - late final bool _isTraversalBarrier = this is CustomTraversal; - - /// Appends this component's subtree to [out] in pre-order (children in - /// priority order), stopping at (but including) `CustomTraversal` barriers - /// and skipping paused subtrees. Used by the root to build its flattened - /// update list. + /// once in the constructor, so that the per-frame traversal loops pay a + /// plain field load instead of a type check. + bool _isTraversalBarrier = false; + + /// Runs one update pass over a flattened traversal list produced by + /// [updateAndFlattenInto]. + @internal + static void updateFlatList(List list, double dt) { + for (var i = 0; i < list.length; i++) { + final component = list[i]; + if (component._isTraversalBarrier) { + (component as CustomTraversal).updateSubtree(dt); + } else { + component.update(dt); + } + } + } + + /// Combined update pass and flatten: updates this component's subtree + /// recursively while appending the visited components to [out], in + /// pre-order with children in priority order, stopping at (but including) + /// `CustomTraversal` barriers and skipping paused subtrees. Used by the + /// root on ticks where the structure changed, so that the flat-list + /// rebuild does not cost a separate pass over the tree. @internal - void flattenUpdateSubtreeInto(List out) { + void updateAndFlattenInto(List out, double dt) { final children = _children; if (children == null) { return; @@ -625,22 +641,11 @@ class Component { continue; } out.add(child); - if (!child._isTraversalBarrier) { - child.flattenUpdateSubtreeInto(out); - } - } - } - - /// Runs one update pass over a flattened traversal list produced by - /// [flattenUpdateSubtreeInto]. - @internal - static void updateFlatList(List list, double dt) { - for (var i = 0; i < list.length; i++) { - final component = list[i]; - if (component._isTraversalBarrier) { - (component as CustomTraversal).updateSubtree(dt); + if (child._isTraversalBarrier) { + (child as CustomTraversal).updateSubtree(dt); } else { - component.update(dt); + child.update(dt); + child.updateAndFlattenInto(out, dt); } } } diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 3ba29dfdd5a..0fc05a2ec21 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -88,9 +88,14 @@ class ComponentList extends Iterable { /// dense array anyway). static const int _tombstoneCompactionThreshold = 16; - /// The per-type query caches, created by [register]. + /// The per-type query caches, created by [register], keyed by type for + /// [query] lookups. Map>? _queries; + /// The same caches as [_queries], as a list, so that the add/remove hot + /// paths can iterate them without allocating a map-values iterator. + List<_QueryCache>? _queryCaches; + /// A monotonically increasing counter, bumped on every membership or order /// change of any [ComponentList] (adds, removes, clears, reorders). The /// root's flattened update list compares against this to know when it must @@ -212,9 +217,10 @@ class ComponentList extends Iterable { component._containerList = this; _length++; structureVersion++; - final queries = _queries; - if (queries != null) { - for (final cache in queries.values) { + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + final cache = caches[i]; if (cache.check(component)) { cache.insertSorted(component); } @@ -263,9 +269,10 @@ class ComponentList extends Iterable { _length--; _tombstones++; structureVersion++; - final queries = _queries; - if (queries != null) { - for (final cache in queries.values) { + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + final cache = caches[i]; if (cache.check(component)) { cache.data.remove(component); } @@ -383,7 +390,9 @@ class ComponentList extends Iterable { data.add(element); } } - queries[C] = _QueryCache(data); + final cache = _QueryCache(data); + queries[C] = cache; + (_queryCaches ??= []).add(cache); } /// All elements of type [C], in priority order, in O(1). diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index a847b0e912f..bc2c5a17a3f 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -130,11 +130,15 @@ class ComponentTreeRoot extends Component { @internal void updateChildrenFlat(double dt) { if (_flatVersion != ComponentList.structureVersion) { + // The version is captured before the pass: structural changes made by + // update callbacks (pause toggles, detached-tree edits) invalidate the + // list that is being built and must trigger a rebuild next tick. _flatVersion = ComponentList.structureVersion; _flatUpdateList.clear(); - flattenUpdateSubtreeInto(_flatUpdateList); + updateAndFlattenInto(_flatUpdateList, dt); + } else { + Component.updateFlatList(_flatUpdateList, dt); } - Component.updateFlatList(_flatUpdateList, dt); } /// A future that will complete once all lifecycle events have been From 845b90d8414bac24ba2e36917a81a545206c24dc Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:48:46 +0200 Subject: [PATCH 16/26] perf: Insertion-sort the sweep broadphase, cache camera render closures, trim hot-path overhead --- .../lib/src/camera/camera_component.dart | 36 +++++++++++-------- .../collisions/broadphase/sweep/sweep.dart | 21 +++++++++-- .../lib/src/components/core/component.dart | 6 ++-- .../components/core/component_tree_root.dart | 3 +- 4 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/flame/lib/src/camera/camera_component.dart b/packages/flame/lib/src/camera/camera_component.dart index 8b0998f77a7..b96dd9a1c5c 100644 --- a/packages/flame/lib/src/camera/camera_component.dart +++ b/packages/flame/lib/src/camera/camera_component.dart @@ -203,16 +203,6 @@ class CameraComponent extends Component { canvas.save(); try { currentCameras.add(this); - void renderWorld(Canvas canvas) { - canvas.transform2D(viewfinder.transform); - world!.renderFromCamera(canvas); - - // Render the viewfinder elements, which will be in front of - // the world, - // but with the same transforms applied to them. - viewfinder.renderTree(canvas); - } - final postProcessors = children.query(); if (postProcessors.isNotEmpty) { assert( @@ -223,13 +213,11 @@ class CameraComponent extends Component { postProcessor.render( canvas, viewport.virtualSize, - renderWorld, - (context) { - renderContext.currentPostProcess = context; - }, + _renderWorld, + _updatePostProcessContext, ); } else { - renderWorld(canvas); + _renderWorld(canvas); } } finally { currentCameras.removeLast(); @@ -242,6 +230,24 @@ class CameraComponent extends Component { canvas.restore(); } + /// Renders the world and the viewfinder elements through the camera + /// transform. An instance method rather than a local function, so that the + /// render pass does not allocate a closure per camera per frame. + void _renderWorld(Canvas canvas) { + canvas.transform2D(viewfinder.transform); + world!.renderFromCamera(canvas); + // Render the viewfinder elements, which will be in front of the world, + // but with the same transforms applied to them. + viewfinder.renderTree(canvas); + } + + // Not a setter: this is passed as a `ValueSetter` tear-off to + // `PostProcess.render`. + // ignore: use_setters_to_change_properties + void _updatePostProcessContext(PostProcess? context) { + renderContext.currentPostProcess = context; + } + /// Converts from the global (canvas) coordinate space to /// local (camera = viewport + viewfinder). /// diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index f79cc6e33e0..f5962fa0452 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -17,7 +17,21 @@ class Sweep> extends Broadphase { @override void update() { - items.sort((a, b) => a.aabb.min.x.compareTo(b.aabb.min.x)); + // Between two ticks the hitboxes only move a little, so [items] is + // always nearly sorted: an insertion sort runs in close to linear time + // here, where a general-purpose sort would pay its full O(n log n) on + // every tick. This also avoids allocating a comparator closure per tick. + final items = this.items; + for (var i = 1; i < items.length; i++) { + final item = items[i]; + final key = item.aabb.min.x; + var j = i - 1; + while (j >= 0 && items[j].aabb.min.x > key) { + items[j + 1] = items[j]; + j--; + } + items[j + 1] = item; + } } @override @@ -44,7 +58,10 @@ class Sweep> extends Broadphase { _prospectPool.acquire(item, activeItem); } } else { - _active.remove(activeItem); + // The order of the active list does not matter, so the removal can + // swap in the last element instead of searching and shifting. + _active[i] = _active.last; + _active.removeLast(); } } _active.add(item); diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index e73f8167c2c..44b3516cf91 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -693,7 +693,7 @@ class Component { child.renderTree(canvas); return; } - final childContexts = child._renderContexts ??= QueueList(); + final childContexts = child._renderContexts ??= []; final originalLength = childContexts.length; childContexts.addAll(contexts); child.renderTree(canvas); @@ -710,7 +710,7 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; if (context != null) { - (_renderContexts ??= QueueList()).add(context); + (_renderContexts ??= []).add(context); } render(canvas); @@ -1279,7 +1279,7 @@ class Component { /// The stack of render contexts inherited from ancestors during the render /// pass. Created lazily: most components never provide or receive one. - QueueList? _renderContexts; + List? _renderContexts; /// Override this method if you want your component to provide a custom /// render context to all its children (recursively). diff --git a/packages/flame/lib/src/components/core/component_tree_root.dart b/packages/flame/lib/src/components/core/component_tree_root.dart index bc2c5a17a3f..4cdd8b470a3 100644 --- a/packages/flame/lib/src/components/core/component_tree_root.dart +++ b/packages/flame/lib/src/components/core/component_tree_root.dart @@ -189,7 +189,8 @@ class ComponentTreeRoot extends Component { for (final event in queue) { final child = event.child!; final parent = event.parent!; - if (_blocked.contains(child) || _blocked.contains(parent)) { + if (_blocked.isNotEmpty && + (_blocked.contains(child) || _blocked.contains(parent))) { continue; } From ab2440155f0a2f6f2486499ac01c71f4f1fd615a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:51:24 +0200 Subject: [PATCH 17/26] refactor!: Move updateSubtree onto Component so traversal mixins carry the CustomTraversal marker --- doc/flame/components/components.md | 7 ++-- doc/flame/examples/lib/router.dart | 2 +- doc/flame/other/util.md | 4 +- .../collision_detection/quadtree_example.dart | 6 +-- .../stories/router/router_world_example.dart | 2 +- .../children_traversal_benchmark.dart | 2 +- .../lib/src/components/core/component.dart | 19 ++++++++-- .../src/components/core/custom_traversal.dart | 38 ++++++++----------- .../src/components/mixins/has_time_scale.dart | 3 +- .../lib/src/components/router/route.dart | 2 +- .../mixins/has_time_scale_test.dart | 3 +- 11 files changed, 47 insertions(+), 41 deletions(-) diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index c85a102e320..88e73120bd5 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -161,9 +161,10 @@ class SlowMotionArea extends Component with CustomTraversal { ``` The engine treats every `CustomTraversal` component as a traversal barrier: it appears in the -flattened list itself and its `updateSubtree` drives its subtree. Mixins that compose with other -custom traversals (like `HasTimeScale`) are declared `on CustomTraversal` and chain via -`super.updateSubtree`. +flattened list itself and its `updateSubtree` drives its subtree. `updateSubtree` lives on +`Component`, but it is only invoked for components carrying the marker. Mixins that provide a +custom traversal (like `HasTimeScale`) declare `implements CustomTraversal`, so their users do not +need to add the marker themselves, and chain via `super.updateSubtree`. To temporarily stop updating a component and its whole subtree, set `updatePaused` to true. While paused, no `update` calls happen in the subtree, but rendering and event handling continue, and diff --git a/doc/flame/examples/lib/router.dart b/doc/flame/examples/lib/router.dart index ecbf2e2f0e1..c72141101a8 100644 --- a/doc/flame/examples/lib/router.dart +++ b/doc/flame/examples/lib/router.dart @@ -422,7 +422,7 @@ class PausePage extends Component void onTapUp(TapUpEvent event) => game.router.pop(); } -class DecoratedWorld extends World with CustomTraversal, HasTimeScale { +class DecoratedWorld extends World with HasTimeScale { PaintDecorator? decorator; @override diff --git a/doc/flame/other/util.md b/doc/flame/other/util.md index f0d8da2afcd..aa7111dd722 100644 --- a/doc/flame/other/util.md +++ b/doc/flame/other/util.md @@ -165,9 +165,7 @@ game events. A very common approach to achieve these results is to manipulate th tick rate. To make this manipulation easier, Flame provides a `HasTimeScale` mixin. This mixin can be attached -to any Flame `Component` that has the `CustomTraversal` mixin (so a component declares -`with CustomTraversal, HasTimeScale`; `FlameGame` already has `CustomTraversal` built in) and -exposes a simple get/set API for `timeScale`. The default value of +to any Flame `Component` and exposes a simple get/set API for `timeScale`. The default value of `timeScale` is `1`, implying in-game time of the component is running at the same speed as real life time. Setting it to `2` will make the component tick twice as fast and setting it to `0.5` will make it tick at half the speed as compared to real life time. This mixin also provides `pause` and `resume` diff --git a/examples/lib/stories/collision_detection/quadtree_example.dart b/examples/lib/stories/collision_detection/quadtree_example.dart index 85d9d4b3a50..d35ca8467a6 100644 --- a/examples/lib/stories/collision_detection/quadtree_example.dart +++ b/examples/lib/stories/collision_detection/quadtree_example.dart @@ -310,7 +310,7 @@ class Bullet extends PositionComponent with CollisionCallbacks, HasPaint { //#region Environment class Brick extends SpriteComponent - with CollisionCallbacks, GameCollidable, CustomTraversal, UpdateOnce { + with CollisionCallbacks, GameCollidable, UpdateOnce { Brick({ required super.position, required super.size, @@ -332,7 +332,7 @@ class Brick extends SpriteComponent } class Water extends SpriteComponent - with CollisionCallbacks, GameCollidable, CustomTraversal, UpdateOnce { + with CollisionCallbacks, GameCollidable, UpdateOnce { Water({ required super.position, required super.size, @@ -363,7 +363,7 @@ mixin GameCollidable on PositionComponent { //#region Utils -mixin UpdateOnce on PositionComponent, CustomTraversal { +mixin UpdateOnce on PositionComponent implements CustomTraversal { bool updateOnce = true; @override diff --git a/examples/lib/stories/router/router_world_example.dart b/examples/lib/stories/router/router_world_example.dart index a6f69c3aecb..f908a721e53 100644 --- a/examples/lib/stories/router/router_world_example.dart +++ b/examples/lib/stories/router/router_world_example.dart @@ -433,7 +433,7 @@ class PausePage extends Component void onTapUp(TapUpEvent event) => game.router.pop(); } -class DecoratedWorld extends World with CustomTraversal, HasTimeScale { +class DecoratedWorld extends World with HasTimeScale { PaintDecorator? decorator; @override diff --git a/packages/flame/benchmark/children_traversal_benchmark.dart b/packages/flame/benchmark/children_traversal_benchmark.dart index c7d91640a03..b8f7badff1d 100644 --- a/packages/flame/benchmark/children_traversal_benchmark.dart +++ b/packages/flame/benchmark/children_traversal_benchmark.dart @@ -185,7 +185,7 @@ class BarrierTreeUpdateBenchmark extends _TraversalBenchmark { } } -class _TimeScaledParent extends Component with CustomTraversal, HasTimeScale { +class _TimeScaledParent extends Component with HasTimeScale { _TimeScaledParent({super.children}); } diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 44b3516cf91..facdae82b8c 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -576,12 +576,25 @@ class Component { return; } if (_isTraversalBarrier) { - (this as CustomTraversal).updateSubtree(dt); + updateSubtree(dt); } else { defaultUpdateSubtree(dt); } } + /// Updates this component and its subtree. + /// + /// The engine only invokes this method for components that are marked with + /// the [CustomTraversal] mixin, either directly or through a mixin that + /// `implements` it (such as `HasTimeScale`). The marker is what makes the + /// flattened update pass treat the component as a traversal barrier; + /// overriding this method without the marker has no effect. + /// + /// Call `super.updateSubtree` to run the surrounding traversal (the + /// standard one, or the next custom traversal in the mixin chain), + /// possibly with a modified time delta. + void updateSubtree(double dt) => defaultUpdateSubtree(dt); + /// Whether the update pass is paused for this component and its entire /// subtree. /// @@ -614,7 +627,7 @@ class Component { for (var i = 0; i < list.length; i++) { final component = list[i]; if (component._isTraversalBarrier) { - (component as CustomTraversal).updateSubtree(dt); + component.updateSubtree(dt); } else { component.update(dt); } @@ -642,7 +655,7 @@ class Component { } out.add(child); if (child._isTraversalBarrier) { - (child as CustomTraversal).updateSubtree(dt); + child.updateSubtree(dt); } else { child.update(dt); child.updateAndFlattenInto(out, dt); diff --git a/packages/flame/lib/src/components/core/custom_traversal.dart b/packages/flame/lib/src/components/core/custom_traversal.dart index 5e8f82de52a..6f357f3b5dd 100644 --- a/packages/flame/lib/src/components/core/custom_traversal.dart +++ b/packages/flame/lib/src/components/core/custom_traversal.dart @@ -1,19 +1,17 @@ import 'package:flame/src/components/core/component.dart'; -/// A mixin for components that manage the update traversal of their own -/// subtree, instead of the engine's standard "update self, then update every -/// child in priority order" recursion. +/// The marker mixin for components that manage the update traversal of their +/// own subtree, instead of the engine's standard "update self, then update +/// every child in priority order" recursion. /// -/// Mixing this in is both the capability and the barrier marker: the engine's -/// flattened update pass treats every [CustomTraversal] component as a -/// barrier, calls its [updateSubtree], and lets that method drive the -/// subtree. This is also why [Component.updateTree] is non-virtual: an -/// updateTree override without a marker would be silently skipped by the -/// flattened traversal, whereas overriding [updateSubtree] cannot be. +/// Mix this in (and override [Component.updateSubtree]) to customize the +/// traversal. The mixin is both the capability and the barrier marker: the +/// engine's flattened update pass treats every [CustomTraversal] component +/// as a barrier, calls its [Component.updateSubtree], and lets that method +/// drive the subtree. This is also why [Component.updateTree] is +/// non-virtual: an updateTree override without a marker would be silently +/// skipped by the flattened traversal. /// -/// Override [updateSubtree] to customize the traversal, and call -/// `super.updateSubtree` to run the standard traversal, possibly with a -/// modified time delta: /// ```dart /// class SlowMotionArea extends Component with CustomTraversal { /// @override @@ -21,13 +19,9 @@ import 'package:flame/src/components/core/component.dart'; /// } /// ``` /// -/// Mixins that want to compose with other custom traversals (like -/// `HasTimeScale`) should be declared `on CustomTraversal` and call -/// `super.updateSubtree`, so that they chain instead of replacing each other. -mixin CustomTraversal on Component { - /// Updates this component and its subtree. - /// - /// The default implementation performs the engine's standard traversal: - /// update this component, then update the children in priority order. - void updateSubtree(double dt) => defaultUpdateSubtree(dt); -} +/// Mixins that provide a custom traversal (like `HasTimeScale`) should be +/// declared `on Component implements CustomTraversal`: the `implements` +/// carries the marker, so that their users do not need to add +/// [CustomTraversal] themselves, and `super.updateSubtree` chains through +/// any other custom traversal in the mixin application order. +mixin CustomTraversal on Component {} diff --git a/packages/flame/lib/src/components/mixins/has_time_scale.dart b/packages/flame/lib/src/components/mixins/has_time_scale.dart index 21da01361d9..02a316d870c 100644 --- a/packages/flame/lib/src/components/mixins/has_time_scale.dart +++ b/packages/flame/lib/src/components/mixins/has_time_scale.dart @@ -1,3 +1,4 @@ +import 'package:flame/src/components/core/component.dart'; import 'package:flame/src/components/core/custom_traversal.dart'; /// This mixin allows components to control their speed as compared to the @@ -5,7 +6,7 @@ import 'package:flame/src/components/core/custom_traversal.dart'; /// changes. /// /// Note: Modified [timeScale] will be applied to all children as well. -mixin HasTimeScale on CustomTraversal { +mixin HasTimeScale on Component implements CustomTraversal { /// The ratio of components tick speed and normal tick speed. /// It defaults to 1.0, which means the component moves normally. /// A value of 0.5 means the component moves half the normal speed diff --git a/packages/flame/lib/src/components/router/route.dart b/packages/flame/lib/src/components/router/route.dart index 13ecf6c52f5..a89866e23f2 100644 --- a/packages/flame/lib/src/components/router/route.dart +++ b/packages/flame/lib/src/components/router/route.dart @@ -21,7 +21,7 @@ import 'package:meta/meta.dart'; /// /// Routes are managed by the [RouterComponent] component. class Route extends PositionComponent - with ParentIsA, CustomTraversal, HasTimeScale { + with ParentIsA, HasTimeScale { Route( Component Function()? builder, { this._loadingBuilder, diff --git a/packages/flame/test/components/mixins/has_time_scale_test.dart b/packages/flame/test/components/mixins/has_time_scale_test.dart index 04e3b2f4e6b..44e2596b061 100644 --- a/packages/flame/test/components/mixins/has_time_scale_test.dart +++ b/packages/flame/test/components/mixins/has_time_scale_test.dart @@ -147,8 +147,7 @@ void main() { class _GameWithTimeScale extends FlameGame with HasTimeScale {} -class _ComponentWithTimeScale extends Component - with CustomTraversal, HasTimeScale {} +class _ComponentWithTimeScale extends Component with HasTimeScale {} class _MovingComponent extends PositionComponent { final speed = 1.0; From 8d53fb89f9d7e8f1e5ba901355db829d59c23c9f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 12:57:51 +0200 Subject: [PATCH 18/26] refactor: Use update and updatePaused in examples that do not need a custom traversal --- .../collision_detection/quadtree_example.dart | 15 ++++++++------- .../stories/components/time_scale_example.dart | 9 +++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/examples/lib/stories/collision_detection/quadtree_example.dart b/examples/lib/stories/collision_detection/quadtree_example.dart index d35ca8467a6..9cec3ac5b72 100644 --- a/examples/lib/stories/collision_detection/quadtree_example.dart +++ b/examples/lib/stories/collision_detection/quadtree_example.dart @@ -363,15 +363,16 @@ mixin GameCollidable on PositionComponent { //#region Utils -mixin UpdateOnce on PositionComponent implements CustomTraversal { - bool updateOnce = true; +/// Lets the subtree update once and then pauses it; set [updateOnce] back to +/// true to let it update once more. +mixin UpdateOnce on PositionComponent { + bool get updateOnce => !updatePaused; + set updateOnce(bool value) => updatePaused = !value; @override - void updateSubtree(double dt) { - if (updateOnce) { - super.updateSubtree(dt); - updateOnce = false; - } + void update(double dt) { + super.update(dt); + updatePaused = true; } } diff --git a/examples/lib/stories/components/time_scale_example.dart b/examples/lib/stories/components/time_scale_example.dart index 39d66c90ef3..53f68da120d 100644 --- a/examples/lib/stories/components/time_scale_example.dart +++ b/examples/lib/stories/components/time_scale_example.dart @@ -75,10 +75,7 @@ class TimeScaleExample extends FlameGame } class _Chopper extends SpriteAnimationComponent - with - HasGameReference, - CollisionCallbacks, - CustomTraversal { + with HasGameReference, CollisionCallbacks { _Chopper({ super.animation, super.position, @@ -105,9 +102,9 @@ class _Chopper extends SpriteAnimationComponent } @override - void updateSubtree(double dt) { + void update(double dt) { position.setFrom(position + _moveDirection * _speed * dt); - super.updateSubtree(dt); + super.update(dt); } @override From d652a6c52ca75b1053a4a7dfd7a54062402d598f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:06:31 +0200 Subject: [PATCH 19/26] refactor!: Base Route.stopTime on updatePaused instead of zeroing timeScale --- .../lib/src/components/router/route.dart | 31 +++++++++---------- .../flame/test/components/route_test.dart | 9 ++++-- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/flame/lib/src/components/router/route.dart b/packages/flame/lib/src/components/router/route.dart index a89866e23f2..d76d4adcf06 100644 --- a/packages/flame/lib/src/components/router/route.dart +++ b/packages/flame/lib/src/components/router/route.dart @@ -90,16 +90,20 @@ class Route extends PositionComponent /// Completely stops time for the managed page. /// - /// When the time is stopped, the [updateTree] method of the page is not - /// called at all, which can save computational resources. However, this also - /// means that the lifecycle events on the page will not be processed, and - /// therefore no components will be able to be added or removed from the - /// page. - void stopTime() => timeScale = 0; - - /// Resumes normal time progression for the page, if it was previously slowed - /// down or stopped. - void resumeTime() => timeScale = 1.0; + /// While stopped, neither the page nor any of its descendants are updated + /// at all (see [Component.updatePaused]), which saves computational + /// resources. The page keeps rendering, and pending lifecycle events + /// (adding or removing components) still complete. The [timeScale] is left + /// untouched, so a slow-motion factor survives a stop/resume cycle of the + /// route below a popup. + void stopTime() => updatePaused = true; + + /// Resumes normal time progression for the page, if it was previously + /// slowed down or stopped. + void resumeTime() { + updatePaused = false; + timeScale = 1.0; + } /// Applies the provided [Decorator] to the page. /// @@ -173,13 +177,6 @@ class Route extends PositionComponent } } - @override - void updateSubtree(double dt) { - if (timeScale > 0) { - super.updateSubtree(dt); - } - } - @override Iterable componentsAtLocation( T locationContext, diff --git a/packages/flame/test/components/route_test.dart b/packages/flame/test/components/route_test.dart index 2098ade5b03..b1e92da650c 100644 --- a/packages/flame/test/components/route_test.dart +++ b/packages/flame/test/components/route_test.dart @@ -275,18 +275,23 @@ void main() { router.pushNamed('pause'); await game.ready(); expect(router.currentRoute.name, 'pause'); - expect(router.previousRoute!.timeScale, 0); + expect(router.previousRoute!.updatePaused, isTrue); + expect(router.previousRoute!.timeScale, 1); game.update(10); expect(timer.elapsedTime, 1); - router.previousRoute!.timeScale = 0.1; + // Resuming into slow motion: unpause and slow the route down. + router.previousRoute! + ..updatePaused = false + ..timeScale = 0.1; game.update(10); expect(timer.elapsedTime, 2); router.pop(); await game.ready(); expect(router.currentRoute.name, 'start'); + expect(router.currentRoute.updatePaused, isFalse); expect(router.currentRoute.timeScale, 1); game.update(10); From 7d2a4e720d6a08c22101bdef0f17d67ea64f3096 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:45:26 +0200 Subject: [PATCH 20/26] style: Spell out abbreviated identifiers introduced by this branch --- .../collisions/broadphase/sweep/sweep.dart | 12 ++--- .../src/components/core/component_list.dart | 44 +++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index f5962fa0452..e446d5ce2b3 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -24,13 +24,13 @@ class Sweep> extends Broadphase { final items = this.items; for (var i = 1; i < items.length; i++) { final item = items[i]; - final key = item.aabb.min.x; - var j = i - 1; - while (j >= 0 && items[j].aabb.min.x > key) { - items[j + 1] = items[j]; - j--; + final minX = item.aabb.min.x; + var previousIndex = i - 1; + while (previousIndex >= 0 && items[previousIndex].aabb.min.x > minX) { + items[previousIndex + 1] = items[previousIndex]; + previousIndex--; } - items[j + 1] = item; + items[previousIndex + 1] = item; } } diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 0fc05a2ec21..8581c75a5b2 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -58,11 +58,11 @@ class ComponentList extends Iterable { int _compareOrder(Component a, Component b) { final comparator = this.comparator; if (comparator == null) { - final ap = a._priority; - final bp = b._priority; - return ap < bp + final priorityA = a._priority; + final priorityB = b._priority; + return priorityA < priorityB ? -1 - : ap > bp + : priorityA > priorityB ? 1 : 0; } @@ -234,19 +234,19 @@ class ComponentList extends Iterable { void _insertSorted(Component component) { _compact(); final elements = _elements; - var lo = 0; - var hi = elements.length; - while (lo < hi) { - final mid = (lo + hi) >>> 1; - if (_compareOrder(elements[mid]!, component) <= 0) { - lo = mid + 1; + var low = 0; + var high = elements.length; + while (low < high) { + final middle = (low + high) >>> 1; + if (_compareOrder(elements[middle]!, component) <= 0) { + low = middle + 1; } else { - hi = mid; + high = middle; } } - elements.insert(lo, component); - component._containerIndex = lo; - for (var i = lo + 1; i < elements.length; i++) { + elements.insert(low, component); + component._containerIndex = low; + for (var i = low + 1; i < elements.length; i++) { elements[i]!._containerIndex = i; } _shiftCount++; @@ -530,17 +530,17 @@ class _QueryCache { list.add(component as C); return; } - var lo = 0; - var hi = list.length; - while (lo < hi) { - final mid = (lo + hi) >>> 1; - if (list[mid]._containerIndex < index) { - lo = mid + 1; + var low = 0; + var high = list.length; + while (low < high) { + final middle = (low + high) >>> 1; + if (list[middle]._containerIndex < index) { + low = middle + 1; } else { - hi = mid; + high = middle; } } - list.insert(lo, component as C); + list.insert(low, component as C); } /// Re-sorts the cache after the main array has been re-sorted (at which From 7e3569ffb490b3f5d755625aeda3e28c2b01aef9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:46:57 +0200 Subject: [PATCH 21/26] style: Finish the set-to-list rename in names and docs --- .../lib/src/components/core/component.dart | 10 ++-- .../src/components/core/component_list.dart | 58 +++++++++---------- 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index facdae82b8c..6362dff944f 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -273,9 +273,9 @@ class Component { /// /// ```dart /// coin.parent = inventory; - /// // The inventory.children set does not include coin yet. + /// // The inventory.children list does not include coin yet. /// await game.lifecycleEventsProcessed; - /// // The inventory.children set now includes coin. + /// // The inventory.children list now includes coin. /// ``` Component? get parent => _parent; Component? _parent; @@ -288,7 +288,7 @@ class Component { } /// This field should be used internally for functionality when you don't need - /// to create a component set for the children if one doesn't already exist. + /// to create a children list if one doesn't already exist. /// /// This makes it possible to have lighter components that don't have any /// children. @@ -304,7 +304,7 @@ class Component { int _containerIndex = -1; /// This field should be used internally for functionality when you need to - /// make sure that the component set is created if it doesn't already exist. + /// make sure that the children list is created if it doesn't already exist. ComponentList get _internalChildren => _children ??= createComponentList(); /// Restores the priority ordering of the [children], after one or more of @@ -515,7 +515,7 @@ class Component { /// its [children] yet. /// /// After this method completes, the component is added to the parent's - /// children set, and then the flag [isMounted] set to true. + /// children list, and then the flag [isMounted] set to true. /// /// Example: /// ```dart diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 8581c75a5b2..2227c9de53e 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -84,7 +84,7 @@ class ComponentList extends Iterable { int _shiftCount = 0; /// Compaction is deferred until this many tombstones have accumulated - /// (unless the whole set empties out, or a structural operation needs a + /// (unless the whole list empties out, or a structural operation needs a /// dense array anyway). static const int _tombstoneCompactionThreshold = 16; @@ -117,7 +117,7 @@ class ComponentList extends Iterable { @override Iterator get iterator => _ComponentListIterator(this); - /// The elements of this set in reverse order. + /// The elements of this list in reverse order. /// /// Unlike the rest of the [Iterable] interface, this is a method and not a /// getter, for historical reasons. The returned iterable is a lazy view: it @@ -184,8 +184,8 @@ class ComponentList extends Iterable { } } - /// Adds [component] to this set, keeping the priority ordering; returns - /// whether the component was added (`false` if it already was in the set). + /// Adds [component] to this list, keeping the priority ordering; returns + /// whether the component was added (`false` if it already was in the list). /// /// This is internal machinery: adding a component here does not make it go /// through the component lifecycle. Use [Component.add] instead. @@ -252,7 +252,7 @@ class ComponentList extends Iterable { _shiftCount++; } - /// Removes [component] from this set; returns whether it was present. + /// Removes [component] from this list; returns whether it was present. /// /// This is internal machinery: removing a component here does not make it /// go through the component lifecycle. Use [Component.remove] instead. @@ -288,9 +288,9 @@ class ComponentList extends Iterable { return true; } - /// Removes all elements from this set. + /// Removes all elements from this list. /// - /// This is internal machinery: clearing this set does not make the + /// This is internal machinery: clearing this list does not make the /// components go through the component lifecycle. Use [Component.removeAll] /// instead. @internal @@ -375,7 +375,7 @@ class ComponentList extends Iterable { /// Registers [C] as a queryable type, so that [query] can answer in O(1). /// /// If the type is already registered this is a no-op. Registering a type on - /// a non-empty set costs one pass over the existing elements, so it is + /// a non-empty list costs one pass over the existing elements, so it is /// recommended to register the desired types as early as possible. void register() { final queries = _queries ??= {}; @@ -424,9 +424,9 @@ class ComponentList extends Iterable { } class _ComponentListIterator implements Iterator { - _ComponentListIterator(this._set) : _shiftCount = _set._shiftCount; + _ComponentListIterator(this._list) : _shiftCount = _list._shiftCount; - final ComponentList _set; + final ComponentList _list; final int _shiftCount; int _index = -1; Component? _current; @@ -438,11 +438,11 @@ class _ComponentListIterator implements Iterator { @pragma('wasm:prefer-inline') @override bool moveNext() { - final set = _set; - if (set._shiftCount != _shiftCount) { - throw ConcurrentModificationError(set); + final list = _list; + if (list._shiftCount != _shiftCount) { + throw ConcurrentModificationError(list); } - final elements = set._elements; + final elements = list._elements; for (var i = _index + 1; i < elements.length; i++) { final element = elements[i]; if (element != null) { @@ -458,30 +458,30 @@ class _ComponentListIterator implements Iterator { } class _ReversedComponentListView extends Iterable { - _ReversedComponentListView(this._set); + _ReversedComponentListView(this._list); - final ComponentList _set; + final ComponentList _list; @override - int get length => _set._length; + int get length => _list._length; @override - bool get isEmpty => _set._length == 0; + bool get isEmpty => _list._length == 0; @override - bool get isNotEmpty => _set._length != 0; + bool get isNotEmpty => _list._length != 0; @override - Iterator get iterator => _ReversedComponentListIterator(_set); + Iterator get iterator => _ReversedComponentListIterator(_list); } class _ReversedComponentListIterator implements Iterator { - _ReversedComponentListIterator(ComponentList set) - : _set = set, - _shiftCount = set._shiftCount, - _index = set._elements.length; + _ReversedComponentListIterator(ComponentList list) + : _list = list, + _shiftCount = list._shiftCount, + _index = list._elements.length; - final ComponentList _set; + final ComponentList _list; final int _shiftCount; int _index; Component? _current; @@ -493,11 +493,11 @@ class _ReversedComponentListIterator implements Iterator { @pragma('wasm:prefer-inline') @override bool moveNext() { - final set = _set; - if (set._shiftCount != _shiftCount) { - throw ConcurrentModificationError(set); + final list = _list; + if (list._shiftCount != _shiftCount) { + throw ConcurrentModificationError(list); } - final elements = set._elements; + final elements = list._elements; for (var i = _index - 1; i >= 0; i--) { final element = elements[i]; if (element != null) { From 17a0b84707720457dc89b30a66495e92f3c77bb9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:50:43 +0200 Subject: [PATCH 22/26] refactor!: Turn CustomTraversal into a marker interface instead of an empty mixin --- doc/flame/components/components.md | 4 ++-- .../lib/src/components/core/custom_traversal.dart | 14 +++++++------- .../flame/lib/src/effects/combined_effect.dart | 2 +- .../flame/lib/src/effects/sequence_effect.dart | 2 +- packages/flame/lib/src/game/flame_game.dart | 4 ++-- .../components/core/custom_traversal_test.dart | 6 +++--- .../test/components/core/update_paused_test.dart | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/doc/flame/components/components.md b/doc/flame/components/components.md index 88e73120bd5..e8d1eee728c 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -151,10 +151,10 @@ class MyComponent extends PositionComponent with TapCallbacks { The engine drives the update pass through a flattened traversal list owned by the game, so `updateTree` is non-virtual and cannot be overridden. Components that need to control how their subtree is updated (changing the effective `dt`, skipping children, or updating them manually) -should mix in `CustomTraversal` and override its `updateSubtree` method: +should implement the `CustomTraversal` marker and override the `updateSubtree` method: ```dart -class SlowMotionArea extends Component with CustomTraversal { +class SlowMotionArea extends Component implements CustomTraversal { @override void updateSubtree(double dt) => super.updateSubtree(dt / 2); } diff --git a/packages/flame/lib/src/components/core/custom_traversal.dart b/packages/flame/lib/src/components/core/custom_traversal.dart index 6f357f3b5dd..20a25b91bf0 100644 --- a/packages/flame/lib/src/components/core/custom_traversal.dart +++ b/packages/flame/lib/src/components/core/custom_traversal.dart @@ -1,11 +1,11 @@ import 'package:flame/src/components/core/component.dart'; -/// The marker mixin for components that manage the update traversal of their -/// own subtree, instead of the engine's standard "update self, then update -/// every child in priority order" recursion. +/// The marker interface for components that manage the update traversal of +/// their own subtree, instead of the engine's standard "update self, then +/// update every child in priority order" recursion. /// -/// Mix this in (and override [Component.updateSubtree]) to customize the -/// traversal. The mixin is both the capability and the barrier marker: the +/// Implement this (and override [Component.updateSubtree]) to customize the +/// traversal. The interface is both the capability and the barrier marker: the /// engine's flattened update pass treats every [CustomTraversal] component /// as a barrier, calls its [Component.updateSubtree], and lets that method /// drive the subtree. This is also why [Component.updateTree] is @@ -13,7 +13,7 @@ import 'package:flame/src/components/core/component.dart'; /// skipped by the flattened traversal. /// /// ```dart -/// class SlowMotionArea extends Component with CustomTraversal { +/// class SlowMotionArea extends Component implements CustomTraversal { /// @override /// void updateSubtree(double dt) => super.updateSubtree(dt / 2); /// } @@ -24,4 +24,4 @@ import 'package:flame/src/components/core/component.dart'; /// carries the marker, so that their users do not need to add /// [CustomTraversal] themselves, and `super.updateSubtree` chains through /// any other custom traversal in the mixin application order. -mixin CustomTraversal on Component {} +abstract interface class CustomTraversal implements Component {} diff --git a/packages/flame/lib/src/effects/combined_effect.dart b/packages/flame/lib/src/effects/combined_effect.dart index a21f0c12e25..86ca7905404 100644 --- a/packages/flame/lib/src/effects/combined_effect.dart +++ b/packages/flame/lib/src/effects/combined_effect.dart @@ -17,7 +17,7 @@ import 'package:flame/effects.dart'; /// infinitely. This is equivalent to setting `repeatCount = infinity`. If both /// the `infinite` and the `repeatCount` parameters are given, then `infinite` /// takes precedence. -class CombinedEffect extends Effect with CustomTraversal { +class CombinedEffect extends Effect implements CustomTraversal { CombinedEffect( List effects, { bool alternate = false, diff --git a/packages/flame/lib/src/effects/sequence_effect.dart b/packages/flame/lib/src/effects/sequence_effect.dart index 0d11eebce0a..4ff2286b497 100644 --- a/packages/flame/lib/src/effects/sequence_effect.dart +++ b/packages/flame/lib/src/effects/sequence_effect.dart @@ -46,7 +46,7 @@ EffectController _createController({ /// [EffectController] as a parameter. This is because the timing of a sequence /// effect depends on the timings of individual effects, and cannot be /// represented as a regular effect controller. -class SequenceEffect extends Effect with CustomTraversal { +class SequenceEffect extends Effect implements CustomTraversal { SequenceEffect( List effects, { bool alternate = false, diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart index 5d722a88f6f..bfbe0ea50c7 100644 --- a/packages/flame/lib/src/game/flame_game.dart +++ b/packages/flame/lib/src/game/flame_game.dart @@ -38,8 +38,8 @@ import 'package:meta/meta.dart'; /// When [W] is specified, a matching world instance **must** be passed to the /// constructor; otherwise, a runtime assertion error is thrown. class FlameGame extends ComponentTreeRoot - with Game, CustomTraversal - implements ReadOnlySizeProvider { + with Game + implements ReadOnlySizeProvider, CustomTraversal { FlameGame({ super.children, W? world, diff --git a/packages/flame/test/components/core/custom_traversal_test.dart b/packages/flame/test/components/core/custom_traversal_test.dart index d4e60b209a9..1f9b62fd0ac 100644 --- a/packages/flame/test/components/core/custom_traversal_test.dart +++ b/packages/flame/test/components/core/custom_traversal_test.dart @@ -69,18 +69,18 @@ class _DtRecorder extends Component { } } -class _PlainBarrier extends Component with CustomTraversal { +class _PlainBarrier extends Component implements CustomTraversal { _PlainBarrier({super.children}); } -class _HalfSpeedBarrier extends Component with CustomTraversal { +class _HalfSpeedBarrier extends Component implements CustomTraversal { _HalfSpeedBarrier({super.children}); @override void updateSubtree(double dt) => super.updateSubtree(dt / 2); } -class _FrozenBarrier extends _DtRecorder with CustomTraversal { +class _FrozenBarrier extends _DtRecorder implements CustomTraversal { _FrozenBarrier({List? children}) { if (children != null) { addAll(children); diff --git a/packages/flame/test/components/core/update_paused_test.dart b/packages/flame/test/components/core/update_paused_test.dart index 99715f93df3..dca7f71c3e2 100644 --- a/packages/flame/test/components/core/update_paused_test.dart +++ b/packages/flame/test/components/core/update_paused_test.dart @@ -92,7 +92,7 @@ class _Counter extends Component { } } -class _ScaledBarrier extends Component with CustomTraversal { +class _ScaledBarrier extends Component implements CustomTraversal { _ScaledBarrier({super.children}); @override From 00bf07fbabab256a64e43f76afd2af71abf07a2e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:56:56 +0200 Subject: [PATCH 23/26] test: Pin that HasTimeScale scales the dt of the component it is mixed onto --- .../mixins/has_time_scale_self_test.dart | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/flame/test/components/mixins/has_time_scale_self_test.dart diff --git a/packages/flame/test/components/mixins/has_time_scale_self_test.dart b/packages/flame/test/components/mixins/has_time_scale_self_test.dart new file mode 100644 index 00000000000..5a322aa16c0 --- /dev/null +++ b/packages/flame/test/components/mixins/has_time_scale_self_test.dart @@ -0,0 +1,36 @@ +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('HasTimeScale self-scaling', () { + testWithFlameGame('scales the dt of the component it is mixed onto', ( + game, + ) async { + final component = _ScaledRecorder()..timeScale = 0.5; + await game.world.ensureAdd(component); + + game.update(1.0); + expect(component.recordedDts, [0.5]); + }); + + testWithFlameGame('scales its own dt when called via update directly', ( + game, + ) async { + final component = _ScaledRecorder()..timeScale = 0.5; + await game.world.ensureAdd(component); + + component.update(1.0); + expect(component.recordedDts, [1.0]); + }); + }); +} + +class _ScaledRecorder extends Component with HasTimeScale { + final List recordedDts = []; + + @override + void update(double dt) { + recordedDts.add(dt); + } +} From fb203dad99d6aeefbc9aa37733fbd59df241e80b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 14:39:35 +0200 Subject: [PATCH 24/26] perf: Iterate query caches with indexed loops in clear and rebalance --- .../lib/src/components/core/component_list.dart | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 2227c9de53e..033a92127b8 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -308,7 +308,12 @@ class ComponentList extends Iterable { _tombstones = 0; _shiftCount++; structureVersion++; - _queries?.forEach((_, cache) => cache.data.clear()); + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + caches[i].data.clear(); + } + } } /// Restores the priority ordering after one or more elements have changed @@ -341,7 +346,12 @@ class ComponentList extends Iterable { for (var i = 0; i < elements.length; i++) { elements[i]!._containerIndex = i; } - _queries?.forEach((_, cache) => cache.resort()); + final caches = _queryCaches; + if (caches != null) { + for (var i = 0; i < caches.length; i++) { + caches[i].resort(); + } + } } /// Rewrites the backing array without its tombstones, restoring exact From b083b5fa9650676ba2ecf22a69d2eb6bb1c36cd4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 15:50:05 +0200 Subject: [PATCH 25/26] docs: Add the FCS core rewrite to the v2.0.0 migration guide --- doc/flame/migration.md | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/doc/flame/migration.md b/doc/flame/migration.md index e0047302499..721f73d01b2 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -25,3 +25,88 @@ GameWidget.managed( gameFactory: MyGame.new, ); ``` + +### `children` is now a `ComponentList` instead of an `OrderedSet` + +The `ordered_set` package is no longer used; children live in a Flame-owned `ComponentList` that +is significantly faster. The iterable surface, `query()`, `register()`, and `reversed()` are +unchanged, so most code compiles as is. If you imported `package:ordered_set` types to annotate +variables, use `ComponentList` (from `package:flame/components.dart`) instead: + +```dart +// Before +import 'package:ordered_set/ordered_set.dart'; +OrderedSet children = component.children; + +// After +ComponentList children = component.children; +``` + +Two behavioral notes: + +- `query()` results are now always in priority order. +- Mutating `children` while iterating it now tolerates removals and appends at the end; only + position-shifting operations (a mid-list insertion, a reorder, or tombstone compaction) throw + `ConcurrentModificationError`. + + +### `Component.childrenFactory` is removed + +The global children-container factory is gone. Override `createComponentList()` on the component +instead. The constructor accepts an optional `Comparator` that replaces priority +ordering for that parent, which gives custom orderings such as y-sort a supported home: + +```dart +// Before +Component.childrenFactory = () => OrderedSet.mapping((c) => c.priority); + +// After +class YSortedWorld extends World { + @override + ComponentList createComponentList() { + return ComponentList( + comparator: (a, b) => (a as PositionComponent) + .position.y + .compareTo((b as PositionComponent).position.y), + ); + } +} +``` + + +### `Component.updateTree` is non-virtual + +The update pass runs over a flattened traversal list owned by the game, so `updateTree` can no +longer be overridden. If you overrode it, implement the `CustomTraversal` marker interface and +override `Component.updateSubtree` instead; call `super.updateSubtree(dt)` to run the standard +traversal: + +```dart +// Before +class SlowMotionArea extends Component { + @override + void updateTree(double dt) => super.updateTree(dt / 2); +} + +// After +class SlowMotionArea extends Component implements CustomTraversal { + @override + void updateSubtree(double dt) => super.updateSubtree(dt / 2); +} +``` + +`HasTimeScale` usage is unchanged (`with HasTimeScale` still works; the mixin carries the marker +itself). To simply stop updating a subtree, the new `updatePaused` flag replaces the common +gating override: `component.updatePaused = true` pauses the update pass for the component and its +whole subtree while rendering, event handling, and lifecycle processing continue. + + +### `Route.stopTime()` no longer zeroes `timeScale` + +A stopped route is now paused through `updatePaused` instead of `timeScale = 0`: + +- While stopped, `timeScale` keeps its previous value, so a slow-motion factor survives a + stop/resume cycle. +- Assigning a new `timeScale` no longer resumes a stopped route; use `resumeTime()` or + `updatePaused = false`. +- Pending lifecycle events (adding or removing components) on a stopped route now still complete. From e18779044684d3ae029d4651025407f9f81130f7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 23 Jul 2026 14:09:26 +0200 Subject: [PATCH 26/26] Update doc/flame/migration.md --- doc/flame/migration.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/flame/migration.md b/doc/flame/migration.md index 721f73d01b2..f7aa1373fe2 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -26,6 +26,7 @@ GameWidget.managed( ); ``` + ### `children` is now a `ComponentList` instead of an `OrderedSet` The `ordered_set` package is no longer used; children live in a Flame-owned `ComponentList` that