The headless simulation layer of Joy Engine (the joy-environment, joy-physics, and joy-gameplay crates): the environment clock, rigid-body physics and raycast vehicles, destructible chunk structures, entities, runtime components, systems, and controllers, authored level and object documents with the editor's undo history, the component schema catalog, and the level runtime. It uses only the foundation layer (joy-core, plus joy-terrain for terrain colliders), never content, rendering, or platform modules, and builds identically for native targets and wasm32-unknown-unknown.
| Module | Contents |
|---|---|
environment |
EnvironmentOptions (serde), EnvironmentSystem, EnvironmentFrame |
physics |
PhysicsWorld, BodyOptions/BODY_DEFAULTS, VehicleOptions/WheelOptions/VEHICLE_DEFAULTS/WHEEL_DEFAULTS, BodyHandle, RaycastHit |
physics (SDF colliders) |
SdfShape, SdfPrimitive, SdfBakeOptions/SDF_BAKE_DEFAULTS, SdfCollisionStatistics, PhysicsWorld::create_sdf_body |
physics (terrain) |
HeightfieldOptions, PhysicsWorld::create_heightfield_body, TerrainColliders, TerrainColliderOptions, TerrainColliderReport |
physics (backend contract) |
PhysicsBackend (implemented by PhysicsWorld), PhysicsResidency, PhysicsCollisionShape, PhysicsMutationBatch, PhysicsQueryHit, create_removal_mutation_batch |
physics::destruction |
ChunkStructure, DestructibleChunkDefinition, RadialDamage, ChunkChangeSet, ChunkDamageState, ChunkRaycastHit, ChunkSupportOptions, ChunkSupportState, create_chunk_mesh_data, DestructionError |
gameplay (documents) |
EntityDefinition, EntityTransform, LevelData, parse_level/serialize_level, LevelDocument, ObjectDocument, resolve_level_objects, asset_path |
gameplay (components) |
component_type, entity_type, component_preset, component_category, component_asset_kind; the presentation components' readers and schemas (RenderComponent, LightComponent, PostProcessComponent, engine_component_catalog) live in rendering |
gameplay (schemas) |
ComponentSchemaCatalog, ComponentSchema, ComponentFieldSchema, ComponentFieldKind, ComponentFieldCondition, ComponentPreset, ComponentIssue, CreatedComponent, is_component_field_active, built_in_component_schemas, ComponentSchemaError |
gameplay (runtime) |
EntityId, Entity/EntityState, EntityBehavior, EntityComponent, ComponentRegistry, SimulationContext, EntityRegistry, EntityWorld, EntitySystem, World, LevelRuntime |
gameplay (control) |
ControlIntent, ControlValue, Controller, IntentController (with PlayerController and AiController aliases), ControllerId |
gameplay::grid_navigation |
search_grid, trace_grid_route |
Simulation also uses these foundation additions in joy-core: config_values (ConfigField, ConfigApplyMode, create_config_values, create_config_document_values, create_config_schema), math (glam's f32 and f64 vector, matrix, and quaternion types, scalar helpers, and math::deterministic), and color (Color for sRGB-encoded values such as hex palettes, and LinearColor for linear-light, HDR-capable values that lights and meshes take). Simulation positions and directions use math::DVec2/DVec3. Foundation records use f64; core::Rgb/Rgba remain the f32 GPU colors.
Owners hand out copyable ids and handles instead of references. PhysicsWorld issues BodyHandle values stamped with the issuing world's identity, so a handle from another world or a removed body is reported as unknown rather than aliasing a different body. A raycast vehicle is a kind of body: PhysicsWorld::create_vehicle validates the chassis body and its wheels together and returns the chassis BodyHandle, vehicle operations (set_vehicle_input, wheel_transforms, vehicle_speed, and tuning) take that handle, and removing the body removes the vehicle. EntityWorld owns entities (insertion-ordered), controllers, and systems, and issues EntityId, ControllerId, and SystemId values.
Each Entity is split into plain EntityState, an optional boxed EntityBehavior, and runtime EntityComponents keyed by component type. The world takes the behavior out while it runs, and borrows a component and the state as disjoint fields, so hooks receive &mut EntityState plus a SimulationContext (a mutable borrow of physics and read access to the entity-to-body map) without aliasing their owner. Structural changes from hooks go through SimulationContext::request_spawn/request_remove. The body listed for an entity (vehicle chassis included) is owned by it: removing the entity removes it. Component and behavior resources are Rust values released by Drop.
Spawning registers the entity, attaches a runtime component for every authored component type the ComponentRegistry knows (in authored order), and then runs on_spawn; a failing on_attach detaches that component (running on_detach) and rolls back the whole spawn. Removal releases possession, runs on_destroy, detaches components in reverse attachment order (each on_detach, then drop), removes the entity's body, and finally drops the behavior. Systems run before behaviors in insertion order and receive &mut [&mut Entity] matched by runtime components; on_add failures remove the system again (running on_remove). World is the composition root that owns EntityWorld, PhysicsWorld, and the entity-to-body map (World::split provides the borrows, World::from_parts accepts an EntityWorld built with a component registry); dropping it removes systems (reverse order), then entities, then physics.
ChunkStructure copies its definitions and owns all damage state; queries borrow definitions, and change sets, damage states, and support diagnostics are owned copies. LevelDocument owns its level, up to 80 undo snapshots, the redo stack, and the selection; edits run on isolated drafts and snapshot() returns owned copies. ComponentSchemaCatalog owns registered schemas and lends read-only borrows.
Physics uses rapier3d 0.36 with enhanced-determinism; identical worlds stepped identically produce bit-identical transforms, and rapier types never appear in the public API. Inputs that feed the solver (Euler rotations and damping coefficients) are computed with rapier's libm-backed scalar math rather than platform math, so native and WebAssembly builds create bit-identical bodies. Stepping uses a bounded accumulator (accumulate, clamp to fixed_step * max_sub_steps, step while accumulator + 1e-12 >= fixed_step, discard the excess), and each fixed step records every body's starting pose, updates vehicles, steps the solver, and then clears one-step user forces.
Presentation interpolates between fixed steps. PhysicsWorld::interpolation is the leftover accumulator time as a fraction of a step, and interpolated_body_transform and interpolated_wheel_transforms blend each body from its pose before the latest step toward its current pose by that fraction (positions linearly, rotations along the shortest arc; teleports and new bodies start without a blend). Drawn motion trails the simulation by up to one step but advances every display frame, so a 144 Hz display no longer shows 60 Hz bodies moving on an uneven two-or-three-frame cadence. World::presented_entities and World::presentation_transform apply the blend to entities that own bodies, and LevelScene presents them directly; gameplay keeps reading the solver state through body_transform and EntityState::transform.
PhysicsWorld implements the PhysicsBackend contract as the engine's CPU backend: apply_mutations removes listed shape ids and adds static boxes keyed by shape id, atomically, rejecting batches older than the newest applied sequence, duplicate or empty ids, and non-finite or non-positive boxes; query casts only against resident shapes and reports world distance; step advances the solver. Destructible structures feed it through create_removal_mutation_batch(change.revision, removed_ids), so collision changes at the same boundary as damage, and debris bodies collide with the surviving chunks.
- Mass zero is fixed; unrotated planes face +Y. Friction and restitution combine by multiplication.
- Damping is an authored per-second fraction translated into the solver coefficient. Bodies do not sleep.
- Raycasts inspect current poses and ignore a body containing the ray origin.
- Vehicles use Y-up, +Z forward, downward suspension rays, and axle (-1, 0, 0). Positive throttle drives forward; positive steering turns toward +X.
rollInfluenceis validated and stored, but rapier 0.36 keeps its internal value private at 0.1.- Rolling/brake friction is divided by grounded-wheel count before clamping. Opposite side impulses are not applied to dynamic ground bodies.
- Wheel ray queries see new/moved colliders after the next solver step. Discrete solver behavior and authored tuning can produce wheelies or tunneling; test driving interactively.
SdfShape describes a solid by its signed distance: SdfShape::primitive takes an analytical box, sphere, capsule, cylinder, or torus, and SdfShape::from_triangle_mesh bakes any triangle mesh (FORM output included) into a voxel grid of distances and gradients, with no convex decomposition. Baking signs the grid by winding number along X, Y, and Z grid lines with a majority vote, so open seams, flipped winding, and overlapping parts still enclose a solid. A shape precomputes its bounds, unit-density mass properties, and high-curvature seed points once; it is an immutable shared handle, so every body built from it reuses the grid. PhysicsWorld::create_sdf_body takes the shape plus ordinary BodyOptions (mass, pose, materials, and damping apply; the primitive fields are ignored), and the shape's frame is the body frame.
Contacts follow Giles and Andrews, Real-Time Collision Handling for Signed Distance Fields via Caching and Importance Sampling (Pacific Graphics 2026), adapted from its MIT-licensed reference implementation (see crates/joy-physics/src/sdf/LICENSE-sdf-dcd-demo; web builds include it in THIRD-PARTY-NOTICES.txt). For each pair with overlapping bounds, Nelder-Mead minimizes a smooth maximum of both distances, minus a repulsion from points already found, from a few seeds: last step's points (a temporal cache that also carries the solver's warm-start impulses) and a random high-curvature seed. A chained narrow-phase dispatcher handles pairs involving an SDF collider (against SDFs, boxes, spheres, and planes) and passes every other pair to rapier's default dispatcher unchanged, so worlds without SDF bodies simulate exactly as before. Each manifold point becomes its own rapier manifold, which rapier's contact clustering merges by normal. PhysicsWorld::sdf_collision_statistics reports the latest fixed step's pairs, contacts, iterations, and distance queries.
The prototype departs from the paper where the engine needed it:
- Normals and depths are read one-sidedly at the optimized point's projections onto both surfaces, keeping the deeper reading, because the paper's "deeper field at the point" rule tilts resting contacts wherever an edge or corner meets a face.
- Cached points live in both bodies' frames and ride their poses, refine to a quarter of the tolerance, keep their position unless the refinement is deeper, and skip optimization when the bodies barely moved relative to each other.
- Pairs with SDF colliders opt out of rapier's contact recycling (through a no-op contact filter hook), which would otherwise freeze the cache's step-by-step refinement while bodies settle.
- Sampling uses fixed per-pair seeded streams and the solver's scalar math, so identical worlds produce identical results, natively and in WebAssembly.
Limits: collision stays discrete, so fast or thin bodies can tunnel; SDF colliders ignore meshes, compounds, sensors, shape casts, and continuous collision detection; and several contact points spread across a small curved-on-flat overlap, which acts as rolling resistance (an SDF sphere launched at 4 m/s on a plane rolls at about 1.85 m/s after three seconds, where rapier's ball keeps 2.57 m/s). Cost is CPU work per overlapping pair. In the SDF collisions example benchmark, 50 non-convex props cost about 4 ms per fixed step on average and 8 ms once the pile is complete natively, and about twice that in WebAssembly, against 0.1 to 0.2 ms for the same props as boxes. Baked grids take about 0.4 to 2.6 MB at 40 to 48 cells per side.
PhysicsWorld::create_heightfield_body builds a static heightfield from a row-major grid (HeightfieldOptions: the -X, -Z corner, sample spacing, and heights). Every cell splits along its (-X, -Z) to (+X, +Z) diagonal, the split the terrain renderer and TerrainSurface sampling use, so bodies rest on the triangles players see. Rapier collides boxes, spheres, planes, and vehicle wheel rays with it natively. SDF bodies collide with it through the SDF dispatcher, which reads a heightfield as a distance field: the signed distance to the plane of the triangle under a point, with that triangle's upward normal as the gradient (a conservative Lipschitz bound of two keeps the overlap test safe across triangle edges). Parry's internal-edge correction stays off, because it assumes parry's default diagonal and discards contacts on the terrain split.
TerrainColliders keeps one heightfield per resident tile of a joy_terrain::TerrainSurface's collision level within a radius of world-space anchors (the player, props, vehicles). sync creates colliders for tiles that come within reach, rebuilds those whose tile revision changed (a crater, for example), and removes the rest, visiting tiles in key order so solver order stays deterministic. A surface of another lineage replaces every collider. The terrain's local space maps to the world by translation only (TerrainColliderOptions::offset). Keep a streamer's collision interests on the same anchors and radius, since colliders exist only where the collision level is resident.
ChunkStructure is solver-independent and uses f64 arithmetic and portable distance helpers, preserving recorded deterministic results. Damage is queued and applied atomically at commit(): overlapping hits accumulate, chunks at zero health leave first, then support is evaluated once and unsupported chunks detach in the same revision (the support diagnostics describe that evaluation). Support is a geometric masonry rule: chunks touching the ground plane anchor, stacked contacts and side-face contacts (never corners) carry support, and horizontal offsets spend the cantilever allowance; a shortest-path search with generation-order tie-breaking resolves reach. Revisions increment only when chunks leave and on every reset(). create_chunk_mesh_data returns closed boxes as a plain render_types::Mesh (positions, indices, per-vertex material tints, and chunkCount/closedInteriorSurfaces metadata) for the rendering layer to upload.
Entities update in insertion order. World steps physics before entities; LevelRuntime updates entities with at most 0.1 seconds, steps physics, then copies poses. Component setup rolls back on failure, possession is exclusive, and removal clears control intent. Spawn/remove requests from lifecycle hooks apply at the next update boundary.
LevelDocument validates isolated drafts and keeps up to 80 undo snapshots. EntityTransform stores f32; authored transforms are rounded at that boundary. serialize_level writes the supplied data, so validate edited levels explicitly; object serialization validates and returns Result.
Registries reject duplicate/blank types. Runtime component setup can fail in on_attach; factories themselves are infallible. Systems cannot be structurally changed during their update. Worlds own their physics. Removed-body and non-vehicle queries return None, zero, or empty results, and nonfinite ray input is rejected. Mutation sequences are unsigned and backend operations synchronous.
Recorded fixtures cover destruction revisions, support/raycast/mesh results, schema defaults and validation, document edit/undo/redo serialization, grid tie-breaking, and component/system lifecycle order. The joy-physics and joy-gameplay test suites (cargo test -p joy-physics -p joy-gameplay) run those scenarios natively without building the renderer or window stack; WebAssembly checks ensure the same headless code builds for browsers.