Tank Movement Controller
A fully-featured tank controller built in Unity 6 without WheelCollider — raycast spring-damper suspension, a four-gear state machine, Quaternion turret aiming and a gold-plated "Pimp My Tank" easter egg.
- Type
- University Module
- Module
- GDEV50004
- Level
- 5 · Year 2
- Engine
- Unity 6 (C#)
- Period
- Jan – Mar 2026
- Team
- Solo
- Suspension Arms
- Per-wheel raycast
Take It for a Drive
What is Tank Movement Controller?
GDEV50004 Advanced Mechanics Design is a solo Level 5 module at Staffordshire University. The challenge: build a convincing, fully-featured tank controller in Unity 6 without using Unity's WheelCollider at all. Every aspect of suspension, traction, gear behaviour, and terrain traversal had to be constructed from scratch in C#.
The result is a full-featured tank prototype on a custom test range: ramped terrain, slope seams, and bumpy obstacles. Spring-damper forces are
calculated per suspension arm and applied at the exact hit point in world space. Turret and barrel aiming use Quaternion math directly from
lecture material — ProjectOnPlane, LookRotation, and Slerp — with live debug gizmos in scene view.
// Tank controller — in-engine overview
A "Pimp My Tank" easter egg mode (P key) drops the tank into hydraulic bounce mode with gold body paint, gangster chains, money stacks flying out of the barrel, and a custom BGM playlist swap. The UI is built entirely in UI Toolkit (UXML/USS) — no legacy Canvas — including a live debug panel, auto-swapping input glyphs (keyboard vs gamepad), a crosshair with cooldown ring, and a controls panel.
Spring-Damper Suspension
Each suspension arm fires a downward raycast every FixedUpdate. If grounded, it calculates compression as a 0–1 float
based on how far the ray has penetrated the rest length. A spring force (Hooke's law equivalent) and a
damper force (velocity component along the surface normal) are combined and applied at the ray hit point via
AddForceAtPosition. This means suspension forces correctly torque the rigidbody rather than applying everything at the centre of
mass.
// Per-arm suspension force (simplified)
float compression = 1f - (hitDistance / m_RestLength);
float springForce = compression * m_SpringStiffness;
float damperForce = Vector3.Dot(m_RB.GetPointVelocity(hitPoint), hitNormal)
* m_DamperCoefficient;
Vector3 force = hitNormal * (springForce - damperForce);
m_RB.AddForceAtPosition(force, hitPoint);
The wheel visual GameObject is placed at springBasedDistance from the arm origin each frame, smoothed via Lerp so it
never snaps. When airborne, a reduced fallback force prevents the nose from pitching down aggressively. Traction force is also computed per arm —
lateral slip is cancelled via the Track component's TractionPercent, so rough terrain and slopes authentically reduce grip.
Suspension parameters (rest length, spring stiffness, damper coefficient, rest force) are all stored in a TankSuspensionConfig ScriptableObject, making terrain tuning a pure data operation rather than a code change.
Four-Gear FSM
The gearbox runs in a coroutine at 20 Hz — an intentional performance decision. Gear state evaluation does not need to happen every frame; running it every 50 ms keeps it responsive while freeing the main thread. Four gear states are defined:
// HUD — gear indicator at 50% speed
REVERSE
Gear RLOW
Gear 1MEDIUM
Gear 2HIGH
Gear 3Each gear state has MaxSpeed and Acceleration defined in a TankGearConfig ScriptableObject. The engine
model (ArcadeEngine or RealisticEngine ScriptableObject) can be swapped at runtime — arcade gives sharp per-gear
acceleration curves with immediate torque feel, realistic gives smoother torque simulation that builds over RPM. Both are tunable in the Inspector
without touching code.
Quaternion Rotation
The turret yaws by projecting the camera's look direction onto the turret's yaw plane via Vector3.ProjectOnPlane,
then constructing the target rotation with Quaternion.LookRotation and interpolating to it with Quaternion.Slerp. The
ProjectOnPlane guard is what prevents gimbal lock at extreme camera pitch — without it, aiming steeply up or down causes the turret to spin
erratically as the look direction becomes nearly parallel to the yaw axis.
The barrel pitches independently from the turret yaw. A target pitch angle is extracted by projecting the camera forward onto
the turret's local vertical plane, then an AngleAxis rotation is applied around the turret's local right axis. This separation means
the turret and barrel can run at different Slerp speeds for more natural feel.
// Turret debug gizmos: yaw arc, Barrel Forward, Hull Forward
// Turret yaw — ProjectOnPlane guard prevents gimbal lock
Vector3 flatLook = Vector3.ProjectOnPlane(cam.forward, m_Turret.up);
Quaternion target = Quaternion.LookRotation(flatLook, m_Turret.up);
m_Turret.rotation = Quaternion.Slerp(m_Turret.rotation, target,
m_YawSpeed * Time.deltaTime);
// Barrel pitch — AngleAxis on turret's local right
float pitchAngle = Vector3.SignedAngle(m_Turret.forward,
Vector3.ProjectOnPlane(cam.forward, m_Turret.right),
m_Turret.right);
m_Barrel.localRotation = Quaternion.AngleAxis(
Mathf.Clamp(pitchAngle, m_MinPitch, m_MaxPitch),
Vector3.right);
Debug gizmos render during play in the Scene view: a filled-arc wedge from hull-forward to turret-forward showing the yaw delta, the barrel's forward vector as a coloured ray, the turret's right axis as the pitch pivot indicator, and a trajectory arc from the barrel tip (LineRenderer with physics simulation). These were invaluable for diagnosing the gimbal-lock issue and validating the aiming pipeline visually.
Pimp My Tank Mode (P)
Pressing P toggles Pimp My Tank — a ScriptableObject-controlled chaos mode that reimagines the tank as a gold-plated hydraulic lowrider. The visual body (separated from the physics hull) gets a sine-wave vertical bounce driven by the average suspension compression across all arms. A hang-time bias makes the body linger at the apex of each bounce before slamming down, exaggerated further by a compression boost multiplier.
Gamepad players receive rumble pulses timed to the rear/front hop cycle — the rear pads fire first on lift, front pads fire on the slam, creating a rocking sensation that matches the visual. The hull swaps to gold paint, gangster chains spawn as physics props at attachment points, and money stacks fire from the barrel instead of rockets. The background music playlist swaps to a custom BGM track.
// Pimp My Tank — 90% customisation
Hydraulic Bounce
BodyHang-Time Bias
EasingGamepad Rumble Sync
Haptics
Money Stack Weapon
WeaponGold Material Swap
VisualBGM Playlist Swap
Audiom_VisualBody.localPosition is hard-reset to m_BodyBaseLocalPos before the sine offset is applied — ensuring the base is
always known-good regardless of what the previous frame left behind.UI Toolkit Interface
The entire HUD is built in UI Toolkit (UXML/USS) — Unity's modern retained-mode UI system. No legacy Canvas or uGUI components anywhere in the project. The HUD is composed of several independent panels, each wired via UnityEvents (the Observer pattern) so the physics system raises events and the HUD responds — neither side holds a reference to the other.
// HUD — high speed state
Gear Panel
Panel
Speed Bar
BarRPM Bar
BarWeapon Panel
PanelSuspension Bar
BarInput Glyph Auto-Swap
InputTURNING Indicator
StateDebug Panel
DebugWhat Went Wrong
-
Bug 01
Suspension arms fighting each other
Early iterations applied suspension force at the rigidbody centre, causing arms to compete. Each arm's response to compression overwrote its neighbour's contribution. Fixed by computing force strictly per arm from the exact raycast hit point —
AddForceAtPositiondistributes the torque correctly from that world-space location, so arms cooperate rather than interfere. -
Bug 02
Slope seams causing the hull to catch and halt
The tank would snag on terrain mesh edge seams at speed, killing all momentum abruptly. Fixed by the SlopeTransitionAssist component: a forward sphere-cast constantly probes for upcoming seams and applies a pre-emptive lift force plus a forward nudge to carry the hull over cleanly before it contacts the geometry edge.
-
Bug 03
Turret gimbal lock at extreme pitch
Aiming steeply caused the turret to spin erratically. Root cause: the camera's forward vector was nearly parallel to the yaw axis at steep angles, making
LookRotationdegenerate. Resolved by always projecting onto the yaw plane first —ProjectOnPlaneensures the look vector is always perpendicular to the up axis before it reachesLookRotation. -
Bug 04
HUD DebugPanel chunks not showing
An identical quirk to other projects: UI Toolkit VisualElement children not rendering because the parent panel reference was wrong in the UXML binding. The DebugPanel container required an explicit reference to its parent panel element — the same UMG Canvas Panel equivalent issue documented in the C++ project.
-
Bug 05
Pimp My Tank body drifting off
After multiple Pimp mode toggle cycles, the visual body drifted progressively off its attachment point. Residual offset accumulation from incomplete bounce frames. Fixed by hard-resetting
localPositiontom_BodyBaseLocalPosat the start of each bounce calculation — base is always authoritative.







