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#

RaycastPatterns.ts
typescript
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.

EventDescriptionUse Case
onTriggerEnterEntity enters sensor volumePickup collection, zone entry
onTriggerExitEntity exits sensor volumeZone exit, area detection

Trigger Examples#

TriggerExamples.ts
typescript
// === 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#

FeatureCharacter ControllerRigidBody
Movementapi.controller.move()api.velocity or applyImpulse()
CollisionSlide along surfacesBounce or stop
Groundingapi.controller.isGroundedManual raycast
SlopesAuto-handledSlide down unless friction high
StepsAuto-climb small stepsBlocked by steps
Use CasePlayers, NPCsProps, vehicles, projectiles
FirstPersonController.ts
typescript
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#

PlatformerController.ts
typescript
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#

ProjectileLauncher.ts
typescript
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)#

DamageZone.ts
typescript
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 set api.velocity directly 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.isGrounded to 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.move which handles this automatically.
  • Cap velocities: Prevent objects from moving too fast by capping api.velocity magnitude. 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.
Physics in Scripts - Web Engine Docs | Web Engine Docs