Recoil, Spread, and Camera Motion
Separate input, weapon kick, random spread, camera shake and other camera motion, and recovery to understand why the view moves after a shot.
Cheatsheet for Lesson 7.7
A one-page summary of “Recoil, Spread, and Camera Motion” you can keep beside your notes. Plain Markdown.
Lesson 7.7 — Recoil, Spread, and Camera Motion: cheatsheet
- Remember: View kick, weapon animation, and projectile spread can look related while coming from different calculations.
- Also useful: Picture a spring pushed by each shot: it stores motion, overshoots, and settles. Use that picture only when measured recovery supports it.
Key terms
- angle: An angle describes rotation.
enable_assaultcube_no_recoil: The compiled in-process implementation isenable_assaultcube_no_recoil.
Formulas and patterns
weapon fired;→ recoil amount selected;→ horizontal and vertical kick calculated;→ camera/view state updated
Review
- Q: Why record recoil as a time series instead of one final camera angle? A: The samples reveal the impulse, recovery, timing, and damping behavior
- Q: Removing the camera kick from a weapon is tested and the view stops rising. What may you conclude? A: Only that the camera kick is gone; projectile spread may still be there
What recoil is inside a program
Section titled “What recoil is inside a program”On screen, recoil looks like the weapon pushing the camera. In code, it is usually one or more numbers added to a view model over time.
The game may keep separate values for:
- the player’s intended yaw and pitch from the mouse;
- an immediate weapon kick;
- a recovery amount that eases back toward zero;
- random spread used by projectile calculations;
- camera shake used only for presentation;
- the weapon model’s animation.
Those systems can produce similar movement while controlling different things. Removing a camera kick should not be assumed to remove projectile spread, and changing the weapon animation should not be assumed to change aim.
This diagram deliberately gives rendered view and shot direction separate paths. If two systems read the same base aim but add different offsets, changing one will not necessarily change the other.
An angle describes rotation. Yaw usually turns left and right around a
vertical axis. Pitch looks up and down. Games may store degrees, radians, fixed
point units, or normalized values, so the number 90.0 has meaning only after
measurement.
Observe one axis at a time
Section titled “Observe one axis at a time”In an offline AssaultCube session, log view angles while:
- standing still;
- moving the mouse;
- firing one shot;
- firing a burst;
- switching weapons.
Recoil may change the same yaw and pitch values as the mouse, or it may update separate kick values that are combined later.
Log time as well as values. A single before-and-after pair shows total movement; a short time series shows whether the kick happens instantly and how recovery behaves across later frames.
Give each sample a monotonic timestamp and, when available, the frame or simulation tick. Wall-clock timestamps can jump; a monotonic clock measures elapsed duration. Each sample also needs the weapon, fire mode, and whether the shot was accepted. Otherwise a reload or dry-fire sample can be mistaken for zero recoil.
Break on the angle write
Section titled “Break on the angle write”Set a write breakpoint on the confirmed pitch field and fire once.

Compare the call stack with an ordinary mouse movement. A path that appears only after firing is a useful lead.
Step upward until you can explain the inputs:
weapon fired→ recoil amount selected→ horizontal and vertical kick calculated→ camera/view state updated
Model recoil separately
Section titled “Model recoil separately”The logged view angle mixes player input with weapon effects. Give the weapon’s temporary kick its own two-axis value so it can be compared across shots without treating every camera movement as recoil. The validity check rejects non-finite readings and values outside this lab’s expected range:
#[derive(Clone, Copy, Debug, Default)]struct Recoil { yaw: f32, pitch: f32,}
impl Recoil { fn is_reasonable(self) -> bool { self.yaw.is_finite() && self.pitch.is_finite() && self.yaw.abs() <= 90.0 && self.pitch.abs() <= 90.0 }}Do not assume the units are degrees. Compare known view changes and weapon differences.
is_finite rejects NaN and infinity. Those special floating-point values can
spread through later math and make comparisons or drawing unreliable. The
90.0 limits are a lab sanity check, not proof of the game’s real unit system.
Name samples with units:
#[derive(Clone, Copy, Debug)]struct TimedKick { seconds_after_shot: f32, yaw_degrees: f32, pitch_degrees: f32,}Degrees per frame and degrees per second are different quantities. A per-frame recovery constant produces different behavior when frame rate changes unless the engine runs that update on a fixed simulation tick.
Recoil is often an impulse plus recovery
Section titled “Recoil is often an impulse plus recovery”A simple model keeps a kick displacement x and velocity v. Firing adds an
impulse; each update pulls the displacement toward zero and damps velocity:
on shot: v ← v + impulseupdate: acceleration = -stiffness × x - damping × v v ← v + acceleration × dt x ← x + v × dtThis damped-spring model explains overshoot and smooth return. Other games use a fixed curve, exponential decay, a table of weapon-specific offsets, or direct angle changes. Fit the simplest model supported by measured samples rather than assuming every recovery is a spring.
For exponential recovery, x(t) = x₀e^(-kt). Equal fractions disappear in equal
time intervals. For linear recovery, equal absolute amounts disappear. Plotting a
few samples makes the difference visible.
Prefer measurement over removal
Section titled “Prefer measurement over removal”First build an observer that logs a short sequence:
#[derive(Debug)]struct RecoilSample { shot: u32, before: Angles, after: Angles,}
impl RecoilSample { fn delta(&self) -> Recoil { Recoil { yaw: shortest_angle_delta(self.before.yaw, self.after.yaw), pitch: self.after.pitch - self.before.pitch, } }}This can reveal:
- a fixed pattern;
- random spread;
- weapon-specific values;
- accumulation and recovery over time.
Repeat the same shot many times. Compute mean kick to expose a fixed pattern and the spread around that mean to expose randomness. If shot number inside a burst matters, group by shot index instead of averaging the whole magazine together.
mean = sum(samples) / countvariance = sum((sample - mean)²) / (count - 1) when count > 1Variance describes observed variation; it does not prove which random-number source or probability distribution produced it.
Test one instruction with a temporary patch
Section titled “Test one instruction with a temporary patch”A temporary debugger patch can show whether one instruction contributes to recoil. Keep a copy of the original bytes so you can restore them, change one instruction, and compare the same controlled shot.

If weapon animation still moves but the camera does not, you may have isolated view kick. If accuracy changes too, the instruction controls more than presentation.
Compare at least these observables separately:
| Observable | What it can tell you |
|---|---|
| stored base yaw/pitch | player-input orientation |
| final camera matrix | presentation actually rendered |
| shot direction or impact | gameplay trajectory |
| weapon-model transform | view-model animation |
| kick accumulator over time | recovery state |
Apply the AssaultCube 1.2.0.2 no-recoil patch
Section titled “Apply the AssaultCube 1.2.0.2 no-recoil patch”The measured recoil layers above establish what the patch should change. The
automated PatchPlan version below applies the verified patch lifecycle from
Lesson 8.3. Its byte check, write, and restoration must all
refer to the same pinned build.
The firing path begins around 0x0046363A; after breaking on the live pitch field while firing, the recoil store is found at 0x0045BAAD. Replace its complete three-byte instruction with:
const RECOIL_STORE: usize = 0x0045_BAAD;const ORIGINAL_RECOIL_STORE: [u8; 3] = [0xD9, 0x5B, 0x44];const NO_RECOIL: [u8; 3] = [0xDD, 0xD8, 0x90];These bytes pop the pending x87 value with fstp st(0) and pad the remaining byte, so the recoil value is discarded instead of being stored to [ebx+0x44], the pitch field.
The older x87 floating-point unit behaves like a small stack of number
registers. The original store removes its top value after writing it. Merely
NOPing the whole instruction would leave that value on the x87 stack and could
break later calculations. fstp st(0) discards the value while preserving the
original stack effect; nop fills the final byte so the patch length remains
three.
Use a verified patch:
let plan = PatchPlan::new( RECOIL_STORE, &ORIGINAL_RECOIL_STORE, &NO_RECOIL,)?;let mut no_recoil = plan.apply(&process)?;Fire a single shot before and after enabling it. Ammo and firing animation should still work, but the camera should no longer kick upward. Restore the original three bytes and confirm recoil returns.

The original no-recoil demonstration shows firing with the camera effect altered. Watch camera motion separately from ammunition and firing animation; changing one recoil store does not establish that every weapon effect has changed.
Download Recoil behavior during AssaultCube firing videoRandom spread: choosing a point in a circle
Section titled “Random spread: choosing a point in a circle”Spread adds a random offset to the direction of a shot. The offset is usually a random point inside a small circle around the aim direction, and picking that point fairly is easier to get wrong than it looks.
The obvious recipe is a random distance r = R × u, with u chosen evenly between 0
and 1, at a random angle. It bunches the shots near the middle. Half of the values of
u fall below 0.5, so half of the shots land inside the circle of radius 0.5 × R, but
that circle has only (0.5)² = 25% of the area. The fix is to take a square root, r = R × √u,
because the area inside a radius grows as the radius squared:
spread radius R = 2 degreesu = 0.25 -> r = 2 × √0.25 = 2 × 0.5 = 1.0 degree (the naive recipe gives 0.5)v = 0.125 -> angle = 360° × 0.125 = 45°offset = (r × cos 45°, r × sin 45°) = (0.7071, 0.7071) degreesThe two values u and v come from a pseudo-random number generator. A game that seeds
it with the same number gets the same “random” sequence every time, which is how replays
and networked games keep every machine in agreement. Two runs that start from the same
seed repeat their spread exactly, and runs that start from different seeds do not.
The spread offset is added to the shot direction, not to the camera, which is why the diagram above gives them separate paths.
Camera motion: shake, follow, orbit, and zoom
Section titled “Camera motion: shake, follow, orbit, and zoom”The camera has more layers than recoil. Each calculates a position or viewing direction: shake adds an offset, follow tracks a subject, orbit rotates around a point, and zoom changes the framing.
- Shake is a short random offset that fades. With an amplitude of 4 pixels that fades
to 0 over 0.5 s, the largest offset at 0.1 s is
4 × (1 − 0.1 ÷ 0.5) = 3.2pixels, at 0.25 s it is 2.0, and at 0.5 s it is 0. Many games track a “trauma” value from 0 to 1 and shake by its square, so a light hit of 0.5 shakes at only 0.25 of the maximum. Shake is presentation: it moves the picture, not the aim. - Follow cameras trail the player. A top-down 2D camera eases toward the player with the smoothing from Lesson 4.4, and often has a dead zone, a box around the screen’s centre inside which the player can move without moving the camera.
- Orbit cameras circle a target. With
zup, the camera sits at the target plusdistance × (cos(pitch) × cos(yaw), cos(pitch) × sin(yaw), sin(pitch))and always looks back at the target. For a target at the origin, a distance of 10, a yaw of 90°, and a pitch of 30°, that is10 × (0.866 × 0, 0.866 × 1, 0.5) = (0, 8.66, 5). - First-person cameras sit at the character’s head and take yaw and pitch from the mouse. Pitch is clamped just short of straight up or down, so the view cannot flip over (Lesson 7.1). The weapon in the player’s hand, the view model, is often drawn with its own field of view, narrower than the world’s, so it does not look stretched.
- Free and pan cameras are for editors: a free camera moves along its own forward and right directions, and a 2D pan camera moves opposite to the drag.
- Zoom narrows the field of view for a perspective camera, which Lesson 7.1 showed scales the picture by 1.732 when it goes from 90° to 60°. For an orthographic camera, zooming shrinks the box of visible world, so halving its width doubles the pixels per unit.
This builder needs JavaScript. Here is the lesson’s worked example.
amplitude × (1 − time since hit ÷ fade time): with the lesson’s numbers, 4 × (1 − 0.1 ÷ 0.5), which gives 3.2. 4 px × (1 − 0.1 ÷ 0.5) = 3.2 px. The largest offset at 0.1 s is 3.2 px, and it shrinks in a straight line to 0 at 0.5 s. Shake is presentation: it moves the picture, not the aim.
Recoil is a sum of layers
Section titled “Recoil is a sum of layers”Many effects are layers:
input angle+ recoil kick+ camera shake+ animation sway= final viewDo not label the first changing float “recoil.” Trace when it is produced and where it is combined.
Composition order matters for 3D rotations. Adding small yaw/pitch offsets is a common approximation, but full orientations may be matrices or quaternions. Large rotations do not generally commute: applying yaw then pitch can differ from pitch then yaw. Identify where the engine converts or combines its representation before assigning meaning to one component.
Common false conclusions
Section titled “Common false conclusions”| What changed | What it does not prove |
|---|---|
| camera no longer rises | projectile spread is gone |
| weapon model stops moving | aim direction is unchanged |
| pitch write disappears | no later camera transform adds kick |
| repeated shots look similar | recoil is fully deterministic |
| one weapon uses an offset table | every weapon uses that table |
Keep this experiment offline. Its value is learning to separate a visible effect into measurable contributions.
The compiled in-process implementation is
enable_assaultcube_no_recoil.
After injection, F2 toggles it and restoration puts D9 5B 44 back.
game_hooks.rs
Show the codeHide the code
//! Exact x86 hooks used by the version-pinned course targets.
#![cfg(target_arch = "x86")]
use std::{
ptr,
sync::atomic::{AtomicBool, Ordering},
};
use anyhow::{Context, Result};
use windows::Win32::UI::Input::KeyboardAndMouse::{
INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEINPUT, SendInput,
};
use super::{LocalPatch, input_structure_size, near_jump};
const WESNOTH_PLAYER_ROOT: usize = 0x017E_ED18;
const WESNOTH_GAME_OFFSET: usize = 0x0A90;
const WESNOTH_GOLD_OFFSET: usize = 0x0004;
const WESNOTH_TERRAIN_HOOK: usize = 0x00CC_AF8A;
const WESNOTH_TERRAIN_RESUME: usize = 0x00CC_AF90;
const AC_TRIGGER_HOOK: usize = 0x0040_AD9D;
const AC_TRIGGER_ORIGINAL: usize = 0x0046_07C0;
const AC_TRIGGER_RESUME: usize = 0x0040_ADA2;
const AC_RECOIL_STORE: usize = 0x0045_BAAD;
const AC_RADAR_FILTER: usize = 0x0040_9FB3;
const URT_RENDER_HOOK: usize = 0x0052_D2FD;
const URT_RENDER_RESUME: usize = 0x0052_D303;
pub static TARGET_UNDER_CROSSHAIR: AtomicBool = AtomicBool::new(false);
/// Resolves Wesnoth's two pointers and writes the live gold field.
///
/// # Safety
/// Call only inside 32-bit Wesnoth 1.14.9 after a local match has started.
unsafe fn set_wesnoth_gold(amount: u32) -> Result<()> {
// SAFETY: the caller verified the target build; volatile access makes the
// actual memory operation visible at this exact point.
let player = unsafe { ptr::read_volatile(WESNOTH_PLAYER_ROOT as *const u32) } as usize;
anyhow::ensure!(
player != 0,
"start a Wesnoth match before using the terrain hook"
);
let side_pointer_address = player
.checked_add(WESNOTH_GAME_OFFSET)
.context("Wesnoth side-pointer address overflowed")?;
// SAFETY: the verified player structure owns a 32-bit pointer at +0xA90.
let side = unsafe { ptr::read_volatile(side_pointer_address as *const u32) } as usize;
anyhow::ensure!(side != 0, "Wesnoth side pointer is null");
let gold_address = side
.checked_add(WESNOTH_GOLD_OFFSET)
.context("Wesnoth gold address overflowed")?;
// SAFETY: the verified side structure owns its four-byte gold field at +4.
unsafe { ptr::write_volatile(gold_address as *mut u32, amount) };
Ok(())
}
pub fn write_wesnoth_gold(amount: u32) -> Result<()> {
// SAFETY: the DLL worker calls this only after matching wesnoth.exe. The
// pointer resolver still rejects the normal "no active match" null state.
unsafe { set_wesnoth_gold(amount) }
}
extern "C" fn wesnoth_cave_body() {
// A naked cave cannot return `Result`, so the worker verifies the target and
// this tiny body simply leaves the game unchanged if the match is not ready.
// SAFETY: the cave is installed only after the exact Wesnoth hook bytes
// match; the helper checks every nullable pointer in the live chain.
let _ = unsafe { set_wesnoth_gold(888) };
}
#[unsafe(naked)]
unsafe extern "C" fn wesnoth_terrain_cave() {
core::arch::naked_asm!(
"pushfd",
"pushad",
"call {body}",
"popad",
"popfd",
// Replay the behavior of the six bytes replaced at the hook site.
"mov eax, dword ptr [ecx]",
"lea esi, [esi]",
"jmp {resume}",
body = sym wesnoth_cave_body,
resume = const WESNOTH_TERRAIN_RESUME,
);
}
extern "C" fn record_crosshair_target(value: u32) {
TARGET_UNDER_CROSSHAIR.store(value != 0, Ordering::Release);
}
#[unsafe(naked)]
unsafe extern "C" fn assaultcube_trigger_cave() {
core::arch::naked_asm!(
// The removed instruction was this call. Run it first so EAX contains
// the game's real target-under-crosshair result.
"call {original}",
"pushfd",
"pushad",
"push eax",
"call {record}",
"add esp, 4",
"popad",
"popfd",
"jmp {resume}",
original = const AC_TRIGGER_ORIGINAL,
record = sym record_crosshair_target,
resume = const AC_TRIGGER_RESUME,
);
}
#[unsafe(naked)]
unsafe extern "C" fn urban_terror_render_cave() {
core::arch::naked_asm!(
"pushfd",
"pushad",
"mov dword ptr [ebx + 4], 0x0D",
"popad",
"popfd",
// Replay the complete instruction that the detour replaced.
"mov dword ptr [0x0102AE98], ebx",
"jmp {resume}",
resume = const URT_RENDER_RESUME,
);
}
fn six_byte_detour(hook: usize, cave: usize) -> Result<[u8; 6]> {
let jump = near_jump(hook, cave)?;
let mut replacement = [0x90_u8; 6];
replacement[..5].copy_from_slice(&jump);
Ok(replacement)
}
pub fn install_wesnoth_terrain_hook() -> Result<LocalPatch> {
// mov eax,[ecx] ; lea esi,[esi+eiz]
const ORIGINAL: [u8; 6] = [0x8B, 0x01, 0x8D, 0x74, 0x26, 0x00];
let cave = wesnoth_terrain_cave as *const () as usize;
let replacement = six_byte_detour(WESNOTH_TERRAIN_HOOK, cave)?;
LocalPatch::apply(WESNOTH_TERRAIN_HOOK, &ORIGINAL, &replacement)
}
pub fn install_assaultcube_trigger_hook() -> Result<LocalPatch> {
// call 0x004607C0, encoded relative to the end of this instruction.
const ORIGINAL: [u8; 5] = [0xE8, 0x1E, 0x5A, 0x05, 0x00];
let cave = assaultcube_trigger_cave as *const () as usize;
let replacement = near_jump(AC_TRIGGER_HOOK, cave)?;
LocalPatch::apply(AC_TRIGGER_HOOK, &ORIGINAL, &replacement)
}
pub fn install_urban_terror_memory_wallhook() -> Result<LocalPatch> {
// mov dword ptr [0x0102AE98],ebx
const ORIGINAL: [u8; 6] = [0x89, 0x1D, 0x98, 0xAE, 0x02, 0x01];
let cave = urban_terror_render_cave as *const () as usize;
let replacement = six_byte_detour(URT_RENDER_HOOK, cave)?;
LocalPatch::apply(URT_RENDER_HOOK, &ORIGINAL, &replacement)
}
pub fn enable_assaultcube_no_recoil() -> Result<LocalPatch> {
// fstp dword ptr [ebx+0x44] -> fstp st(0), nop
LocalPatch::apply(AC_RECOIL_STORE, &[0xD9, 0x5B, 0x44], &[0xDD, 0xD8, 0x90])
}
pub fn enable_assaultcube_show_all_radar() -> Result<LocalPatch> {
// 0F 85 is a six-byte near JNE. Capture its four-byte destination exactly,
// NOP the full branch, and let LocalPatch restore all six captured bytes.
LocalPatch::apply_masked(
AC_RADAR_FILTER,
&[Some(0x0F), Some(0x85), None, None, None, None],
&[0x90; 6],
)
}
pub fn send_left_mouse(pressed: bool) -> Result<()> {
let flags = if pressed {
MOUSEEVENTF_LEFTDOWN
} else {
MOUSEEVENTF_LEFTUP
};
let input = INPUT {
r#type: INPUT_MOUSE,
Anonymous: INPUT_0 {
mi: MOUSEINPUT {
dx: 0,
dy: 0,
mouseData: 0,
dwFlags: flags,
time: 0,
dwExtraInfo: 0,
},
},
};
// SAFETY: `input` is a fully initialized INPUT value and the structure size
// is the one expected by this build of Windows.
let sent = unsafe { SendInput(&[input], input_structure_size()) };
anyhow::ensure!(sent == 1, "SendInput sent {sent} of 1 mouse event");
Ok(())
}Model camera motion
Why record recoil as a time series instead of one final camera angle?
Recoil is a dynamic system. Sampling over time exposes how the game adds an impulse and gradually returns the camera.
Finished Lesson 7.7?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.