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:
- Point Ingestion: Capturing raw
Offsetpoints viaonPanUpdatelisteners. - Path Smoothing: Applying Chaikin's algorithm or Catmull-Rom splines to eliminate micro-jitter from capacitive touchscreens.
- 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 dedicatedRepaintBoundaryso re-rendering the physics world never invalidates surrounding UI widgets like scoreboards or pause overlays. - Pre-allocate Paint Objects: Never instantiate
Paint()inside thepaint()method. Reuse static or cachedPaintobjects. - 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:
- Decouple Physics from Rendering: Run position integration in plain Dart models, utilizing
ChangeNotifierstrictly to request repaints. - Profile with DevTools: Monitor frame rasterization times and ensure GPU draw passes remain under 8ms.
- 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.