Migrating from forge2d 0.14¶
Forge2D 0.15 is a ground-up rewrite: instead of a pure Dart port of Box2D 2.x it is now a set of bindings for Box2D v3, running as native code on mobile and desktop and as WebAssembly on the web. Because of that, the entire public API changed.
If you use Forge2D through Flame, see the
flame_forge2d migration guide as well, which
covers the changes to BodyComponent, Forge2DWorld, and the contact callbacks on top of the
changes described here.
Note
The particle system (LiquidFun) is not part of Box2D v3 and has been removed. If your game depends on it, stay on forge2d 0.14.
Initialization is required¶
await initializeForge2D() has to complete before the first World is created. On native
platforms it returns immediately; on the web it loads the Box2D WebAssembly module, and creating
a world without it throws a StateError. There was no such call in 0.14, so add one during
startup:
await initializeForge2D();
final world = World(gravity: Vector2(0, -10));
Platform requirements¶
The Dart SDK floor is now 3.12 (Flutter 3.44). On native platforms the bundled Box2D sources are
compiled through the Dart build hooks, so a C toolchain is needed: Xcode on iOS and macOS, the NDK
on Android, Visual Studio Build Tools on Windows, and clang or gcc on Linux. On the web the
bundled WebAssembly module is found automatically in the common hosting setups, so beyond
awaiting initializeForge2D() no extra setup is needed.
Fixtures are gone, bodies carry shapes¶
A Body no longer holds Fixtures. It holds Shapes, which are created from an immutable
ShapeGeometry and an optional ShapeDef. Friction and restitution moved into the def’s
material.
// Before
final shape = CircleShape()..radius = 5;
body.createFixture(FixtureDef(shape, restitution: 0.8, friction: 0.4, density: 2));
// After
body.createShape(
Circle(radius: 5),
ShapeDef(
material: SurfaceMaterial(restitution: 0.8, friction: 0.4),
density: 2,
),
);
Warning
The default friction changed. FixtureDef defaulted to a friction of 0,
while SurfaceMaterial defaults to 0.6. Box2D mixes the friction of a
contact as sqrt(frictionA * frictionB), so a pair where one side relied on
the old default was frictionless and no longer is. Pass
SurfaceMaterial(friction: 0) explicitly wherever you relied on it.
body.fixtures becomes body.shapes, and fixture.testPoint becomes shape.testPoint (still in
world coordinates). The shape’s geometry can be read back for rendering or inspection with
shape.geometry, which returns the sealed ShapeGeometry type:
switch (shape.geometry) {
case Circle(:final center, :final radius):
case Capsule(:final center1, :final center2, :final radius):
case Segment(:final point1, :final point2):
case Polygon(:final points, :final radius):
}
Shape construction¶
Before |
After |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Capsule is new; there is no 0.14 equivalent.
Chains now require at least four points, and they are one-sided: the solid surface is to the right
of the winding direction, so wind loops counter-clockwise and list open ground chains from right to
left. For an open chain the first and last points are ghost anchors used for smooth collision and
are not part of the collidable segments, so a four-point open chain produces one segment. The
segments of a chain are available through chain.segments.
Contact listeners become polled events¶
ContactListener and world.setContactListener no longer exist. After each step the world exposes
the events that occurred during it, and each shape has to opt in to the events it should generate.
// Before
class MyListener extends ContactListener {
@override
void beginContact(Contact contact) { ... }
}
world.setContactListener(MyListener());
// After
body.createShape(Circle(radius: 1), ShapeDef(enableContactEvents: true));
world.step(1 / 60);
for (final event in world.contactEvents.begin) {
// event.shapeA, event.shapeB, event.normal, event.points
}
world.contactEventsholdsbegin,end, andhitlists. Begin events carry the contact normal and the contact points, which replace the oldManifold.world.sensorEventsholds thebeginandendsensor overlaps, each with asensorand avisitorshape. Both the sensor and its visitors needShapeDef.enableSensorEvents.world.bodyMoveEventsreports the bodies that moved during the step.End events can reference shapes that were destroyed in the meantime, so check
Shape.isValidbefore using them.
preSolve becomes the world-level world.preSolveCallback, which returns whether the contact
should be solved this step and requires ShapeDef.enablePreSolveEvents on the shapes. There is no
postSolve and no ContactImpulse: for impact strength enable ShapeDef.enableHitEvents and read
world.contactEvents.hit, whose events carry a point, a normal, and an approachSpeed.
Custom pair filtering, previously done by subclassing the contact filter, is now
world.customFilterCallback.
The old Contact class is gone entirely, so its methods have no direct replacement. In particular
contact.isTouching(), which was commonly used to guard callbacks, is no longer needed because a
begin event means the shapes started touching, and contact.getWorldManifold(...) is replaced by
the normal and points on the begin event.
Queries return their results¶
The ray cast and query callback classes are replaced by methods on World that return their
results. Note that the ray is now expressed as an origin and a translation, not two points.
// Before
class MyCallback extends RayCastCallback {
@override
double reportFixture(
Fixture fixture,
Vector2 point,
Vector2 normal,
double fraction,
) { ... }
}
world.raycast(MyCallback(), start, end);
// After
final hit = world.castRayClosest(start, end - start);
final allHits = world.castRayAll(start, end - start);
world.castRay(start, end - start, (hit) => 1);
Each RayHit carries the shape, point, normal, and fraction. world.queryAABB(callback, aabb) becomes world.overlapAabb(aabb), returning the overlapping shapes, and the axis-aligned
bounding box class was renamed from AABB to Aabb. world.clearForces() is gone, as forces are
applied per step in Box2D v3. Explosions are available through world.explode(ExplosionDef(...)).
Joints¶
Joints are created through typed methods on the world and destroyed on the joint itself. The
initialize helpers on the defs are gone; anchors are given as local points, which you can compute
with body.localPoint(worldAnchor).
// Before
final jointDef = RevoluteJointDef()..initialize(bodyA, bodyB, anchor);
final joint = RevoluteJoint(jointDef);
world.createJoint(joint);
world.destroyJoint(joint);
// After
final joint = world.createRevoluteJoint(
RevoluteJointDef(
bodyA: bodyA,
bodyB: bodyB,
localAnchorA: bodyA.localPoint(anchor),
localAnchorB: bodyB.localPoint(anchor),
),
);
joint.destroy();
The available joints are distance, filter, motor, mouse, prismatic, revolute, weld, and wheel. The
gear, pulley, rope, friction, and constant-volume joints do not exist in Box2D v3. FilterJoint
(which only disables collision between two bodies) and WheelJoint are new.
Spring parameters are named hertz instead of frequencyHz, and springs generally have to be
enabled explicitly with enableSpring. Joint accessors are now getters and setters rather than
getX()/setX() methods, for example joint.motorSpeed = 2 and joint.angle, and the limit
setters take named arguments: joint.setLimits(lower: 0, upper: pi).
The world-space anchors joint.anchorA and joint.anchorB no longer exist; only the local
anchors do. Compute the world position when you need it, for example when rendering a joint:
final anchorA = joint.bodyA.worldPoint(joint.localAnchorA);
final anchorB = joint.bodyB.worldPoint(joint.localAnchorB);
World and body changes¶
world.stepDt(dt)becomesworld.step(dt, subStepCount: 4). The velocity and position iteration counts are replaced by the singlesubStepCount, which defaults to 4.There is no
world.bodies. Track the bodies you create yourself, or useworld.bodyMoveEvents.World,Body,Shape,Chain, and the joints are cheap value-like handles over ids in the native engine. Destroy them explicitly withdestroy(), and checkisValidwhen a handle may refer to something that has already been destroyed.Rotations are represented by
Rot(a cosine/sine pair), soBodyDef(angle: a)becomesBodyDef(rotation: Rot.fromAngle(a))andbody.setTransform(position, rotation)takes aRot.body.anglestill exists.Body renames:
worldCenteris nowworldCenterOfMass,getLocalCenter()islocalCenterOfMass,setAwake(value)isisAwake = value,getInertia()isrotationalInertia,bodyTypeistype,resetMassData()isapplyMassFromShapes(),setMassData(data)ismassData = data,worldVector(v)isrotation.rotate(v), andlocalVector(v)isrotation.inverseRotate(v).BodyDefrenames:allowSleepis nowenableSleep,bulletisisBullet, andactiveisisEnabled.Per-body gravity changed.
gravityScaleis now adoubleinstead of aVector2, and it is a multiplier of the world gravity, so it does nothing in a zero-gravity world.gravityOverridewas an extension of the old Dart port and has no replacement in Box2D v3: to give a body its own gravity vector, setgravityScale: 0(or keep the world gravity at zero) and apply the force yourself every update, for example in aBodyComponent:@override void update(double dt) { super.update(dt); body.applyForce(customGravity * body.mass); }
Forces are cleared after every step, so this has to be applied each update rather than once. Pass
wake: falseif resting bodies should be allowed to stay asleep, which is how regular gravity behaves.userDatais stored on the Dart side in the world instead of a native pointer, and is cleared when the owning handle is destroyed.