Back
TANK CONTROLLER
92% · First Class

What is Tank Movement Controller?

Tank controller overview // Tank controller — in-engine overview

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.

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.

  • Awarded 92% — First Class for GDEV50004 Advanced Mechanics Design
  • Custom raycast spring-damper suspension — no WheelCollider used at all
  • Four-gear state machine (Reverse, Low, Medium, High) running at 20 Hz in a coroutine
  • Three weapons: Money Stack, Rocket launcher, and Flamethrower mode (Tab to toggle)
  • Turret yaw via ProjectOnPlane + LookRotation + Slerp; barrel pitch via AngleAxis on turret's local right
  • Accurate trajectory preview using LineRenderer with physics simulation
  • Per-wheel traction percent from Track component; slip ratio reduces force by up to 40% at high throttle
  • Slope seam assist — forward sphere-cast probes for edge seams, applies lift + nudge to clear them
  • Slope collision assist — OnCollisionStay redirects momentum along surface normal
  • Differential drive bias — rear wheels get 1.3× force on acceleration, 0.7× on braking
  • Pimp My Tank mode: hydraulic bounce, sine-wave body pitch, hang-time bias, gamepad rumble sync
  • Full UI Toolkit HUD: Gear panel, Speed bar (green→orange→red), RPM bar, Weapon panel, Suspension bar, Input glyph auto-swap
  • Observer pattern (UnityEvents) decouples physics from HUD, audio, and camera systems
  • ScriptableObject-driven suspension configs, gear configs, and engine models (ArcadeEngine / RealisticEngine swap)
  • DebugPanel (UI Toolkit): live Accel/Steer input, tank speed, system FPS

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.

DIFFERENTIAL DRIVE BIAS
Rear wheels receive 1.3× force multiplier on acceleration and 0.7× on braking, mimicking the rear-biased torque distribution of tracked vehicle drivetrains. This produces a satisfying weight-shift feel on take-off and deceleration without requiring any physics rig beyond the rigidbody itself.

Four-Gear FSM

Gear indicator HUD // HUD — gear indicator at 50% speed

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:

REVERSE
Low MaxSpeed, fixed Acceleration. Engages on sustained negative throttle input while stationary or near-stopped.
LOW
High torque, low speed ceiling. Used for climbing and obstacle traversal. Default spawn gear state.
MEDIUM
Balanced torque and speed. Hard steer input (>0.4) caps the gearbox here — prevents high-speed spinning that would look wrong.
HIGH
Max speed, reduced torque. Only available on straight runs or gentle steers. Engages automatically once Medium speed threshold is exceeded.

Each 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 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.

Hydraulic Bounce
Sine-wave offset applied to the visual body's localPosition each FixedUpdate. Driven by average compression across all suspension arms.
Hang-Time Bias
Custom easing curve biases the sine phase — body lingers at the top, then drops faster than a natural sine. Exaggerated by compression boost multiplier.
Gamepad Rumble Sync
Rear motor rumbles on lift, front motor on slam. Pulse magnitudes are tuned in the PimpConfig ScriptableObject — no code change needed to retune.
Money Stack Weapon
The barrel weapon slot swaps to a MoneyStack projectile — physics-simulated stacks with a brief spin and scatter VFX on impact.
Gold Material Swap
Hull and turret renderers swap to gold PBR materials at mode entry. Restored cleanly on mode exit.
BGM Playlist Swap
AudioSource playlist reference swaps to a custom Pimp BGM track list at mode entry. Crossfades over 1.5 s to avoid an abrupt cut.
Hard-reset localPosition rule An early bug caused the body to drift progressively off-axis after multiple Pimp My Tank session toggles. The bounce logic was accumulating residual offsets. Fix: at the start of every bounce calculation, m_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.

Gear Panel
Displays current gear state (REVERSE / LOW / MEDIUM / HIGH). Flashes on gear change. State label colours defined in USS.
Speed Bar
Colour interpolates green → orange → red as speed increases toward the current gear's MaxSpeed. Numeric km/h readout alongside.
RPM Bar
Engine RPM displayed as a fill bar. Turns red above the engine's redline threshold. Stays at 800 idle when stationary.
Weapon Panel
Shows current weapon (MONEY / ROCKET / FLAME) with icon and cooldown ring around the crosshair. Tab cycles weapons.
Suspension Bar
Average compression across all arms displayed as a 0–100% fill bar. Updates every FixedUpdate via UnityEvent broadcast.
Input Glyph Auto-Swap
Enhanced Input device-changed callback swaps glyph sprites between keyboard icons and gamepad button icons with no manual toggle needed.
TURNING Indicator
Active when steer input exceeds 0.4 threshold. Reminds the player gear is capped at MEDIUM. Red highlight on RPM bar activates simultaneously.
Debug Panel
Live Accel/Steer input floats, tank speed, system FPS. Toggled with a key bind. Implemented in UI Toolkit — same UXML/USS pipeline as the HUD.

What Went Wrong

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 — AddForceAtPosition distributes the torque correctly from that world-space location, so arms cooperate rather than interfere.

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.

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 LookRotation degenerate. Resolved by always projecting onto the yaw plane first — ProjectOnPlane ensures the look vector is always perpendicular to the up axis before it reaches LookRotation.

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.

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 localPosition to m_BodyBaseLocalPos at the start of each bounce calculation — base is always authoritative.

No WheelCollider — ever The brief explicitly prohibited WheelCollider as a learning constraint. While this made early suspension work harder, it produced a deeper understanding of how spring-damper physics actually works. Debugging the arm-fight issue and building SlopeTransitionAssist were direct consequences of building from first principles — and both solutions are transferable to any physics-based vehicle, not just tanks.