Boks2D
Kotlin Multiplatform bindings for the Box2D physics engine.
Getting Started
The API closely mirrors Box2D v3, so existing knowledge of Box2D translates directly. The examples below cover the most common use cases to get you started.
World
The world is the root of the simulation. All bodies live inside it.
val world = World(WorldDef(gravity = Vec2(0f, -10f)))
world.step(1f / 60f, subStepCount = 4)
world.destroy() // always destroy when donestep() advances the simulation by one fixed timestep. Call it once per frame with a consistent value, typically 1/60 seconds. subStepCount controls accuracy vs performance (4 is a good default).
Bodies and Shapes
A body defines position, rotation, and movement. A shape defines the collision geometry and physical properties. Every body needs at least one shape to participate in collisions.
// static body - never moves (ground, walls, obstacles)
val ground = world.createBody(BodyDef(type = BodyType.Static))
ground.createPolygonShape(
ShapeDef(friction = 0.5f),
Polygon.makeBox(halfWidth = 10f, halfHeight = 0.5f)
)
// dynamic body - moved by the physics engine
val ball = world.createBody(BodyDef(
type = BodyType.Dynamic,
position = Vec2(0f, 10f)
))
ball.createCircleShape(
ShapeDef(density = 1f, friction = 0.3f, restitution = 0.6f),
Circle(center = Vec2.Zero, radius = 0.5f)
)Available shape types: Circle, Polygon (box or arbitrary convex hull), Capsule, Segment.
Reading State
After each step(), read the body's position and angle to update your renderer.
world.step(1f / 60f, 4)
val position: Vec2 = ball.position // world-space center
val angle: Float = ball.angle // radiansSleep
Box2D automatically puts idle bodies to sleep to save CPU. Sleeping bodies do not generate contact events. If you need events to fire continuously (e.g. in tests or always-on sensors), disable sleep:
// globally
world.isSleepingEnabled = false
// per body
ball.isAwake = trueRemoving Bodies
It is safe to call world.destroyBody() any time after step() returns. The only place you must not destroy a body is inside callbacks that fire during the step (e.g. setPreSolveCallback), since the engine may still be processing that body on another thread.
world.step(1f / 60f, 4)
// safe! step() has already returned
bodies.removeAll { body ->
(body.position.y < -20f).also { if (it) world.destroyBody(body) }
}Collision Events
Contact events are opt-in per shape for performance. Enable them in ShapeDef, then query after each step().
val ground = world.createBody(BodyDef(type = BodyType.Static))
ground.createPolygonShape(
ShapeDef(enableContactEvents = true),
Polygon.makeBox(10f, 0.5f)
)
val ball = world.createBody(BodyDef(type = BodyType.Dynamic, position = Vec2(0f, 5f)))
ball.createCircleShape(
ShapeDef(density = 1f, restitution = 0.7f, enableContactEvents = true, enableHitEvents = true),
Circle(Vec2.Zero, 0.5f)
)
world.step(1f / 60f, 4)
val events = world.getContactEvents()
// fired once when two shapes start touching
events.beginEvents.forEach { e -> println("contact begin: ${e.shapeA} / ${e.shapeB}") }
// fired once when they stop touching
events.endEvents.forEach { e -> println("contact end: ${e.shapeA} / ${e.shapeB}") }
// fired on significant impacts (speed > world.hitEventThreshold, default 1 m/s)
events.hitEvents.forEach { e ->
println("impact at ${e.point}, speed=${e.approachSpeed} m/s")
}Note:
endEventsshapes may have been destroyed. Always checkshape.isValidbefore accessing them.
Note: Box2D suppresses restitution for low-speed impacts (
restitutionThreshold, default 1 m/s). If a bouncing body stops reacting, lower the threshold.
Identifying objects in events
Events give you a Shape. Use shape.userData: Long to map shapes back to your game objects.
// assign an id when creating the shape
val shape = ball.createCircleShape(ShapeDef(enableContactEvents = true), Circle(Vec2.Zero, 0.5f))
shape.userData = myGameObject.id // any Long
// retrieve it in the event handler
val events = world.getContactEvents()
events.beginEvents.forEach { e ->
val objA = gameObjects[e.shapeA.userData]
val objB = gameObjects[e.shapeB.userData]
}Collision Filtering
Control which shapes collide with which using category and mask bits. A collision happens only when both shapes accept each other:
collides = (A.categoryBits AND B.maskBits) ≠ 0
AND
(B.categoryBits AND A.maskBits) ≠ 0categoryBits and maskBits are ULong (64 available categories). Use shl for readable bit definitions:
object Category {
val CHARACTER = 1uL shl 0 // ...0001
val OBSTACLE = 1uL shl 1 // ...0010
val PICKUP = 1uL shl 2 // ...0100
}
// character: collides with obstacles and pickups
val characterShape = characterBody.createCircleShape(
ShapeDef(filter = Filter(
categoryBits = Category.CHARACTER,
maskBits = Category.OBSTACLE or Category.PICKUP
)),
Circle(Vec2.Zero, 0.5f)
)
// obstacle: collides with characters only (obstacles don't push pickups around)
val obstacleShape = obstacleBody.createPolygonShape(
ShapeDef(filter = Filter(
categoryBits = Category.OBSTACLE,
maskBits = Category.CHARACTER
)),
Polygon.makeBox(1f, 1f)
)
// pickup (coin, power-up): collides with characters only
val pickupShape = pickupBody.createCircleShape(
ShapeDef(filter = Filter(
categoryBits = Category.PICKUP,
maskBits = Category.CHARACTER
)),
Circle(Vec2.Zero, 0.3f)
)