Game Engineering
February 15, 2025 (1y ago)
8 min read

Building 2D Physics Drawing Games in Flutter: A Gravity Drop Engineering Case Study

A deep technical breakdown of engineering real-time 2D physics simulations, custom canvas line rendering, trajectory prediction, and 60 FPS mobile performance in Flutter.

#Flutter#Dart#Game Development#Canvas#Physics#Mobile
Key Engineering Takeaways
  • CustomPainter combined with TickerProviderStateMixin delivers smooth 60 FPS rendering without heavy game engine overhead.
  • Verlet integration and discrete raycasting provide predictable 2D trajectory tracing for touch-drawn lines.
  • Isolating physics math from UI widget rebuilds prevents frame jank on low-end Android hardware.
  • State-driven audio and particle effects can be pooled using object recycling patterns.

Introduction

When developing interactive 2D physics puzzle games on mobile, developers frequently reach for dedicated game engines like Flame, Unity, or Godot. However, for games centered on dynamic touch gestures—such as freehand path drawing, directional gravity manipulation, and ball trajectory puzzles like Gravity Drop (Basket & Beyond)—Flutter's native rendering pipeline and the CustomPainter API can achieve high-performance results with a lightweight footprint.

In this deep dive, we explore how to architect a 2D physics game entirely in Flutter, detailing the game loop lifecycle, touch line discretization, continuous collision detection, and performance optimizations.


1. The Game Loop: Ticker vs Game Engine

Every physics game requires a deterministic loop that calculates state delta times ($Delta t$) and redraws screen frames. While engines like Flame provide an out-of-the-box Game loop, building directly on Flutter's Ticker provides granular control over frame budgets:

class GameEngineController with ChangeNotifier {
  late final Ticker _ticker;
  Duration _lastElapsed = Duration.zero;
  final PhysicsWorld _world = PhysicsWorld();
 
  void initialize(TickerProvider provider) {
    _ticker = provider.createTicker(_onTick)..start();
  }
 
  void _onTick(Duration elapsed) {
    final double dt = (elapsed - _lastElapsed).inMicroseconds / 1000000.0;
    _lastElapsed = elapsed;
 
    // Cap delta time to prevent spiral of death during backgrounding
    final clampedDt = dt.clamp(0.0, 1.0 / 30.0);
 
    _world.step(clampedDt);
    notifyListeners();
  }
 
  @override
  void dispose() {
    _ticker.dispose();
    super.dispose();
  }
}

By capping $Delta t$ at $1/30$ second, we prevent astronomical position jumps when the application loses focus or encounters a transient garbage collection pause.


2. Converting Touch Gestures to Collidable Geometry

In Gravity Drop, players draw paths across the screen on which falling balls roll and bounce. Converting arbitrary touch inputs into solid geometric colliders involves three sequential stages:

  1. Point Ingestion: Capturing raw Offset points via onPanUpdate listeners.
  2. Path Smoothing: Applying Chaikin's algorithm or Catmull-Rom splines to eliminate micro-jitter from capacitive touchscreens.
  3. Line Segment Discretization: Splitting continuous curves into contiguous 2-point line segments with defined normal vectors.
class DrawnLineSegment {
  final Offset start;
  final Offset end;
  final Offset normal;
  final double restitution; // Coefficient of bounciness
 
  DrawnLineSegment({
    required this.start,
    required this.end,
    this.restitution = 0.65,
  }) : normal = _computeNormal(start, end);
 
  static Offset _computeNormal(Offset p1, Offset p2) {
    final dx = p2.dx - p1.dx;
    final dy = p2.dy - p1.dy;
    final length = math.sqrt(dx * dx + dy * dy);
    if (length == 0) return const Offset(0, -1);
    // 90-degree counter-clockwise perpendicular
    return Offset(-dy / length, dx / length);
  }
}

3. Continuous Collision Detection (CCD)

A classic challenge in fast-paced physics games is tunneling: when a ball moves at high velocity, its position between consecutive frames can leap entirely past a thin drawn obstacle line.

To eliminate tunneling without taxing mobile CPUs, we execute swept raycasting along each dynamic body's displacement vector:

bool checkBallLineCollision(
  BallEntity ball,
  DrawnLineSegment line,
  double dt,
) {
  final prevPos = ball.position;
  final nextPos = ball.position + (ball.velocity * dt);
 
  // Line-to-line intersection between ball displacement and obstacle segment
  final intersection = findIntersection(prevPos, nextPos, line.start, line.end);
 
  if (intersection != null) {
    // Reflect velocity vector across the line normal
    final dot = ball.velocity.dx * line.normal.dx + ball.velocity.dy * line.normal.dy;
    final reflectedVelocity = Offset(
      (ball.velocity.dx - 2 * dot * line.normal.dx) * line.restitution,
      (ball.velocity.dy - 2 * dot * line.normal.dy) * line.restitution,
    );
 
    ball.position = intersection.point;
    ball.velocity = reflectedVelocity;
    return true;
  }
  return false;
}

4. High-Fidelity Rendering with CustomPainter

To maintain a consistent 60–120 FPS on diverse Android hardware, isolate the painting surface completely from Flutter's standard widget tree layout passes:

  • Use RepaintBoundary: Wrap your game canvas in a dedicated RepaintBoundary so re-rendering the physics world never invalidates surrounding UI widgets like scoreboards or pause overlays.
  • Pre-allocate Paint Objects: Never instantiate Paint() inside the paint() method. Reuse static or cached Paint objects.
  • Path Caching: Cache drawn lines as recorded paths rather than recalculating bezier segments on every frame tick.
class PhysicsGamePainter extends CustomPainter {
  final PhysicsWorld world;
  final Paint ballPaint = Paint()
    ..color = const Color(0xFFF25F4C)
    ..style = PaintingStyle.fill;
 
  final Paint linePaint = Paint()
    ..color = const Color(0xFF2E4057)
    ..strokeWidth = 4.0
    ..strokeCap = StrokeCap.round
    ..style = PaintingStyle.stroke;
 
  PhysicsGamePainter({required this.world}) : super(repaint: world);
 
  @override
  void paint(Canvas canvas, Size size) {
    // Render all active drawn lines
    for (final line in world.lines) {
      canvas.drawLine(line.start, line.end, linePaint);
    }
 
    // Render active ball entities
    for (final ball in world.balls) {
      canvas.drawCircle(ball.position, ball.radius, ballPaint);
    }
  }
 
  @override
  bool shouldRepaint(covariant PhysicsGamePainter oldDelegate) => true;
}

5. Architectural Takeaways

Developing Gravity Drop proved that Flutter is more than capable of handling physics-based arcade titles when backed by clean mathematical abstractions:

  1. Decouple Physics from Rendering: Run position integration in plain Dart models, utilizing ChangeNotifier strictly to request repaints.
  2. Profile with DevTools: Monitor frame rasterization times and ensure GPU draw passes remain under 8ms.
  3. Simplicity Over Complexity: For 2D drawing mechanics, custom point-to-line projection math often out-performs large generic 2D C++ wrappers like Box2D while keeping APK binaries compact.
Nishan Pradhan
Nishan Pradhan· Author

Cross-platform Flutter mobile developer and tech enthusiast specializing in Clean Architecture, BLoC, and production Android & iOS applications.

All articles