Repository: flame-engine/flame

Issue:
Render loop stops under certain conditions
### What happened?

I turned my device (physcial and emulator) (from portraint to landscape mode) and the render loop stops. This also means that there aren't any visual updates to the game anymore. This behavior seems to be the case when Flame is inside an OrientationBuilder widget.

### What do you expect?

I expect that the render loop does NOT stop when I turn the device (rotate the screen).
Run the app, you see the render loop counter going up, both on screen and in the console. You can even toggle visibility of this. Rotate the screen. The render loop counter stops, the visibility toggling also doesn't work anymore.

### How can we reproduce this?

Use the code below. Flame v1.29.0 was used.

### What steps should take to fix this?

_No response_

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

```dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flame Rotation Bug Test'),
          backgroundColor: Colors.blue,
        ),
        body: SafeArea(
          child: OrientationBuilder(
            builder: (context, orientation) {
              return orientation == Orientation.portrait
                  ? _buildPortraitLayout()
                  : _buildLandscapeLayout();
            },
          ),
        ),
      ),
    );
  }

  RotationTestGameWithButton game = RotationTestGameWithButton();

  Widget _buildPortraitLayout() {
    return Column(
      children: [
        Expanded(
          flex: 1,
          child: GameWidget(game: game),
        ),
        Expanded(
          flex: 1,
          child: TestControlsContainer(game: game),
        ),
      ],
    );
  }

  Widget _buildLandscapeLayout() {
    return Row(
      children: [
        Expanded(
          flex: 1,
          child: GameWidget(game: game),
        ),
        Expanded(
          flex: 1,
          child: TestControlsContainer(game: game),
        ),
      ],
    );
  }
}

class TestControlsContainer extends StatelessWidget {
  final RotationTestGameWithButton game;

  TestControlsContainer({required this.game});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.grey[200],
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'Rotation Bug Test Controls',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 20),
          ElevatedButton(
            onPressed: () => game.toggleTestVisibility(),
            child: Text('Toggle Visibility'),
          ),
          SizedBox(height: 10),
          Text(
            'Rotate device to test!\nWatch debug console for render counts.',
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 14),
          ),
          SizedBox(height: 20),
          Container(
            padding: EdgeInsets.all(16),
            margin: EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.blue[50],
              borderRadius: BorderRadius.circular(8),
              border: Border.all(color: Colors.blue),
            ),
            child: Text(
              'Expected Bug:\n'
                  '1. Before rotation: render() runs continuously\n'
                  '2. After rotation: render() stops or runs only once\n'
                  '3. Toggle button may not work properly',
              style: TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
  }
}

class RotationTestGameWithButton extends FlameGame {
  TestRenderComponent? _testComponent;

  @override
  Color backgroundColor() => const Color(0xFF222222);

  @override
  FutureOr<void> onLoad() async {
    debugPrint('=== GAME ONLOAD ===');

    final camera = CameraComponent(world: world);
    camera.viewfinder.anchor = Anchor.topLeft;
    camera.viewport.anchor = Anchor.topLeft;
    await addAll([world, camera]);

    _testComponent = TestRenderComponent();
    world.add(_testComponent!);

    debugPrint('=== GAME ONLOAD COMPLETE ===');
  }

  @override
  void onGameResize(Vector2 size) {
    debugPrint('=== GAME RESIZE: $size ===');
    super.onGameResize(size);
    _testComponent?.onParentResize(size);
  }

  void toggleTestVisibility() {
    debugPrint('=== TOGGLE VISIBILITY CALLED ===');
    _testComponent?.toggleVisibility();
  }
}

class TestRenderComponent extends Component {
  int renderCount = 0;
  bool visible = true;

  @override
  void render(Canvas canvas) {
    renderCount++;
    debugPrint('TestRenderComponent.render() called #$renderCount, visible: $visible');

    if (!visible) return;

    final paint = Paint()..color = Colors.green;
    final size = Vector2(100, 100);

    canvas.drawRect(
        Rect.fromLTWH(50, 50, size.x, size.y),
        paint
    );

    final textStyle = TextStyle(
      color: Colors.white,
      fontSize: 20,
      fontWeight: FontWeight.bold,
    );

    final textPainter = TextPainter(
      text: TextSpan(text: 'Renders: $renderCount', style: textStyle),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );

    textPainter.layout();
    textPainter.paint(canvas, Offset(60, 170));
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (renderCount % 60 == 0 && renderCount > 0) {
      debugPrint('TestRenderComponent.update() - renderCount: $renderCount');
    }
  }

  void onParentResize(Vector2 newSize) {
    debugPrint('TestRenderComponent: Parent resized to $newSize');
  }

  void toggleVisibility() {
    visible = !visible;
    debugPrint('TestRenderComponent: visibility toggled to $visible');
  }
}
```

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

Output of: flutter doctor -v

[√] Flutter (Channel stable, 3.27.1, on Microsoft Windows [Version 10.0.19045.5965], locale de-DE)
    • Flutter version 3.27.1 on channel stable at D:\flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 17025dd882 (6 months ago), 2024-12-17 03:23:09 +0900
    • Engine revision cb4b5fff73
    • Dart version 3.6.0
    • DevTools version 2.40.2

[√] Windows Version (Installed version of Windows is version 10 or higher)

[√] Android toolchain - develop for Android devices (Android SDK version 35.0.0)
    • Android SDK at C:\Users\MyUserName\AppData\Local\Android\sdk
    • Platform android-35, build-tools 35.0.0
    • Java binary at: D:\Android Studio\jbr\bin\java
    • Java version OpenJDK Runtime Environment (build 21.0.6+-13368085-b895.109)
    • All Android licenses accepted.

[X] Chrome - develop for the web (Cannot find Chrome executable at .\Google\Chrome\Application\chrome.exe)
    ! Cannot find Chrome. Try setting CHROME_EXECUTABLE to a Chrome executable.

[X] Visual Studio - develop Windows apps
    X Visual Studio not installed; this is necessary to develop Windows apps.
      Download at https://visualstudio.microsoft.com/downloads/.
      Please install the "Desktop development with C++" workload, including all of its default components

[√] Android Studio (version 2024.3.2)
    • Android Studio at D:\Android Studio
    • Flutter plugin can be installed from:
       https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
       https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 21.0.6+-13368085-b895.109)

[√] Connected device (2 available)
    • Windows (desktop) • windows • windows-x64    • Microsoft Windows [Version 10.0.19045.5965]
    • Edge (web)        • edge    • web-javascript • Microsoft Edge 134.0.3124.51

[√] Network resources
    • All expected network resources are available.


### Affected platforms

All

### Other information

_No response_

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

- [ ] I want to work on this


Discussion: You're keeping your `game` as a state inside of a stateless widget, the analyzer should have warned you about this?
It's most likely the source of this weirdness.

And also an unrelated suggestion.

This part of your code will probably be very confusing for you later, because it makes you have two cameras and two worlds:
```dart
  FutureOr<void> onLoad() async {
    debugPrint('=== GAME ONLOAD ===');

    final camera = CameraComponent(world: world);
    camera.viewfinder.anchor = Anchor.topLeft;
    camera.viewport.anchor = Anchor.topLeft;
    await addAll([world, camera]);
    ...
  }
```
Do this instead, there is already a camera and a world added to `FlameGame` by default:
```dart
  FutureOr<void> onLoad() async {
    debugPrint('=== GAME ONLOAD ===');
    camera.viewfinder.anchor = Anchor.topLeft;
    camera.viewport.anchor = Anchor.topLeft;
    ...
  }
```
And also think through if you really want the viewfinder's anchor to be `topLeft`, it will mean that if you're following any components with the camera later those components will be shown in the top left of the viewport, instead of in the center.

Thank you for your feedback, spydon. I've refactored the code to have "game" be from a StatefulWidget. Still the issue persists.
```dart

import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late final RotationTestGameWithButton game;

  @override
  void initState() {
    super.initState();
    game = RotationTestGameWithButton();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        appBar: AppBar(
          title: Text('Flame Rotation Bug Test'),
          backgroundColor: Colors.blue,
        ),
        body: SafeArea(
          child: OrientationBuilder(
            builder: (context, orientation) {
              return orientation == Orientation.portrait
                  ? _buildPortraitLayout()
                  : _buildLandscapeLayout();
            },
          ),
        ),
      ),
    );
  }

  Widget _buildPortraitLayout() {
    return Column(
      children: [
        Expanded(
          flex: 1,
          child: GameWidget(game: game),
        ),
        Expanded(
          flex: 1,
          child: TestControlsContainer(game: game),
        ),
      ],
    );
  }

  Widget _buildLandscapeLayout() {
    return Row(
      children: [
        Expanded(
          flex: 1,
          child: GameWidget(game: game),
        ),
        Expanded(
          flex: 1,
          child: TestControlsContainer(game: game),
        ),
      ],
    );
  }
}

class TestControlsContainer extends StatelessWidget {
  final RotationTestGameWithButton game;

  TestControlsContainer({required this.game});

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.grey[200],
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            'Rotation Bug Test Controls',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 20),
          ElevatedButton(
            onPressed: () => game.toggleTestVisibility(),
            child: Text('Toggle Visibility'),
          ),
          SizedBox(height: 10),
          Text(
            'Rotate device to test!\nWatch debug console for render counts.',
            textAlign: TextAlign.center,
            style: TextStyle(fontSize: 14),
          ),
          SizedBox(height: 20),
          Container(
            padding: EdgeInsets.all(16),
            margin: EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.blue[50],
              borderRadius: BorderRadius.circular(8),
              border: Border.all(color: Colors.blue),
            ),
            child: Text(
              'Expected Bug:\n'
                  '1. Before rotation: render() runs continuously\n'
                  '2. After rotation: render() stops or runs only once\n'
                  '3. Toggle button may not work properly',
              style: TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
  }
}

class RotationTestGameWithButton extends FlameGame {
  TestRenderComponent? _testComponent;

  @override
  Color backgroundColor() => const Color(0xFF222222);

  @override
  FutureOr<void> onLoad() async {
    debugPrint('=== GAME ONLOAD ===');

    camera.viewfinder.anchor = Anchor.topLeft;
    camera.viewport.anchor = Anchor.topLeft;

    _testComponent = TestRenderComponent();
    world.add(_testComponent!);

    debugPrint('=== GAME ONLOAD COMPLETE ===');
  }

  @override
  void onGameResize(Vector2 size) {
    debugPrint('=== GAME RESIZE: $size ===');
    super.onGameResize(size);
    _testComponent?.onParentResize(size);
  }

  void toggleTestVisibility() {
    debugPrint('=== TOGGLE VISIBILITY CALLED ===');
    _testComponent?.toggleVisibility();
  }
}

class TestRenderComponent extends Component {
  int renderCount = 0;
  bool visible = true;

  @override
  void render(Canvas canvas) {
    renderCount++;
    debugPrint('TestRenderComponent.render() called #$renderCount, visible: $visible');

    if (!visible) return;

    final paint = Paint()..color = Colors.green;
    final size = Vector2(100, 100);

    canvas.drawRect(
        Rect.fromLTWH(50, 50, size.x, size.y),
        paint
    );

    final textStyle = TextStyle(
      color: Colors.white,
      fontSize: 20,
      fontWeight: FontWeight.bold,
    );

    final textPainter = TextPainter(
      text: TextSpan(text: 'Renders: $renderCount', style: textStyle),
      textAlign: TextAlign.center,
      textDirection: TextDirection.ltr,
    );

    textPainter.layout();
    textPainter.paint(canvas, Offset(60, 170));
  }

  @override
  void update(double dt) {
    super.update(dt);

    if (renderCount % 60 == 0 && renderCount > 0) {
      debugPrint('TestRenderComponent.update() - renderCount: $renderCount');
    }
  }

  void onParentResize(Vector2 newSize) {
    debugPrint('TestRenderComponent: Parent resized to $newSize');
  }

  void toggleVisibility() {
    visible = !visible;
    debugPrint('TestRenderComponent: visibility toggled to $visible');
  }
}

```
@Wunderwuzzy please use proper code blocks so that the code is readable.

![Image](https://github.com/user-attachments/assets/1299b231-9a17-4b60-8ba5-1da7be002674)
@spydon yeah, sorry, I was using the code-tag of the text edit, but I was using it wrongly.
It should look more readable now.
It appears the root cause is that `OrientationBuilder` rebuilds the entire widget tree whenever the orientation changes. In this process, the previously used `GameWidget` is disposed, but the new `GameWidget` attempts to reuse the *same* `game` instance. This could be causing a lifecycle conflict that stops the render loop.

The most idiomatic, "Flutter-y" way to solve this would typically be to use a `Key` to help Flutter identify and correctly reuse a widget. However, in this case, we need the opposite: we must force the internal Flame game to be recreated whenever the `GameWidget` is destroyed and rebuilt.

A simpler solution would be to change the structure so that `OrientationBuilder` creates a new `game` instance on each build. But to resolve this while maintaining the current structure, the game logic itself (`RotationTestGameWithButton`) would need to be managed inside the `State` of the `StatefulWidget`, not just the `GameWidget`.

A much simpler and clearer solution is to use the `GameWidget.controlled` constructor. This constructor accepts a `gameFactory`, which guarantees that the game instance is created only once when the widget is first inserted into the tree.
There shouldn't be a problem to re-use the `FlameGame` instance with a new `GameWidget`, that is for example what happens if you're not using the `controlled` constructor and just resize the window, so I think there is something else that is going wrong here.
Thank you for the clear explanation, spydon. If reusing the `FlameGame` instance isn't the problem, then you're right, something else is definitely going wrong.

Your point about window resizing makes a lot of sense. It suggests the issue isn't the reuse itself, but perhaps the specific way `OrientationBuilder` rebuilds the tree.

My new hypothesis is this; unlike a simple resize, `OrientationBuilder` completely **unmounts** the old `GameWidget` from the tree when the layout changes (e.g., from `Column` to `Row`). When the `GameWidget` is disposed, it seems plausible that it signals the `FlameGame` instance to pause or detach its game loop.

Immediately after, a new `GameWidget` is mounted with the same `game` instance, which might now be in a "paused" or "detached" state. It seems possible that the logic to **resume** the game loop isn't being triggered correctly when the game instance is re-attached to a new `GameWidget` in this manner.
@0disoft I think you are correct, it pauses it [here](https://github.com/flame-engine/flame/blob/main/packages/flame/lib/src/game/flame_game.dart#L282) and then it doesn't get a `lifecycleStateChanged` event when the new widget is created.
So we have to check if `_pauseBecauseBackgrounded` is true when the new game widget is created and unpause the game if that is the case, possibly in [mount](https://github.com/flame-engine/flame/blob/main/packages/flame/lib/src/game/flame_game.dart#L111). I'm a bit busy this week, but if someone is keen on submitting a PR for that I'd be happy to review it.

Fix patch:
diff --git a/packages/flame/lib/src/game/flame_game.dart b/packages/flame/lib/src/game/flame_game.dart
index 1df0a9b91c5..be6e450223a 100644
--- a/packages/flame/lib/src/game/flame_game.dart
+++ b/packages/flame/lib/src/game/flame_game.dart
@@ -110,6 +110,9 @@ class FlameGame<W extends World> extends ComponentTreeRoot
   @internal
   void mount() {
     super.mount();
+    if (_pausedBecauseBackgrounded) {
+      resumeEngine();
+    }
     setMounted();
   }
 
@@ -266,9 +269,13 @@ class FlameGame<W extends World> extends ComponentTreeRoot
   bool pauseWhenBackgrounded = true;
   bool _pausedBecauseBackgrounded = false;
 
+  @visibleForTesting
+  bool get isPausedOnBackground => _pausedBecauseBackgrounded;
+
   @override
   @mustCallSuper
   void lifecycleStateChange(AppLifecycleState state) {
+    super.lifecycleStateChange(state);
     switch (state) {
       case AppLifecycleState.resumed:
       case AppLifecycleState.inactive:


Test patch:
diff --git a/packages/flame/test/game/flame_game_test.dart b/packages/flame/test/game/flame_game_test.dart
index fbdf89269e9..d096c7f0143 100644
--- a/packages/flame/test/game/flame_game_test.dart
+++ b/packages/flame/test/game/flame_game_test.dart
@@ -314,6 +314,29 @@ void main() {
   });
 
   group('pauseWhenBackgrounded:', () {
+    testWidgets(
+      'game resumes when widget is rebuilt',
+      (tester) async {
+        final game = FlameGame();
+
+        await tester.pumpWidget(GameWidget(game: game));
+        expect(game.paused, isFalse);
+        expect(game.isPausedOnBackground, isFalse);
+
+        await tester.pumpWidget(Container());
+        expect(game.paused, isTrue);
+        expect(game.isPausedOnBackground, isTrue);
+
+        await tester.pumpWidget(GameWidget(game: game));
+        expect(game.paused, isFalse, reason: 'Game should resume on remount');
+        expect(
+          game.isPausedOnBackground,
+          isFalse,
+          reason: 'Background pause flag should be cleared on remount resume',
+        );
+      },
+    );
+
     testWithFlameGame('true', (game) async {
       game.pauseWhenBackgrounded = true;