Physics in Scripts
Access and control physics simulation from your scripts. Master velocity manipulation, force application, raycasting, collision callbacks, and character movement.
Web Engine provides a comprehensive physics API accessible from scripts. Built on Rapier3D, it supports rigid body dynamics, character controllers, raycasting, collision detection, and advanced queries.
Forces & Impulses
Apply physics forces and impulses to entities
Raycasting
Cast rays to detect objects and surfaces
Collision Events
React to collisions and triggers
Velocity Control
Read and write linear velocity
Character Control
Move characters with collision response
Physics Queries
Overlap tests and shape casting
Accessing Physics in Scripts#
Physics operations are available through the api object. Most physics methods are exposed directly on the API surface for convenience.
export default (api) => { return (dt, time) => { // Direct physics access const vel = api.velocity; // Get current velocity api.applyImpulse({ x: 0, y: 10, z: 0 }); // Apply jump impulse // Raycasting const hit = await api.raycast(origin, direction); // Character controller api.controller.move({ x: 1, y: 0, z: 0 }); const grounded = api.controller.isGrounded; };};Reading and Writing Velocity#
Velocity represents the linear motion of a rigid body in meters per second. You can both read and write velocity directly.
Reading Velocity#
export default (api) => { return (dt, time) => { // Get current velocity (Vector3) const vel = api.velocity; // Check if moving const speed = Math.sqrt(vel.x ** 2 + vel.y ** 2 + vel.z ** 2); if (speed > 0.1) { api.log(`Moving at ${speed.toFixed(2)} m/s`); } // Check if falling if (vel.y < -1.0) { api.log("Falling fast!"); } // Horizontal speed (XZ plane) const horizontalSpeed = Math.sqrt(vel.x ** 2 + vel.z ** 2); };};Writing Velocity#
Setting velocity directly overrides the physics simulation. Use this for instant speed changes, teleportation, or resetting motion.
export default (api) => { let hasLaunched = false; return (dt, time) => { const input = api.input; // Launch pad - instant velocity change if (input.jumpDown && !hasLaunched) { api.velocity = { x: 0, y: 20, z: 0 }; // Launch upward hasLaunched = true; } // Stop all movement instantly if (input.sprint) { api.velocity = { x: 0, y: 0, z: 0 }; } // Cap maximum speed (speed limiter) const vel = api.velocity; const speed = Math.sqrt(vel.x ** 2 + vel.y ** 2 + vel.z ** 2); const maxSpeed = 10.0; if (speed > maxSpeed) { const scale = maxSpeed / speed; api.velocity = { x: vel.x * scale, y: vel.y * scale, z: vel.z * scale }; } };};Direct Velocity vs Forces
Setting api.velocity directly overrides physics simulation. For realistic motion, prefer applyImpulse() which respects mass and momentum.
Applying Forces and Impulses#
Forces and impulses modify velocity over time. Impulses are instant velocity changes (jump, explosion), while forces are continuous acceleration (thrusters, wind).
Impulses (Instant Force)#
export default (api) => { const jumpForce = 10.0; const dashForce = 15.0; return (dt, time) => { const input = api.input; // Jump - instant upward impulse if (input.jumpDown && api.controller.isGrounded) { api.applyImpulse({ x: 0, y: jumpForce, z: 0 }); } // Dash forward - instant horizontal impulse if (input.sprint && input.move.y > 0) { const rot = api.rotation; const forwardX = -Math.sin(rot.y) * dashForce; const forwardZ = -Math.cos(rot.y) * dashForce; api.applyImpulse({ x: forwardX, y: 0, z: forwardZ }); } // Explosion knockback (from point) const explosionPos = { x: 0, y: 0, z: 10 }; const pos = api.position; const dx = pos.x - explosionPos.x; const dy = pos.y - explosionPos.y; const dz = pos.z - explosionPos.z; const dist = Math.sqrt(dx ** 2 + dy ** 2 + dz ** 2); if (dist < 5.0) { const force = 20.0 / (dist + 1.0); // Falloff with distance api.applyImpulse({ x: (dx / dist) * force, y: (dy / dist) * force, z: (dz / dist) * force }); } };};Impulse vs Velocity
applyImpulse() adds to existing velocity (cumulative). Setting api.velocity replaces it entirely. For jumps, use impulse. For teleportation, set velocity to zero.
Continuous Forces#
For continuous forces (thrusters, wind, magnets), apply small impulses every frame. This simulates constant acceleration.
export default (api) => { const thrustPower = 5.0; const gravity = -9.81; return (dt, time) => { const input = api.input; // Hover - counteract gravity continuously if (input.sprint) { // Apply upward force equal to gravity const antiGravityForce = -gravity * dt; api.applyImpulse({ x: 0, y: antiGravityForce, z: 0 }); } // Thruster control (space flight) if (input.move.y !== 0 || input.move.x !== 0) { const rot = api.rotation; // Forward/backward const thrustZ = -input.move.y * thrustPower * dt; const forwardX = -Math.sin(rot.y) * thrustZ; const forwardZ = -Math.cos(rot.y) * thrustZ; // Strafe left/right const thrustX = input.move.x * thrustPower * dt; const strafeX = Math.cos(rot.y) * thrustX; const strafeZ = Math.sin(rot.y) * thrustX; api.applyImpulse({ x: forwardX + strafeX, y: 0, z: forwardZ + strafeZ }); } // Wind force (environmental effect) const windStrength = Math.sin(time * 0.5) * 2.0; // Oscillating wind api.applyImpulse({ x: windStrength * dt, y: 0, z: 0 }); };};Raycasting from Scripts#
Raycasting casts an invisible ray through the world to detect objects. Use it for shooting, line-of-sight checks, ground detection, and interaction.
Basic Raycast#
export default (api) => { return async (dt, time) => { const pos = api.position; const rot = api.rotation; // Cast ray forward from entity position const origin = { x: pos.x, y: pos.y + 1.0, z: pos.z }; const direction = { x: -Math.sin(rot.y), y: 0, z: -Math.cos(rot.y) }; const hit = await api.raycast(origin, direction); if (hit) { api.log(`Hit entity ${hit.eid} at distance ${hit.distance.toFixed(2)}`); api.log(`Hit point: [${hit.point}]`); api.log(`Hit normal: [${hit.normal}]`); } else { api.log("No hit detected"); } };};Common Raycast Patterns#
export default (api) => { let lastHitEid = -1; return async (dt, time) => { const input = api.input; const pos = api.position; const rot = api.rotation; // === Ground Check === const groundOrigin = { x: pos.x, y: pos.y, z: pos.z }; const downDir = { x: 0, y: -1, z: 0 }; const groundHit = await api.raycast(groundOrigin, downDir); if (groundHit && groundHit.distance < 1.1) { api.log("On ground"); } // === Shooting/Firing === if (input.jumpDown) { const gunOrigin = { x: pos.x, y: pos.y + 1.5, z: pos.z }; const aimDir = { x: -Math.sin(rot.y), y: -Math.sin(rot.x), z: -Math.cos(rot.y) }; const shotHit = await api.raycast(gunOrigin, aimDir); if (shotHit) { api.log(`Shot hit entity ${shotHit.eid}!`); // Deal damage, spawn impact effect, etc. } } // === Interaction/Targeting (Highlight on Hover) === const lookOrigin = { x: pos.x, y: pos.y + 1.6, z: pos.z }; const lookDir = { x: -Math.sin(rot.y), y: 0, z: -Math.cos(rot.y) }; const lookHit = await api.raycast(lookOrigin, lookDir); if (lookHit && lookHit.distance < 3.0) { if (lookHit.eid !== lastHitEid) { api.log(`Looking at entity ${lookHit.eid}`); lastHitEid = lookHit.eid; // Highlight entity, show tooltip, etc. } } else { if (lastHitEid !== -1) { api.log("No longer looking at entity"); lastHitEid = -1; // Remove highlight } } // === Wall Detection (Obstacle Avoidance) === const wallCheckDist = 2.0; const forwardOrigin = { x: pos.x, y: pos.y + 1.0, z: pos.z }; const forwardDir = { x: -Math.sin(rot.y), y: 0, z: -Math.cos(rot.y) }; const wallHit = await api.raycast(forwardOrigin, forwardDir); if (wallHit && wallHit.distance < wallCheckDist) { api.log("Wall ahead! Stopping movement."); // Slow down, stop, or redirect movement } };};Async Raycasting
api.raycast() is asynchronous (returns a Promise). Always use await or .then(). Results may take 1-2 frames to arrive.
Collision Callbacks#
Collision callbacks fire when two rigid bodies collide. Use them for damage, sound effects, particle spawns, and gameplay events.
onCollisionEnter#
Fires once when two non-sensor colliders begin overlapping.
class DamageOnHit { constructor(api) { this.api = api; } onCollisionEnter(otherEid) { this.api.log(`Collision with entity ${otherEid}`); // Check what we hit (by tag, name, or other logic) // For this example, assume any collision deals damage // Play impact sound this.api.audio.play(/* impactSoundId */); // Spawn particle effect at collision point // const pos = this.api.position; // await this.api.spawn('ImpactParticles', pos); // Apply knockback const vel = this.api.velocity; const knockback = { x: -vel.x * 0.5, y: 5.0, z: -vel.z * 0.5 }; this.api.applyImpulse(knockback); } onUpdate(dt, time) { // Regular update logic }} export default (api) => new DamageOnHit(api);onCollisionExit#
Fires once when two colliders stop overlapping.
class ContactTracker { constructor(api) { this.api = api; this.contactCount = 0; } onCollisionEnter(otherEid) { this.contactCount++; this.api.log(`Contact started. Total contacts: ${this.contactCount}`); } onCollisionExit(otherEid) { this.contactCount--; this.api.log(`Contact ended. Total contacts: ${this.contactCount}`); if (this.contactCount === 0) { this.api.log("No longer in contact with any objects"); } } onUpdate(dt, time) { // Use contact count for gameplay logic if (this.contactCount > 0) { // Entity is touching something } }} export default (api) => new ContactTracker(api);Trigger Callbacks (Sensors)#
Triggers are sensor colliders that detect overlap without physical collision. Use them for zones, pickups, checkpoints, and area detection.
| Event | Description | Use Case |
|---|---|---|
| onTriggerEnter | Entity enters sensor volume | Pickup collection, zone entry |
| onTriggerExit | Entity exits sensor volume | Zone exit, area detection |
Trigger Examples#
// === Pickup Item ===class PickupItem { constructor(api) { this.api = api; this.collected = false; } onTriggerEnter(otherEid) { if (this.collected) return; this.api.log(`Entity ${otherEid} collected pickup!`); this.collected = true; // Play pickup sound this.api.audio.play(); // Destroy this pickup (remove from scene) // Note: Actual entity removal requires system-level support } onUpdate(dt, time) { if (!this.collected) { // Rotate pickup for visual effect const rot = this.api.rotation; rot.y += dt * 2.0; this.api.rotation = rot; } }} // === Zone Detector ===class DangerZone { constructor(api) { this.api = api; this.entitiesInZone = new Set(); } onTriggerEnter(otherEid) { this.entitiesInZone.add(otherEid); this.api.log(`Entity ${otherEid} entered danger zone!`); } onTriggerExit(otherEid) { this.entitiesInZone.delete(otherEid); this.api.log(`Entity ${otherEid} left danger zone`); } onUpdate(dt, time) { // Apply damage to all entities in zone for (const eid of this.entitiesInZone) { // Deal damage per second const damagePerSecond = 10.0; const damage = damagePerSecond * dt; // Apply damage logic here } }} // === Checkpoint System ===class Checkpoint { constructor(api) { this.api = api; this.activated = false; } onTriggerEnter(otherEid) { if (!this.activated) { this.api.log(`Checkpoint activated by entity ${otherEid}`); this.activated = true; // Save checkpoint position const pos = this.api.position; // Store checkpoint data (e.g., in global state) // Visual feedback this.api.audio.play(/* checkpointSound */); } } onUpdate(dt, time) { // Animate checkpoint if activated if (this.activated) { // Glow effect, particle system, etc. } }} export default (api) => new PickupItem(api);Collision vs Trigger
Collisions provide physical response (bounce, slide). Triggers only detect overlap without physics. Mark a collider as "Sensor" to make it a trigger.
Character Controller Movement#
Character controllers provide collision-aware movement for players and NPCs. They handle slopes, steps, and grounding automatically.
Moving the Character#
export default (api) => { const moveSpeed = 5.0; const sprintMultiplier = 2.0; return (dt, time) => { const input = api.input; // Check if grounded (for jump logic) const isGrounded = api.controller.isGrounded; // Calculate movement direction from input if (input.move.x !== 0 || input.move.y !== 0) { const rot = api.rotation; const speed = input.sprint ? moveSpeed * sprintMultiplier : moveSpeed; // Forward/backward movement const forwardX = -Math.sin(rot.y) * input.move.y; const forwardZ = -Math.cos(rot.y) * input.move.y; // Strafe left/right const strafeX = Math.cos(rot.y) * input.move.x; const strafeZ = Math.sin(rot.y) * input.move.x; // Apply movement via controller api.controller.move({ x: (forwardX + strafeX) * speed, y: 0, // Vertical handled separately z: (forwardZ + strafeZ) * speed }); } // Jump (use impulse, not controller.move) if (input.jumpDown && isGrounded) { api.applyImpulse({ x: 0, y: 10, z: 0 }); } };};Character Controller vs RigidBody#
| Feature | Character Controller | RigidBody |
|---|---|---|
| Movement | api.controller.move() | api.velocity or applyImpulse() |
| Collision | Slide along surfaces | Bounce or stop |
| Grounding | api.controller.isGrounded | Manual raycast |
| Slopes | Auto-handled | Slide down unless friction high |
| Steps | Auto-climb small steps | Blocked by steps |
| Use Case | Players, NPCs | Props, vehicles, projectiles |
export default (api) => { // Movement const walkSpeed = 5.0; const sprintSpeed = 10.0; const jumpForce = 10.0; // Camera let yaw = 0; let pitch = 0; const sensitivity = 0.002; const maxPitch = Math.PI / 3; return (dt, time) => { const input = api.input; // === Mouse Look === if (input.look.x !== 0 || input.look.y !== 0) { yaw -= input.look.x * sensitivity; pitch -= input.look.y * sensitivity; pitch = Math.max(-maxPitch, Math.min(maxPitch, pitch)); const rot = api.rotation; rot.y = yaw; // Note: pitch is typically applied to camera, not body api.rotation = rot; } // === Movement === const moveLen = Math.sqrt(input.move.x ** 2 + input.move.y ** 2); if (moveLen > 0.01) { const speed = input.sprint ? sprintSpeed : walkSpeed; // Direction relative to camera const sin = Math.sin(yaw); const cos = Math.cos(yaw); const fwdX = -sin * input.move.y; const fwdZ = -cos * input.move.y; const rightX = cos * input.move.x; const rightZ = -sin * input.move.x; api.controller.move({ x: (fwdX + rightX) * speed, y: 0, z: (fwdZ + rightZ) * speed }); } // === Jump === if (input.jumpDown && api.controller.isGrounded) { api.applyImpulse({ x: 0, y: jumpForce, z: 0 }); } };};Physics Queries (Overlap & Sweep)#
Physics queries detect objects in a region without raycasting. Use overlap tests for area detection and shape casting for swept collision detection.
Advanced Feature
Overlap and shape casting are advanced features not yet exposed in the standard script API. They're available in the IPhysicsBridge interface for system-level code.
// Conceptual example (not yet in script API)// Future API might look like: export default (api) => { return async (dt, time) => { // Sphere overlap - find all entities within radius const pos = api.position; const radius = 5.0; // const entities = await api.physics.overlapSphere(pos, radius); // Shape cast - sweep a box through space const origin = pos; const direction = { x: 0, y: -1, z: 0 }; const boxSize = { x: 1, y: 1, z: 1 }; // const hit = await api.physics.boxCast(origin, direction, boxSize); };};Common Physics Patterns#
Platformer Movement#
export default (api) => { const moveSpeed = 6.0; const jumpForce = 12.0; const maxFallSpeed = 20.0; const coyoteTime = 0.1; // Grace period for jumping after leaving ledge let timeSinceGrounded = 0; return (dt, time) => { const input = api.input; const isGrounded = api.controller.isGrounded; // Track time since grounded (for coyote time) if (isGrounded) { timeSinceGrounded = 0; } else { timeSinceGrounded += dt; } // Horizontal movement if (input.move.x !== 0) { api.controller.move({ x: input.move.x * moveSpeed, y: 0, z: 0 }); } // Jump with coyote time if (input.jumpDown && timeSinceGrounded < coyoteTime) { api.applyImpulse({ x: 0, y: jumpForce, z: 0 }); timeSinceGrounded = coyoteTime; // Prevent double jump } // Cap fall speed const vel = api.velocity; if (vel.y < -maxFallSpeed) { api.velocity = { x: vel.x, y: -maxFallSpeed, z: vel.z }; } };};Projectile Launcher#
export default (api) => { const launchSpeed = 20.0; const fireRate = 0.2; // Seconds between shots let timeSinceLastShot = fireRate; return async (dt, time) => { const input = api.input; timeSinceLastShot += dt; // Fire projectile if (input.jumpDown && timeSinceLastShot >= fireRate) { const pos = api.position; const rot = api.rotation; // Spawn position (in front of player) const spawnOffset = 2.0; const spawnPos = { x: pos.x - Math.sin(rot.y) * spawnOffset, y: pos.y + 1.5, z: pos.z - Math.cos(rot.y) * spawnOffset }; // Launch direction const launchDir = { x: -Math.sin(rot.y) * launchSpeed, y: -Math.sin(rot.x) * launchSpeed, z: -Math.cos(rot.y) * launchSpeed }; // Spawn projectile const projectileEid = await api.spawn('Projectile', spawnPos); // Set projectile velocity (requires access to other entity) // This would typically be done via the projectile's own script: // api.velocity = launchDir; timeSinceLastShot = 0; } };};Damage Zone (Trigger)#
class DamageZone { constructor(api) { this.api = api; this.entitiesInZone = new Map(); // eid -> time entered this.damagePerSecond = 5.0; this.damageInterval = 0.5; // Deal damage every 0.5s } onTriggerEnter(otherEid) { this.entitiesInZone.set(otherEid, 0); this.api.log(`Entity ${otherEid} entered damage zone`); } onTriggerExit(otherEid) { this.entitiesInZone.delete(otherEid); this.api.log(`Entity ${otherEid} left damage zone`); } onUpdate(dt, time) { // Deal damage to entities in zone for (const [eid, timeInZone] of this.entitiesInZone) { const newTime = timeInZone + dt; // Check if enough time has passed if (Math.floor(newTime / this.damageInterval) > Math.floor(timeInZone / this.damageInterval)) { this.api.log(`Dealing damage to entity ${eid}`); // Deal damage (requires health system integration) } this.entitiesInZone.set(eid, newTime); } }} export default (api) => new DamageZone(api);Best Practices#
- Prefer impulses over velocity: Use
applyImpulse()for physics-based movement. Only setapi.velocitydirectly for instant changes (teleport, launch pad). - Cache raycast results: Raycasting is async and may take 1-2 frames. Cache results and avoid raycasting every frame for the same query.
- Use triggers for zones: Mark colliders as "Sensor" for pickups, zones, and area detection. This avoids unwanted physical collisions.
- Limit collision callbacks: Collision callbacks fire frequently. Keep logic minimal and avoid expensive operations (spawning, raycasting) directly in callbacks.
- Check isGrounded before jumping: Always check
api.controller.isGroundedto prevent mid-air jumps (unless intentional). - Normalize movement input: Diagonal movement (W+D) can be faster than cardinal directions. Normalize the input vector or use
api.input.movewhich handles this automatically. - Cap velocities: Prevent objects from moving too fast by capping
api.velocitymagnitude. This prevents physics tunneling and keeps gameplay predictable.
Performance Considerations#
- Raycast sparingly: Raycasting is relatively expensive. Avoid raycasting every frame for every entity. Use throttling or LOD (distance-based frequency).
- Batch collision events: The engine batches collision events for efficiency. Process all events in a single frame rather than spreading them out.
- Reuse vector objects: The script API uses object pools for Vector3/Euler. Avoid creating new objects with
new THREE.Vector3()in hot paths. - Limit active physics bodies: Only enable physics (RigidBody/CharacterController) on entities that need it. Static geometry should use static colliders.
- Use collision groups: Filter collisions with collision groups to reduce unnecessary collision checks and callbacks.