Implementing Accelerometer Controls for Mobile Games
Phone tilt as gamepad — intuitive control for racing games, ball games, arcades. Developers often add it in a day. Then spend a week polishing: smoothing latency, fighting drift, tuning dead zones, calibration. Proper implementation requires understanding sensor fusion and where responsiveness is lost.
Why "Just Take Accelerometer" Doesn't Work
Raw accelerometer contains gravity. On level table: (x: 0, y: 0, z: -9.81) — not motion, it's gravity on Z. If person holds phone at 45° in game, gravity vector spreads across axes. Tilting left-right changes x, but also changes z. This confusion breaks control.
Correct source: Device Motion / Linear Acceleration — data already without gravity. But they have noise and slow gyro drift on long sessions.
Implementation on iOS (Unity + CoreMotion native plugin)
For native UIKit/SwiftUI games (SpriteKit, SceneKit):
let motionManager = CMMotionManager()
motionManager.deviceMotionUpdateInterval = 1.0 / 60.0
motionManager.startDeviceMotionUpdates(
using: .xArbitraryZVertical, // no magnetometer — lower latency
to: OperationQueue.main
) { [weak self] motion, _ in
guard let motion = motion else { return }
self?.applyTilt(
pitch: Float(motion.attitude.pitch),
roll: Float(motion.attitude.roll)
)
}
xArbitraryZVertical doesn't require magnetometer, reducing latency ~5–10 ms and power consumption. For racing games, north direction irrelevant.
For Unity use Input.gyro + Input.acceleration via UnityEngine.InputSystem:
using UnityEngine.InputSystem;
void Update()
{
var attitude = AttitudeSensor.current;
if (attitude == null || !attitude.enabled) return;
Quaternion deviceOrientation = attitude.attitude.ReadValue();
// Compensate screen orientation
Quaternion fixedOrientation = Quaternion.Euler(90, 0, 0) * deviceOrientation;
float roll = fixedOrientation.eulerAngles.z;
float pitch = fixedOrientation.eulerAngles.x;
MovePlayer(roll, pitch);
}
AttitudeSensor — new Input System. Old Input.gyro.attitude works but deprecated.
Implementation on Android
private var baselineAttitude: FloatArray? = null
private val currentRotationMatrix = FloatArray(16)
// In SensorEventListener.onSensorChanged for TYPE_ROTATION_VECTOR:
val rotationMatrix = FloatArray(9)
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
val orientationAngles = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientationAngles)
val pitch = orientationAngles[1] // forward/back tilt
val roll = orientationAngles[2] // left/right tilt
// Apply to baseline (calibration)
val calibratedPitch = pitch - (baselineAttitude?.get(0) ?: 0f)
val calibratedRoll = roll - (baselineAttitude?.get(1) ?: 0f)
gameEngine.setTilt(calibratedPitch, calibratedRoll)
Smoothing: Low-Pass Filter
Raw data jitters — hands never perfectly still. Simple exponential filter:
struct LowPassFilter {
var value: Float = 0
let alpha: Float // 0.1 = strong smoothing, 0.8 = nearly raw data
mutating func update(_ newValue: Float) -> Float {
value = alpha * newValue + (1 - alpha) * value
return value
}
}
// alpha = 0.3 for racing game (balance between responsiveness and smoothness)
var rollFilter = LowPassFilter(alpha: 0.3)
let smoothRoll = rollFilter.update(rawRoll)
Tuning alpha — empirically. Rule: smaller alpha = smoother but more latency. For maze ball — 0.2, for racing — 0.3–0.4, for aiming shooter — 0.6–0.7.
Calibrating "Neutral" Position
Users hold phone differently: one at 30°, another at 60°. "Neutral" should be where phone starts, not strictly horizontal.
fun calibrate() {
baselineAttitude = floatArrayOf(currentPitch, currentRoll)
}
Call on "Calibrate" button press or automatically 2 seconds after game start. Save baseline in SharedPreferences — no recalibration next launch.
Dead Zone and Nonlinear Sensitivity
Central dead zone ±5° — removes unintended movement while holding:
func applyDeadZone(_ value: Float, threshold: Float = 0.087) -> Float { // 5 degrees in radians
guard abs(value) > threshold else { return 0 }
let sign: Float = value > 0 ? 1 : -1
return sign * (abs(value) - threshold)
}
Nonlinear sensitivity (power function): small tilts — slow movement, large — fast. Allows precise control and quick turns:
let normalizedRoll = clamp(calibratedRoll / maxAngle, -1, 1) // normalize to [-1, 1]
let curvedInput = sign(normalizedRoll) * pow(abs(normalizedRoll), 1.5)
playerSpeed = curvedInput * maxSpeed
Combining with Touch
Give users choice: accelerometer or virtual joystick. Part of audience doesn't like tilt — especially in transport. Both modes work without restart, switching via settings.
Timeline
Basic tilt control with calibration and filtering — 3–5 work days. With polishing for specific genre, nonlinear sensitivity, and device pool testing — 1–2 weeks.







