Camera follow, shake and hitstop
Two things that make a game feel solid: a camera that moves well, and hits you can feel. This page
has a CameraRig script for the player (follow with a look-ahead, stay inside the level’s
bounds, zoom out while a button is held) and a Hit_Feel procedure any script can call when
something gets hit: a camera shake plus a hitstop, the few frames of freeze that make a blow
land. Both come from the demo’s forest level (scripts/hero.odin, scripts/effects.odin).
The scene
Section titled “The scene”- A Camera node anywhere in the scene, not inside the player: the script moves it. The first
Camera of a scene is its camera (
scene.camera). - The player (a CharacterMover, like the platformer character)
gets
CameraRigas another script (Add script in the inspector). Set Level size to the level’s size in pixels. - Project Settings > Input: an action look (Tab, the gamepad’s North button) for zooming out.
The scripts
Section titled “The scripts”package scripts
import "core:math"import e "engine:."import sdl "vendor:sdl3"
/*CameraRig: on the player. The scene's camera follows it, looking ahead the way it moves, and nevershows anything outside the level (0, 0 to levelSize). Holding "look" zooms out to see more.*/CameraRig :: struct { using script: e.Script, // The level's size in pixels (0, 0: no bounds) levelSize: [2]f32 `export:"default=2560, 720"`, // How far ahead of the player the camera looks, in pixels at full speed lookAhead: f32 `export:"default=80 min=0 max=400"`, // The point followed: this far above the player's origin (its feet) height: f32 `export:"default=24 min=-200 max=200"`, zoomedOut: f32 `export:"default=0.6 min=0.1 max=1"`, // The look-ahead now (eased, so turning around doesn't jerk the view) ahead: f32,}
@(init)register_CameraRig :: proc "contextless" () { e.Script_Register(CameraRig, { ready = proc(self: ^CameraRig) { camera := self.scene.camera if camera == nil do return if self.levelSize != {} do e.Camera2D_SetBounds(camera, {0, 0}, self.levelSize) // Start on the player instead of sliding over from where the camera was placed e.Camera2D_SnapTo(camera, e.Node_GetWorldPosition(self.node) - {0, self.height}) }, update = proc(self: ^CameraRig, _: []sdl.Event, deltaTime: f64) { camera := self.scene.camera if camera == nil do return // Look ahead the way it's moving: -1 to 1 of the look-ahead wanted: f32 = 0 if e.Node_Is(self.node, e.CharacterMover) { velocity := (cast(^e.CharacterMover)self.node).velocity wanted = clamp(velocity.x / 200, -1, 1) * self.lookAhead } self.ahead += (wanted - self.ahead) * (1 - math.exp(-3 * f32(deltaTime))) target := e.Node_GetWorldPosition(self.node) + {self.ahead, -self.height} e.Camera2D_Follow(camera, target, deltaTime)
// Zoom out while "look" is held (eased over a third of a second) if e.Input_IsActionJustPressed(ACTION_LOOK) do e.Camera2D_ZoomTo(camera, self.zoomedOut, 0.35) if e.Input_IsActionJustReleased(ACTION_LOOK) do e.Camera2D_ZoomTo(camera, 1, 0.35) }, })}package scripts
import e "engine:."
// How hard a hit feelsHitWeight :: enum { // A light blow (the player's sword on an enemy) Light, // A defeat Heavy, // The player hurt Hurt,}
/*A hitstop and a camera shake for a hit. The hitstop freezes the game for a few frames (scenes andglobals update with a delta time of 0: physics, animations, particles and timers stop) while theshake goes on, so the frozen frame shakes.*/Hit_Feel :: proc(scene: ^e.Scene, weight: HitWeight) { switch weight { case .Light: e.Game_HitStop(scene.game, 0.06) e.Camera2D_Shake(scene.camera, 2.5, 0.15) case .Heavy: e.Game_HitStop(scene.game, 0.09) e.Camera2D_Shake(scene.camera, 4, 0.25) case .Hurt: e.Game_HitStop(scene.game, 0.12) e.Camera2D_Shake(scene.camera, 6, 0.3) }}package scripts
ACTION_LOOK :: "look"Call it wherever a hit happens, for example in an enemy’s hurt procedure:
// Given: self: ^e.Script, defeated: boolHit_Feel(self.scene, defeated ? .Heavy : .Light)How it works
Section titled “How it works”e.Camera2D_Follow(camera, position, dt)eases the camera toward a point with the camera’s follow settings,camera.follow(smoothing 6 by default; a deadzone and a top speed are off). Call it every frame from the followed node’s script. (A camera placed inside the player follows it rigidly instead, with no easing or look-ahead.)- The look-ahead shifts the followed point in the direction of movement, so the player sees more of where they’re going. It’s eased itself, so turning around swings the view over smoothly.
e.Camera2D_SetBounds(camera, min, max)keeps the view inside a rectangle of the world: at the level’s edges the camera stops and the player walks toward the screen’s edge. For rooms the camera stays in, use CameraZone nodes instead.e.Camera2D_SnapToputs the camera there at once (a level start, a respawn), with no easing.e.Camera2D_ZoomTo(camera, zoom, seconds)eases the zoom: below 1 shows more of the world.e.Camera2D_Shake(camera, strength, seconds)shakes by up tostrengthpixels, fading out.e.Game_HitStop(game, seconds)freezes the game in real time. A longer one extends a running one; a shorter one doesn’t cut it, so many hits at once don’t stack into a long freeze.
Try it
Section titled “Try it”-
A box the player moves in without moving the camera, and a looser follow, set in
ready:// Given: camera: ^e.Camera2Dcamera.follow.deadzone = {120, 60}camera.follow.smoothing = 4 -
Slow motion for a finishing blow:
e.Game_SetTimeScale(self.game, 0.3), and1to go back. -
A camera that leads more upward when falling: add
velocity.yto the target the same way. -
Pan to show the player a door that opened, then come back:
e.Camera2D_MoveTo(camera, door_position, 1.2). While it pans,Camera2D_Followwaits. -
With an LDtk level, take the bounds from the level instead of a field, as
hero.odindoes:level.views[0].level.sizeon the scene’s LDtkLevel node.
