Imported from Xyrces/godot-ecs-gamedev-playbook (
skills/2d_camera_systems/SKILL.md). Install upstream withnpx skills add Xyrces/godot-ecs-gamedev-playbook --skill 2d_camera_systems. Copyright stays with the author.
2D Camera Systems
Overview
A well-implemented camera makes the game feel alive and responsive. Poor camera work creates motion sickness, disorientation, or a lifeless feel. In an ECS architecture, camera intent (target, shake, zone) lives in components, while the Godot Camera2D node applies the final transform.
1. Camera Smoothing
Exponential Decay (Recommended Default)
namespace Game.Core.Systems;
public static class CameraSmoothingSystem
{
/// <summary>
/// Exponential decay smoothing. Frame-rate independent.
/// Speed ~= 1/halflife. halflife of 0.1 = snappy, 0.5 = smooth.
/// </summary>
public static float ExpDecay(float current, float target, float decay, float dt)
{
return target + (current - target) * MathF.Exp(-decay * dt);
}
public static void UpdateSmooth(
ref CameraComponent camera,
in CameraTargetComponent target,
float deltaTime)
{
float decay = 1f / MathF.Max(camera.SmoothingHalflife, 0.001f);
camera.X = ExpDecay(camera.X, target.X, decay, deltaTime);
camera.Y = ExpDecay(camera.Y, target.Y, decay, deltaTime);
}
}
SmoothDamp (Critically Damped Spring)
namespace Game.Core.Math;
public static class CameraMath
{
/// <summary>
/// SmoothDamp equivalent — critically damped spring.
/// Prevents overshoot, smooth deceleration to target.
/// </summary>
public static float SmoothDamp(
float current, float target, ref float velocity,
float smoothTime, float deltaTime, float maxSpeed = float.MaxValue)
{
smoothTime = MathF.Max(smoothTime, 0.0001f);
float omega = 2f / smoothTime;
float x = omega * deltaTime;
float exp = 1f / (1f + x + 0.48f * x * x + 0.235f * x * x * x);
float change = current - target;
float maxChange = maxSpeed * smoothTime;
change = MathF.Max(-maxChange, MathF.Min(maxChange, change));
float adjustedTarget = current - change;
float temp = (velocity + omega * change) * deltaTime;
velocity = (velocity - omega * temp) * exp;
float result = adjustedTarget + (change + temp) * exp;
// Prevent overshoot
if ((target - current > 0f) == (result > target))
{
result = target;
velocity = 0f;
}
return result;
}
}
ECS Camera Components
namespace Game.Core.Components;
public struct CameraComponent
{
public float X;
public float Y;
public float Zoom;
public float SmoothingHalflife; // Seconds — lower = snappier
public float MinX, MaxX, MinY, MaxY; // Bounds
public bool BoundsEnabled;
}
public struct CameraTargetComponent
{
public float X;
public float Y;
public float LookAheadX; // Offset in movement direction
public float LookAheadY;
}
2. Screen Shake
Trauma-Based System (Squirrel Eiserloh)
Screen shake quality comes from using Perlin noise (smooth, organic) instead of random values (jittery, mechanical). The trauma model provides intensity that decays naturally.
namespace Game.Core.Components;
public struct CameraShakeComponent
{
/// <summary>Current trauma level (0–1). Squared for shake intensity.</summary>
public float Trauma;
/// <summary>Decay rate per second.</summary>
public float TraumaDecay;
/// <summary>Maximum pixel offset.</summary>
public float MaxOffset;
/// <summary>Maximum rotation in radians.</summary>
public float MaxRotation;
/// <summary>Noise sample time (advances each frame).</summary>
public float NoiseTime;
/// <summary>Speed of noise sampling.</summary>
public float NoiseSpeed;
}
Shake System
namespace Game.Core.Systems;
public static class CameraShakeSystem
{
public static void AddTrauma(ref CameraShakeComponent shake, float amount)
{
shake.Trauma = MathF.Min(shake.Trauma + amount, 1f);
}
/// <summary>
/// Returns (offsetX, offsetY, rotation) for this frame.
/// Uses trauma² for intensity — small hits are subtle, big hits are dramatic.
/// </summary>
public static (float offsetX, float offsetY, float rotation) Update(
ref CameraShakeComponent shake, float deltaTime,
Func<float, float, float> noiseSample)
{
shake.Trauma = MathF.Max(shake.Trauma - shake.TraumaDecay * deltaTime, 0f);
shake.NoiseTime += shake.NoiseSpeed * deltaTime;
float intensity = shake.Trauma * shake.Trauma; // Quadratic falloff
float offsetX = intensity * shake.MaxOffset
* noiseSample(shake.NoiseTime, 0f);
float offsetY = intensity * shake.MaxOffset
* noiseSample(0f, shake.NoiseTime);
float rotation = intensity * shake.MaxRotation
* noiseSample(shake.NoiseTime, shake.NoiseTime);
return (offsetX, offsetY, rotation);
}
}
Godot Camera Shake Bridge
using Godot;
public partial class CameraShakeBridge : Camera2D
{
private FastNoiseLite _noise = new();
public override void _Ready()
{
_noise.NoiseType = FastNoiseLite.NoiseTypeEnum.Simplex;
_noise.Frequency = 0.5f;
}
public void ApplyShake(float offsetX, float offsetY, float rotation)
{
Offset = new Vector2(offsetX, offsetY);
Rotation = rotation;
}
public float SampleNoise(float x, float y) =>
_noise.GetNoise2D(x * 100f, y * 100f);
}
Accessibility: Disable Shake
// In settings system — respect user preference
public struct AccessibilitySettings
{
public bool ScreenShakeEnabled;
public float ScreenShakeIntensity; // 0.0 to 1.0 multiplier
}
3. Camera Bounds & Clamping
namespace Game.Core.Systems;
public static class CameraBoundsSystem
{
public static void ClampToLevel(ref CameraComponent camera)
{
if (!camera.BoundsEnabled) return;
camera.X = MathF.Max(camera.MinX, MathF.Min(camera.MaxX, camera.X));
camera.Y = MathF.Max(camera.MinY, MathF.Min(camera.MaxY, camera.Y));
}
/// <summary>Soft clamp — pushes back gently instead of hard stop.</summary>
public static float SoftClamp(
float value, float min, float max, float softness = 0.1f)
{
if (value < min)
return min + (value - min) * softness;
if (value > max)
return max + (value - max) * softness;
return value;
}
}
Camera Zones (Trigger-Based)
namespace Game.Core.Components;
public struct CameraZoneComponent
{
public float BoundsMinX, BoundsMinY;
public float BoundsMaxX, BoundsMaxY;
public float ZoomOverride; // 0 = use default
public float TransitionDuration; // Seconds to blend to this zone
}
4. Parallax Scrolling
Godot ParallaxBackground
using Godot;
public partial class ParallaxSetup : ParallaxBackground
{
public override void _Ready()
{
// Layer 1: Far background (slow scroll)
var farLayer = new ParallaxLayer();
farLayer.MotionScale = new Vector2(0.1f, 0.05f);
farLayer.MotionMirroring = new Vector2(1920, 0); // Infinite scroll
AddChild(farLayer);
var farSprite = new Sprite2D();
farSprite.Texture = GD.Load<Texture2D>("res://backgrounds/sky.png");
farSprite.Centered = false;
farLayer.AddChild(farSprite);
// Layer 2: Mid ground (medium scroll)
var midLayer = new ParallaxLayer();
midLayer.MotionScale = new Vector2(0.4f, 0.2f);
midLayer.MotionMirroring = new Vector2(1920, 0);
AddChild(midLayer);
// Layer 3: Near foreground (fast scroll, almost 1:1)
var nearLayer = new ParallaxLayer();
nearLayer.MotionScale = new Vector2(0.8f, 0.5f);
nearLayer.MotionMirroring = new Vector2(1920, 0);
AddChild(nearLayer);
}
}
Depth-Based Scroll Speed Formula
MotionScale = 1.0 - (layerDepth / maxDepth)
Depth 0 (foreground): MotionScale = 1.0
Depth 5 (mid): MotionScale = 0.5
Depth 10 (far): MotionScale = 0.0 (static)
5. Zoom & Pan Controls
using Godot;
public partial class ZoomController : Camera2D
{
[Export] public float MinZoom = 0.5f;
[Export] public float MaxZoom = 3.0f;
[Export] public float ZoomSpeed = 0.1f;
[Export] public float ZoomSmoothing = 5.0f;
private float _targetZoom = 1.0f;
public override void _UnhandledInput(InputEvent @event)
{
if (@event is InputEventMouseButton mouseBtn)
{
if (mouseBtn.ButtonIndex == MouseButton.WheelUp)
_targetZoom *= (1f + ZoomSpeed);
else if (mouseBtn.ButtonIndex == MouseButton.WheelDown)
_targetZoom *= (1f - ZoomSpeed);
_targetZoom = Mathf.Clamp(_targetZoom, MinZoom, MaxZoom);
}
}
public override void _Process(double delta)
{
float currentZoom = Zoom.X;
float newZoom = Mathf.Lerp(currentZoom, _targetZoom,
1f - Mathf.Exp(-ZoomSmoothing * (float)delta));
Zoom = new Vector2(newZoom, newZoom);
}
/// <summary>Zoom towards a world point (keeps point stable on screen).</summary>
public void ZoomTowards(Vector2 worldPoint, float zoomDelta)
{
var preZoomPos = worldPoint;
_targetZoom = Mathf.Clamp(_targetZoom + zoomDelta, MinZoom, MaxZoom);
// Adjust position to keep the world point under cursor
GlobalPosition += (preZoomPos - GlobalPosition) * (1f - Zoom.X / _targetZoom);
}
}
6. Cinematic Camera Transitions
namespace Game.Core.Components;
public struct CameraTransitionComponent
{
public float FromX, FromY, FromZoom;
public float ToX, ToY, ToZoom;
public float Duration;
public float Elapsed;
public bool Active;
}
namespace Game.Core.Systems;
using Game.Core.Math;
public static class CameraTransitionSystem
{
public static void Update(
ref CameraComponent camera,
ref CameraTransitionComponent transition,
float deltaTime)
{
if (!transition.Active) return;
transition.Elapsed += deltaTime;
float t = MathF.Min(transition.Elapsed / transition.Duration, 1f);
float eased = Easing.CubicOut(t); // Smooth deceleration
camera.X = Easing.Lerp(transition.FromX, transition.ToX, eased);
camera.Y = Easing.Lerp(transition.FromY, transition.ToY, eased);
camera.Zoom = Easing.Lerp(transition.FromZoom, transition.ToZoom, eased);
if (t >= 1f)
transition.Active = false;
}
}
7. Multi-Target Camera
Frame multiple entities by computing a bounding box and adjusting zoom/position:
namespace Game.Core.Systems;
public static class MultiTargetCameraSystem
{
public static void FrameTargets(
ref CameraComponent camera,
ReadOnlySpan<(float x, float y)> targets,
float padding = 100f,
float viewportWidth = 1920f,
float viewportHeight = 1080f)
{
if (targets.Length == 0) return;
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (var (x, y) in targets)
{
minX = MathF.Min(minX, x);
maxX = MathF.Max(maxX, x);
minY = MathF.Min(minY, y);
maxY = MathF.Max(maxY, y);
}
// Center point
camera.X = (minX + maxX) / 2f;
camera.Y = (minY + maxY) / 2f;
// Zoom to fit all targets
float spanX = (maxX - minX) + padding * 2f;
float spanY = (maxY - minY) + padding * 2f;
float zoomX = viewportWidth / spanX;
float zoomY = viewportHeight / spanY;
camera.Zoom = MathF.Min(zoomX, zoomY);
camera.Zoom = MathF.Max(0.3f, MathF.Min(2f, camera.Zoom));
}
}
8. Look-Ahead
Move the camera slightly ahead of the player's movement direction:
namespace Game.Core.Systems;
public static class LookAheadSystem
{
public static void UpdateLookAhead(
ref CameraTargetComponent target,
in VelocityComponent velocity,
float lookAheadDistance = 60f,
float smoothing = 3f,
float deltaTime = 0.016f)
{
float targetAheadX = 0f;
if (MathF.Abs(velocity.X) > 10f)
targetAheadX = MathF.Sign(velocity.X) * lookAheadDistance;
target.LookAheadX += (targetAheadX - target.LookAheadX)
* (1f - MathF.Exp(-smoothing * deltaTime));
}
}
9. Godot Camera2D Bridge
using Godot;
public partial class CameraBridge : Camera2D
{
public void SyncFromECS(
in CameraComponent camera,
float shakeOffsetX = 0f,
float shakeOffsetY = 0f,
float shakeRotation = 0f)
{
GlobalPosition = new Vector2(camera.X + shakeOffsetX,
camera.Y + shakeOffsetY);
Zoom = new Vector2(camera.Zoom, camera.Zoom);
Rotation = shakeRotation;
}
}