Repository: flame-engine/flame

Issue:
router and forge2d integration
### What happened?

Hello,
I'm encountering an issue when using `RouterComponent` with `WorldRoute` in a flame_forge2d project. 
Specifically, when transitioning between routes (e.g., from a WorldRoute to an OverlayRoute using replace), 
I get the following assertion error:

```
The following assertion was thrown during a scheduler callback:
'package:forge2d/src/dynamics/world.dart': Failed assertion: line 148 pos 12: 'bodies.isNotEmpty': is not true.
When the exception was thrown, this was the stack:
#2 World.destroyBody (package:forge2d/src/dynamics/world.dart:148:12)
#3 Forge2DWorld.destroyBody (package:flame_forge2d/forge2d_world.dart:31:18)
#4 BodyComponent.onRemove (package:flame_forge2d/body_component.dart:217:11)
```

### context
My game uses RouterComponent to manage navigation between different screens, including a start screen (OverlayRoute), a main game world (WorldRoute), a dungeon world (WorldRoute), and a config dialog (OverlayRoute). Below is an example of my router setup:

```dart
RouterComponent(
  routes: {
    RouteType.start.name: OverlayRoute((context, _) {
      return StartScreenWidget(game: game);
    }),
    RouteType.main.name: WorldRoute(
      () => MainWorld(),
      maintainState: false,
    ),
    RouteType.dungeon.name: WorldRoute(
      () => DungeonWorld(),
      maintainState: false,
    ),
    RouteType.configDialog.name: OverlayRoute((context, _) {
      return ConfigDialogWidget(game: game);
    }),
  },
  initialRoute: RouteType.start.name,
);
```

The error occurs when replacing a WorldRoute (e.g., main or dungeon) with another route (e.g., start) using router.replaceNamed. It appears that BodyComponent instances added to the current WorldRoute are not removed immediately. Instead, their removal seems to be queued via root.enqueueRemove(), and their **onRemove method (which calls world.destroyBody(body)) is invoked after the router has already switched to a new World.**
### Temporary Workaround
As a temporary fix, I explicitly remove all BodyComponent instances before the route transition and added a 1-second delay to ensure the components are fully removed:

```dart
void clearGameBodies() {
  final components = game.world.children.whereType<BodyComponent>().toList();
  for (final component in components) {
    component.removeFromParent();
  }
}

Future<void> pushReplacementOverlay(String routeName) async {
  clearGameBodies();

  await Future.delayed(Duration(seconds: 1)); // Wait for removal

  router.replaceNamed(routeName); // OK
}
```
This workaround prevents the assertion error, but it feels like a suboptimal solution due to the arbitrary delay.


- Is the combination of RouterComponent and flame_forge2d with WorldRoute inherently problematic due to the timing of World replacement and BodyComponent removal?
- What is the recommended approach to handle route transitions with WorldRoute to avoid this assertion error? Should I manually clean up bodies in the World before switching routes, or is there a better way to synchronize BodyComponent removal with World transitions?

### What do you expect?

I would ideally expect route transitions in flame_forge2d using WorldRoute to work seamlessly without requiring special handling. Specifically, it would be great if switching between routes (e.g., from a WorldRoute to another route) automatically manages the cleanup of BodyComponent instances and their associated Body objects in the Forge2DWorld, preventing assertion errors like assert(bodies.isNotEmpty) without manual intervention.

### How can we reproduce this?

_No response_

### What steps should take to fix this?

_No response_

### Do have an example of where the bug occurs?

_No response_

### Relevant log output

```shell

```

### Execute in a terminal and put output into the code block below

Output of: flutter doctor -v

### Affected platforms

Android

### Other information

_No response_

### Are you interested in working on a PR for this?

- [ ] I want to work on this


Discussion: @teamboot88 can you try the forge2d branch from [this PR](https://github.com/flame-engine/forge2d/pull/106)?

Add this to your pubspec:
```yaml
dependency_overrides:
  forge2d:
    git:
      url: https://github.com/flame-engine/forge2d.git
      ref: fix/destroy-bodies-after-step
      path: packages/forge2d
```
@spydon 

Thank you very much for the super-fast feedback!
However, I’ve confirmed that onRemove() for BodyComponent instances from the previous world is still being called after the transition to the new world. 😢

```
I/flutter ( 7467): 2025-09-08 00:40:32.625234 [main][INFO] before pushReplacementNamed
I/flutter ( 7467): 2025-09-08 00:40:32.632879 [main][INFO] after pushReplacementNamed
I/flutter ( 7467): 2025-09-08 00:40:32.647988 [main][INFO] onRemove~!!!! Instance of 'Mage', world : Instance of 'DungeonWorld'
I/flutter ( 7467): 2025-09-08 00:40:32.694213 [main][INFO] onRemove~!!!! Instance of 'Mage', world : Instance of 'DungeonWorld'

======== Exception caught by scheduler library =====================================================
The following _TypeError was thrown during a scheduler callback:
Null check operator used on a null value

When the exception was thrown, this was the stack: 
#0      DynamicTree._removeLeaf (package:forge2d/src/collision/broadphase/dynamic_tree.dart:614:36)
#1      DynamicTree.destroyProxy (package:forge2d/src/collision/broadphase/dynamic_tree.dart:72:5)
#2      DefaultBroadPhaseBuffer.destroyProxy (package:forge2d/src/collision/broadphase/default_broadphase_buffer.dart:29:11)
#3      Fixture.destroyProxies (package:forge2d/src/dynamics/fixture.dart:178:18)
#4      World.destroyBody (package:forge2d/src/dynamics/world.dart:173:9)
#5      Forge2DWorld.destroyBody (package:flame_forge2d/forge2d_world.dart:31:18)
#6      BodyComponent.onRemove (package:flame_forge2d/body_component.dart:217:11)
```


```
// pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.6.1
  flame: ^1.30.1
  flame_riverpod: ^5.4.18
  flame_tiled: ^3.0.6
  flame_forge2d: ^0.19.0+6
  logging: ^1.3.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

dependency_overrides:
  forge2d:
    git:
      url: https://github.com/flame-engine/forge2d.git
      ref: fix/destroy-bodies-after-step
      path: packages/forge2d
  meta: ^1.16.0
```

Fix patch:
diff --git a/packages/flame_forge2d/lib/body_component.dart b/packages/flame_forge2d/lib/body_component.dart
index ea10fad70c5..1d71200fa27 100644
--- a/packages/flame_forge2d/lib/body_component.dart
+++ b/packages/flame_forge2d/lib/body_component.dart
@@ -73,10 +73,17 @@ class BodyComponent<T extends Forge2DGame> extends Component
   @override
   Future<void> onLoad() async {
     await super.onLoad();
+    world = game.world;
     body = createBody();
   }
 
-  Forge2DWorld get world => game.world;
+  @override
+  void onMount() {
+    super.onMount();
+    world = game.world;
+  }
+
+  late Forge2DWorld world;
   CameraComponent get camera => game.camera;
   Vector2 get center => body.worldCenter;
 


Test patch:
diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart
index 4b6f4d2c5e7..67423f81bc6 100644
--- a/packages/flame_forge2d/test/body_component_test.dart
+++ b/packages/flame_forge2d/test/body_component_test.dart
@@ -480,5 +480,64 @@ void main() {
         expect(component.tapCount, 2);
       });
     });
+
+    testWithGame(
+      'BodyComponent.world consistency in onRemove',
+      Forge2DGame.new,
+      (game) async {
+        final bodyDef = BodyDef();
+        final body = game.world.createBody(bodyDef);
+        final shape = CircleShape()..radius = 5;
+        body.createFixture(FixtureDef(shape));
+
+        final component = _ConsistentBodyComponent(bodyDef: bodyDef);
+        await game.world.add(component);
+        await game.ready();
+        component.removeFromParent();
+        await game.ready();
+
+        // Verify that the world is the same in onMount and onRemove
+        expect(component.onMountWorld, equals(component.onRemoveWorld));
+      },
+    );
+
+    testWithGame(
+      'BodyComponent.world consistency in onRemove with world change',
+      Forge2DGame.new,
+      (game) async {
+        final bodyDef = BodyDef();
+        final body = game.world.createBody(bodyDef);
+        final shape = CircleShape()..radius = 5;
+        body.createFixture(FixtureDef(shape));
+
+        final component = _ConsistentBodyComponent(bodyDef: bodyDef);
+        await game.world.add(component);
+        await game.ready();
+        game.world = Forge2DWorld();
+        await game.ready();
+
+        // Verify that the world is the same in onMount and onRemove
+        expect(component.onMountWorld, equals(component.onRemoveWorld));
+      },
+    );
   });
 }
+
+class _ConsistentBodyComponent extends BodyComponent {
+  _ConsistentBodyComponent({super.bodyDef});
+
+  Forge2DWorld? onMountWorld;
+  Forge2DWorld? onRemoveWorld;
+
+  @override
+  void onMount() {
+    super.onMount();
+    onMountWorld = world;
+  }
+
+  @override
+  void onRemove() {
+    super.onRemove();
+    onRemoveWorld = world;
+  }
+}