Cocos Creator 2D Physics on iOS: Notes on Fixed Timestep, ProMotion, and CCD
Decoupling Physics from Display: iOS Runtime Optimization for 2D Engines
Current Situation Analysis
Desktop preview environments create a false sense of stability for 2D physics simulations. When developing in Cocos Creator, the editor runs on a controlled desktop runtime with consistent frame pacing, predictable thermal headroom, and direct event routing. Shipping to iOS introduces a completely different execution model that silently alters simulation behavior.
The core disconnect stems from three iOS-specific runtime characteristics that desktop testing masks:
Adaptive Display Refresh Rates: Since the iPhone 13 Pro, Apple devices utilize ProMotion, allowing the display to scale between 60Hz and 120Hz based on content. The native
CADisplayLinkloop and JavaScriptrequestAnimationFramecallbacks synchronize to this refresh rate. A physics integration that consumes per-frame delta time directly will execute twice as many integration steps on a 120Hz device compared to a 60Hz device, producing divergent trajectories from identical initial conditions.WebView Event Routing Latency: Touch events originate in the iOS kernel, traverse the WebView bridge, enter the JavaScript event loop, and finally dispatch through the engine's input system. This pipeline introduces 2-3 frames of latency. In precision gameplay, players perceive this as sluggish response, even when the physics simulation itself is mathematically correct.
Aggressive Lifecycle Suspension: iOS aggressively suspends JavaScript execution when an app backgrounds. Upon foregrounding, the engine receives a single
updatecall containing the entire suspension duration asdeltaTime. Without safeguards, this triggers thousands of physics steps in one frame, causing tunneling, state corruption, and temporary UI freezes.
These issues are frequently misdiagnosed as engine bugs or collider configuration errors. In reality, they are architectural mismatches between variable-rate rendering and deterministic physics integration. The solution requires decoupling simulation time from display time, implementing bounded accumulation, and applying continuous collision detection strategically rather than globally.
WOW Moment: Key Findings
The following comparison illustrates how runtime variance impacts simulation stability across different iOS environments. The metrics demonstrate why a unified fixed-timestep architecture is non-negotiable for production physics games.
| Environment | Frame Pacing | Physics Integration Stability | Input-to-Action Latency | CPU Overhead |
|---|---|---|---|---|
| Desktop Preview | Stable 60Hz | High (uniform steps) | ~1 frame (direct routing) | Low |
| iOS Standard (60Hz) | Stable 60Hz | High (uniform steps) | ~2-3 frames (WebView bridge) | Low |
| iOS ProMotion (120Hz) | Adaptive 60-120Hz | Low (variable step count) | ~2-3 frames (WebView bridge) | Medium |
| iOS Thermal Throttle | Drops to 30-45Hz | Low (accumulated drift) | ~3-4 frames (queue backup) | High |
Why this matters: The table reveals that simulation instability is not caused by incorrect math, but by unbounded time accumulation. When frame pacing varies, raw delta integration compounds errors. Decoupling physics from the render loop eliminates environment-dependent behavior, ensuring identical gameplay across all iOS devices regardless of refresh rate or thermal state. This architectural shift also reduces CPU spikes during background/foreground transitions and provides a predictable foundation for input handling and state serialization.
Core Solution
Building a resilient 2D physics pipeline requires enforcing temporal isolation between rendering and simulation. The following implementation demonstrates a production-ready architecture using Cocos Creator 3.x.
Step 1: Enforce Fixed Timestep Architecture
Physics integration must operate on a constant time interval. Variable frame rates should only affect rendering, not simulation state. Configure the engine's physics system to run at a fixed interval, typically 1/60 seconds.
import { _decorator, PhysicsSystem2D, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PhysicsSimulationManager')
export class PhysicsSimulationManager extends Component {
@property
public fixedStepDuration: number = 1 / 60;
private _isInitialized: boolean = false;
public initialize(): void {
if (this._isInitialized) return;
PhysicsSystem2D.instance.fixedTimeStep = this.fixedStepDuration;
PhysicsSystem2D.instance.enabled = true;
this._isInitialized = true;
}
}
Rationale: Explicitly setting fixedTimeStep overrides engine defaults and guarantees consistent integration steps. The manager pattern centralizes configuration, making it easier to adjust for different game genres (e.g., 1/120 for high-precision arcade titles, 1/30 for turn-based strategy).
Step 2: Implement a Bounded Accumulator
Even with a fixed timestep, the render loop will fire at variable intervals. An accumulator pattern consumes elapsed time and steps the simulation only when sufficient time has accumulated. This prevents drift and handles frame drops gracefully.
import { _decorator, Component, systemEvent, SystemEventType } from 'cc';
const { ccclass } = _decorator;
@ccclass('SimulationClock')
export class SimulationClock extends Component {
private _accumulatedTime: number = 0;
private _maxAccumulator: number = 0.25; // Prevents runaway steps
private _physicsManager: PhysicsSimulationManager | null = null;
public linkPhysicsManager(manager: PhysicsSimulationManager): void {
this._physicsManager = manager;
}
protected update(dt: number): void {
if (!this._physicsManager) return;
// Clamp delta to handle background/foreground spikes
const safeDelta = Math.min(dt, this._maxAccumulator);
this._accumulatedTime += safeDelta;
// Step physics only when enough time has accumulated
while (this._accumulatedTime >= this._physicsManager.fixedStepDuration) {
this._physicsManager.initialize();
this._accumulatedTime -= this._physicsManager.fixedStepDuration;
}
// Optional: Interpolate render transforms for smooth visuals
this._applyRenderInterpolation();
}
private _applyRenderInterpolation(): void {
const interpolationRatio = this._accumulatedTime / this._physicsManager!.fixedStepDuration;
// Apply interpolation to visual components only
// Physics bodies remain at discrete step positions
}
}
Rationale: The accumulator decouples simulation frequency from frame rate. The maxAccumulator clamp prevents the engine from attempting thousands of steps after app resume. Render interpolation (applied to visual nodes, not physics bodies) maintains smooth animation without compromising simulation determinism.
Step 3: Strategic Continuous Collision Detection
Discrete collision detection checks overlap at frame boundaries. Fast-moving objects can pass through thin colliders between steps. Box2D's CCD implementation uses the bullet flag to perform swept collision tests.
import { _decorator, RigidBody2D, Component } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ProjectileController')
export class ProjectileController extends Component {
@property
public velocityThreshold: number = 800; // px/s
private _rigidBody: RigidBody2D | null = null;
protected onLoad(): void {
this._rigidBody = this.getComponent(RigidBody2D);
this._configureCollisionBehavior();
}
private _configureCollisionBehavior(): void {
if (!this._rigidBody) return;
const currentSpeed = this._rigidBody.linearVelocity.length();
// Enable CCD only for high-velocity bodies
this._rigidBody.bullet = currentSpeed > this.velocityThreshold;
// Note: CCD only guarantees collision against static/kinematic bodies.
// Dynamic-dynamic collisions at extreme speeds may still require manual raycasting.
}
}
Rationale: CCD introduces significant CPU overhead because it performs continuous geometric queries instead of discrete overlap checks. Applying it globally degrades performance. The velocity threshold ensures only fast-moving entities trigger swept tests. For dynamic-dynamic collisions, supplement with segment raycasts between frames if tunneling persists.
Step 4: Lifecycle & Visibility Management
iOS suspends JavaScript execution on backgrounding. The engine's visibility API provides a clean hook to pause simulation without corrupting state.
import { sys } from 'cc';
export class LifecycleGuard {
public static attachToPhysicsSystem(): void {
const pauseSimulation = () => {
PhysicsSystem2D.instance.enabled = false;
};
const resumeSimulation = () => {
PhysicsSystem2D.instance.enabled = true;
};
if (sys.platform === sys.Platform.IOS) {
document.addEventListener('visibilitychange', () => {
document.hidden ? pauseSimulation() : resumeSimulation();
});
}
}
}
Rationale: Disabling the physics system during suspension prevents accumulator bloat and state corruption. Re-enabling on foreground restores normal operation without requiring manual state resets.
Pitfall Guide
1. The Raw Delta Trap
Explanation: Passing deltaTime directly into velocity or position calculations ties simulation speed to frame rate. On 120Hz devices, objects move twice as fast; on throttled devices, they slow down.
Fix: Always route time through a fixed-timestep accumulator. Never apply raw dt to physics integration.
2. Global CCD Activation
Explanation: Enabling bullet = true on every dynamic body forces the engine to perform swept collision tests for all pairs. CPU usage spikes, causing frame drops on mid-tier iOS devices.
Fix: Apply CCD conditionally based on velocity thresholds or entity roles. Profile with Xcode Instruments to verify CPU impact.
3. The Resume Spike
Explanation: When an app returns from background, deltaTime contains the entire suspension duration. Without clamping, the accumulator triggers thousands of steps, freezing the UI and teleporting objects.
Fix: Clamp deltaTime to a maximum threshold (e.g., 0.1 or 0.25 seconds) and pause the physics system on visibilitychange.
4. Input Buffering Overhead
Explanation: Queuing touch events and draining them on the next physics tick adds unnecessary latency. Players perceive delayed feedback, especially in aiming or reaction-based mechanics. Fix: Process input in the same frame it arrives. Use visual trajectory previews to mask WebView routing latency, committing physics forces only on release.
5. Chasing Bit-Perfect Determinism
Explanation: Attempting to achieve identical simulation results across iOS and Android devices is mathematically impractical due to floating-point implementation differences and CPU architecture variations. Fix: Design around state snapshots instead of frame-accurate replays. Validate outcomes server-side. Use video recording for replay sharing rather than deterministic simulation playback.
6. Ignoring Dynamic-Dynamic CCD Limits
Explanation: Box2D's CCD flag only guarantees collision detection against static and kinematic bodies. Two fast-moving dynamic bodies can still tunnel through each other. Fix: Implement manual raycasts between frames for critical dynamic-dynamic interactions. Sample start/end positions and cast a segment ray to detect early collisions.
7. Mismatched Fixed Timestep Values
Explanation: Setting fixedTimeStep to 1/30 while targeting 60Hz rendering creates visible stutter. Setting it to 1/120 unnecessarily increases CPU load for games that don't require high precision.
Fix: Align fixed timestep with gameplay requirements. Use 1/60 for standard physics games, 1/120 for high-precision arcade titles, and 1/30 for turn-based or slow-paced simulations.
Production Bundle
Action Checklist
- Configure explicit fixed timestep in Project Settings or via
PhysicsSystem2D.instance.fixedTimeStep - Implement accumulator pattern with delta clamping to prevent resume spikes
- Apply
bullet = trueconditionally based on velocity thresholds, not globally - Attach
visibilitychangelistener to pause/resume physics system on app lifecycle transitions - Process touch input in the arrival frame; avoid multi-tick buffering
- Validate dynamic-dynamic collision behavior; supplement CCD with raycasts if needed
- Test on ProMotion devices (iPhone 13 Pro+) and thermal-throttled scenarios
- Profile CPU overhead with Xcode Instruments to verify CCD and accumulator performance
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Arcade / Precision Physics | Fixed timestep 1/60 + Velocity-threshold CCD |
Ensures consistent hit registration and fair gameplay across devices | Medium CPU (CCD on fast bodies only) |
| Turn-Based / Strategy | Fixed timestep 1/30 + Disabled CCD |
Reduces CPU load; precision less critical for slow movement | Low CPU |
| Competitive Multiplayer | Fixed timestep 1/60 + Server-side state validation |
Client simulation handles rendering; server validates outcomes | Network overhead + Medium CPU |
| High-Framerate Showcase | Fixed timestep 1/120 + Render interpolation |
Matches ProMotion refresh for ultra-smooth visuals | High CPU (requires optimization) |
Configuration Template
import { _decorator, Component, PhysicsSystem2D, RigidBody2D, sys } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PhysicsRuntimeConfig')
export class PhysicsRuntimeConfig extends Component {
@property
public targetFixedStep: number = 1 / 60;
@property
public maxDeltaClamp: number = 0.2;
@property
public ccdVelocityThreshold: number = 750;
private _accumulator: number = 0;
private _isPhysicsActive: boolean = true;
protected onLoad(): void {
this._configureEngine();
this._attachLifecycleHooks();
}
private _configureEngine(): void {
PhysicsSystem2D.instance.fixedTimeStep = this.targetFixedStep;
PhysicsSystem2D.instance.enabled = true;
}
private _attachLifecycleHooks(): void {
document.addEventListener('visibilitychange', () => {
this._isPhysicsActive = !document.hidden;
PhysicsSystem2D.instance.enabled = this._isPhysicsActive;
});
}
protected update(dt: number): void {
if (!this._isPhysicsActive) return;
const safeDt = Math.min(dt, this.maxDeltaClamp);
this._accumulator += safeDt;
while (this._accumulator >= this.targetFixedStep) {
this._accumulator -= this.targetFixedStep;
// Physics steps automatically execute via engine loop
// Custom gameplay logic that modifies physics should run here
}
}
public applyCCDIfFast(body: RigidBody2D): void {
const speed = body.linearVelocity.length();
body.bullet = speed > this.ccdVelocityThreshold;
}
}
Quick Start Guide
- Initialize the Manager: Attach
PhysicsRuntimeConfigto a root node in your scene. SettargetFixedStepto1/60andmaxDeltaClampto0.2. - Configure Fast Bodies: For projectiles or high-speed entities, call
applyCCDIfFast()during initialization or velocity changes. Avoid enabling CCD in the editor inspector for all bodies. - Verify Lifecycle Handling: Test backgrounding and foregrounding the app on a physical iOS device. Confirm that physics pauses on suspension and resumes without state corruption or UI freezes.
- Profile & Iterate: Run Xcode Instruments (Time Profiler) during gameplay. Monitor CPU usage for physics steps and CCD queries. Adjust velocity thresholds or fixed timestep values based on performance data.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
