Web Engine Docs
Preparing documentation
Use the search bar to quickly find any topic
Preparing documentation
Use the search bar to quickly find any topic
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.
Apply physics forces and impulses to entities
Cast rays to detect objects and surfaces
React to collisions and triggers
Read and write linear velocity
Move characters with collision response
Overlap tests and shape casting
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 accessconst vel = api.velocity; // Get current velocityapi.applyImpulse({ x: 0, y: 10, z: 0 }); // Apply jump impulse// Raycastingconst hit = await api.raycast(origin, direction);// Character controllerapi.controller.move({ x: 1, y: 0, z: 0 });const grounded = api.controller.isGrounded;};};
Velocity represents the linear motion of a rigid body in meters per second. You can both read and write velocity directly.
export default (api) => {return (dt, time) => {// Get current velocity (Vector3)const vel = api.velocity;// Check if movingconst 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 fallingif (vel.y < -1.0) {api.log("Falling fast!");}// Horizontal speed (XZ plane)const horizontalSpeed = Math.sqrt(vel.x ** 2 + vel.z ** 2);};};
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 changeif (input.jumpDown && !hasLaunched) {api.velocity = { x: 0, y: 20, z: 0 }; // Launch upwardhasLaunched = true;}// Stop all movement instantlyif (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.
Forces and impulses modify velocity over time. Impulses are instant velocity changes (jump, explosion), while forces are continuous acceleration (thrusters, wind).
export default (api) => {const jumpForce = 10.0;const dashForce = 15.0;return (dt, time) => {const input = api.input;// Jump - instant upward impulseif (input.jumpDown && api.controller.isGrounded) {api.applyImpulse({ x: 0, y: jumpForce, z: 0 });}// Dash forward - instant horizontal impulseif (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 distanceapi.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.
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 continuouslyif (input.sprint) {// Apply upward force equal to gravityconst 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/backwardconst thrustZ = -input.move.y * thrustPower * dt;const forwardX = -Math.sin(rot.y) * thrustZ;const forwardZ = -Math.cos(rot.y) * thrustZ;// Strafe left/rightconst 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 windapi.applyImpulse({ x: windStrength * dt, y: 0, z: 0 });};};
Raycasting casts an invisible ray through the world to detect objects. Use it for shooting, line-of-sight checks, ground detection, and interaction.
export default (api) => {return async (dt, time) => {const pos = api.position;const rot = api.rotation;// Cast ray forward from entity positionconst 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");}};};
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 fire when two rigid bodies collide. Use them for damage, sound effects, particle spawns, and gameplay events.
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 soundthis.api.audio.play(/* impactSoundId */);// Spawn particle effect at collision point// const pos = this.api.position;// await this.api.spawn('ImpactParticles', pos);// Apply knockbackconst 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);
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 logicif (this.contactCount > 0) {// Entity is touching something}}}export default (api) => new ContactTracker(api);
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 |
// === 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 soundthis.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 effectconst 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 zonefor (const eid of this.entitiesInZone) {// Deal damage per secondconst 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 positionconst pos = this.api.position;// Store checkpoint data (e.g., in global state)// Visual feedbackthis.api.audio.play(/* checkpointSound */);}}onUpdate(dt, time) {// Animate checkpoint if activatedif (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 controllers provide collision-aware movement for players and NPCs. They handle slopes, steps, and grounding automatically.
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 inputif (input.move.x !== 0 || input.move.y !== 0) {const rot = api.rotation;const speed = input.sprint ? moveSpeed * sprintMultiplier : moveSpeed;// Forward/backward movementconst forwardX = -Math.sin(rot.y) * input.move.y;const forwardZ = -Math.cos(rot.y) * input.move.y;// Strafe left/rightconst strafeX = Math.cos(rot.y) * input.move.x;const strafeZ = Math.sin(rot.y) * input.move.x;// Apply movement via controllerapi.controller.move({x: (forwardX + strafeX) * speed,y: 0, // Vertical handled separatelyz: (forwardZ + strafeZ) * speed});}// Jump (use impulse, not controller.move)if (input.jumpDown && isGrounded) {api.applyImpulse({ x: 0, y: 10, z: 0 });}};};
| 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) => {// Movementconst walkSpeed = 5.0;const sprintSpeed = 10.0;const jumpForce = 10.0;// Cameralet 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 bodyapi.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 cameraconst 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 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 radiusconst pos = api.position;const radius = 5.0;// const entities = await api.physics.overlapSphere(pos, radius);// Shape cast - sweep a box through spaceconst 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);};};
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 ledgelet 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 movementif (input.move.x !== 0) {api.controller.move({x: input.move.x * moveSpeed,y: 0,z: 0});}// Jump with coyote timeif (input.jumpDown && timeSinceGrounded < coyoteTime) {api.applyImpulse({ x: 0, y: jumpForce, z: 0 });timeSinceGrounded = coyoteTime; // Prevent double jump}// Cap fall speedconst vel = api.velocity;if (vel.y < -maxFallSpeed) {api.velocity = { x: vel.x, y: -maxFallSpeed, z: vel.z };}};};
export default (api) => {const launchSpeed = 20.0;const fireRate = 0.2; // Seconds between shotslet timeSinceLastShot = fireRate;return async (dt, time) => {const input = api.input;timeSinceLastShot += dt;// Fire projectileif (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 directionconst launchDir = {x: -Math.sin(rot.y) * launchSpeed,y: -Math.sin(rot.x) * launchSpeed,z: -Math.cos(rot.y) * launchSpeed};// Spawn projectileconst 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;}};};
class DamageZone {constructor(api) {this.api = api;this.entitiesInZone = new Map(); // eid -> time enteredthis.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 zonefor (const [eid, timeInZone] of this.entitiesInZone) {const newTime = timeInZone + dt;// Check if enough time has passedif (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);
applyImpulse() for physics-based movement. Only set api.velocity directly for instant changes (teleport, launch pad).api.controller.isGrounded to prevent mid-air jumps (unless intentional).api.input.move which handles this automatically.api.velocity magnitude. This prevents physics tunneling and keeps gameplay predictable.new THREE.Vector3() in hot paths.