Skip to main content

Physics 2D: Bouncy Drops

A 2D physics playground in screen space: tap anywhere to drop a colored square that falls under tuned gravity, bounces off three static walls (left, right, floor), and rests at the bottom. A cap of 14 simultaneously live boxes keeps the scene tidy. A second script on the floor logs every contact via the per-collider CollisionEvent2D.Enter emitter.

Bouncy Drops demo running in Effect House preview

What you'll build

  • A 2D scene with a 2D Camera (orthographic, orthoHeight = 20) parenting all the Screen Image objects.
  • A static Floor Screen Image (660 × 60 px) at the bottom, two static vertical walls (40 × 900 px each) at x = ±320, all with RigidBody2D (static = true, useGravity = false) + BoxCollider2D for containment.
  • A hidden BoxTemplate Screen Image (60 × 60 px) carrying RigidBody2D (dynamic), BoxCollider2D, and a bouncy PhysicsMaterial resource — the source the spawner clones at runtime.
  • A GameController empty SceneObject hosting BallSpawner, which on every tap clones the template at the tap location, sets its ScreenTransform.anchoredPosition, and applies a one-shot impulse via RigidBody2D.addForce(impulse, ForceMode2D.Impulse).
  • A CollisionLogger2D script on the Floor that flips BoxCollider2D.emitCollisionEvent = true at runtime and listens for CollisionEvent2D.Enter through the per-collider emitter.
  • RecordStart event → clear all spawned boxes back to the cap-of-zero starting state.

Open the demo

↓ bouncy-drops.zip

Unzip and open in Effect House (5.9.0+). The opening scene contains:

  • Camera — the default 3D perspective camera, untouched. Its camera-feed render shows behind every Screen Image.
  • 2D Camera — orthographic, auto-created when the first Screen Image was added. Lives in its own 2D Foreground render group above General. Parents every Screen Image in the scene.
  • Floor — Screen Image at (0, -440) px, size 660 × 60 px, dark slate color. Static RigidBody2D + BoxCollider2D of size (20.625, 1.875) world units (660 px / 32 ppu, 60 px / 32 ppu). Hosts the CollisionLogger2D script.
  • LeftWall / RightWall — Screen Images at (±320, 0) px, size 40 × 900 px, same dark slate. Static physics, BoxCollider2D of (1.25, 28.125) world units.
  • BoxTemplate — hidden Screen Image at (0, 200) px, size 60 × 60 px, orange. Dynamic RigidBody2D + BoxCollider2D of (1.875, 1.875) world units, with a PhysicsMaterial resource assigned (bounciness = 0.75, dynamicFriction = 0.3, staticFriction = 0.4).
  • GameController — empty SceneObject hosting the BallSpawner script.

Read the scripts

BallSpawner.ts

@component()
export class BallSpawner extends APJS.BasicScriptComponent {
// The hidden BoxTemplate SceneObject — cloned each tap. Wired in the
// inspector. Stays visible:false in the editor; clones flip themselves on.
@serializeProperty template!: APJS.SceneObject;

// Hard cap on simultaneously-live balls.
@serializeProperty maxBalls: number = 14;

// World-space gravity in pixels/sec². Default Physics2D gravity is (0, -9.8)
// which reads as nearly weightless on a 1280px-tall viewport — the 2D system
// operates in pixel-space, so we override with a stronger value here.
@serializeProperty gravityY: number = -2500;

// Random horizontal-impulse range applied at spawn (±half this).
@serializeProperty kickX: number = 200;

// Downward impulse component so balls don't just dribble — they punch
// off the spawn point.
@serializeProperty kickDown: number = 80;

private spawned: APJS.SceneObject[] = [];
private touchCallback!: (e: APJS.IEvent) => void;
private recordStartCallback!: (e: APJS.IEvent) => void;
private inited: boolean = false;

onUpdate(_dt: number): void {
if (this.inited) return;
if (!this.template) return;

// Override the project's default Physics2D gravity to something visible.
APJS.Physics2D.gravity = new APJS.Vector2f(0, this.gravityY);

this.touchCallback = (event: APJS.IEvent) => {
const t = event.args[0] as APJS.TouchData;
if (t.phase !== APJS.TouchPhase.Began) return;
this.spawnAt(t.position.x, t.position.y);
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.Touch, this.touchCallback, this);

this.recordStartCallback = (_e: APJS.IEvent) => {
this.clearAll();
};
APJS.EventManager.getGlobalEmitter()
.on(APJS.EventType.RecordStart, this.recordStartCallback, this);

this.inited = true;
console.log("[BallSpawner] ready — gravity set to (0, " + this.gravityY + ")");
}

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 spawnAt(normX: number, normY: number): void {
if (this.spawned.length >= this.maxBalls) {
const oldest = this.spawned.shift();
if (oldest) oldest.parent = null;
}

// SceneObject.clone() produces a deep copy with all components
// (RigidBody2D, BoxCollider2D, PhysicsMaterial reference). The copy
// lands as a sibling of the source under the same parent (the 2D
// Camera), with the source's hidden state — so we flip it visible
// before positioning.
const inst = this.template.clone();
if (!inst) return;
inst.setEnabledInHierarchy(true);

// Touch position is normalized 0..1 with y=0 at top; convert to
// centered pixel space (y up) for ScreenTransform.anchoredPosition.
const px = (normX - 0.5) * 720;
const py = (0.5 - normY) * 1280;

const st = inst.getComponent("ScreenTransform") as APJS.ScreenTransform;
if (st) {
st.anchoredPosition = new APJS.Vector2f(px, py);
}

// Apply a one-shot impulse so the new ball punches off the spawn
// point instead of just falling. Impulse mode is mass-scaled, so
// behavior stays consistent if the user changes the prefab's mass.
const rb = inst.getComponent("RigidBody2D") as APJS.RigidBody2D;
if (rb) {
const ix = (Math.random() - 0.5) * this.kickX;
rb.addForce(
new APJS.Vector2f(ix, -this.kickDown),
APJS.ForceMode2D.Impulse,
);
}

this.spawned.push(inst);
}

private clearAll(): void {
for (const obj of this.spawned) {
if (obj) obj.parent = null;
}
this.spawned.length = 0;
console.log("[BallSpawner] cleared");
}
}

The Physics-2D-namespace calls of interest:

  • APJS.Physics2D.gravity is a Vector2f getter/setter expressed in pixels per second² (the 2D system's units, not meters). The default (0, -9.8) is fine for world-space 3D physics but barely registers in a 1280-pixel viewport — the script overrides to (0, -2500) so the drop is visible without bumping every body's gravityScale individually.
  • SceneObject.clone() produces a deep copy of a hidden template including its RigidBody2D, BoxCollider2D, and the PhysicsMaterial reference. Using a hidden template + clone avoids the Prefab.instantiate(parent) requirement for an explicit parent and keeps the wired SceneObject reference simple.
  • RigidBody2D.addForce(force, mode) with ForceMode2D.Impulse is the 2D analogue of P3's 3D impulse. The other modes (Force, Acceleration, VelocityChange) match the 3D ones one-for-one.
  • ScreenTransform.anchoredPosition is the pixel-space layout position (relative to the anchor pivot). Setting it teleports the body; during physics simulation the engine writes back to ScreenTransform.localPosition (world units) instead.
  • SceneObject.parent = null detaches an object from its parent — the lightweight way to "destroy" a clone for the cap and reset paths.

CollisionLogger2D.ts

Lives on the Floor. Demonstrates the per-collider event emitter pattern that's specific to 2D physics: the emitter is keyed off the collider component, not the SceneObject — same as the 3D version (Physics 3D tutorial).

@component()
export class CollisionLogger2D extends APJS.BasicScriptComponent {
private collider!: APJS.BoxCollider2D;
private inited: boolean = false;
private hits: number = 0;

private onEnter = (event: APJS.IEvent) => {
this.hits++;
const infos = event.args[0] as APJS.CollisionInfo2D[];
for (const info of infos) {
const otherName = info.otherObject ? info.otherObject.name : "unknown";
const p = info.point;
console.log(
"[CollisionLogger2D] " + this.getSceneObject().name +
" <- " + otherName +
" #" + this.hits +
" at (" + p.x.toFixed(0) + ", " + p.y.toFixed(0) + ")"
);
}
};

onUpdate(_dt: number): void {
if (this.inited) return;
const obj = this.getSceneObject();
if (!obj) return;
this.collider = obj.getComponent("BoxCollider2D") as APJS.BoxCollider2D;
if (!this.collider) return;
// emitCollisionEvent is runtime-only — must be flipped to true before
// the per-collider emitter will fire any events.
this.collider.emitCollisionEvent = true;
APJS.EventManager.getObjectEmitter(this.collider)
.on(APJS.CollisionEvent2D.Enter, this.onEnter, this);
this.inited = true;
}

onDestroy(): void {
if (this.collider) {
APJS.EventManager.getObjectEmitter(this.collider)
.off(APJS.CollisionEvent2D.Enter, this.onEnter, this);
}
}
}

The 2D collision-event API mirrors the 3D one:

  • Collider2D.emitCollisionEvent = true is runtime-only — the editor doesn't accept it as a set_component property, so a script has to flip it. Without this, the per-collider emitter is silent.
  • getObjectEmitter(collider) — pass the collider component, not the SceneObject. Passing the SceneObject silently fails (no events fire).
  • CollisionEvent2D.Enter / .Stay / .Exit are static UserEventType values on the CollisionEvent2D class.
  • event.args[0] as APJS.CollisionInfo2D[] — collision events deliver an array of contacts; iterate to handle each.

Customize

On GameControllerBallSpawner:

  • gravityY (default -2500) — Physics2D world gravity in px/sec². Bump to -5000 for a heavy "Mars" feel; drop to -800 for floaty zero-G.
  • kickX (default 200) — random horizontal impulse range applied at spawn. Larger values scatter the spawn cluster wider.
  • kickDown (default 80) — fixed downward impulse so taps land as punches. Set to 0 for pure gravity-only drops.
  • maxBalls (default 14) — hard cap. The oldest box gets detached to make room when the cap is hit.

On the BoxTemplate SceneObject:

  • Image.color — pick any color; clones inherit it.
  • ScreenTransform.sizeDelta — change box size; re-tune BoxCollider2D.size to match (sizeDelta_pixels / 32).
  • RigidBody2D.mass — heavier boxes feel chunkier; the Impulse-mode kick stays consistent because impulses are mass-scaled.

On the Physics Matter resource (Assets/Physics Matters/Physics Matter.phyxMat):

  • restitutionCoef (default 0.75) — bounciness. 1.0 is a perfect bouncing ball; 0 is a beanbag.
  • dynamicFrictionCoef / staticFrictionCoef — set both close to 0 for an icy floor where boxes slide indefinitely.

Suggestions for further play:

  • Replace BoxCollider2D on the BoxTemplate with CircleCollider2D (radius 30) — clones bounce as circles even though their visual stays square. Tweak Image to add a circular texture for a matching look.
  • Add a Physics2D.raycast2D "bullet" that fires from the bottom of the screen on tap, applying impulses to whichever box it hits first.
  • Add a 2D Text HUD that increments on each CollisionEvent2D.Enter, turning the floor into a "drops landed" counter.

What you learned

This tutorial used:

  • RigidBody2Dstatic, useGravity, addForce(force, ForceMode2D).
  • BoxCollider2Dsize in world units (= sizeDelta_px / pixelsPerUnit), physicsMaterial reference for bounciness.
  • PhysicsMaterial resource — restitutionCoef, dynamicFrictionCoef, staticFrictionCoef.
  • Physics2D.gravity — global 2D gravity setter in pixel-space.
  • ForceMode2D.Impulse — one-shot, mass-scaled velocity change.
  • Collider2D.emitCollisionEvent = true plus EventManager.getObjectEmitter(collider).on(CollisionEvent2D.Enter, …) for per-collider 2D collision callbacks.
  • SceneObject.clone() — runtime cloning of a hidden template, preserving its full component stack including the PhysicsMaterial-bound BoxCollider2D.

Read the full Physics2D reference, the RigidBody2D reference, the BoxCollider2D reference, the CircleCollider2D reference, the ForceMode2D reference, the RaycastHit2D reference, and the Physics 2D namespace overview.

For collision events, see CollisionEvent2D and CollisionInfo2D.

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