From 7c4ec659d64157e13eafa38b5f4f883864fb52f4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:31:30 +0200 Subject: [PATCH 01/27] 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 7fca4bb0510..94a9daf2e91 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -598,19 +598,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 ??= []; + 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]. @@ -623,7 +620,7 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; if (context != null) { - _renderContexts.add(context); + (_renderContexts ??= []).add(context); } render(canvas); @@ -641,7 +638,7 @@ class Component { } if (context != null) { - _renderContexts.removeLast(); + _renderContexts!.removeLast(); } } @@ -1217,14 +1214,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. + List? _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 @@ -1255,8 +1254,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 264fc664599f72bea48fce91ee322530ea735873 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 16 Aug 2026 15:35:52 +0200 Subject: [PATCH 02/27] refactor: Address review comments on lazy render contexts --- .../flame/lib/src/components/core/component.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 94a9daf2e91..620a7fa624e 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -598,8 +598,8 @@ class Component { /// cleans them up afterwards. @protected void renderChild(Canvas canvas, Component child) { - final contexts = _renderContexts; - if (contexts == null || contexts.isEmpty) { + final contexts = _renderContexts ?? const []; + if (contexts.isEmpty) { child.renderTree(canvas); return; } @@ -619,8 +619,10 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; + List? renderContexts; if (context != null) { - (_renderContexts ??= []).add(context); + renderContexts = _renderContexts ??= []; + renderContexts.add(context); } render(canvas); @@ -637,9 +639,7 @@ class Component { renderDebugMode(canvas); } - if (context != null) { - _renderContexts!.removeLast(); - } + renderContexts?.removeLast(); } //#endregion From 8d97b08ca0329c34d991a8835dc906ff3aa33b93 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:32:09 +0200 Subject: [PATCH 03/27] perf: Replace generator-based removal teardown with an explicit collection pass --- .../lib/src/components/core/component.dart | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 620a7fa624e..be3864d94a8 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -1183,24 +1183,38 @@ class Component { 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 = []; + _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); + } _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) { + for (final child in children.reversed()) { + child._collectTeardown(out); + } + } + out.add(this); + } + void _unregisterKey() { if (key != null) { final game = findGame(); From 0421745a3643b28dcd26dbb50ea0f390434cccb5 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:32:50 +0200 Subject: [PATCH 04/27] perf: Insertion-sort the sweep broadphase and swap-remove its active list --- .../collisions/broadphase/sweep/sweep.dart | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index f79cc6e33e0..e446d5ce2b3 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 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[previousIndex + 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); From 4dbea34cb63be48e162e7450750e6f27bf5c6d18 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 16 Aug 2026 15:44:02 +0200 Subject: [PATCH 05/27] refactor: Use insertionSort from package:collection with a static comparator tear-off --- .../collisions/broadphase/sweep/sweep.dart | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index e446d5ce2b3..42aabb4bb81 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flame/collisions.dart'; class Sweep> extends Broadphase { @@ -20,20 +21,14 @@ class Sweep> extends Broadphase { // 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 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[previousIndex + 1] = item; - } + // every tick. The comparator is a static tear-off, so nothing is + // allocated per tick. + insertionSort(items, compare: _compareMinX); } + static int _compareMinX(Hitbox a, Hitbox b) => + a.aabb.min.x.compareTo(b.aabb.min.x); + @override Iterable> query() { _active.clear(); From d72802c6cedd2bb6d4f0213741849cc1d6636559 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 09:53:32 +0200 Subject: [PATCH 06/27] 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 be3864d94a8..32bf208f126 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]. /// @@ -302,26 +302,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. @@ -329,26 +334,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. @@ -577,8 +573,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); } } } @@ -628,8 +628,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); } @@ -874,8 +878,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 47287774f5a..eda9aa33b8c 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -7,8 +7,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'; @@ -1746,18 +1744,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 6e8355867f5b06a7042e31626e10fdec9e269aa1 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 10:03:02 +0200 Subject: [PATCH 07/27] 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 236fad43331..ee1f3131ed4 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -382,7 +382,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 a8b937e7d47e6e00c77f9f70de3bb023751f5cfb Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 10:26:20 +0200 Subject: [PATCH 08/27] 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 ee1f3131ed4..5a47faf2a40 100644 --- a/doc/flame/components/components.md +++ b/doc/flame/components/components.md @@ -382,7 +382,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 32bf208f126..2a08e902deb 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]. /// @@ -302,20 +302,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]. @@ -323,28 +323,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 eda9aa33b8c..a1fd51debc5 100644 --- a/packages/flame/test/components/component_test.dart +++ b/packages/flame/test/components/component_test.dart @@ -1740,19 +1740,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 { @@ -2337,3 +2352,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 23aa2237d6d9e854af20c22871f8a25cfe6b6fa2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:00:17 +0200 Subject: [PATCH 09/27] 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 85578f0dbccc6b11ff4315962e34152c10ef0707 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:18:38 +0200 Subject: [PATCH 10/27] 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 836f1ae9c67..83e418107df 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 2a08e902deb..30a38fcbd57 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -573,7 +573,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 dbbd91d21d3..696d52c6627 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, @@ -170,9 +170,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 32065890ec3..3b99ab96c63 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 2a52aa0ae78..c375cc22bb5 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 a6d3089e51b202e2583519f784fc501ba2b033e2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:21:09 +0200 Subject: [PATCH 11/27] 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 9627a335075..3f5737819da 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 3b99ab96c63..fea9c7acee3 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 19a1b777363878ec03ce8433cfe1d8e45f7a6950 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:24:38 +0200 Subject: [PATCH 12/27] 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 5d3730ec038..31fc2115cfc 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 b3f2b3edb59..3f2cf85941b 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 30a38fcbd57..20d9a0deabb 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -582,6 +582,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); @@ -590,6 +593,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. /// @@ -605,7 +628,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 3f5737819da..7eda6214dc1 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 6a9753a22d876177291895b7c6a8ef7b4b3159c5 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:25:19 +0200 Subject: [PATCH 13/27] 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 5a47faf2a40..259d2c419f3 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 39a964a56913f62e6f94a33464011084a8e7c94a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:29:12 +0200 Subject: [PATCH 14/27] 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 20d9a0deabb..a566fc14b6f 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -613,6 +613,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 7eda6214dc1..062833ccbda 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 914f0907c83a498df77fba1d70cd60fcfac65f9f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:39:24 +0200 Subject: [PATCH 15/27] 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 a566fc14b6f..d96b8b7a453 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); } @@ -585,9 +586,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); } @@ -614,16 +614,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; @@ -636,22 +652,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 062833ccbda..25f74650f4a 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 738a2d8189e0d9ed99876069331e419ffa8db439 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 11:51:24 +0200 Subject: [PATCH 16/27] 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 259d2c419f3..1e2908ada48 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 31fc2115cfc..89a8c139fc9 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 d96b8b7a453..e23e5a0813d 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -587,12 +587,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. /// @@ -625,7 +638,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); } @@ -653,7 +666,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 696d52c6627..57205daf37b 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 c375cc22bb5..2a52aa0ae78 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 66c5fc394bb7afb4ad4f7abf5504b17c7a68a11f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 12:57:51 +0200 Subject: [PATCH 17/27] 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 89a8c139fc9..fc9d633a969 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 3f2cf85941b..685f97cbe8d 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 df3a78606719363a3f7405d96e30e3449f8e815c Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:06:31 +0200 Subject: [PATCH 18/27] 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 57205daf37b..49fc9680a29 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. /// @@ -169,13 +173,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 bea6363891c0432501a171ff7900e1b4dd6bc7c0 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:45:26 +0200 Subject: [PATCH 19/27] style: Spell out abbreviated identifiers introduced by this branch --- .../src/components/core/component_list.dart | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) 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 364132a6f63c436edd023760ac44429e1514f65e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:46:57 +0200 Subject: [PATCH 20/27] 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 e23e5a0813d..0294e6ba65c 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -284,9 +284,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; @@ -299,7 +299,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. @@ -315,7 +315,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 @@ -526,7 +526,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 1bbb2934229939ed9e82030a2203b31050f16db9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:50:43 +0200 Subject: [PATCH 21/27] 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 1e2908ada48..514356833ac 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 fea9c7acee3..fe8894b3fd3 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 3dfc24558c4f165f8793f972935f77edda80b89d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 13:56:56 +0200 Subject: [PATCH 22/27] 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 e099663b02d078520a7f728fe8101502c3a5cb54 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 14:39:35 +0200 Subject: [PATCH 23/27] 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 fdf262b73da6009783b7c068348076499b59af59 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 22 Jul 2026 15:50:05 +0200 Subject: [PATCH 24/27] docs: Add the FCS core rewrite to the v2.0.0 migration guide --- doc/flame/migration.md | 86 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/doc/flame/migration.md b/doc/flame/migration.md index 17bf96f38a9..58021819914 100644 --- a/doc/flame/migration.md +++ b/doc/flame/migration.md @@ -476,3 +476,89 @@ if (game.isPaused) { game.isPaused = false; } ``` + + +### `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 f28286c61905ec33c571a7c9b525f092f21473f7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:37:38 +0200 Subject: [PATCH 25/27] perf: Collect the removal teardown through the backing array --- packages/flame/lib/src/components/core/component.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 0294e6ba65c..a601bfdaf8f 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -1328,8 +1328,9 @@ class Component { void _collectTeardown(List out) { final children = _children; if (children != null) { - for (final child in children.reversed()) { - child._collectTeardown(out); + final elements = children._elements; + for (var i = elements.length - 1; i >= 0; i--) { + elements[i]?._collectTeardown(out); } } out.add(this); From 75cb8869a410a71d8660a5b0c777f9599a46acab Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 23:28:43 +0200 Subject: [PATCH 26/27] test: Split the type-query benchmark by cache count and cache size The suite measured a single 'two registered queries' churn case, which could not tell apart the cost of having one more cache from the cost of having a cache that matches most of the container. It also had no read-side measurement to weigh those costs against. Add churn variants for no caches, one cache, and a second cache that matches either 1 in 50 or 4 in 5 of the children, plus a read suite that compares a cached query against the whereType scan that an unregistered type falls back to. --- packages/flame/benchmark/README.md | 7 +- .../flame/benchmark/type_query_benchmark.dart | 181 +++++++++++++++--- 2 files changed, 159 insertions(+), 29 deletions(-) diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md index 16c4521fa2b..2bb2c966f3e 100644 --- a/packages/flame/benchmark/README.md +++ b/packages/flame/benchmark/README.md @@ -45,7 +45,12 @@ the benchmark results are printed above it. changes across many parents, and the y-sort pattern where a whole container reorders every tick. - `type_query_benchmark.dart`: maintenance and read cost of the - `register()`/`query()` type-query caches under mixed-type churn. + `register()`/`query()` type-query caches. The churn suite varies how + many types are registered and how much of the container each cache matches; + the read suite compares a cached `query()` against the `whereType()` + scan that an unregistered type falls back to. Together they say what a cache + is worth, and what an accidental registration (the thing that + `Component.strictQueryMode` turns into an error) costs. - `update_components_benchmark.dart`: end-to-end update pass with game-like logic and inputs on a two-level tree. - `render_components_benchmark.dart`: render pass over a randomized tree onto diff --git a/packages/flame/benchmark/type_query_benchmark.dart b/packages/flame/benchmark/type_query_benchmark.dart index 1fd3f134f95..1a5a60c46af 100644 --- a/packages/flame/benchmark/type_query_benchmark.dart +++ b/packages/flame/benchmark/type_query_benchmark.dart @@ -8,54 +8,100 @@ import 'common.dart'; const _dt = 1.0 / 60; +/// Builds a population of [amount] components in which one in five is a +/// `_MarkedComponent`, one in fifty is a `_RareComponent`, and the remaining +/// four in five are `_PlainComponent`s. +List _mixedComponents(int amount) { + return List.generate(amount, (i) { + if (i % 50 == 0) { + return _RareComponent(); + } + return i % 5 == 0 ? _MarkedComponent() : _PlainComponent(); + }); +} + +/// Which query caches are registered on the container that is churned by +/// [TypeQueryChurnBenchmark]. +enum QueryRegistrations { + none('no registered queries, whereType scan'), + marked('1 registered query (matches 1 in 5)'), + markedAndRare('2 registered queries (second matches 1 in 50)'), + markedAndPlain('2 registered queries (second matches 4 in 5)'); + + const QueryRegistrations(this.label); + + final String label; + + /// Whether `_MarkedComponent` has a cache, and reads can go through + /// `query()` instead of a `whereType()` scan. + bool get isMarkedRegistered => this != none; + + void applyTo(ComponentList children) { + if (this != none) { + children.register<_MarkedComponent>(); + } + if (this == markedAndRare) { + children.register<_RareComponent>(); + } + if (this == markedAndPlain) { + children.register<_PlainComponent>(); + } + } +} + /// Measures the per-type query caches of the children container /// (`children.register()` / `children.query()`), which Flame uses /// internally for hitboxes (`GestureHitboxes`), post-processing /// (`CameraComponent`), and layout (`LinearLayoutComponent`). /// -/// Every add and remove has to update all registered caches, so this -/// benchmark churns a mixed-type population in a container with two -/// registered queries while reading one query per tick. A children-container -/// replacement must keep both the cache-maintenance and the query-read cost -/// at least this fast. +/// Every add and remove has to update all registered caches, so this benchmark +/// churns a mixed-type population while reading one query per tick. A +/// children-container replacement must keep both the cache-maintenance and the +/// query-read cost at least this fast. +/// +/// The [registrations] variants separate the two things that could drive the +/// maintenance cost: +/// - [QueryRegistrations.marked] against [QueryRegistrations.markedAndRare]: +/// one more cache, but one that almost never matches, so only the per-add +/// and per-remove type check is added; +/// - [QueryRegistrations.markedAndRare] against +/// [QueryRegistrations.markedAndPlain]: the same number of caches, but the +/// second one now holds most of the container, so the cost of maintaining a +/// cache entry itself dominates; +/// - [QueryRegistrations.none] against [QueryRegistrations.marked]: the whole +/// trade, cache maintenance on every structural change against a +/// `whereType` scan on every read. The no-cache variant reads through +/// `whereType()`, which falls back to a linear scan of the backing array +/// when no cache exists for the type. class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { static const _amountStatic = 1000; static const _batchSize = 50; static const _liveBatches = 5; static const _amountTicks = 60; - static const _markedInterval = 5; + + final QueryRegistrations registrations; late final FlameGame _game; final Queue> _batches = Queue(); - TypeQueryChurnBenchmark() : super('Type-query churn (2 registered queries)'); + TypeQueryChurnBenchmark({ + this.registrations = QueryRegistrations.markedAndPlain, + }) : super('Type-query churn (${registrations.label})'); static Future main() async { - await TypeQueryChurnBenchmark().report(); - } - - List _newBatch() { - return List.generate( - _batchSize, - (i) => i % _markedInterval == 0 ? _MarkedComponent() : _PlainComponent(), - ); + for (final registrations in QueryRegistrations.values) { + await TypeQueryChurnBenchmark(registrations: registrations).report(); + } } @override Future setup() async { _game = FlameGame(); await mountGame(_game); - _game.world.children.register<_MarkedComponent>(); - _game.world.children.register<_PlainComponent>(); - _game.world.addAll( - List.generate( - _amountStatic, - (i) => - i % _markedInterval == 0 ? _MarkedComponent() : _PlainComponent(), - ), - ); + registrations.applyTo(_game.world.children); + _game.world.addAll(_mixedComponents(_amountStatic)); for (var i = 0; i < _liveBatches; i++) { - final batch = _newBatch(); + final batch = _mixedComponents(_batchSize); _batches.addLast(batch); _game.world.addAll(batch); } @@ -64,14 +110,19 @@ class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { @override Future run() async { + final children = _game.world.children; + final isMarkedRegistered = registrations.isMarkedRegistered; var visited = 0; for (var i = 0; i < _amountTicks; i++) { _game.world.removeAll(_batches.removeFirst()); - final batch = _newBatch(); + final batch = _mixedComponents(_batchSize); _batches.addLast(batch); _game.world.addAll(batch); - for (final marked in _game.world.children.query<_MarkedComponent>()) { - visited += marked.marker; + final marked = isMarkedRegistered + ? children.query<_MarkedComponent>() + : children.whereType<_MarkedComponent>(); + for (final component in marked) { + visited += component.marker; } _game.update(_dt); } @@ -79,12 +130,86 @@ class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { } } +/// Measures the read side of the query caches in isolation: [_amountReads] +/// repeated reads of every `_MarkedComponent` in a static container of +/// [amountChildren] children, one fifth of which match. +/// +/// The [cached] variant registers the type and reads through `query()`, +/// which returns a maintained list of exactly the matching children. The +/// uncached variant reads through `whereType()`, which, without a cache for +/// the type, scans the whole backing array. +/// +/// The gap between the two is what a cache buys on the read side, and it is +/// the number to weigh against the maintenance cost measured by +/// [TypeQueryChurnBenchmark]. It is measured at both a large container size +/// (where the scan has to skip many non-matching children) and at a typical +/// per-component size (where a query such as `GestureHitboxes.hitboxes` runs +/// over a handful of children). +class TypeQueryReadBenchmark extends AsyncBenchmarkBase { + static const _amountReads = 500; + + final int amountChildren; + final bool cached; + + late final FlameGame _game; + late final Component _parent; + + TypeQueryReadBenchmark({required this.amountChildren, required this.cached}) + : super( + 'Type-query read ($amountChildren children, ' + '${cached ? 'cached query' : 'whereType scan'})', + ); + + static Future main() async { + for (final amountChildren in [1000, 16]) { + for (final cached in [false, true]) { + await TypeQueryReadBenchmark( + amountChildren: amountChildren, + cached: cached, + ).report(); + } + } + } + + @override + Future setup() async { + _game = FlameGame(); + await mountGame(_game); + _parent = Component(); + _game.world.add(_parent); + await _game.ready(); + if (cached) { + _parent.children.register<_MarkedComponent>(); + } + _parent.addAll(_mixedComponents(amountChildren)); + await _game.ready(); + } + + @override + Future run() async { + final children = _parent.children; + var visited = 0; + for (var i = 0; i < _amountReads; i++) { + final marked = cached + ? children.query<_MarkedComponent>() + : children.whereType<_MarkedComponent>(); + for (final component in marked) { + visited += component.marker; + } + } + assert(visited > 0); + } +} + class _MarkedComponent extends Component { final int marker = 1; } class _PlainComponent extends Component {} +class _RareComponent extends Component {} + Future main() async { await TypeQueryChurnBenchmark.main(); + await TypeQueryReadBenchmark.main(); } From d5bda10622617b70f91b18d2e4e4858f1a92dfb9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 23:28:43 +0200 Subject: [PATCH 27/27] perf: Drop query cache entries lazily instead of searching on every removal Removing a component searched every matching query cache for it with List.remove, a linear scan plus a shift, so a removal cost O(n) in the size of each cache it matched, while the removal from the backing array itself is O(1). A cache that matched most of the container therefore made removals about three times more expensive. Mirror what the backing array already does: a removal only marks the caches it matches, and the entries of the removed components are dropped in a single pass, before anything reads, reorders or adds to the caches again. The pass covers any number of removals at once, so a removal is O(1) regardless of how many types are registered or how much of the container they match. --- .../src/components/core/component_list.dart | 78 ++++++- .../core/component_list_query_test.dart | 215 ++++++++++++++++++ 2 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 packages/flame/test/components/core/component_list_query_test.dart diff --git a/packages/flame/lib/src/components/core/component_list.dart b/packages/flame/lib/src/components/core/component_list.dart index 033a92127b8..9a8bbc8c5f2 100644 --- a/packages/flame/lib/src/components/core/component_list.dart +++ b/packages/flame/lib/src/components/core/component_list.dart @@ -23,7 +23,11 @@ part of 'component.dart'; /// 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. +/// array. The per-type [query] caches work the same way: a removal only marks +/// them, and the entries of the removed components are dropped in a single +/// pass before anything reads, reorders or adds to them again, so that a +/// removal stays O(1) no matter how many types are registered or how much of +/// the container each of them matches. /// /// Mutating the container while iterating it is allowed in the ways that the /// component lifecycle needs: removals take effect immediately (the removed @@ -96,6 +100,15 @@ class ComponentList extends Iterable { /// paths can iterate them without allocating a map-values iterator. List<_QueryCache>? _queryCaches; + /// Whether any of [_queryCaches] may still hold entries for components that + /// have since been removed from this list. + /// + /// Removals only raise this flag instead of searching every matching cache + /// for the removed component, which would be O(n) per removal. The stale + /// entries are dropped by [_compactQueryCaches], in a single pass that + /// covers any number of removals at once, before anything can observe them. + bool _hasStaleQueryEntries = false; + /// 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 @@ -198,6 +211,10 @@ class ComponentList extends Iterable { component._containerList == null, 'A component cannot be contained by two children containers at once', ); + // Must happen before [component] is linked to this list: a component that + // is removed and added back before the caches are compacted would + // otherwise be seen as a live entry and end up in a cache twice. + _compactQueryCaches(); final elements = _elements; if (_length == 0 && elements.isNotEmpty) { // The array contains only tombstones; reset it. @@ -274,16 +291,22 @@ class ComponentList extends Iterable { for (var i = 0; i < caches.length; i++) { final cache = caches[i]; if (cache.check(component)) { - cache.data.remove(component); + cache.hasStaleEntries = true; + _hasStaleQueryEntries = true; } } } if (_length == 0) { _elements.clear(); _tombstones = 0; + // Nothing is left to hold on to, so drop the stale cache entries right + // away instead of keeping the removed components alive until the next + // add or query. + _compactQueryCaches(); } else if (_tombstones >= _tombstoneCompactionThreshold && _tombstones * 2 >= _elements.length) { _compact(); + _compactQueryCaches(); } return true; } @@ -311,9 +334,12 @@ class ComponentList extends Iterable { final caches = _queryCaches; if (caches != null) { for (var i = 0; i < caches.length; i++) { - caches[i].data.clear(); + caches[i] + ..data.clear() + ..hasStaleEntries = false; } } + _hasStaleQueryEntries = false; } /// Restores the priority ordering after one or more elements have changed @@ -324,6 +350,10 @@ class ComponentList extends Iterable { /// components with equal priority keep their relative order. void rebalance() { _compact(); + // Not needed for correctness, since every read compacts as well, but it + // keeps [_QueryCache.resort] from ordering entries that are about to be + // dropped, by an element index that they no longer have. + _compactQueryCaches(); final elements = _elements; var isSorted = true; for (var i = 1; i < elements.length; i++) { @@ -377,6 +407,21 @@ class ComponentList extends Iterable { _shiftCount++; } + /// Drops the entries of removed components from the query caches, if any + /// removal has left some behind. + @pragma('vm:prefer-inline') + @pragma('wasm:prefer-inline') + void _compactQueryCaches() { + if (!_hasStaleQueryEntries) { + return; + } + _hasStaleQueryEntries = false; + final caches = _queryCaches!; + for (var i = 0; i < caches.length; i++) { + caches[i].compact(this); + } + } + /// Whether type [C] has been registered as a queryable type. bool isRegistered() { return _queries?.containsKey(C) ?? false; @@ -418,6 +463,7 @@ class ComponentList extends Iterable { register(); return query(); } + _compactQueryCaches(); // 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; @@ -427,6 +473,7 @@ class ComponentList extends Iterable { Iterable whereType() { final cache = _queries?[C]; if (cache != null) { + _compactQueryCaches(); return cache.data as Iterable; } return super.whereType(); @@ -529,8 +576,33 @@ class _QueryCache { final List data; + /// Whether [data] may hold entries for components that have since been + /// removed from the list; see [ComponentList._hasStaleQueryEntries]. + bool hasStaleEntries = false; + bool check(Component component) => component is C; + /// Drops the entries that are no longer in [list], in a single pass that + /// preserves the order of the remaining ones. + void compact(ComponentList list) { + if (!hasStaleEntries) { + return; + } + hasStaleEntries = false; + final data = this.data; + var write = 0; + for (var read = 0; read < data.length; read++) { + final element = data[read]; + if (identical(element._containerList, list)) { + if (write != read) { + data[write] = element; + } + write++; + } + } + data.length = write; + } + /// Inserts [component] into [data], keeping it ordered consistently with /// the main backing array (which orders by priority). void insertSorted(Component component) { diff --git a/packages/flame/test/components/core/component_list_query_test.dart b/packages/flame/test/components/core/component_list_query_test.dart new file mode 100644 index 00000000000..68469152db8 --- /dev/null +++ b/packages/flame/test/components/core/component_list_query_test.dart @@ -0,0 +1,215 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('ComponentList queries', () { + test('a removed component leaves the cache', () { + final list = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + list + ..add(marked) + ..add(_Plain()); + + expect(list.query<_Marked>(), [marked]); + + list.remove(marked); + expect(list.query<_Marked>(), isEmpty); + }); + + test('removing and adding back does not duplicate the cache entry', () { + final list = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + // A sibling keeps the list non-empty, so that the removal does not + // compact the cache on its own. + list + ..add(marked) + ..add(_Marked(2)); + + list + ..remove(marked) + ..add(marked); + + expect(list.query<_Marked>().map((c) => c.id), [2, 1]); + }); + + test('a component moved to another list leaves the first cache', () { + final source = ComponentList()..register<_Marked>(); + final target = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + source + ..add(marked) + ..add(_Marked(2)); + target.add(_Marked(3)); + + source.remove(marked); + target.add(marked); + + expect(source.query<_Marked>().map((c) => c.id), [2]); + expect(target.query<_Marked>().map((c) => c.id), [3, 1]); + }); + + test('the cache keeps the list order across removals and additions', () { + final list = ComponentList()..register<_Marked>(); + final marked = List.generate(10, (i) => _Marked(i, priority: i)); + for (var i = 0; i < 10; i++) { + list + ..add(marked[i]) + ..add(_Plain(priority: i)); + } + + // Removals from the front, the middle and the end, all before anything + // reads the cache again. + list + ..remove(marked[0]) + ..remove(marked[4]) + ..remove(marked[5]) + ..remove(marked[9]); + expect(list.query<_Marked>().map((c) => c.id), [1, 2, 3, 6, 7, 8]); + + // A component that sorts into the middle lands in the right place. + final inserted = _Marked(99, priority: 4); + list.add(inserted); + expect(list.query<_Marked>().map((c) => c.id), [1, 2, 3, 99, 6, 7, 8]); + }); + + test('the cache is reordered after a rebalance that follows a removal', () { + final list = ComponentList()..register<_Marked>(); + final marked = List.generate(5, (i) => _Marked(i, priority: i)); + for (final component in marked) { + list.add(component); + } + + list.remove(marked[2]); + marked[0].priority = 10; + list.rebalance(); + + expect(list.query<_Marked>().map((c) => c.id), [1, 3, 4, 0]); + }); + + test('emptying the list empties the caches', () { + final list = ComponentList()..register<_Marked>(); + final marked = List.generate(3, _Marked.new); + for (final component in marked) { + list.add(component); + } + + for (final component in marked) { + list.remove(component); + } + + expect(list.query<_Marked>(), isEmpty); + expect(list, isEmpty); + }); + + test('clear empties the caches', () { + final list = ComponentList()..register<_Marked>(); + list + ..add(_Marked(1)) + ..add(_Plain()) + ..clear(); + + expect(list.query<_Marked>(), isEmpty); + }); + + test('removals past the tombstone compaction threshold', () { + final list = ComponentList()..register<_Marked>(); + // More than the threshold at which the backing array compacts itself. + final marked = List.generate(100, (i) => _Marked(i, priority: i)); + for (final component in marked) { + list.add(component); + } + + for (var i = 0; i < 100; i += 2) { + list.remove(marked[i]); + } + + expect( + list.query<_Marked>().map((c) => c.id), + [for (var i = 1; i < 100; i += 2) i], + ); + }); + + test('whereType sees removals as query does', () { + final list = ComponentList()..register<_Marked>(); + final marked = _Marked(1); + list.add(marked); + + list.remove(marked); + + expect(list.whereType<_Marked>(), isEmpty); + // Unregistered types scan the backing array instead of a cache. + expect(list.whereType<_Plain>(), isEmpty); + }); + + test('a cache of an unrelated type is unaffected by removals', () { + final list = ComponentList() + ..register<_Marked>() + ..register<_Plain>(); + final marked = _Marked(1); + final plain = _Plain(); + list + ..add(marked) + ..add(plain); + + list.remove(marked); + + expect(list.query<_Plain>(), [plain]); + expect(list.query<_Marked>(), isEmpty); + }); + + testWithFlameGame('queries follow the component lifecycle', (game) async { + game.world.children.register<_Marked>(); + final marked = List.generate(20, (i) => _Marked(i, priority: i)); + await game.world.ensureAddAll([ + ...marked, + ...List.generate(20, (i) => _Plain(priority: i)), + ]); + + expect(game.world.children.query<_Marked>(), marked); + + game.world.removeAll(marked.sublist(0, 10)); + game.update(0); + expect(game.world.children.query<_Marked>(), marked.sublist(10)); + + // Removals and additions within the same tick. + final added = _Marked(100, priority: 100); + game.world + ..removeAll(marked.sublist(10, 15)) + ..add(added); + game.update(0); + expect(game.world.children.query<_Marked>(), [ + ...marked.sublist(15), + added, + ]); + }); + + testWithFlameGame('hitbox queries follow removals', (game) async { + final component = _Hitboxes(); + final hitboxes = List.generate(3, (_) => RectangleHitbox()); + await game.world.ensureAdd(component); + await component.ensureAddAll(hitboxes); + + expect(component.hitboxes, hitboxes); + + hitboxes.first.removeFromParent(); + game.update(0); + expect(component.hitboxes, hitboxes.sublist(1)); + }); + }); +} + +class _Marked extends Component { + _Marked(this.id, {super.priority}); + + final int id; +} + +class _Plain extends Component { + _Plain({super.priority}); +} + +class _Hitboxes extends PositionComponent with GestureHitboxes { + _Hitboxes() : super(size: Vector2.all(10)); +}