
Box2D v3 bindings mirroring original API; high-performance simulation, circle/polygon/capsule/segment shapes, opt-in contact/hit events, 64-bit collision filtering, world stepping and sleep controls.
Kotlin Multiplatform bindings for the Box2D physics engine (v3). Supported platforms: Android, iOS and Desktop.
Benchmarked using the classic tumbler scenario: a square container rotates continuously at 0.5 rad/s while N dynamic circle bodies collide inside it. Body count doubles from 100 to 3200. Each run performs 300 warmup steps followed by 1000 measured steps, reporting average microseconds per step.
Boks2d is compared against KBox2D (a Kotlin Multiplatform port of Box2D 2.x) and, on JVM only, dyn4j.
JVM — Boks2d vs KBox2D vs dyn4j
iOS — Boks2d vs KBox2D
The benchmark code is in the benchmark/ directory and can be reproduced locally.
// build.gradle.kts
dependencies {
implementation("io.github.joaomcl:boks2d:<version>")
}Full API reference at joaomcl.github.io/Boks2d
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.
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).
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.
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 // radiansBox2D 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 = trueIt 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) }
}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.
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]
}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) ≠ 0
categoryBits 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)
)Kotlin Multiplatform bindings for the Box2D physics engine (v3). Supported platforms: Android, iOS and Desktop.
Benchmarked using the classic tumbler scenario: a square container rotates continuously at 0.5 rad/s while N dynamic circle bodies collide inside it. Body count doubles from 100 to 3200. Each run performs 300 warmup steps followed by 1000 measured steps, reporting average microseconds per step.
Boks2d is compared against KBox2D (a Kotlin Multiplatform port of Box2D 2.x) and, on JVM only, dyn4j.
JVM — Boks2d vs KBox2D vs dyn4j
iOS — Boks2d vs KBox2D
The benchmark code is in the benchmark/ directory and can be reproduced locally.
// build.gradle.kts
dependencies {
implementation("io.github.joaomcl:boks2d:<version>")
}Full API reference at joaomcl.github.io/Boks2d
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.
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).
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.
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 // radiansBox2D 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 = trueIt 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) }
}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.
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]
}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) ≠ 0
categoryBits 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)
)