Physics
Physics in Eustress is rigid-body simulation by Avian, stepped at a fixed 60 Hz in SI units. Parts hold still while you edit; press Play and every unanchored part falls and collides, and a destructible part can dent and crack. Press Stop and the world returns to where it was.
01Overview
Avian at a Fixed Step
Eustress simulates rigid bodies with Avian 0.7, a physics engine built for Bevy, the ECS that Eustress runs on. Avian does collision detection, contact solving, joints, sleeping and spatial queries. Eustress decides which parts become bodies, what shape their colliders take, when the physics clock runs, and what happens when a part breaks.
The substep count and solver settings are set explicitly to Avian's defaults, so an Avian upgrade cannot quietly change trajectories. The frame time fed to the fixed step is capped at 33 ms, so one slow frame costs about two catch-up steps instead of a spiral of them. A body that stays at rest for 0.5 s falls asleep (Avian's default) and costs no solver time until it is disturbed.
The headless runtime runs the same Avian setup and the same Play activation. Joint binding, deformation, fracture and collider streaming are registered by Studio, so a headless run simulates bodies and colliders without them. See CLI & Headless. Heat, electrochemistry, material laws and particle fluids come from the Realism libraries, and the simulation clock and recordings from Simulation.
SI Units
The world is meter-native: one unit of space is one meter. Everything on this page is in SI units.
| Quantity | Unit | Where you meet it |
|---|---|---|
| Length | meter (m) | Size, Position, ray distances |
| Mass | kilogram (kg) | Body mass, computed from volume and density |
| Density | kg/m³ | The density key under [properties.physics] |
| Time | second (s) | The 1/60 s physics step |
| Force, impulse | newton (N), N·s | Contacts and impacts |
| Energy | joule (J) | Impact energy and fracture thresholds |
| Stiffness, strength | pascal (Pa) | Young's modulus, yield strength |
| Fracture toughness | Pa·√m | K_IC in the material presets |
| Angle | radian | Joint angle limits |
Repeatable Runs
Same inputs, same world. The step, substeps and solver settings are pinned, and
randomness that shapes a simulation draws from one global seed (GlobalRngSeed, a fixed constant by default) instead
of system entropy. The repository's same_seed_same_world test
holds the engine to it: it drops 16 cubes onto a floor twice with the same seed,
steps each world 120 times and requires identical final poses.
Agents can advance the live world one tick at a time. The sim_step tool of the MCP server runs N fixed steps of 1/60 s (up to 10,000 per call) and leaves physics paused
afterward, so nothing moves between steps.
Eustress builds Avian without its enhanced-determinism option, which makes math identical across CPU architectures. Identical
results are expected from the same build on the same kind of machine.
Large Scenes
A still scene is cheap. While the physics clock is paused, Avian's per-step collider upkeep runs only on steps where a collider moved, appeared or disappeared, so a static Edit-mode Space skips it however many parts it holds.
Very large Spaces streamed from the WorldDb go further. Their anchored parts load with a collider description instead of a collider, and during Play a real collider is created only for parts within 128 m of physics activity (an awake dynamic body, the character or the main camera) and removed again beyond 160 m, with at most 512 such changes per frame.
To time Avian on your own hardware, the repository includes a headless benchmark. It runs 600 steps at 60 Hz for piles of 1,000 to 25,000 falling cubes and for 10,000 and 100,000 static colliders with 100 dynamic bodies, and reports the mean step time over all steps, the first 100 and the last 100.
cd eustress/benches/instance-capacity
cargo run --release --bin avian-physics-bench02Edit and Play
Edit Mode
Edit mode does not simulate. The physics clock is paused from the moment Studio starts, and every part that collides is a static body, so nothing falls or drifts while you build.
Colliders still exist in Edit mode. Clicking and hovering parts in the viewport and agent raycasts use the same Avian colliders that Play will simulate.
Starting Play
| Key | Action |
|---|---|
F5 | Play with a character |
F7 | Play Solo: free editor camera, no character |
F6 | Pause or resume the physics clock |
F8 or Esc | Stop |
When Play starts, the physics clock unpauses and every unanchored part that has a collider becomes a dynamic body. Anchored parts stay static, and a part with CanCollide off has no collider, so it is never simulated. Play with a character also spawns the avatar, at a SpawnLocation when the Space has one.
Anchored is live during Play: unanchoring a part makes it dynamic at once, and
anchoring it makes it static again with its velocity zeroed. Pausing freezes the
physics clock without leaving Play. With the Roblox keymap, Play Solo moves to F8 and Stop to Shift+F5.
What Stop Restores
Pressing Play takes a snapshot of the world, and Stop puts it back. The physics clock pauses and the world returns to the state it had when you pressed Play:
| What | On Stop |
|---|---|
| Parts | Position, rotation and scale; Anchored, CanCollide, Transparency and Color |
| Deleted parts | Reloaded from the snapshot if a script or tool removed them |
| Play-only objects | The character and its camera, fracture fragments and anything else spawned during Play are removed |
| Damage | Dented meshes return to their authored shape and fractured parts reappear |
| Humanoids | Health, maximum health, walk speed and jump power |
| Simulation state | Battery and thermal state, and the Lighting service |
That full restore runs for the Stop button, F8 and Esc. A second check runs on every return to Edit mode, whatever
ended Play: if the snapshot has not been restored yet, it rewinds part poses,
Anchored, CanCollide and simulation state, so no part is left where physics threw it.
03Bodies and Colliders
Physics Properties
A part's physics comes from a few properties. The Properties panel lists them
under Physics, and the part's file stores them in its [properties] table.
| Property | File key | Default | Effect |
|---|---|---|---|
Anchored | anchored | false | Static in Play when on, dynamic when off |
CanCollide | can_collide | true | Off means no collider: never simulated, not hit by rays, and bodies pass through it |
Destructible | destructible | false | Impacts in Play can dent the part and crack it in two |
Locked | locked | false | Stops click selection in the viewport; no effect on physics |
Parts also carry CanTouch, CollisionGroup, Density and Mass values, but the rigid-body solver does not read them yet. Density is used only when a part fractures (see Fracture).
Whether a part gets a collider is decided when it loads. Toggling CanCollide on a part already in the world saves the new value, and Rune raycasts honor it at once, but the collider itself is added or removed only when the Space next loads.
Mass, Friction and Bounce
Avian derives each body's mass, center of mass and inertia from its collider's volume and a density. A part without a physics table gets Avian's defaults: density 1.0 kg/m³, friction 0.5 and restitution 0. A 1 m cube therefore has a mass of 1 kg, slides moderately and does not bounce.
A part's Material sets how it looks and, for a destructible part, how it takes
damage. It does not change mass, friction or bounce in the solver. Those come from
a [properties.physics] table, which the Roblox importer writes for
every part with custom physical properties (Roblox friction fills both friction
coefficients, and elasticity becomes restitution). You can write one by hand:
[properties]
anchored = false
material = "Metal"
[properties.physics]
density = 7850.0 # kg/m³, drives mass and inertia
friction_static = 0.74 # resistance before sliding starts
friction_kinetic = 0.57 # resistance while sliding
restitution = 0.6 # bounce, 0 to 1Each key is optional. A single friction value is used for both coefficients, and a density must be positive to apply.
Collider Shapes
Colliders are simple shapes fitted to the part's Size, which keeps contacts fast and stable:
| Part shape | Collider | Fitted to |
|---|---|---|
| Block | Box | Size on all three axes |
| Wedge, CornerWedge | Box | The full bounding box, so a slope collides as its box |
| Ball | Sphere | A radius of half Size X |
| Cylinder, Cone | Cylinder | A radius of half Size X and a height of Size Y |
| Custom mesh (GLB) | Box | The part's Size |
| CAD part | Convex hull or convex pieces | The body's real shape |
A convex CAD body gets a single hull, and any other body is decomposed into convex pieces. A body over 20,000 triangles gets one hull instead, and one whose surface is not closed falls back to a box. The character collides as a capsule, and fracture fragments as convex hulls.
Size, Applied Once
A part renders a unit mesh stretched by its transform, so its Transform scale equals its Size. Avian also multiplies every collider by the scale of the entity it sits on. Eustress therefore builds the collider at Size divided by scale (a unit shape for an ordinary part), and Avian's multiply brings it back to exactly Size. A 6 m plate collides as a 6 m plate.
The same rule runs whenever a part loads and whenever you resize one with the Scale tool or the Size property, so what collides always matches what you see. A part whose scale is negative or not a finite number, such as a mirrored import, gets no collider rather than an inverted one.
04Joints
Imported Constraints
A joint connects two bodies and removes some of their relative freedom. Constraints
in an imported Roblox place become Avian joints when you press Play. The importer
stores each constraint's settings in a [constraint] table and its
two ends in a [references] table, as the UUIDs of the parts (Part0, Part1) or attachments (Attachment0, Attachment1) it connects. At Play,
Eustress looks those instances up, walks from each attachment to the part that owns
it, and inserts the joint once both ends exist.
| Class | Ends | Avian joint | Reads |
|---|---|---|---|
| WeldConstraint | Parts | FixedJoint | enabled |
| Motor6D | Parts | RevoluteJoint | enabled, lower_angle, upper_angle |
| HingeConstraint | Attachments | RevoluteJoint | enabled, lower_angle, upper_angle |
| TorsionSpringConstraint | Attachments | RevoluteJoint | enabled |
| PrismaticConstraint | Attachments | PrismaticJoint | enabled |
| CylindricalConstraint | Attachments | PrismaticJoint (slide only) | enabled |
| BallSocketConstraint | Attachments | SphericalJoint | enabled |
| UniversalConstraint | Attachments | SphericalJoint | enabled |
| DistanceConstraint | Attachments | DistanceJoint, 0 to max_distance (5 m if absent) | enabled, max_distance |
| RopeConstraint | Attachments | DistanceJoint, 0 to length (10 m if absent) | enabled, length |
| RodConstraint | Attachments | DistanceJoint held at length (2 m if absent) | enabled, length |
| SpringConstraint | Attachments | DistanceJoint held at rest_length (5 m if absent) | enabled, rest_length |
Attachment offsets are not applied yet, so every imported joint binds at
the two parts' centers: a weld pulls the centers together, and a hinge turns
about the centers and each part's local Z axis (Avian's default). Angle
limits apply only when both are present and are read in radians. Setting enabled = false binds the joint but tells Avian to skip it.
Mates
To join two parts by hand, use the Constraints group on the Model tab. Hinge, Slide and Ball each start a pick tool: click the first part, then the second, and Eustress inserts an Avian joint between them.
| Button | Avian joint | Free motion |
|---|---|---|
| Hinge | RevoluteJoint | Rotation about the Y axis |
| Slide | PrismaticJoint | Sliding along the X axis |
| Ball | SphericalJoint | Rotation in every direction |
A mate acts in Play, on whichever of its two parts is unanchored. It can be undone, and it lasts for the current session: mates are not saved with the Space yet.
Not Wired Yet
Several constraint classes exist without a working path to Avian today:
- Weld and Motor buttons insert a WeldConstraint or Motor6D whose part fields are empty and which has no [references] table, so it binds nothing.
- Movers (VectorForce, Torque, AlignPosition, AlignOrientation, LinearVelocity, AngularVelocity and the legacy BodyPosition, BodyVelocity, BodyGyro, BodyAngularVelocity, BodyForce and BodyThrust) have a runtime that applies forces in Play, but loading a Space does not give them their settings, so they exert nothing.
- PlaneConstraint has no Avian equivalent and does nothing.
- Seat and VehicleSeat are data only; nothing seats a character.
- Motors and springs: Motor6D and HingeConstraint joints have no motor drive, SpringConstraint is rigid at its rest length, and TorsionSpringConstraint has no spring.
05Queries and Contacts
Raycasts in Rune
Rune scripts cast rays against the live colliders. workspace_raycast returns the closest hit or None, and workspace_raycast_all returns up to max_hits hits sorted by distance. Each hit has instance (the part's name), entity_id, position, normal, distance and material.
use eustress::{Vector3, workspace_raycast, set_sim_value};
pub fn on_update(dt) {
let origin = Vector3::new(0.0, 50.0, 0.0);
let down = Vector3::new(0.0, -1.0, 0.0);
// The answer is last frame's cast from this same call site.
if let Some(hit) = workspace_raycast(origin, down, None) {
set_sim_value("probe.ground_height", hit.position.y);
}
}A script's raycast is answered after the frame, not during the call. The Nth
raycast a script makes in a frame returns the answer to its Nth raycast of the
previous frame, so a fixed pattern of casts per on_update reads
results one frame old. An answer whose origin has moved more than 2 m since it was
cast is discarded, and the call returns None.
Pass None for the parameters. The default ray reaches 1,000 m and
skips parts whose CanCollide is off. The RaycastParams type has
no constructor registered for Rune yet, so filtered casts are not available from
scripts.
The Luau raycast module (workspace:Raycast and RaycastParams) is not connected to the Luau VM, so Luau
scripts have no spatial queries today. Use Rune for raycasts; see Scripting.
Raycasts for Agents
Over the MCP server, scene_raycast casts a ray against the
running engine's Avian colliders, in Edit mode or Play, and returns its hits
nearest first, each with an entity id, name, distance and hit point.
{ "tool": "scene_raycast",
"arguments": { "origin": [0, 50, 0], "direction": [0, -1, 0],
"max_distance": 200, "max_hits": 4 } }origin defaults to [0, 0, 0] and direction to straight down; max_distance defaults
to 1,000 m and max_hits to 8, at most 256. Use scene_raycast rather than the older raycast tool,
which does not reach the live engine and returns an error.
Touched Events
When Play starts, every part is registered with the Luau runtime and asks Avian to
report its collisions. When two registered parts start touching, each part's Touched signal fires with the other part as its argument, and TouchEnded fires when they separate. CanTouch does not filter these
events yet.
06Characters
The Character Body
Play with a character spawns the avatar as a kinematic capsule: Avian collides it with the world but never integrates it, and each frame the controller moves it with Avian's move-and-slide query. Its rotation is locked, its friction and bounce are zero, and its mass and capsule come from the avatar's height (1.45 m to 2.05 m) and build. Studio and the Player share this controller.
Two things follow from a kinematic body. Walking into an unanchored part does not push it; the capsule stops or slides along it. And the character falls under its own gravity of 9.80665 m/s², separate from the gravity that dynamic parts use.
Ground, Slopes and Steps
| Rule | Value |
|---|---|
| Walkable slope | Up to 50°; anything steeper is a wall |
| Step-up | Ledges up to 0.30 m are stepped, not jumped |
| Ground probe | A sphere cast 0.35 m below the capsule, so edges and stair noses do not flicker between grounded and airborne |
| Coyote time | A jump still works 0.12 s after leaving an edge |
| Jump buffer | A jump pressed up to 0.10 s before landing still fires |
| Speeds | Walk 1.45 m/s and run 3.9 m/s, scaled by leg length |
| Jump | An apex of 0.55 m plus 0.35 m scaled by leg length, launched at √(2 g h) |
A Humanoid's walk speed and jump power are saved and restored with the world, but this controller takes its speeds from the avatar's body. If the capsule ends up wedged between colliders, the controller searches nearby for free space and moves it there.
Climbing and Feet
Every climbing move goes through one placement check: the capsule is tested against the colliders at its target, and if it would not fit, it stops at the last clear point on the way. That keeps a climbing character out of walls and out of the ground.
Foot placement casts a ray down from each foot, plants a foot that has nearly stopped and holds it in place so it cannot slide, and tilts it by up to 35° to follow a slope. It fades out above 1.4 times run speed, where the animation reads better on its own.
07Deformation and Fracture
Making a Part Destructible
A destructible part can be dented by impacts and cracked into two bodies. Turn on Destructible in the Physics section of Properties, or set it in the part's file:
[properties]
material = "Concrete"
destructible = trueDamage happens only in Play, in Studio. The part's Material name picks its mechanical constants, matched regardless of case, spaces and underscores:
| Material names | Preset | Young's modulus | Yield strength | Toughness K_IC |
|---|---|---|---|---|
| Metal, CorrodedMetal, DiamondPlate, Steel | Steel | 200 GPa | 250 MPa | 50 MPa·√m |
| Aluminum, Foil | Aluminum | 70 GPa | 270 MPa | 30 MPa·√m |
| Concrete, Brick, Granite, Marble, Slate, Cobblestone | Concrete | 30 GPa | 30 MPa | 1 MPa·√m |
| Glass | Glass | 70 GPa | 45 MPa | 0.7 MPa·√m |
| Ice | Ice | 9 GPa | 1 MPa | 0.1 MPa·√m |
| Wood, WoodPlanks | Wood (Oak) | 12 GPa | 60 MPa | 10 MPa·√m |
| Rubber, Fabric, Grass | Rubber | 10 MPa | 15 MPa | 5 MPa·√m |
| Plastic, SmoothPlastic, Neon, Sand, Pebble | Plastic (ABS) | 2.3 GPa | 40 MPa | 3 MPa·√m |
Any other material name uses a generic plastic-like fallback. The MCP tool query_material returns these same constants for a material name.
Writing an explicit [material] table switches the name
lookup off, and every field you leave out is read as zero. A zero modulus,
yield strength or toughness then falls back to the generic plastic value,
not to your material's preset, so write every constant you need.
From Impact to Dent
On every physics step in Play, Eustress reads Avian's contact list for destructible parts. A contact counts as an impact when the two bodies close at 0.5 m/s or faster; slower contact is resting weight. The energy the collision must absorb is:
Contact mechanics turns that energy into a depth. Below the material's yield-onset energy E_y the dent is elastic (Hertz contact) and springs back, about 95 percent within 0.6 s. Above it the dent is plastic, set by an indentation hardness H of three times the yield strength, and it stays until Stop.
The impactor's radius is taken as half its smallest dimension. The dent pushes along the struck surface's normal, is capped at a quarter of the part's smallest dimension and fades to nothing within 5 cm of an edge. A part takes at most one impact every 0.25 s, so one collision leaves one dent. The mesh keeps its authored vertices until it is struck, then is refined only where the crater lands, with edges down to 1.5 cm and at most 8,000 triangles per part.
Depths are physical, not exaggerated, so stiff and strong materials such as steel barely mark under everyday hits. A dent changes the rendered mesh only; the part's collider keeps its shape.
Fracture
Before denting, the impact energy is compared with the energy needed to run a crack through the part's smallest cross-section, the Griffith criterion:
When the energy exceeds it, the part splits in two along a plane that contains the impact direction, so a part struck from above cracks down through itself. Each half becomes a dynamic body with a convex-hull collider and the part's Density (900 kg/m³ unless changed) and inherits the part's motion, and each half is pushed off the crack plane at 0.35 m/s so the crack opens. The original part is hidden and its collider switched off, not deleted; Stop brings it back and removes the fragments.
Because toughness enters squared, materials separate sharply: a crack through concrete costs about 33 J per square meter of cross-section, and through steel about 12,500 J. Fragments smaller than 5 cm are refused, at most four fractures run per frame, and fragments are not destructible themselves, so each part splits once per Play session.
08What's Next
Material-Driven Contact
Mass, friction and bounce will follow each part's Material and Density, so a steel
ball will outweigh a wooden one without a [properties.physics] table. CanTouch and CollisionGroup will filter touches and contacts, CanCollide
will switch a collider on and off while the part is in the world, and Studio will
be able to show every collision shape in the viewport.
Joints and Movers
Joints will bind at their attachments' offsets and axes, motors and springs will drive and stiffen them, and Weld and Motor from the ribbon will bind the parts you choose. Movers will load their settings from disk so VectorForce and its family push bodies, seats will seat characters, and mates will be saved with the Space.
Script Physics
Luau scripts will get workspace:Raycast on the same bridge Rune
uses, and Rune scripts will build RaycastParams filters.
Destructible parts will gain a visual damage scale, so a scene can exaggerate dents
without changing the physics behind them.
Press Play to drop it. Press Stop to put it back.