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.

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 withRigidBody2D(static = true,useGravity = false) +BoxCollider2Dfor containment. - A hidden BoxTemplate Screen Image (60 × 60 px) carrying
RigidBody2D(dynamic),BoxCollider2D, and a bouncyPhysicsMaterialresource — 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 itsScreenTransform.anchoredPosition, and applies a one-shot impulse viaRigidBody2D.addForce(impulse, ForceMode2D.Impulse). - A
CollisionLogger2Dscript on the Floor that flipsBoxCollider2D.emitCollisionEvent = trueat runtime and listens forCollisionEvent2D.Enterthrough the per-collider emitter. RecordStartevent → clear all spawned boxes back to the cap-of-zero starting state.
Open the demo
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, size660 × 60px, dark slate color. StaticRigidBody2D+BoxCollider2Dof size(20.625, 1.875)world units (660 px / 32 ppu, 60 px / 32 ppu). Hosts theCollisionLogger2Dscript. - LeftWall / RightWall — Screen Images at
(±320, 0)px, size40 × 900px, same dark slate. Static physics, BoxCollider2D of(1.25, 28.125)world units. - BoxTemplate — hidden Screen Image at
(0, 200)px, size60 × 60px, orange. DynamicRigidBody2D+BoxCollider2Dof(1.875, 1.875)world units, with aPhysicsMaterialresource assigned (bounciness = 0.75,dynamicFriction = 0.3,staticFriction = 0.4). - GameController — empty SceneObject hosting the
BallSpawnerscript.
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.gravityis aVector2fgetter/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'sgravityScaleindividually.SceneObject.clone()produces a deep copy of a hidden template including itsRigidBody2D,BoxCollider2D, and thePhysicsMaterialreference. Using a hidden template + clone avoids thePrefab.instantiate(parent)requirement for an explicit parent and keeps the wired SceneObject reference simple.RigidBody2D.addForce(force, mode)withForceMode2D.Impulseis the 2D analogue of P3's 3D impulse. The other modes (Force,Acceleration,VelocityChange) match the 3D ones one-for-one.ScreenTransform.anchoredPositionis the pixel-space layout position (relative to the anchor pivot). Setting it teleports the body; during physics simulation the engine writes back toScreenTransform.localPosition(world units) instead.SceneObject.parent = nulldetaches 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 = trueis runtime-only — the editor doesn't accept it as aset_componentproperty, 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/.Exitare staticUserEventTypevalues on theCollisionEvent2Dclass.event.args[0] as APJS.CollisionInfo2D[]— collision events deliver an array of contacts; iterate to handle each.
Customize
On GameController → BallSpawner:
gravityY(default-2500) — Physics2D world gravity in px/sec². Bump to-5000for a heavy "Mars" feel; drop to-800for floaty zero-G.kickX(default200) — random horizontal impulse range applied at spawn. Larger values scatter the spawn cluster wider.kickDown(default80) — fixed downward impulse so taps land as punches. Set to0for pure gravity-only drops.maxBalls(default14) — 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-tuneBoxCollider2D.sizeto match (sizeDelta_pixels / 32).RigidBody2D.mass— heavier boxes feel chunkier; theImpulse-mode kick stays consistent because impulses are mass-scaled.
On the Physics Matter resource (Assets/Physics Matters/Physics Matter.phyxMat):
restitutionCoef(default0.75) — bounciness.1.0is a perfect bouncing ball;0is a beanbag.dynamicFrictionCoef/staticFrictionCoef— set both close to0for an icy floor where boxes slide indefinitely.
Suggestions for further play:
- Replace
BoxCollider2Don the BoxTemplate withCircleCollider2D(radius30) — clones bounce as circles even though their visual stays square. TweakImageto 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:
RigidBody2D—static,useGravity,addForce(force, ForceMode2D).BoxCollider2D—sizein world units (=sizeDelta_px / pixelsPerUnit),physicsMaterialreference for bounciness.PhysicsMaterialresource —restitutionCoef,dynamicFrictionCoef,staticFrictionCoef.Physics2D.gravity— global 2D gravity setter in pixel-space.ForceMode2D.Impulse— one-shot, mass-scaled velocity change.Collider2D.emitCollisionEvent = trueplusEventManager.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 thePhysicsMaterial-boundBoxCollider2D.
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.