Cocos Creator's 2D physics looks solid in editor preview. Bouncing balls behave. Collisions trigger. Frame rate sits at 60. Then you ship to iOS, install on an actual device, and small things start to feel off — a ball that occasionally tunnels through a thin wall, gameplay that feels subtly faster on newer iPhones, a stuck collision after the user backgrounds the app and reopens it.
None of these are obvious bugs. They're the result of how the engine's physics interacts with iOS-specific runtime behavior that doesn't show up in desktop testing.
After shipping a 2D physics-based ricochet game built on Cocos Creator 3.x to the App Store, here's the short list of things I'd configure differently if I were starting over.
1. ProMotion silently changes your simulation
iPhone 13 Pro was the first iPhone with ProMotion, and every Pro model since has it. ProMotion means the display refreshes at up to 120Hz. WebViews and native runtime loops driven by CADisplayLink follow that refresh rate, which means requestAnimationFrame — and the Cocos Creator game loop bound to it — can fire up to 120 times per second on these devices.
If your physics integration uses the per-frame delta time directly — velocity += accel * dt; position += velocity * dt — your simulation now runs at a different effective resolution on a 120Hz device than on a 60Hz device. Same starting state, same input, slightly different physical outcome. Players on newer iPhones experience a subtly different game.
The fix is to lock physics to a fixed timestep, decoupled from rendering. In Project Settings → Physics 2D, set the Fixed Time Step explicitly (1/60s is the standard choice):
import { PhysicsSystem2D } from 'cc';
PhysicsSystem2D.instance.fixedTimeStep = 1 / 60;
Rendering still happens at whatever rate the device offers. Physics ticks at 60Hz regardless. Behavior becomes consistent across devices.
2. Don't integrate with variable dt
Even on a constant 60Hz display, the actual frame interval can vary — a thermal-throttled iPhone can drop to 45fps mid-game, or hit 30fps if other apps are competing for CPU. The classic accumulator pattern handles this without breaking the simulation:
const FIXED_DT = 1 / 60;
let accumulator = 0;
update(deltaTime: number) {
accumulator += deltaTime;
while (accumulator >= FIXED_DT) {
this.stepPhysics(FIXED_DT);
accumulator -= FIXED_DT;
}
// Optional: interpolate render position using accumulator / FIXED_DT
}
Cocos Creator's built-in physics system does this internally once you configure a fixed time step. But if you're running any of your own integration — custom forces, custom motion on non-rigidbody entities, gameplay logic that touches positions — apply the same pattern there. The moment any part of your simulation uses raw deltaTime, you've reintroduced the inconsistency you just fixed.
3. CCD is not on by default, and you usually want it
A ball moving at 1000 px/s in a 60Hz simulation moves about 16.7 px per frame. If your wall collider is 10 px thick — or your ball is small and your wall is at an angle — there's a real chance the ball is in front of the wall on frame N and behind it on frame N+1, with no collision detected in between.
Discrete collision detection misses these. The fix is continuous collision detection, which in box2d terms means setting the bullet flag on the rigid body:
const rb = this.getComponent(RigidBody2D);
rb.bullet = true;
Two things to know:
- CCD has a real CPU cost. Apply it only to bodies that actually move fast — typically the player projectile, not every dynamic body in the scene.
- It only protects against passing through static and kinematic bodies. Two CCD-enabled dynamic bodies can still miss each other under box2d's defaults.
If you have geometry that's both fast-moving and dynamic-on-dynamic, supplement with manual raycasts between frames. Sample the start and end positions, fire a raycast along that segment, snap to the first hit.
4. Touch input lands later than you think
A user taps the screen. The native touch event arrives at the WebView. WebView forwards it to JavaScript. Your input handler runs. Cocos Creator's event system dispatches it on the next tick. Your physics responds on the tick after that.
That's two to three frames of latency between the finger landing and the ball reacting. At 60Hz that's 33-50ms — noticeable in a precision physics game where the player is reading visual feedback to aim.
You can't eliminate the WebView → JS hop, but you can avoid adding more delay:
- Don't queue input into a buffer that's drained on the next physics tick. Handle it in the same tick if the input affects current-frame simulation.
- For aiming-style mechanics, render a preview of the trajectory the moment the touch starts, and only commit to a physics force on release. The visual responsiveness masks the input lag — by the time the player releases, they've already been seeing what would happen.
5. Background/foreground destroys your physics state
When the user switches to another app and comes back, the JS event loop has been suspended for an arbitrary duration — seconds, minutes, hours. The next update() call receives a delta time that reflects that entire pause.
If you pass that delta unfiltered into your physics step, the accumulator from section 2 runs hundreds or thousands of fixed steps in a single tick. The game freezes for a moment, then resumes with the ball teleported across the level — through walls, past triggers, anywhere.
Two complementary fixes:
// Clamp delta to prevent runaway accumulation
update(deltaTime: number) {
const safeDt = Math.min(deltaTime, 0.1);
// ... use safeDt for physics
}
// Pause physics on visibility change
document.addEventListener('visibilitychange', () => {
PhysicsSystem2D.instance.enable = !document.hidden;
});
The clamp protects you from runaway dt. The visibilitychange handler is cleaner — physics genuinely shouldn't tick when the user can't see the game. Use both.
6. Determinism is fragile, and you probably don't need it
If you ever want replay validation, network sync, or "the same level always plays out the same given the same input," you need deterministic physics. Floating-point math across iOS device generations — and especially across iOS vs Android — is not guaranteed to produce bit-identical results, even with a fixed timestep.
This is solvable: fixed-point math, integer-only simulation, or running physics on a server. Each path is heavyweight.
For most single-player physics games, the right answer is to not need determinism. Save state snapshots instead of replays. Validate completion server-side based on outcomes (level cleared, score reached) rather than exact motion paths. Build replay sharing as a video, not a deterministic simulation.
Pre-ship checklist
Before submitting a Cocos Creator 2D physics game to App Review, I'd now check:
- Physics fixed timestep is set explicitly, not left to default
- Fast-moving rigid bodies have
bullet = true - Input handlers process within the same tick as the affected physics step
-
deltaTimeis clamped, and physics pauses onvisibilitychange - Tested on at least one ProMotion device (iPhone 13 Pro or later) and one older device
None of this is obscure. It's just easy to miss when desktop preview looks perfect.
The game these notes come from is Juicy Ricochet — playable in the browser at phyfun.com or as a native iOS build on the App Store. Most of what's above is the result of behavior I didn't see until I was running on a real iPhone in real hands.
Top comments (0)