Physics 3D: Tap to Knock Down
A 2-wide × 3-tall wall of dynamic cubes sitting on a static floor.
Tap anywhere — the script projects a ray from the camera through the
tap point with Camera.viewportPointToRay, casts it via
Physics3D.raycast, and applies an ForceMode3D.Impulse-mode
addForce to whichever cube it hits, sending the wall tumbling.
A separate script on the top-left cube subscribes to CollisionEvent.Enter
through the per-collider object emitter and logs every contact.

What you'll build
- A static Floor cube (RigidBody with
static = true,useGravity = false) scaled into a thin slab — the ground every dynamic cube settles on. - Six dynamic colored cubes in a 2 × 3 wall, each with
RigidBody(defaults: mass 1, gravity on, dynamic) andBoxCollider. They settle into a stack during the first second of preview. - A GameController empty SceneObject that hosts
TapKnockdown— global touch listener that converts a tap into a world-spaceRay, callsPhysics3D.raycast, and impulses the hit cube along the ray's direction. RecordStartevent → reset every cube to its starting transform viaRigidBody.position/rotation/velocity/angularVelocity.CollisionLoggeron Cube_Blue — setsBoxCollider.emitCollisionEventto true at runtime and listens on the per-collider emitter forCollisionEvent.Enter, logging the other object plus contact point.
Open the demo
Unzip and open in Effect House (5.9.0+). The opening scene contains:
- Camera — at
(0, 1.5, 6)with a 10° downward pitch. - Directional Light / Environment Light — defaults.
- Floor — Cube primitive,
(0, -0.05, 0), scale(0.7, 0.05, 0.4)(world5.6 × 0.4 × 3.2), grey Standard PBR. RigidBody static + BoxCollider. - Cube_Red, Cube_Orange — bottom row at
y = 0.6, x = ±0.45. - Cube_Yellow, Cube_Green — middle row at
y = 1.45. - Cube_Blue, Cube_Purple — top row at
y = 2.3. Cube_Blue also hostsCollisionLogger. - GameController — empty SceneObject hosting
TapKnockdown.
All six dynamic cubes share the same physics setup: scale 0.1 (world
0.8 × 0.8 × 0.8), one Standard PBR material, RigidBody (mass 1, gravity
on), BoxCollider (default size — automatically matches the Cube primitive's
8 × 8 × 8 local bounds, scaled with the transform).
Read the scripts
TapKnockdown.ts
Lives on the GameController. Holds a SceneObject reference to the camera
(not a Camera component reference — @serializeProperty doesn't pick up
component types in APJS, so we look up the Camera at runtime), the array
of cubes for reset, and the impulse strength.
@component()
export class TapKnockdown extends APJS.BasicScriptComponent {
// The SceneObject hosting the Camera component. We look up the Camera
// at runtime because @serializeProperty doesn't pick up Component types
// — the proven pattern is SceneObject + getComponent.
@serializeProperty cameraObject!: APJS.SceneObject;
// The cubes whose start positions get cached for the RecordStart reset.
@serializeProperty cubes: APJS.SceneObject[] = [];
// Magnitude of the impulse applied along the ray direction.
@serializeProperty forceStrength: number = 60;
private cam!: APJS.Camera;
private startPositions: APJS.Vector3f[] = [];
private inited: boolean = false;
private touchCallback!: (e: APJS.IEvent) => void;
private recordStartCallback!: (e: APJS.IEvent) => void;
onUpdate(_dt: number): void {
if (this.inited) return;
if (!this.cameraObject || !this.cubes || this.cubes.length === 0) return;
this.cam = this.cameraObject.getComponent("Camera") as APJS.Camera;
if (!this.cam) return;
// Cache starting positions so RecordStart can reset cubes to the wall.
for (let i = 0; i < this.cubes.length; i++) {
const c = this.cubes[i];
if (!c) continue;
const p = c.getTransform().localPosition;
this.startPositions.push(new APJS.Vector3f(p.x, p.y, p.z));
}
this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
this.handleTap(t.position.x, t.position.y);
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);
this.recordStartCallback = (_event: APJS.IEvent) => {
this.resetCubes();
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.RecordStart, this.recordStartCallback, this);
this.inited = true;
console.log("[TapKnockdown] ready — " + this.cubes.length + " cubes wired");
}
onDestroy(): void {
if (this.touchCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.Touch, this.touchCallback, this);
}
if (this.recordStartCallback) {
APJS.EventManager.getGlobalEmitter()
.off(APJS.EventType.RecordStart, this.recordStartCallback, this);
}
}
private handleTap(normX: number, normY: number): void {
// touch.position is normalized 0..1 with y=0 at top.
// viewportPointToRay expects 0..1 with y=0 at bottom — hence the flip.
const ray = this.cam.viewportPointToRay(
new APJS.Vector2f(normX, 1 - normY),
);
// Cast 100 units along the ray and grab the nearest hit.
const hits = APJS.Physics3D.raycast(ray, 100, true);
if (!hits || hits.length === 0) return;
const hit = hits[0];
const target = hit.colliderObject;
if (!target) return;
const rb = target.getComponent("RigidBody") as APJS.RigidBody;
if (!rb || rb.static) return; // never push the static floor
// Apply an impulse along the ray's direction (pointing into the scene).
const d = ray.direction;
const impulse = new APJS.Vector3f(
d.x * this.forceStrength,
d.y * this.forceStrength,
d.z * this.forceStrength,
);
rb.addForce(impulse, APJS.ForceMode3D.Impulse);
console.log(
"[TapKnockdown] hit " + target.name +
" at (" + hit.point.x.toFixed(2) + ", " +
hit.point.y.toFixed(2) + ", " + hit.point.z.toFixed(2) + ")"
);
}
private resetCubes(): void {
for (let i = 0; i < this.cubes.length; i++) {
const c = this.cubes[i];
if (!c) continue;
const rb = c.getComponent("RigidBody") as APJS.RigidBody;
if (!rb) continue;
// RigidBody.position bypasses simulation — instant teleport for reset.
const sp = this.startPositions[i];
rb.position = new APJS.Vector3f(sp.x, sp.y, sp.z);
rb.rotation = new APJS.Quaternionf(0, 0, 0, 1);
rb.velocity = new APJS.Vector3f(0, 0, 0);
rb.angularVelocity = new APJS.Vector3f(0, 0, 0);
}
console.log("[TapKnockdown] reset " + this.cubes.length + " cubes");
}
}
The Physics-3D-namespace calls of interest:
Camera.viewportPointToRay(Vector2f)takes a normalized(x, y)∈[0, 1]²viewport coordinate and returns aRay(origin at the camera, direction into the scene). The y-flip (1 - normY) is because TouchData's y-axis goes 0 → 1 top-to-bottom, while the viewport y-axis goes 0 → 1 bottom-to-top.Physics3D.raycast(ray, maxDistance, nearest, layerMask?)returns an array ofRaycastHit3D. Withnearest = true, the array contains at most one element — the closest collider along the ray.RaycastHit3Dexposespoint,normal,collider, andcolliderObject(theSceneObjectcarrying the collider).RigidBody.addForce(force, mode?)withForceMode3D.Impulseis a one-shot velocity change weighted by mass — perfect for taps. The other modes areForce(continuous),Acceleration(continuous, ignores mass), andVelocityChange(one-shot, ignores mass).RigidBody.position/rotation/velocity/angularVelocitysetters bypass the physics simulation — they're the right tool for teleporting a body back to its starting state on reset, but they break the simulation invariants if you abuse them mid-flight.RigidBody.staticis the runtime guard — the floor's RigidBody hasstatic = true, so the script'sif (rb.static) returnskips it even though the raycast might hit it on a steep tap angle.
CollisionLogger.ts
Lives on Cube_Blue. Demonstrates the per-collider event emitter pattern that's specific to APJS physics: the emitter is keyed off the collider component, not the SceneObject.
@component()
export class CollisionLogger extends APJS.BasicScriptComponent {
private collider!: APJS.BoxCollider;
private inited: boolean = false;
private hits: number = 0;
private onEnter = (event: APJS.IEvent) => {
this.hits++;
const infos = event.args[0] as APJS.CollisionInfo[];
for (const info of infos) {
const otherName = info.otherObject ? info.otherObject.name : "unknown";
const p = info.point;
console.log(
"[CollisionLogger] " + this.getSceneObject().name + " <- " + otherName +
" #" + this.hits + " at (" +
p.x.toFixed(2) + ", " + p.y.toFixed(2) + ", " + p.z.toFixed(2) + ")"
);
}
};
onUpdate(_dt: number): void {
if (this.inited) return;
const obj = this.getSceneObject();
if (!obj) return;
this.collider = obj.getComponent("BoxCollider") as APJS.BoxCollider;
if (!this.collider) return;
// emitCollisionEvent is runtime-only (not a DSL-settable editor prop).
// It must be flipped to true before the per-collider emitter will fire.
this.collider.emitCollisionEvent = true;
APJS.EventManager.getObjectEmitter(this.collider)
.on(APJS.CollisionEvent.Enter, this.onEnter, this);
this.inited = true;
}
onDestroy(): void {
if (this.collider) {
APJS.EventManager.getObjectEmitter(this.collider)
.off(APJS.CollisionEvent.Enter, this.onEnter, this);
}
}
}
Things worth highlighting:
getObjectEmitter(this.collider)— pass the collider, not the SceneObject. Passing the SceneObject silently fails (no events fire); the engine binds the collision event stream to the collider component, not its host object.Collider.emitCollisionEventdefaults tofalse. It's a runtime property — it doesn't appear on the editor-sideset_componentschema, so a script has to flip it. Without this, the per-collider emitter is silent.CollisionEvent.Enter/.Stay/.Exitare staticUserEventTypevalues on theCollisionEventclass. The same per-collider emitter handles all three.event.args[0] as APJS.CollisionInfo[]— collision events deliver an array of contacts (one collision can span multiple contact points), so iterate.
Customize
On GameController → TapKnockdown:
forceStrength(default60) — magnitude of the impulse along the ray direction. Bump to120+for explosive knockdowns; drop to15for gentle nudges.cubes— drag in additional dynamic objects to extend the reset set, or remove a cube to leave one out of the reset.
On any cube's RigidBody:
mass(default1) — heavier cubes shrug off impulses; lighter ones fly. Mass-scaling impulse is exactly whatForceMode3D.Impulsedoes, somass = 5makes a cube ~5× harder to nudge.useGravity— turn off on a single cube to leave it floating while its neighbors fall.damping/angularDamping— global drag on linear and angular velocity. Crank both to0.6for a "Mars" feel; leave at0for clean Newtonian motion.
On any cube's BoxCollider:
size(default(8, 8, 8), the Cube primitive local bounds) — the collider scales with the host transform, so the world-space collider auto-fits the cube. Override only if the visual mesh and collider need to differ (e.g. an oversized hit area).physicsMaterial— wire aPhysicsMaterialresource for control overbounciness,staticFriction, anddynamicFriction. The default material is rigid and middling-friction.
Suggestions for further play:
- Replace the impulse with a continuous
force = ray.direction * forceStrength(nomodeargument, defaults toForce) to "push" cubes for as long as the user holds the tap. - Swap
Physics3D.raycast(ray, 100, true)fornearest = falseand knock down every cube along the ray. - Add a
BoxScorerscript: count cubes whoseTransform.localPosition.ydrops below0.4and update a 2DTextHUD.
What you learned
This tutorial used:
Camera.viewportPointToRayto convert a normalized 2D tap into a 3DRay.Physics3D.raycastto find the nearest collider along that ray.RaycastHit3D.colliderObjectand.pointto identify the hit object and its world-space contact location.RigidBody.addForce(impulse, ForceMode3D.Impulse)to apply a one-shot, mass-scaled velocity change.RigidBody.position/rotation/velocity/angularVelocitysetters for instant teleport reset.Collider.emitCollisionEvent = trueplusEventManager.getObjectEmitter(collider).on(CollisionEvent.Enter, …)for per-collider collision callbacks.
Read the full Physics3D reference, the RigidBody reference, the BoxCollider reference, the RaycastHit3D reference, the ForceMode3D reference, and the Physics 3D namespace overview.
For collision events, see CollisionEvent and CollisionInfo.