Skip to main content

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.

Tap to Knock Down demo running in Effect House preview

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) and BoxCollider. 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-space Ray, calls Physics3D.raycast, and impulses the hit cube along the ray's direction.
  • RecordStart event → reset every cube to its starting transform via RigidBody.position/rotation/velocity/angularVelocity.
  • CollisionLogger on Cube_Blue — sets BoxCollider.emitCollisionEvent to true at runtime and listens on the per-collider emitter for CollisionEvent.Enter, logging the other object plus contact point.

Open the demo

↓ tap-knockdown.zip

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) (world 5.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 hosts CollisionLogger.
  • 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 a Ray (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 of RaycastHit3D. With nearest = true, the array contains at most one element — the closest collider along the ray. RaycastHit3D exposes point, normal, collider, and colliderObject (the SceneObject carrying the collider).
  • RigidBody.addForce(force, mode?) with ForceMode3D.Impulse is a one-shot velocity change weighted by mass — perfect for taps. The other modes are Force (continuous), Acceleration (continuous, ignores mass), and VelocityChange (one-shot, ignores mass).
  • RigidBody.position / rotation / velocity / angularVelocity setters 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.static is the runtime guard — the floor's RigidBody has static = true, so the script's if (rb.static) return skips 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.emitCollisionEvent defaults to false. It's a runtime property — it doesn't appear on the editor-side set_component schema, so a script has to flip it. Without this, the per-collider emitter is silent.
  • CollisionEvent.Enter / .Stay / .Exit are static UserEventType values on the CollisionEvent class. 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 GameControllerTapKnockdown:

  • forceStrength (default 60) — magnitude of the impulse along the ray direction. Bump to 120+ for explosive knockdowns; drop to 15 for 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 (default 1) — heavier cubes shrug off impulses; lighter ones fly. Mass-scaling impulse is exactly what ForceMode3D.Impulse does, so mass = 5 makes 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 to 0.6 for a "Mars" feel; leave at 0 for 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 a PhysicsMaterial resource for control over bounciness, staticFriction, and dynamicFriction. The default material is rigid and middling-friction.

Suggestions for further play:

  • Replace the impulse with a continuous force = ray.direction * forceStrength (no mode argument, defaults to Force) to "push" cubes for as long as the user holds the tap.
  • Swap Physics3D.raycast(ray, 100, true) for nearest = false and knock down every cube along the ray.
  • Add a BoxScorer script: count cubes whose Transform.localPosition.y drops below 0.4 and update a 2D Text HUD.

What you learned

This tutorial used:

  • Camera.viewportPointToRay to convert a normalized 2D tap into a 3D Ray.
  • Physics3D.raycast to find the nearest collider along that ray.
  • RaycastHit3D.colliderObject and .point to 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 / angularVelocity setters for instant teleport reset.
  • Collider.emitCollisionEvent = true plus EventManager.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.

Copyright © 2026 TikTok. All rights reserved.
About TikTokHelp CenterCareersContactLegalTerms of ServicePrivacy PolicyCookies