diff --git a/CHANGELOG.md b/CHANGELOG.md
index 022b272..8d5bccb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **`ShapeDefinition.Friction` and `ShapeDefinition.Restitution`**, shortcuts for
+ the two material properties changed most often. Changing one used to take a
+ nested `with`; now `ShapeDefinition.Default with { Friction = 0.7f }` does it.
+ Both read and write `Material` and store nothing of their own, so a definition
+ built either way is equal to the other. Initializers run in order, so set
+ `Material` before them when using both.
+
+### Changed
+
+- **`PhysicsWorld.Explode` says which shapes respond.** It read "only spheres,
+ capsules and hulls respond", which in an API where `Box` and `ConvexHull` are
+ different types reads as "boxes do not". They do: a box is a hull. The remarks
+ now say so, and that meshes, height fields and compounds never respond,
+ because they only go on static bodies and an explosion only reaches dynamic
+ ones; kinematic bodies are not pushed either. `Body.AddBox` and
+ `ShapeType.Hull` note that a box reports `ShapeType.Hull`. `ExplosionTests`
+ pins all of it.
+
+- **The joint guide warns about mass ratios.** A chain of light links holding a
+ heavy body — about 1450:1 across the last joint — lets the links reach
+ hundreds of metres per second, while 20:1 hangs still. That is the solver, not
+ the binding, but nothing said so. `docs/guides/joints.md` now recommends
+ keeping the ratio across each joint to roughly 10:1–20:1 or less, with the
+ measurement, and the `chain` sample says the same where its links are made.
+
+- **`Explode`'s `filter` is documented as the mask it is.** The name suggests a
+ filter type, and the queries take a `QueryFilter`, but this is a `ulong`
+ compared with each shape's `CollisionFilter.Categories`, in that direction
+ only: the shape's own `CollidesWith` is not consulted. The parameter keeps its
+ name, since renaming it would break callers passing it by name. No
+ `QueryFilter` overload was added, because it would have to ignore
+ `QueryFilter.Categories` without saying so.
+
## [0.5.0] - 2026-09-23
An engine release, and a minor version for two reasons at once: the idiomatic
diff --git a/docs/guides/joints.md b/docs/guides/joints.md
index 1bcff95..30943ff 100644
--- a/docs/guides/joints.md
+++ b/docs/guides/joints.md
@@ -51,6 +51,27 @@ Build the assembly in its rest pose. A chain assembled already displaced has
every joint violated on the first step and snaps — give it angular velocity
instead.
+## Keep mass ratios modest
+
+> **Keep the mass ratio between jointed bodies modest — roughly 10:1 to 20:1 or
+> less.** Light links holding a heavy body jitter, stretch or fly apart. Make
+> the links heavier or the load lighter.
+
+This is how the solver behaves, not a fault in a particular joint. It resolves
+each joint iteratively, and a light body between a heavy one and an anchor
+barely moves the heavy one, so the error never converges. Measured with a chain
+of ten `Spherical` joints, links of radius 0.14 and a ball of radius 1.1:
+
+| Link density | Ball density | Mass ratio | Fastest link | Worst stretch at the ball |
+| ---: | ---: | ---: | ---: | ---: |
+| 2 | 6 | about 1450:1 | 427 m/s | 0.66 m |
+| 60 | 2.5 | about 20:1 | 3.1 m/s | 0.003 m |
+
+The masses that matter are the ones either side of each joint, so a chain of
+equal links is fine however heavy it is overall; it is the last link against the
+load that has to be kept in proportion. Density is the lever: mass follows from
+it and the volume.
+
## Limits and motors
Most joints take limits, a motor, or both. The pattern is the same everywhere:
diff --git a/src/Box3D.NET.Samples/JointSamples.cs b/src/Box3D.NET.Samples/JointSamples.cs
index 57044d2..7116c6f 100644
--- a/src/Box3D.NET.Samples/JointSamples.cs
+++ b/src/Box3D.NET.Samples/JointSamples.cs
@@ -107,6 +107,9 @@ public static void Run()
{
Vector3 position = new(0.0f, 10.0f - ((i + 1) * LinkSpacing), 0.0f);
+ // Every link weighs the same. Hanging something heavy off the end of
+ // light links is what makes a chain jitter or fly apart; keep the
+ // mass ratio across each joint to about 10:1 or 20:1 at most.
Body link = world.CreateBody(BodyDefinition.Dynamic(position));
link.AddSphere(new Sphere(0.12f), ShapeDefinition.Default with { Density = 2000.0f });
links[i] = link;
diff --git a/src/Box3D.NET.Tests/ExplosionTests.cs b/src/Box3D.NET.Tests/ExplosionTests.cs
new file mode 100644
index 0000000..115cd6a
--- /dev/null
+++ b/src/Box3D.NET.Tests/ExplosionTests.cs
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: MIT
+
+using System;
+using System.Numerics;
+using Xunit;
+
+namespace Box3D.Tests;
+
+///
+/// Holds to what its documentation says
+/// responds and what does not.
+///
+///
+/// The documentation once read "only spheres, capsules and hulls respond", which
+/// in an API where and are different
+/// types reads as "boxes do not". They do, because a box is a hull, and these
+/// tests pin that alongside the cases that genuinely do not respond.
+///
+[Collection(NativeCollection.Name)]
+public class ExplosionTests : IDisposable
+{
+ private readonly PhysicsWorld _world = new(WorldSettings.Default with { Gravity = Vector3.Zero });
+
+ public void Dispose()
+ {
+ _world.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ // At the default density a one-metre cube weighs a tonne, so the impulse is sized to
+ // move it at about a metre per second.
+ private void Blast(ulong mask = ulong.MaxValue) =>
+ _world.Explode(Vector3.Zero, radius: 5.0f, impulsePerArea: 1000.0f, filter: mask);
+
+ [NativeFact]
+ public void A_box_is_a_hull()
+ {
+ Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f));
+ Shape box = body.AddBox(Box.Cube(0.5f));
+
+ Assert.Equal(ShapeType.Hull, box.Type);
+ }
+
+ [NativeFact]
+ public void A_box_is_pushed_away_from_the_blast()
+ {
+ Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f));
+ body.AddBox(Box.Cube(0.5f));
+
+ Blast();
+
+ Assert.True(body.LinearVelocity.X > 0.1f, $"the box should be pushed along +x, velocity {body.LinearVelocity}");
+ }
+
+ [NativeFact]
+ public void Spheres_and_capsules_are_pushed_too()
+ {
+ Body sphere = _world.CreateDynamicBody(new Vector3(0.0f, 2.0f, 0.0f));
+ sphere.AddSphere(new Sphere(0.5f));
+
+ Body capsule = _world.CreateDynamicBody(new Vector3(0.0f, 0.0f, 2.0f));
+ capsule.AddCapsule(Capsule.Upright(1.0f, 0.25f));
+
+ Blast();
+
+ Assert.True(sphere.LinearVelocity.Y > 0.1f, $"sphere velocity {sphere.LinearVelocity}");
+ Assert.True(capsule.LinearVelocity.Z > 0.1f, $"capsule velocity {capsule.LinearVelocity}");
+ }
+
+ [NativeFact]
+ public void A_kinematic_body_is_not_pushed()
+ {
+ Body body = _world.CreateBody(BodyDefinition.Kinematic(new Vector3(2.0f, 0.0f, 0.0f)));
+ body.AddBox(Box.Cube(0.5f));
+
+ Blast();
+
+ Assert.Equal(Vector3.Zero, body.LinearVelocity);
+ }
+
+ [NativeFact]
+ public void A_body_beyond_the_radius_and_falloff_is_not_pushed()
+ {
+ Body body = _world.CreateDynamicBody(new Vector3(20.0f, 0.0f, 0.0f));
+ body.AddBox(Box.Cube(0.5f));
+
+ Blast();
+
+ Assert.Equal(Vector3.Zero, body.LinearVelocity);
+ }
+
+ [NativeFact]
+ public void A_sleeping_body_is_woken_and_pushed()
+ {
+ Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f));
+ body.AddBox(Box.Cube(0.5f));
+ body.IsAwake = false;
+
+ Blast();
+
+ Assert.True(body.IsAwake, "the blast should wake the body");
+ Assert.True(body.LinearVelocity.X > 0.1f, $"velocity {body.LinearVelocity}");
+ }
+
+ [NativeFact]
+ public void The_filter_is_a_category_mask()
+ {
+ var debris = ShapeDefinition.Default with { Filter = CollisionFilter.Default with { Categories = 0x2 } };
+ var scenery = ShapeDefinition.Default with { Filter = CollisionFilter.Default with { Categories = 0x4 } };
+
+ Body affected = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f));
+ affected.AddBox(Box.Cube(0.5f), debris);
+
+ Body spared = _world.CreateDynamicBody(new Vector3(-2.0f, 0.0f, 0.0f));
+ spared.AddBox(Box.Cube(0.5f), scenery);
+
+ // The mask is compared with each shape's categories, not with what the
+ // shape collides with.
+ Blast(mask: 0x2);
+
+ Assert.True(affected.LinearVelocity.X > 0.1f, $"the debris should be pushed, velocity {affected.LinearVelocity}");
+ Assert.Equal(Vector3.Zero, spared.LinearVelocity);
+ }
+
+ [NativeFact]
+ public void The_filter_ignores_what_the_shape_collides_with()
+ {
+ // Unlike a query, which also needs the shape to accept the query's
+ // categories, an explosion checks one direction only. A shape that
+ // collides with nothing is still pushed.
+ var aloof = ShapeDefinition.Default with { Filter = CollisionFilter.Default with { CollidesWith = 0 } };
+
+ Body body = _world.CreateDynamicBody(new Vector3(2.0f, 0.0f, 0.0f));
+ body.AddBox(Box.Cube(0.5f), aloof);
+
+ Blast();
+
+ Assert.True(body.LinearVelocity.X > 0.1f, $"velocity {body.LinearVelocity}");
+ }
+}
diff --git a/src/Box3D.NET.Tests/ShapeDefinitionTests.cs b/src/Box3D.NET.Tests/ShapeDefinitionTests.cs
new file mode 100644
index 0000000..c9d1ffc
--- /dev/null
+++ b/src/Box3D.NET.Tests/ShapeDefinitionTests.cs
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: MIT
+
+using Xunit;
+
+namespace Box3D.Tests;
+
+///
+/// Covers the friction and restitution shortcuts on .
+///
+[Collection(NativeCollection.Name)]
+public class ShapeDefinitionTests
+{
+ [NativeFact]
+ public void The_friction_shortcut_changes_only_the_friction()
+ {
+ ShapeDefinition def = ShapeDefinition.Default with { Friction = 0.7f };
+
+ Assert.Equal(0.7f, def.Friction);
+ Assert.Equal(PhysicsMaterial.Default with { Friction = 0.7f }, def.Material);
+ }
+
+ [NativeFact]
+ public void The_restitution_shortcut_changes_only_the_restitution()
+ {
+ ShapeDefinition def = ShapeDefinition.Default with { Restitution = 0.8f };
+
+ Assert.Equal(0.8f, def.Restitution);
+ Assert.Equal(PhysicsMaterial.Default with { Restitution = 0.8f }, def.Material);
+ }
+
+ [NativeFact]
+ public void A_shortcut_is_the_same_definition_as_the_nested_form()
+ {
+ ShapeDefinition shortcut = ShapeDefinition.Default with { Friction = 0.7f, Restitution = 0.2f };
+ ShapeDefinition nested = ShapeDefinition.Default with
+ {
+ Material = PhysicsMaterial.Default with { Friction = 0.7f, Restitution = 0.2f },
+ };
+
+ // Nothing is stored outside Material, so equality and hashing agree.
+ Assert.Equal(nested, shortcut);
+ Assert.Equal(nested.GetHashCode(), shortcut.GetHashCode());
+ }
+
+ [NativeFact]
+ public void A_shortcut_after_a_material_refines_it()
+ {
+ var ice = PhysicsMaterial.Default with { Friction = 0.02f, UserMaterialId = 7 };
+
+ ShapeDefinition def = ShapeDefinition.Default with { Material = ice, Restitution = 0.5f };
+
+ Assert.Equal(0.02f, def.Friction);
+ Assert.Equal(0.5f, def.Restitution);
+ Assert.Equal(7UL, def.Material.UserMaterialId);
+ }
+
+ [NativeFact]
+ public void The_shortcuts_reach_the_shape()
+ {
+ using var world = new PhysicsWorld();
+ Body body = world.CreateDynamicBody();
+
+ Shape shape = body.AddBox(Box.Cube(0.5f), ShapeDefinition.Default with { Friction = 0.7f, Restitution = 0.3f });
+
+ Assert.Equal(0.7f, shape.Friction);
+ Assert.Equal(0.3f, shape.Restitution);
+ }
+}
diff --git a/src/Box3D.NET/Body.cs b/src/Box3D.NET/Body.cs
index 2bcb85e..0355b30 100644
--- a/src/Box3D.NET/Body.cs
+++ b/src/Box3D.NET/Body.cs
@@ -290,7 +290,10 @@ public unsafe Shape AddCapsule(Capsule capsule, ShapeDefinition? definition = nu
/// The new shape.
///
/// The box is a convex hull underneath, but it is self-contained, so nothing
- /// needs to be released afterwards.
+ /// needs to be released afterwards. Because it is a hull, the shape reports
+ /// as its , and behaves as
+ /// a hull everywhere, including in . There is
+ /// no need to build a box from eight corners with .
///
///
///
diff --git a/src/Box3D.NET/PhysicsWorld.cs b/src/Box3D.NET/PhysicsWorld.cs
index 837593a..3035204 100644
--- a/src/Box3D.NET/PhysicsWorld.cs
+++ b/src/Box3D.NET/PhysicsWorld.cs
@@ -744,9 +744,25 @@ public WorldEvents Events
/// The impulse per unit of facing area. Negative values implode.
///
/// The distance beyond the radius over which the impulse fades to nothing.
- /// Which shape categories are affected. Defaults to all of them.
+ ///
+ /// A category mask, not a : a shape is affected when
+ /// this has a bit in common with its .
+ /// It plays the part of in a query, but
+ /// only in that direction; the shape's own
+ /// is not consulted. Defaults to
+ /// every category.
+ ///
/// The world has been disposed.
- /// Only spheres, capsules and hulls respond.
+ ///
+ ///
+ /// Spheres, capsules and hulls respond, and boxes are hulls, so a shape added
+ /// with responds too. Meshes, height fields and
+ /// compounds do not: they can only be attached to static bodies, and an
+ /// explosion only reaches dynamic ones. Kinematic bodies are not pushed
+ /// either.
+ ///
+ /// Sleeping bodies within reach are woken.
+ ///
public void Explode(
Vector3 center,
float radius,
diff --git a/src/Box3D.NET/ShapeDefinition.cs b/src/Box3D.NET/ShapeDefinition.cs
index 014ed9d..f5c59bc 100644
--- a/src/Box3D.NET/ShapeDefinition.cs
+++ b/src/Box3D.NET/ShapeDefinition.cs
@@ -19,6 +19,10 @@ public enum ShapeType
HeightField = 2,
/// A convex hull.
+ ///
+ /// Boxes are hulls too: a shape added with reports
+ /// this type, since Box3D has no separate box shape.
+ ///
Hull = 3,
/// A triangle mesh.
@@ -48,7 +52,7 @@ public enum ShapeType
/// var def = ShapeDefinition.Default with
/// {
/// Density = 2000.0f,
-/// Material = PhysicsMaterial.Default with { Friction = 0.9f },
+/// Friction = 0.9f,
/// EnableContactEvents = true,
/// };
///
@@ -76,6 +80,47 @@ public readonly record struct ShapeDefinition
/// Gets the surface properties.
public PhysicsMaterial Material { get; init; }
+ /// Gets the friction of .
+ ///
+ ///
+ /// A shortcut for the common case of changing only the friction, which
+ /// otherwise takes a nested with. Setting it replaces the material
+ /// with a copy that differs only in friction; nothing is stored apart from
+ /// .
+ ///
+ ///
+ /// Initializers run in order, so when setting both, set
+ /// first: a after this one
+ /// replaces the friction it set.
+ ///
+ ///
+ ///
+ ///
+ /// var grippy = ShapeDefinition.Default with { Friction = 0.9f };
+ /// // Same as:
+ /// var nested = ShapeDefinition.Default with
+ /// {
+ /// Material = PhysicsMaterial.Default with { Friction = 0.9f },
+ /// };
+ ///
+ ///
+ public float Friction
+ {
+ get => Material.Friction;
+ init => Material = Material with { Friction = value };
+ }
+
+ /// Gets the restitution, or bounciness, of .
+ ///
+ /// A shortcut in the same way as , and with the same
+ /// caveat: set first when setting both.
+ ///
+ public float Restitution
+ {
+ get => Material.Restitution;
+ init => Material = Material with { Restitution = value };
+ }
+
/// Gets the collision filtering rules.
public CollisionFilter Filter { get; init; }