Skip to content

feat!: Migrate flame_forge2d to the Box2D v3 based forge2d - #3952

Open
spydon wants to merge 31 commits into
mainfrom
forge2d-box2d-v3
Open

feat!: Migrate flame_forge2d to the Box2D v3 based forge2d#3952
spydon wants to merge 31 commits into
mainfrom
forge2d-box2d-v3

Conversation

@spydon

@spydon spydon commented Jul 19, 2026

Copy link
Copy Markdown
Member

Description

Migrates flame_forge2d (and everything in the monorepo that uses it) to the new forge2d, the
native Box2D v3.1.1 bindings that shipped in forge2d 0.15.0 through flame-engine/forge2d#115 (the
native rewrite) and flame-engine/forge2d#116 (web support through a WebAssembly build).

flame_forge2d now depends on the published forge2d: ^0.15.1, which includes
initializeForge2D(lengthUnitsPerMeter:) and Tolerances from flame-engine/forge2d#120.
flame_forge2d stays in the
customer_testing.dart exclusions, because forge2d compiles Box2D from source through the Dart
build hooks and so needs a C toolchain on the flutter/flutter presubmit runner; the reasoning is
documented next to the exclusion.

Core package

  • Forge2DWorld steps the world with physicsWorld.step(dt, subStepCount: subStepCount) and then
    dispatches the polled contact and sensor events through the new overridable
    ContactEventsDispatcher (replacing WorldContactListener, since listener interfaces no longer
    exist). It keeps a Dart-side bodies set (upstream no longer exposes one) which the gravity
    setter uses to wake bodies, and exposes the new query API (castRayClosest, castRay,
    castRayAll, overlapAabb) plus forwarding setters for preSolveCallback and
    customFilterCallback.
  • ContactCallbacks keeps its familiar beginContact(Object other, Contact contact) shape through
    a new lightweight flame-side Contact class that wraps both contact and sensor events.
  • BodyComponent renders from the new Shape.geometry read-back (Circle, Capsule, Segment,
    Polygon; chain segments arrive as Segments), with renderShape/renderSegment and a new
    renderCapsule. fixtureDefs is replaced by shapeSpecs (a list of ShapeSpec, pairing a
    ShapeGeometry with an optional ShapeDef). The default createBody() auto-enables
    contact/sensor events on shapes whose body or shape userData is a ContactCallbacks, since the
    new engine only generates events for shapes that opted in.
  • SDK floors raised to Dart >=3.12.0 / Flutter >=3.44.0 (root workspace, melos bootstrap,
    package, and FLUTTER_MIN_VERSION in CI).
  • Forge2DGame awaits initializeForge2D() in its onLoad, and Forge2DWorld creates its
    physics world lazily so that this can happen first. Without it every Forge2DGame throws on the
    web, since that call is what loads the Box2D WebAssembly module. Code that creates a world
    outside of a Forge2DGame has to await it itself.
  • Tests rewritten against real physics worlds (mocked Fixture/Contact/Manifold are gone) and
    all goldens regenerated. flame's MultiTapDispatcher.handleTapDown annotation changed from
    @internal to @visibleForTesting so the tests can use it without ignores.

Examples and docs

  • The examples stories, padracing, and the package example are migrated. The examples for joints
    that no longer exist in Box2D v3 (gear, pulley, rope, friction, constant-volume) and the blob
    example are removed.
  • doc/bridge_packages/flame_forge2d/forge2d.md and joints.md are rewritten for the new API
    (including the new filter and wheel joints).

Verification

  • flutter test in packages/flame_forge2d (45 tests, compiles native Box2D through build hooks),
    goldens visually inspected.
  • dart analyze clean across the whole workspace.
  • flutter build web of the examples app confirms the Box2D wasm module is bundled automatically
    at the package asset path.

Notes for the forge2d review (found during this migration)

  • Shape.geometry read-back was added upstream during this work and is what makes
    BodyComponent rendering possible without a Dart-side geometry registry.
  • There is no upstream way to enumerate a world's bodies, hence the Dart-side set in
    Forge2DWorld.
  • Behavior change to be aware of: destroying a body clears its userData registries, so removed
    BodyComponents no longer receive a final endContact for contacts that end due to the
    destruction (the old engine fired those synchronously inside destroy).

Checklist

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • I have updated/added relevant examples in examples or docs.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Migration instructions

  • Fixture and FixtureDef are gone: create shapes with
    body.createShape(geometry, ShapeDef(...)), where the geometry is a Circle, Capsule,
    Segment, or Polygon. Friction and restitution now live in ShapeDef.material
    (a SurfaceMaterial). body.fixtures becomes body.shapes.
  • BodyComponent.fixtureDefs becomes shapeSpecs, a list of ShapeSpec(geometry, [shapeDef]).
    renderFixture becomes renderShape, renderEdge becomes renderSegment, and renderChain
    is gone (chain segments render as segments).
  • Shape construction: CircleShape()..radius = r becomes Circle(radius: r, center: c),
    EdgeShape()..set(a, b) becomes Segment(point1: a, point2: b),
    PolygonShape()..setAsBoxXY(w, h) becomes Polygon.box(w, h), and
    ChainShape()..createChain/createLoop becomes body.createChain(ChainDef(points: ..., isLoop: ...)) (chains now require at least four points; the first and last points of an open chain are
    ghost anchors).
  • BodyDef.angle becomes BodyDef(rotation: Rot.fromAngle(angle)).
  • Contact events are now opt-in per shape: set ShapeDef.enableContactEvents (and
    enableSensorEvents for sensors and their visitors). The default BodyComponent.createBody()
    enables them automatically when a ContactCallbacks is present in the body's or shape's
    userData.
  • ContactCallbacks.beginContact/endContact keep their signatures but receive the new flame-side
    Contact (with shapeA, shapeB, bodyA, bodyB, begin-only normal/points, and
    isSensorEvent). preSolve/postSolve are removed: use Forge2DWorld.preSolveCallback with
    ShapeDef.enablePreSolveEvents, and hit events (ShapeDef.enableHitEvents +
    world.physicsWorld.contactEvents.hit) respectively.
  • WorldContactListener is replaced by ContactEventsDispatcher, and the contactListener
    parameter of Forge2DGame by contactEventsDispatcher.
  • Joints are now created with typed methods and destroyed on the joint:
    world.physicsWorld.createRevoluteJoint(RevoluteJointDef(bodyA: ..., bodyB: ...)) and
    joint.destroy(). The available joints are distance, filter, motor, mouse, prismatic, revolute,
    weld, and wheel; gear, pulley, rope, friction, and constant-volume joints no longer exist. The
    def initialize helpers are gone; anchors are passed as local points
    (body.localPoint(worldAnchor)).
  • Queries: world.raycast(callback, p1, p2) becomes castRayClosest/castRay/castRayAll
    (taking an origin and a translation), queryAABB becomes overlapAabb, and clearForces and
    the particle system are removed.
  • Body API renames: worldCenter becomes worldCenterOfMass, setAwake(x) becomes isAwake = x,
    getInertia() becomes rotationalInertia, and worldVector(v) becomes rotation.rotate(v).
  • Platform requirements: Dart 3.12+ / Flutter 3.44+, a C toolchain when building for native
    platforms, and nothing extra on the web (the Box2D wasm module is bundled automatically).

Related Issues

Closes #2613

@spydon
spydon force-pushed the forge2d-box2d-v3 branch from 3778fcb to af1b435 Compare July 19, 2026 22:49
spydon and others added 16 commits July 20, 2026 21:24
Every widget of the default flutter create counter app rests on a
Forge2D body and drops to the floor, keeping its normal function.
Pressing the button counts and launches it, and pressing any widget
sends it flying. The bodies follow the shape Material draws: capsules
for the text, a rounded box for the button. Every widget is given the
same mass so the wide app bar does not crush the small counter, and the
walls are solid boxes so nothing squeezes out through a corner.
Add a heroTag to the counter button, drop a redundant trailing zero in
hue_decorator, teach cspell the word subclassing and use the en_US
spelling of neighboring.
@spydon
spydon marked this pull request as ready for review July 21, 2026 12:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates flame_forge2d (and the repo’s usages/examples/docs) from the legacy pure-Dart Box2D 2.x API to the new Box2D v3-based forge2d ^0.15.0, introducing the new polled contact/sensor event model, updated shape/joint/query APIs, and the required Forge2D initialization flow (especially for web/WASM).

Changes:

  • Reworks flame_forge2d core APIs: lazy physics-world creation + stepping/substepping, new contact event dispatching, new Contact wrapper, and BodyComponent shape rendering via Shape.geometry.
  • Introduces meters-to-pixels scaling via Forge2DViewfinder / Forge2DGame.metersToPixels (decoupled from camera zoom).
  • Migrates/updates tests, examples, and documentation (including new migration guides) to the Forge2D 0.15 / Box2D v3 API surface.

Reviewed changes

Copilot reviewed 71 out of 77 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
scripts/customer_testing.dart Excludes flame_forge2d from customer testing with rationale (native toolchain requirement).
packages/flame/lib/src/rendering/hue_decorator.dart Minor constant formatting change.
packages/flame/lib/src/events/dispatchers/multi_tap_dispatcher.dart Marks handleTapDown as @visibleForTesting for tests.
packages/flame_forge2d/test/world_contact_listener_test.dart Removes tests for deleted legacy listener API.
packages/flame_forge2d/test/helpers/mocks.dart Removes mocks tied to removed legacy Forge2D types.
packages/flame_forge2d/test/helpers/helpers.dart Removes obsolete export.
packages/flame_forge2d/test/forge2d_world_test.dart Updates/expands world behavior tests for new stepping/bodies/query/callback APIs.
packages/flame_forge2d/test/forge2d_viewfinder_test.dart Adds tests for new meters-to-pixels viewfinder behavior.
packages/flame_forge2d/test/forge2d_game_test.dart Updates screen/world conversion tests for meters-to-pixels scaling.
packages/flame_forge2d/test/contact_test.dart Adds tests for new flame-side Contact wrapper.
packages/flame_forge2d/test/contact_events_dispatcher_test.dart Adds tests for new contact/sensor event dispatcher + integration with Forge2DGame.
packages/flame_forge2d/test/contact_callbacks_test.dart Updates tests for new contact model and auto-enabling event flags.
packages/flame_forge2d/test/body_component_test.dart Migrates rendering/shape tests and adds coverage for new behaviors.
packages/flame_forge2d/README.md Updates Forge2D description + adds 0.19→0.20 migration section.
packages/flame_forge2d/pubspec.yaml Bumps dependency to forge2d: ^0.15.0.
packages/flame_forge2d/lib/world_contact_listener.dart Removes legacy listener-based API.
packages/flame_forge2d/lib/forge2d_world.dart Implements lazy world creation, stepping/substeps, bodies tracking, queries, callbacks, and event dispatching.
packages/flame_forge2d/lib/forge2d_viewfinder.dart Adds Forge2DViewfinder to separate meters-to-pixels scaling from camera zoom.
packages/flame_forge2d/lib/forge2d_game.dart Awaits Forge2D initialization on load; wires Forge2DViewfinder and metersToPixels.
packages/flame_forge2d/lib/flame_forge2d.dart Updates exports (adds new types, removes old listener).
packages/flame_forge2d/lib/contact.dart Adds flame-side Contact wrapper spanning contact + sensor events.
packages/flame_forge2d/lib/contact_events_dispatcher.dart Adds polled-events dispatcher routing to ContactCallbacks via userData.
packages/flame_forge2d/lib/contact_callbacks.dart Updates docs/API to shape-based contacts and new preSolve/hit guidance.
packages/flame_forge2d/lib/body_component.dart Replaces fixtures with ShapeSpec/shapes; updates rendering and hit-testing for new geometry model.
packages/flame_forge2d/example/lib/main.dart Migrates package example to new shape/material APIs.
examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart Rebuilds widget overlay example on new API; bodies fitted to measured widget sizes.
examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart Adds shared example palette + Forge2DExampleGame + glowing rendering mixin.
examples/lib/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart Adds joint rendering helpers compatible with new joint API.
examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart Migrates box components + adds mouse-joint rendering.
examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart Migrates boundary walls; updates defaults and styling.
examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart Migrates balls to new shapes/materials; integrates shared styling.
examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart Migrates example to Forge2DExampleGame + async onLoad.
examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart Migrates sprite-body example to new API and async onLoad.
examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart Migrates revolute/motor example; updates shapes/joints/materials.
examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart Migrates raycast example to castRayClosest/castRayAll.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart Adds new wheel joint example for Box2D v3 API.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart Migrates weld joint example and adds joint rendering.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart Removes rope joint example (not in Box2D v3).
examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart Migrates revolute joint example and adds joint rendering.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart Removes pulley joint example (not in Box2D v3).
examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart Migrates prismatic joint example; replaces custom renderer with shared renderer.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart Migrates mouse joint example; adds joint rendering.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart Migrates motor joint example; uses new joint API + renderer.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart Removes gear joint example (not in Box2D v3).
examples/lib/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart Removes friction joint example (not in Box2D v3).
examples/lib/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart Adds filter joint example for Box2D v3 API.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart Migrates distance joint example; uses new spring parameters + renderer.
examples/lib/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart Removes constant-volume joint example (not in Box2D v3).
examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart Updates dashbook story set to match new/removed joint examples.
examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart Migrates drag callbacks example and async onLoad.
examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart Reworks domino example for new API and shared styling.
examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart Migrates contact callbacks example and async onLoad.
examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart Migrates composition example to new base game + async onLoad.
examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart Migrates camera example to Forge2DExampleGame and new scaling model.
examples/lib/stories/bridge_libraries/flame_forge2d/blob_example.dart Removes blob example (depended on removed joint/system).
examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart Migrates animated body example and async onLoad.
examples/lib/main.dart Updates direct-route game registry to match available joint examples.
examples/games/padracing/lib/wall.dart Migrates PadRacing wall shape/material usage.
examples/games/padracing/lib/tire.dart Migrates tire body + revolute joint creation; updates renamed APIs.
examples/games/padracing/lib/padracing_game.dart Updates base game scaling init (meters-to-pixels vs zoom).
examples/games/padracing/lib/lap_line.dart Migrates lap sensor to shape-based sensor events.
examples/games/padracing/lib/car.dart Migrates car body shapes/materials and enables sensor events for lap detection.
examples/games/padracing/lib/ball.dart Migrates ball component to shape/material/contact-event flags.
doc/other_modules/other_modules.md Adds Forge2D module + navigation entries.
doc/other_modules/forge2d/migration.md Adds Forge2D 0.14→0.15 migration guide.
doc/other_modules/forge2d/forge2d.md Adds Forge2D module overview and getting-started docs.
doc/bridge_packages/flame_forge2d/migration.md Adds flame_forge2d 0.19→0.20 migration guide.
doc/bridge_packages/flame_forge2d/forge2d.md Updates Forge2D bridge docs for new initialization and scaling model.
doc/bridge_packages/flame_forge2d/flame_forge2d.md Adds migration page to docs toctree.
.github/.cspell/words_dictionary.txt Adds “subclassing” to spelling dictionary.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/flame_forge2d/lib/forge2d_world.dart
Asserts are stripped in release builds, so a Box2D world that fails to
allocate (for example when the world limit is hit) would be cached and
used by every later step, crashing far from the cause. Check it at
runtime and throw a StateError.
@zeyus

zeyus commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I'm testing this with my existing game / paradigm, I'll let you know. If you want I can also do a full review of the code

@zeyus

zeyus commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

oops something happened with gravity :D
I had it before on the world / game at 0.0, and only on the ball at 9.81, but now with gravityScale: 1.0 on the ball, i guess that applies a relative scaling to the ball from the world (so 1.0 x 0.0)?

I think I also need to re-do my contact callback / hit recognition (I might have forgot to toggle it on) because it should have detected the end of the level. I'll play around some more...

The good news is, all the basics work and it really wasn't a huge effort to migrate the code so far

Screencast_20260803_170348.webm

Would I expect to see lower CPU use? (it's hard to tell with my app the majority of CPU comes from data streaming, logging network message polling)

@spydon

spydon commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

I'm testing this with my existing game / paradigm, I'll let you know. If you want I can also do a full review of the code

That'd be awesome! :)

Would I expect to see lower CPU use? (it's hard to tell with my app the majority of CPU comes from data streaming, logging network message polling)

You would mostly see a lower CPU usage when you have a lot of bodies.

@spydon

spydon commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

oops something happened with gravity :D I had it before on the world / game at 0.0, and only on the ball at 9.81, but now with gravityScale: 1.0 on the ball, i guess that applies a relative scaling to the ball from the world (so 1.0 x 0.0)?

Indeed correct, added some more explanation in the migration instructions: 8f29298

I think I also need to re-do my contact callback / hit recognition (I might have forgot to toggle it on) because it should have detected the end of the level. I'll play around some more...

Tell me if you come up with anything regarding this that we should add to the migration guide. :)

The good news is, all the basics work and it really wasn't a huge effort to migrate the code so far

🙌

@zeyus

zeyus commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

So it was def my bad with the contact callback, I forgot that I had turned the finish line into a sensor a few months (which made more sense, it's not really a physical object), and I hadn't turned on enableSensorEvents (I did have enableContactEvents which is why the paddle worked to move the ball)

Gravity I'm still struggling with... it seems like some of the physics have changed a little - but if the examples work as expected then it's some quirk of my handling. I'll investigate a little more and I'm starting to go through the PR code now :)

@zeyus

zeyus commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

On further investigation, it seems like a contact event is getting triggered while it is not actually in contact.

2026-08-06T16:25:15.218697 [INFO    ] RiseTogether-App: 🔨 Building paddle with widthMultiplier=1.7, start: [-0.2549999952316284,-0.009999999776482582], end: [0.2549999952316284,-0.029999999329447746]
....

2026-08-06T16:25:15.271228 [FINE    ] RiseTogether-App: Ball onLoad called with radius: 0.02, startPosition: [0.0,-0.05000000074505806]
2026-08-06T16:25:15.271447 [INFO    ] RiseTogether-App: 🔨 Building ball with radius: 0.02
2026-08-06T16:25:15.271506 [INFO    ] RiseTogether-App: Team 1 starting height set to -0.05000000074505806
.....

2026-08-06T16:31:39.600289 [FINE    ] RiseTogether-App: Ball beginContact
2026-08-06T16:31:39.600312 [FINE    ] RiseTogether-App: Ball contact with: Wall
2026-08-06T16:31:39.600327 [FINE    ] RiseTogether-App: Ball position: [0.0,-0.0399925522506237], velocity: [0.0,0.0]
2026-08-06T16:31:39.600342 [FINE    ] RiseTogether-App: Wall start: Wall(start: [-0.5,0.0], end: [0.5,1.0], isFatal: true), isLevelEnd: false, isFatal: true

I'll keep digging, but it could be that the ball is just passing straight through the paddle somehow (although would be weird because I could control it before)

Visually, it does not look like it touches the bottom wall (i turned on renderbody to be sure)

Screencast_20260806_164103.webm

this is with gravity scaled down to 0.2 (* 9.81), the paddle is sitting on the bottom "wall" the ball touches the paddle (not of type Wall), then the Ball's beginContact gets fired where other is Wall

Wall is BodyType.static
Paddle is BodyType.kinematic
Ball is BodyType.dynamic

@zeyus

zeyus commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

I'm either losing my mind (possible) or this might be a legit bug, but to confirm that I will need to create a MWE.

This is the contact event that fires - it's far enough away that it shouldn't be a floating point error, and the ball is very squarely outsided of the wall boundary, as well as the event itself being outside of the boundaries of the wall

2026-08-07T13:22:49.984665 [FINE    ] RiseTogether-App: Ball beginContact
Ball beginContact with Wall
Contact normal: [0.0,-1.0]
Is sensor contact: false
Contact point: [0.0,-0.009995091706514359]
Ball position: [0.0,-0.03995427489280701]
Ball velocity: [0.0,0.0]
Ball boundaries (x1, x2, y1, y2): -0.02, 0.02, -0.05995427489280701, -0.019954274892807006
Wall position: [0.0,0.0]
Wall velocity: [0.0,0.0]
Wall boundaries (x1, x2, y1, y2): -0.5, 0.5, 0.0, 1.0
2026-08-07T13:22:49.984756 [INFO    ] RiseTogether-App: Ball hit fatal wall

@zeyus

zeyus commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
/// Reproduction of unwanted contact
library;

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

const double kWidth = 1.0; // x: -0.5 .. 0.5
const double kHeight = 1.0; // y: 0 .. -1
const double kBallRadius = 0.02;
const double kWallThickness = 0.1;
const double kGravity = 9.81;
const double kMetersToPixels = 10.0;

/// How the walls are built
enum WallShape { polygon, segment }

/// Make it segment to use "thin" walls
const WallShape kWallShape = WallShape.polygon;

/// Where the ball starts.
final Vector2 kBallStart = Vector2(0, -0.0399);

void main() {
  runApp(GameWidget(game: MweGame()));
}

class MweGame extends Forge2DGame {
  MweGame()
    : super(gravity: Vector2(0, kGravity), metersToPixels: kMetersToPixels);

  @override
  Future<void> onLoad() async {
    await super.onLoad();

    // Fit the 1x1 playfield in the viewport
    // camera.viewfinder.zoom = size.x / kWidth;
    // camera.viewfinder.position = Vector2(0, -kHeight / 2);
    // Kinematic paddle sitting just above the bottom wall (should prevent contact)
    final paddle = MwePaddle();
    await world.addAll([
      // Bottom wall: top face at y = 0, bottom face at y = +1.
      // This is the wall the ball contacts.
      MweWall(
        'bottom',
        Vector2(-kWidth / 2, 0),
        Vector2(kWidth / 2, 1),
        const Color(0xFF474747),
        horizontal: true,
      ),
      // Right wall.
      MweWall(
        'right',
        Vector2(kWidth / 2, 0),
        Vector2(kWidth / 2 + kWallThickness, -kHeight),
        const Color(0xFF00FF33),
        horizontal: false,
      ),
      // Top wall.
      MweWall(
        'top',
        Vector2(kWidth / 2, -kHeight),
        Vector2(-kWidth / 2, -kHeight - kWallThickness),
        const Color(0xFF3355FF),
        horizontal: true,
      ),
      // Left wall.
      MweWall(
        'left',
        Vector2(-kWidth / 2, -kHeight),
        Vector2(-kWidth / 2 - kWallThickness, 0),
        const Color(0xFFCCFF00),
        horizontal: false,
      ),
      paddle,
      MweBall(),
    ]);

    camera.follow(paddle);
    camera.viewfinder.visibleGameSize = Vector2(kWidth, kHeight);

    _dumpShapes();
  }

  void _dumpShapes() {
    debugPrint(
      '=== stored shape geometry (wall shape: ${kWallShape.name}) ===',
    );
    for (final component in world.children.query<BodyComponent>()) {
      for (final shape in component.body.shapes) {
        final geometry = shape.geometry;
        final description = switch (geometry) {
          Polygon(:final points, :final radius) =>
            'Polygon(radius: $radius, points: $points)',
          Segment(:final point1, :final point2) =>
            'Segment($point1 -> $point2)',
          Circle(:final center, :final radius) =>
            'Circle(center: $center, radius: $radius)',
          Capsule(:final radius) => 'Capsule(radius: $radius)',
        };
        // Body-local geometry + AABB.
        debugPrint('  $component');
        debugPrint('    body position : ${component.body.position}');
        debugPrint('    geometry      : $description');
        debugPrint(
          '    world aabb    : '
          'x[${shape.aabb.lowerBound.x}, ${shape.aabb.upperBound.x}] '
          'y[${shape.aabb.lowerBound.y}, ${shape.aabb.upperBound.y}]',
        );
      }
    }
    debugPrint('=== end shape geometry ===');
  }
}

/// Static wall
class MweWall extends BodyComponent {
  MweWall(
    this.name,
    this._start,
    this._end,
    Color color, {
    required this.horizontal,
  }) : super(
         paint: Paint()
           ..color = color
           ..style = PaintingStyle.fill,
       );

  final String name;
  final Vector2 _start;
  final Vector2 _end;

  final bool horizontal;

  /// The face the ball can actually reach, starting at [_start].
  Vector2 get _faceEnd =>
      horizontal ? Vector2(_end.x, _start.y) : Vector2(_start.x, _end.y);

  /// AABB
  String get bounds {
    final x1 = _start.x < _end.x ? _start.x : _end.x;
    final x2 = _start.x < _end.x ? _end.x : _start.x;
    final y1 = _start.y < _end.y ? _start.y : _end.y;
    final y2 = _start.y < _end.y ? _end.y : _start.y;
    return 'x[$x1, $x2] y[$y1, $y2]';
  }

  @override
  Body createBody() {
    final ShapeGeometry shape = switch (kWallShape) {
      WallShape.polygon => Polygon([
        _start,
        Vector2(_end.x, _start.y),
        _end,
        Vector2(_start.x, _end.y),
      ]),
      WallShape.segment => Segment(point1: _start, point2: _faceEnd),
    };
    final shapeDef = ShapeDef(
      material: SurfaceMaterial(friction: 0.3),
      density: 1.0,
      enableContactEvents: true,
    );
    final bodyDef = BodyDef(
      type: BodyType.static,
      userData: this,
      position: Vector2.zero(),
    );
    renderBody = true;
    return world.createBody(bodyDef)..createShape(shape, shapeDef);
  }

  @override
  String toString() => 'Wall($name)';
}

/// Kinematic paddle, sitting above the bottom wall
class MwePaddle extends BodyComponent {
  MwePaddle()
    : super(
        paint: Paint()
          ..color = const Color(0xFF00BBFF)
          ..style = PaintingStyle.fill,
      );

  static const double halfWidth = 0.15;
  static const double halfHeight = 0.005;

  @override
  Body createBody() {
    final shape = Polygon.box(halfWidth, halfHeight);
    final shapeDef = ShapeDef(
      material: SurfaceMaterial(friction: 20.0, rollingResistance: 1.0),
      density: 1.0,
    );
    final bodyDef = BodyDef(
      type: BodyType.kinematic,
      userData: this,
      position: Vector2(0, -0.01),
      gravityScale: 0,
    );
    renderBody = true;
    return world.createBody(bodyDef)..createShape(shape, shapeDef);
  }

  @override
  String toString() => 'Paddle';
}

/// Dynamic ball, same body/shape parameters as the paradigm's ball.
class MweBall extends BodyComponent with ContactCallbacks {
  MweBall()
    : super(
        paint: Paint()
          ..color = const Color(0xFFFF3366)
          ..style = PaintingStyle.fill,
      );

  @override
  Body createBody() {
    final bodyDef = BodyDef(
      type: BodyType.dynamic,
      position: kBallStart.clone(),
      linearDamping: 1.0,
      angularDamping: 0.8,
      gravityScale: 1.0,
    );
    final shapeDef = ShapeDef(
      material: SurfaceMaterial(friction: 0.5, restitution: 0.0),
      density: 5.0,
      enableContactEvents: true,
      enableSensorEvents: true,
      userData: this,
    );
    renderBody = true;
    return world.createBody(bodyDef)
      ..createShape(Circle(radius: kBallRadius), shapeDef);
  }

  @override
  void beginContact(Object other, Contact contact) {
    final p = body.position;
    debugPrint('--- beginContact with $other ---');
    debugPrint('  normal        : ${contact.normal}');
    debugPrint('  isSensorEvent : ${contact.isSensorEvent}');
    for (final point in contact.points ?? const <ContactPoint>[]) {
      debugPrint(
        '  point         : ${point.point}  separation: ${point.separation}',
      );
    }
    debugPrint('  ball position : $p  velocity: ${body.linearVelocity}');
    debugPrint(
      '  ball bounds   : x[${p.x - kBallRadius}, ${p.x + kBallRadius}] '
      'y[${p.y - kBallRadius}, ${p.y + kBallRadius}]',
    );
    if (other is MweWall) {
      debugPrint('  wall bounds   : ${other.bounds}');
      // For the bottom wall (face at y = 0)
      debugPrint('  gap to y=0    : ${0.0 - (p.y + kBallRadius)}');
    }
  }

  @override
  void endContact(Object other, Contact contact) {
    debugPrint('--- endContact with $other (ball y=${body.position.y}) ---');
  }

  @override
  String toString() => 'Ball';
}

This reproduces the problem.

I think it's to do with the linear slop and how it fuzzes it - it seems like there's a (hardcoded?) 0.02 coming in somehwere...

I think the issue here is that I'm using the default 10 pixels per meter, as I was before, but now objects that are quite "far" apart are triggering contact events, and this seems like a change in behaviour

@zeyus

zeyus commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@spydon I think it might be an option (but it will add complexity if used - e.g. will need to be gated if it can't be used after other calls) to expose https://github.com/flame-engine/forge2d/blob/49dd4048d3be73af542976f9438ac3ed22cf06d4/packages/forge2d/lib/src/ffi/box2d.g.dart#L115

I can see that approach possibly breaking down if you have multiple game instances (or games) that would have a different value for b2SetLengthUnitsPerMeter...

but something like:

// in flame_forge2d
void setLengthUnitsPerMeter(double lengthUnits) => rawBox2D.setLengthUnitsPerMeter(lengthUnits);

// in user code
class myGame extends Forge2DGame {
    static const double lengthUnitsPerMeter = 0.04; // reduces tolerances
    @override
    Future<void> onLoad() async {
      await initializeForge2D();
      setLengthUnitsPerMeter(lengthUnitsPerMeter);
      await super.onLoad();
    }
}

The speculative collision that is new in v3 causes this problem, so if the play area is zoomed to avoid velocity limits, then the tolerances will become relatively large compared to the on-screen/pixel size...alternatively if that makes more sense, it could be a part of the Forge2DGame constructor, just like metersToPixels is currently (but that's quite dangerous because it is a static global, it might be assumed that it applies only to that game instance)

What do you think?

@zeyus

zeyus commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Do you know if I need to override every sub-package in flame to make them work? (i currently have dependency overrides only for flame and flame_forge2d, but now i see that the devtools aren't working (although it's not published so I can't add it as an explicit dependency)

image

@spydon

spydon commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

alternatively if that makes more sense, it could be a part of the Forge2DGame constructor, just like metersToPixels is currently (but that's quite dangerous because it is a static global, it might be assumed that it applies only to that game instance)

What do you think?

Hmm true, maybe we would have to spin up different Box2D backends for each game then. I think it's quite rare to have multiple Forge2DGame instances running at the same time, so maybe easiest would be to start with having it as an argument in the constructor like you suggest.

@spydon

spydon commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Do you know if I need to override every sub-package in flame to make them work? (i currently have dependency overrides only for flame and flame_forge2d, but now i see that the devtools aren't working (although it's not published so I can't add it as an explicit dependency)

image

Hmm... The devtools are a bit special since they build when the publishing happens, so you'd probably have to build it locally for it to work. 🤔

@zeyus

zeyus commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Anyway, regarding the unit scaling, I guess the documentation will need to be updated, because I think the previous version which required a high zoom level for the camera (and therefore "small" physical objects), means that anyone who implemented a flame game with small objects to avoid the speed limit will likely have issues with objects triggering collisions.

And yes, after testing, scaling, and changing the value of metersToPixels to something like 100 seems to work, but there is a side effect, because the physical distance increases, any forces will move an object relatively less. e.g. if gravity is 10, and i have a 10m high world, it will take 1s to get to the bottom. if I have to relatively scale everything up to avoid the 0.02m predictive collision, and I increase the world to 100m, it will now take the object 10 seconds to traverse the playing field...of course gravity + applied forces can also be scaled, but then it might end up hitting the speed limit again?

I feel like I'm missing some fundamental piece of the puzzle here

Screen.Recording.2026-08-11.at.14.14.00.mov

@zeyus

zeyus commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Hmm... The devtools are a bit special since they build when the publishing happens, so you'd probably have to build it locally for it to work. 🤔

No probs, didn't need it in the end, I just didn't see something and wondered where it was rendering but I found the typo.

Hmm true, maybe we would have to spin up different Box2D backends for each game then. I think it's quite rare to have multiple Forge2DGame instances running at the same time, so maybe easiest would be to start with having it as an argument in the constructor like you suggest.

I'm not sure how realistic that is with the dart VM anyway, I have tried to mess around with that for my native lib but ended up giving up (different circumstances but it didn't seem worth the trouble)

I have now updated my game to have everything (including physics values) derived from initial constants * constant scaling factor and it seems to work just fine.

In my game I have up to 4 Forge2DGame instances running, but they all share the exact same physics

If it is a constructor arg, it might make sense having a wrapped static value that no-ops after the forge2d engine is initialised, otherwise the behaviour might become completely undefined if it's changed (globally) after init...that doesn't sound like a very nice solution, but i don't know how many people would even want to use this, it's just in the case where the current object sizes would bump into the speculative collision buffer AND where it would be a huge job to refactor the scale (luckily I had derived most of my object positions and sizes already from the start while I was messing around with layout)

@spydon

spydon commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

@zeyus I had a further look into how other engines that integrate box2d do it, I'll modify this PR a bit according to that.

I also got this reply from the LLM:

You're not missing a concept, you're missing one term in the scaling. If you scale lengths by S you have to scale gravity by S too, and then the timing is unchanged: x(t) = ½gt² becomes S·x(t) at the same t. The full set: lengths, velocities and gravity scale by S; density, friction, restitution and damping stay as they are; mass scales by S², forces and impulses by S³, torque by S⁴. Time is invariant.

The other half is that the advice that led you to a 1 m world is dead. In v2 there was a hardcoded maxTranslation = 2.0 meters per step, so roughly 120 m/s, which is exactly why flame_forge2d told you to shrink everything. In v3 that is WorldDef.maximumLinearSpeed, default 400 m/s and settable per world, and Forge2DWorld already accepts a WorldDef. So there's no reason left to build sub-meter worlds, and several reasons not to: besides the 0.02 m speculative distance, restitutionThreshold (1 m/s), hitEventThreshold (1 m/s) and sleepThreshold (0.05 m/s) are absolute too. In a 1 m world nothing ever bounces and bodies fall asleep mid-flight.

For your game the natural layout is probably a 10 m playfield with a 20 cm ball and gravity 9.81, with metersToPixels picked so that fills the screen. Then every default is correct and no global is involved.

I'll still add the knob, but as initializeForge2D(lengthUnitsPerMeter: ...) rather than a free-floating setter: that call is already the mandatory gate before any Box2D call, so "set once, before everything" becomes structural instead of a warning in a docstring. Conflicting values from two games throw instead of silently corrupting. Along with that, a debug warning when a shape is close to the speculative distance, and a rewrite of the scaling docs, which are what sent you down this path in the first place.

Box2D v2 clamped every body to maxTranslation of 2 meters per step, so
about 120 m/s, and flame_forge2d told people to lay their world out much
smaller than a meter to stay under it. Box2D v3 replaced that clamp with
the per-world WorldDef.maximumLinearSpeed, which defaults to 400 m/s, and
added speculative contacts, which report a contact as soon as two shapes
are within 0.02 m of each other. The old advice went from unnecessary to
harmful: a sub-meter world now reports contacts across visible gaps,
never bounces, and puts bodies to sleep while they are still moving.

- Forge2DViewfinder and the docs no longer justify a shrunken world with
  the speed limit that no longer exists, and the docs gained a section on
  units and scale with the tolerances involved and how quantities scale.
- defaultMetersToPixels goes from 10 to 100, so that a screenful of the
  default is a room rather than a city block.
- Forge2DGame gains lengthUnitsPerMeter, forwarded to initializeForge2D,
  for worlds that cannot be laid out at a realistic scale.
- BodyComponent prints a debug-mode warning once when it creates a moving
  body small enough for the speculative distance to dominate it, which is
  the failure that gets reported as a bug.

The examples keep the scale they were written for by pinning
metersToPixels, except the package example, which is rescaled to show a
world at the default.
spydon added 5 commits August 11, 2026 16:57
flame_forge2d now needs initializeForge2D(lengthUnitsPerMeter:) and
Tolerances, which are in flame-engine/forge2d#120 and not in the
published 0.15.0. The constraint is raised to the ^0.16.0 that melos
will cut from that PR, with a git override so that the workspace
resolves in the meantime. Remove the override once 0.16.0 is out.
# Conflicts:
#	examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart
Forge2DGame.onLoad now awaits initializeForge2D, which loads the Box2D
WebAssembly module on the web, so an override that does not await it
lets the world be created before the module is ready.
The conflict test relied on an earlier test having created a world at
100 units per meter, so it failed when run alone or under a randomized
ordering. The body scale test sat exactly on the warning threshold and
described its size wrongly, and the non-uniform scale assert hid under
the non-positive values test name.
…ty note

Flame's Circle and Polygon live in flame/experimental.dart rather than
flame/geometry.dart, and the collision is on Shape rather than on
Segment. The pubspec requires forge2d ^0.16.0, so the prose should not
say 0.15. The WorldDef note now warns that the definition's gravity
default is y-up.
spydon added a commit to flame-engine/forge2d that referenced this pull request Aug 12, 2026
# Description

Box2D has a handful of tolerances that are absolute lengths rather than
fractions of the shapes
they apply to. The most visible one is the speculative distance:
`manifold.c` stops generating
contact points past `B2_SPECULATIVE_DISTANCE` (`4 * B2_LINEAR_SLOP`, so
0.02 m), and
`contact.c` sets `touching = pointCount > 0`, which means `beginContact`
fires while there is
still a gap of up to 2 cm. A world laid out at a much smaller scale than
a meter is dominated by
this: shapes that are only a couple of centimeters across are
permanently in contact with their
neighbors.

This came up while migrating flame_forge2d (flame-engine/flame#3952),
where a reporter's ball had
a radius of exactly 0.02, and it took a week to track down because the
0.02 is not discoverable
from Dart.

Box2D's answer is `b2SetLengthUnitsPerMeter`, which scales all of them.
Its contract is
`@warning This must be modified before any calls to Box2D`, which a
free-standing setter cannot
enforce, so it is exposed through the `initializeForge2D` gate that
already has to run first:

```dart
await initializeForge2D(lengthUnitsPerMeter: 100);
```

- Passing the value already in effect is a no-op, so several games that
agree on a scale can each
ask for it. The comparison round-trips through float32, since that is
how Box2D stores it and
  values like `0.04` are not representable in either float width.
- A value that conflicts with the one in effect throws a `StateError`
once a `World` exists,
rather than silently corrupting live simulations and the defaults Box2D
hands out.
- Non-positive and non-finite values throw an `ArgumentError`.

`Tolerances` exposes the derived constants (`lengthUnitsPerMeter`,
`linearSlop`,
`speculativeDistance`, `aabbMargin`), so the 0.02 becomes a documented
number that callers can
reason about and assert against instead of a mystery.

The web backend needs a keepalive wrapper: `b2SetLengthUnitsPerMeter`
and
`b2GetLengthUnitsPerMeter` are plain `B2_API` functions, so emcc drops
them without one.

The README gains a "Units" section covering the scale to lay a world out
at, the absolute
tolerances that bite when you do not, and how the other quantities scale
when you rescale a world
(lengths, velocities and gravity by `S`, masses by `S²`, forces and
impulses by `S³`, torques by
`S⁴`, with densities, friction, restitution and damping unchanged, which
leaves the timing of the
simulation unchanged).

## Testing

`dart test` runs suites as isolates that share one process, and
therefore one copy of the native
library, so a suite that changes the length unit would be visible to
whichever suites run
alongside it. `dart_test.yaml` therefore sets `concurrency: 1`, so
suites run one at a time as
part of the normal test run, and the mutating suite puts the length unit
back when it is done.
The whole suite takes a couple of seconds either way.

`melos test` passes and `melos analyze` is clean.

## Checklist

- [x] The title of my PR starts with a [Conventional Commit] prefix
(`fix:`, `feat:`, `docs:` etc).
- [x] I have read the [Contributor Guide] and followed the process
outlined for submitting PRs.
- [x] I have updated/added tests for ALL new/updated/fixed
functionality.
- [x] I have updated/added relevant documentation in `docs` and added
dartdoc comments with `///`.
- [-] I have updated/added relevant examples in `examples`.

## Breaking Change

- [ ] Yes, this is a breaking change.
- [x] No, this is *not* a breaking change.

Everything is additive: the new parameter is optional and defaults to
leaving the length unit
alone, and `Tolerances` is a new class.

## Related Issues

Needed by flame-engine/flame#3952, which uses it for worlds that cannot
be laid out at a
realistic scale, and reports the underlying problem as a debug-mode
warning.

<!-- Links -->
[issue database]: https://github.com/flame-engine/flame/issues
[Contributor Guide]:
https://github.com/flame-engine/flame/blob/main/CONTRIBUTING.md
[Flame Style Guide]:
https://github.com/flame-engine/flame/blob/main/STYLEGUIDE.md
[Conventional Commit]: https://conventionalcommits.org
@zeyus

zeyus commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

@spydon, def, that's what I was trying to get at with scaling the forces too (like I said in my game now all the physics and sizes are now scale-independent) but I think the debug warnings will help others who have used small worlds for the speed limits.

This looks great now, can't wait to bring it into the fold :D nice work!

@zeyus zeyus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't officially review, but all good from my end those 2 comments are just minor

Comment thread doc/bridge_packages/flame_forge2d/migration.md
// onLoad only runs once it has to be recreated here for the component
// to be usable again. Reading a destroyed body is not just stale, it
// reads freed native memory.
body = createBody();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great change, but it changes the behaviour slightly, might be worth a one line mention in one of the docs (e.g. previously removed BodyComponents can now be safely remounted)

@spydon

spydon commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

I can't officially review, but all good from my end those 2 comments are just minor

All your reviews are appreciated! 😊

spydon added 2 commits August 12, 2026 11:03
The length unit support in flame-engine/forge2d#120 was released as
0.15.1, so the git dependency override is no longer needed.
Remounting recreates the destroyed body, which the old version did not
do. Also points the version reference back at Forge2D 0.15, since the
length unit support shipped as 0.15.1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(flame_forge2d): Use a meter-to-pixels constant for rendering instead of zoom

3 participants