Back
TANK CONTROLLER
Explored 0%
Medium gear suspension test on slope High speed slope climb rocket ready Flamethrower rocket fire explosion VFX Pimp My Tank mid-chaos medium gear Turret debug gizmos yaw arc barrel forward Low gear hilltop weapon ready
Unity 6 · Solo · University

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
Unity 6 C# Spring-Damper Physics Raycast Suspension ScriptableObjects UI Toolkit Cinemachine Enhanced Input Observer Pattern Gearbox FSM Pimp My Tank
Final Grade 0%
0
Gear States
0
Weapons
No WheelCollider
Physics
UI Toolkit
HUD
WebGL Build

Take It for a Drive

Overview

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 overview // 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.

Physics

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 BIASRear 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.
Gearbox

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:

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

REVERSE

Gear R
Low MaxSpeed, fixed Acceleration. Engages on sustained negative throttle input while stationary or near-stopped.

LOW

Gear 1
High torque, low speed ceiling. Used for climbing and obstacle traversal. Default spawn gear state.

MEDIUM

Gear 2
Balanced torque and speed. Hard steer input (>0.4) caps the gearbox here — prevents high-speed spinning that would look wrong.

HIGH

Gear 3
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.

Aiming

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

Easter Egg

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% // Pimp My Tank — 90% customisation
Pimp My Tank 10%

Hydraulic Bounce

Body
Sine-wave offset applied to the visual body's localPosition each FixedUpdate. Driven by average compression across all suspension arms.

Hang-Time Bias

Easing
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

Haptics
Rear motor rumbles on lift, front motor on slam. Pulse magnitudes are tuned in the PimpConfig ScriptableObject — no code change needed to retune.
Pimp My Tank 50%

Money Stack Weapon

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

Visual
Hull and turret renderers swap to gold PBR materials at mode entry. Restored cleanly on mode exit.

BGM Playlist Swap

Audio
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 ruleAn 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.
HUD

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 // HUD — high speed state
HUD low speed

Gear Panel

Panel
Displays current gear state (REVERSE / LOW / MEDIUM / HIGH). Flashes on gear change. State label colours defined in USS.
HUD near max speed

Speed Bar

Bar
Colour interpolates green → orange → red as speed increases toward the current gear's MaxSpeed. Numeric km/h readout alongside.

RPM Bar

Bar
Engine RPM displayed as a fill bar. Turns red above the engine's redline threshold. Stays at 800 idle when stationary.

Weapon Panel

Panel
Shows current weapon (MONEY / ROCKET / FLAME) with icon and cooldown ring around the crosshair. Tab cycles weapons.

Suspension Bar

Bar
Average compression across all arms displayed as a 0–100% fill bar. Updates every FixedUpdate via UnityEvent broadcast.

Input Glyph Auto-Swap

Input
Enhanced Input device-changed callback swaps glyph sprites between keyboard icons and gamepad button icons with no manual toggle needed.

TURNING Indicator

State
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

Debug
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.
Challenges

What Went Wrong

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

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

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

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

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

No WheelCollider — everThe 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.
Milestones

Trophies Earned

First ClassAwarded 92% — First Class for GDEV50004 Advanced Mechanics Design
From ScratchCustom raycast spring-damper suspension — no WheelCollider used at all
Shift HappensFour-gear state machine (Reverse, Low, Medium, High) running at 20 Hz in a coroutine
ArmouryThree weapons: Money Stack, Rocket launcher, and Flamethrower mode (Tab to toggle)
Quaternion GunnerTurret yaw via ProjectOnPlane + LookRotation + Slerp; barrel pitch via AngleAxis on turret's local right
Called ShotAccurate trajectory preview using LineRenderer with physics simulation
Grip ControlPer-wheel traction percent from Track component; slip ratio reduces force by up to 40% at high throttle
Seam JumperSlope seam assist — forward sphere-cast probes for edge seams, applies lift + nudge to clear them
Momentum KeeperSlope collision assist — OnCollisionStay redirects momentum along surface normal
Weight ShiftDifferential drive bias — rear wheels get 1.3× force on acceleration, 0.7× on braking
Pimped OutPimp My Tank mode: hydraulic bounce, sine-wave body pitch, hang-time bias, gamepad rumble sync
Heads-UpFull UI Toolkit HUD: Gear panel, Speed bar (green→orange→red), RPM bar, Weapon panel, Suspension bar, Input glyph auto-swap
DecoupledObserver pattern (UnityEvents) decouples physics from HUD, audio, and camera systems
Data-DrivenScriptableObject-driven suspension configs, gear configs, and engine models (ArcadeEngine / RealisticEngine swap)
Under the HoodDebugPanel (UI Toolkit): live Accel/Steer input, tank speed, system FPS