Quick answer: Pinch zoom that drifts off your fingers is a coordinate-space bug. The math must run in layout coordinates, not viewport pixels, and the scroll must be adjusted after every scale change so that the pinch midpoint stays under the same viewport pixel. Convert both touches with TouchLayoutX/Y, compute the midpoint, apply scale, and update scroll so the midpoint has not moved on screen.

You build a city-builder or a tower-defense map where players pinch to zoom the camera. It feels wrong immediately: zoom in and the content slides away from the fingers; zoom out and the map rockets toward the screen center. Users describe it as “slippery.” This is always the same bug: the zoom is centered on the wrong point. Getting the math right is not hard, but it is easy to get subtly wrong.

The Symptom

On a two-finger pinch, the zoom increases or decreases as expected, but the content does not stay “under” the fingers. Instead, the layout appears to slide: pinch in on a tower at the edge of the screen and the tower drifts toward the center of the view. Pinch out and the tower slides toward the top-left corner. In the worst case, a hard pinch flings the camera off the map entirely.

A related symptom: zoom works but panning with one finger on a zoomed-in layout moves the content by a wrong amount (too slow when zoomed in, too fast when zoomed out).

What Causes This

1. Scaling the layer instead of adjusting scroll. Setting LayoutScale without also adjusting ScrollX and ScrollY scales from the current scroll anchor (usually top-left or center), not from the pinch midpoint. Content therefore appears to move.

2. Using viewport pixels for the midpoint. Viewport pixels are screen coordinates. They do not change when you zoom, but the layout coordinate underneath them does. Using viewport pixels for subsequent zoom steps compounds the drift.

3. Using only touch 0 as reference. A pinch involves two touches. If you anchor zoom to Touch.X(0) and ignore the second finger, users expect the content between their fingers to stay put but it stays under just one finger.

4. Updating scale before computing new midpoint. Order matters. You must capture the layout midpoint before applying the zoom, then apply zoom, then compute where that midpoint is now, then shift scroll to cancel the difference.

5. Not accounting for layer parallax. If your game UI is on a layer with Parallax = 0,0, it must not be zoomed with the game world or coordinates will diverge.

The Fix

Step 1: Set up two touch-tracking variables. Store the first two active touch IDs and their starting layout positions.

// Global / family variables:
PinchActive       : Boolean = false
PinchStartDist   : Number  = 0
PinchStartScale  : Number  = 1
AnchorLayoutX    : Number  = 0   // midpoint in layout coords, captured once
AnchorLayoutY    : Number  = 0
AnchorViewportX  : Number  = 0   // midpoint in viewport pixels, captured once
AnchorViewportY  : Number  = 0

Step 2: On the second touch start, capture the anchor.

// Event: Touch.OnNthTouchStart(2)
// Condition: there are now exactly 2 touches

Let t0x = Touch.XForID(0, "Game")    // viewport X of finger 0
Let t0y = Touch.YForID(0, "Game")
Let t1x = Touch.XForID(1, "Game")
Let t1y = Touch.YForID(1, "Game")

// Midpoint in layout coordinates (for zoom anchoring)
AnchorLayoutX   = (t0x + t1x) / 2
AnchorLayoutY   = (t0y + t1y) / 2

// Midpoint in viewport pixels (to cancel drift after zoom)
Let vx0 = (Touch.XAt(0) + Touch.XAt(1)) / 2
Let vy0 = (Touch.YAt(0) + Touch.YAt(1)) / 2
AnchorViewportX = vx0
AnchorViewportY = vy0

// Initial pinch distance (in viewport pixels)
PinchStartDist  = distance(Touch.XAt(0), Touch.YAt(0),
                           Touch.XAt(1), Touch.YAt(1))
PinchStartScale = LayoutScale
PinchActive     = true

Step 3: Every tick, apply zoom and then re-anchor scroll.

// Event: PinchActive is true AND Touch.TouchCount >= 2

Let curDist = distance(Touch.XAt(0), Touch.YAt(0),
                         Touch.XAt(1), Touch.YAt(1))

// Target scale is proportional to pinch distance change
Let targetScale = PinchStartScale * (curDist / max(PinchStartDist, 1))
targetScale = clamp(targetScale, 0.5, 4.0)

// Apply scale first
System.LayoutScale = targetScale

// After zoom, where is the original anchor now in viewport pixels?
Let afterVx = layoutToViewportX(AnchorLayoutX)
Let afterVy = layoutToViewportY(AnchorLayoutY)

// Shift scroll so anchor stays under AnchorViewportX/Y (where the midpoint is now)
Let nowVx = (Touch.XAt(0) + Touch.XAt(1)) / 2
Let nowVy = (Touch.YAt(0) + Touch.YAt(1)) / 2

System.ScrollX = System.ScrollX + (afterVx - nowVx) / targetScale
System.ScrollY = System.ScrollY + (afterVy - nowVy) / targetScale

Step 4: End pinch cleanly.

// Event: Touch.TouchCount < 2 while PinchActive
PinchActive = false

// Optional: capture velocity for momentum
PinchVelocity = (LayoutScale - LastFrameScale) / dt

Step 5: Momentum without drift. If you want the zoom to glide after release, decay the velocity each tick and keep applying it at the last known anchor rather than re-reading finger positions.

// Every tick while PinchVelocity != 0 and PinchActive is false
PinchVelocity = PinchVelocity * exp(-6 * dt)   // decay
Let newScale = clamp(LayoutScale + PinchVelocity * dt, 0.5, 4.0)
System.LayoutScale = newScale

// Keep the last anchor fixed — do NOT recompute from fingers here
System.ScrollX = System.ScrollX + scrollShiftAt(AnchorLayoutX, AnchorViewportX, newScale)
System.ScrollY = System.ScrollY + scrollShiftAt(AnchorLayoutY, AnchorViewportY, newScale)

If abs(PinchVelocity) < 0.01: PinchVelocity = 0

Step 6: Clamp scroll to layout bounds. After every scroll update, clamp ScrollX to [ViewportWidth*0.5 / LayoutScale, LayoutWidth - ViewportWidth*0.5 / LayoutScale] to prevent panning off the map.

Why This Works

Pinch zoom is a transformation about a point. Mathematically, you are applying p’ = center + scale * (p - center) to every layout point. When center is the pinch midpoint, the midpoint itself is a fixed point of the transformation — it does not move. Every other point zooms toward or away from it.

Construct 3’s LayoutScale and ScrollX/Y together implement this transformation, but LayoutScale alone scales around the scroll position, not an arbitrary midpoint. To simulate “zoom around a custom center,” you apply scale and then translate scroll by the amount needed to put the anchor back where it was on screen.

Working in layout coordinates for the anchor matters because, after zoom, the viewport pixel of any given layout point changes. If you tracked the midpoint in viewport pixels only, your anchor would be a moving target that depends on the very scale you are changing.

"Zoom around a point, and the point does not move. That is the whole contract. Every drift bug is a violation of it."

Related Issues

For pan / drag issues on mobile, see Fix: Construct 3 Touch Drag Jitter on Mobile. If your UI layer zooms along with the game, check Fix: Construct 3 UI Scales With Zoom.

Layout coords for anchor, viewport coords for correction, scroll adjustment after every scale change.