
Intro — What this tutorial gives you
This tutorial shows How To Make A VR Game Like The Walking Dead (stabbing, grab+tear, ragdoll, VFX, haptics) in Unity using the XR Interaction Toolkit. It contains full C# files, explanations, a visual prefab diagram, and inspector value suggestions so a user can assemble a working prefab and prototype quickly.
Visual Prefab Diagram — Zombie (hierarchy & components)
- ZombieRoot (Empty GameObject)
- Torso (Root bone) — Components:
- SkinnedMeshRenderer (main skin)
- Animator (Humanoid or Generic)
- Rigidbody (mass: 20)
- Collider (capsule around torso)
- DamageableBase (assign ragdoll bodies here)
- Head — Components:
- Skinned bone transform
- Collider (sphere)
- Rigidbody (mass: 2)
- CharacterJoint (connected to Torso)
- BreakableLimb (breakThreshold: 40)
- XRGrabInteractable (optional — if you want to let players grab heads)
- LimbGrabTear (tearForceThreshold: 20)
- LeftArm / RightArm — Components:
- Collider (capsule)
- Rigidbody (mass: 3)
- CharacterJoint (connected to Torso)
- BreakableLimb (breakThreshold: 35)
- XRGrabInteractable
- LimbGrabTear (tearForceThreshold: 22)
- LeftLeg / RightLeg — Components:
- Collider (capsule)
- Rigidbody (mass: 6)
- CharacterJoint (connected to Torso)
- BreakableLimb (breakThreshold: 60)
- XRGrabInteractable (optional)
- LimbGrabTear
- VFX (child empty)
- BloodParticle prefabs (assign to pooled SimpleObjectPool)
- Scripts on ZombieRoot:
- ZombieAI (NavMeshAgent + attack behavior)
- DamageableBase (assign ragdollBodies array: head/arms/legs/torso Rigidbodies)
- Torso (Root bone) — Components:
Notes: make sure each limb’s CharacterJoint connects to the torso Rigidbody; ensure ragdoll bodies are initially isKinematic = true
until the zombie dies (so animation controls the pose).
Inspector quick-values (starting points)
- Torso Rigidbody.mass = 20
- Head Rigidbody.mass = 2
- Arms Rigidbody.mass = 3
- Legs Rigidbody.mass = 6
- Break thresholds: Head 40, Arm 35, Leg 60 (tweak by feel)
Code — Shared interface & pool (must include first)
IDamageable.cs
Single interface that all damageable things implement. NOTE: signature uses 4 parameters so you can pass hit normal and source (weapon).
using UnityEngine;
public interface IDamageable
{
// amount, hitPoint, hitNormal, source (e.g. weapon)
void ApplyDamage(float amount, Vector3 hitPoint, Vector3 hitNormal, GameObject source);
}
SimpleObjectPool.cs
Lightweight pool for particles / gore / optional zombies. Call Init()
in Awake if you want immediate prefill.
using UnityEngine;
using System.Collections.Generic;
public class SimpleObjectPool : MonoBehaviour
{
public GameObject prefab;
public int initial = 10;
private Queue<GameObject> pool = new Queue<GameObject>();
public void Init()
{
for (int i = 0; i < initial; i++)
{
var go = Instantiate(prefab, transform);
go.SetActive(false);
pool.Enqueue(go);
}
}
public GameObject Get(Vector3 pos, Quaternion rot)
{
GameObject go = pool.Count > 0 ? pool.Dequeue() : Instantiate(prefab, transform);
go.transform.position = pos;
go.transform.rotation = rot;
go.SetActive(true);
return go;
}
public void Return(GameObject go)
{
go.SetActive(false);
pool.Enqueue(go);
}
}
Q: Why the 4-arg ApplyDamage?
A: It lets you pass hit normal + what caused the damage (weapon or source GameObject) — very useful for VFX, ragdoll impulse direction, and logging.
Code — MeleeWeapon (velocity sampling, stab detection, haptics)
This is the main weapon script. It samples transform delta velocity (works while weapon is parented to the hand), detects stabs (forward-aligned fast hits), spawns hit particles, and triggers haptics via an optional XR controller reference. Attach to your weapon prefab (with a Rigidbody and Colliders). If you’re using XR Interaction Toolkit, add WeaponGrabBinder
below to wire haptics automatically.
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
[RequireComponent(typeof(Rigidbody))]
public class MeleeWeapon : MonoBehaviour
{
[Header("Damage")]
public float baseDamage = 12f;
public float minHitVelocity = 1f;
public float minStabVelocity = 3f;
public float stabMultiplier = 2.5f;
public LayerMask damageLayers;
[Header("Haptics & FX")]
public XRBaseController referenceController; // optional: set via WeaponGrabBinder on grab
public float hapticIntensity = 0.5f;
public float hapticDuration = 0.06f;
public GameObject hitParticlePrefab;
public SimpleObjectPool particlePool; // optional pool for particles
Rigidbody rb;
Vector3 lastPosition;
Vector3 velocity;
void Awake()
{
rb = GetComponent<Rigidbody>();
lastPosition = transform.position;
}
void Update()
{
// transform-based velocity (works even when parented/kinematic)
velocity = (transform.position - lastPosition) / Mathf.Max(Time.deltaTime, 1e-6f);
lastPosition = transform.position;
}
void OnCollisionEnter(Collision collision)
{
EvaluateHit(collision.collider, collision.GetContact(0).point, velocity);
}
void OnTriggerEnter(Collider other)
{
EvaluateHit(other, other.ClosestPoint(transform.position), velocity);
}
void EvaluateHit(Collider other, Vector3 hitPoint, Vector3 measuredVelocity)
{
if (((1 << other.gameObject.layer) & damageLayers.value) == 0) return;
float speed = measuredVelocity.magnitude;
if (speed < minHitVelocity) return;
float forwardDot = Vector3.Dot(measuredVelocity.normalized, transform.forward);
bool isStab = speed >= minStabVelocity && forwardDot > 0.7f;
float damage = baseDamage * (speed / minHitVelocity);
if (isStab) damage *= stabMultiplier;
var dmg = other.GetComponentInParent<IDamageable>();
if (dmg != null)
{
Vector3 hitNormal = (hitPoint - transform.position).normalized;
dmg.ApplyDamage(damage, hitPoint, hitNormal, gameObject);
// spawn particle
if (particlePool != null && hitParticlePrefab != null)
particlePool.Get(hitPoint, Quaternion.LookRotation(hitNormal));
else if (hitParticlePrefab != null)
Instantiate(hitParticlePrefab, hitPoint, Quaternion.LookRotation(hitNormal));
// haptics
if (referenceController != null)
referenceController.SendHapticImpulse(hapticIntensity, hapticDuration);
}
}
}
Explanation — MeleeWeapon
- Velocity sampling: Instead of relying on Rigidbody.velocity (which can be zero while kinematic/parented), we sample
transform.position
delta each frame to calculate a reliable velocity vector. - Damage scaling: Damage scales with speed; higher speed => more damage.
- Stab detection: We check the dot product between normalized velocity and weapon forward to see if the motion is a forward thrust; combine this with a minimum speed to classify as a stab and multiply damage.
- Haptics & FX: Optional XRBaseController reference triggers haptics. Hit particle spawns go through a pool if one is provided (recommended for performance).
Q: Weapon throws too many hits per swing (multi-hit spam)?
A: Add a short cooldown or track last-hit collider + time to prevent multiple hits per frame (e.g., store a HashSet of colliders hit this swing and clear it after 0.05s).
Code — WeaponGrabBinder (wire XR haptics automatically)
Attach this to the weapon prefab (alongside MeleeWeapon and XRGrabInteractable). It listens for grab events and sets the referenceController
on MeleeWeapon so haptics are automatically routed to the grabbing controller.
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
[RequireComponent(typeof(XRGrabInteractable))]
public class WeaponGrabBinder : MonoBehaviour
{
XRGrabInteractable grab;
MeleeWeapon melee;
void Awake()
{
grab = GetComponent<XRGrabInteractable>();
melee = GetComponent<MeleeWeapon>();
grab.selectEntered.AddListener(OnSelectEntered);
grab.selectExited.AddListener(OnSelectExited);
}
void OnDestroy()
{
grab.selectEntered.RemoveListener(OnSelectEntered);
grab.selectExited.RemoveListener(OnSelectExited);
}
void OnSelectEntered(SelectEnterEventArgs args)
{
// Try to find XRBaseController on the interactor (the controller object)
var controller = args.interactorObject.transform.GetComponentInParent<XRBaseController>();
if (controller != null) melee.referenceController = controller;
}
void OnSelectExited(SelectExitEventArgs args)
{
melee.referenceController = null;
}
}
Q: My controller type doesn’t have XRBaseController.
A: Some projects attach custom controller wrappers. If you use a different pattern, modify the binder to find the haptic interface on your controller GameObject.
Code — DamageableBase & Ragdoll toggle
Put this on the Zombie root (or torso). Assign all limb Rigidbodies to ragdollBodies
in the inspector. We keep limbs kinematic while animated; on death we switch them to dynamic.
using UnityEngine;
[RequireComponent(typeof(Animator))]
public class DamageableBase : MonoBehaviour, IDamageable
{
public float maxHealth = 120f;
[HideInInspector] public float currentHealth;
public Rigidbody[] ragdollBodies;
public Animator animator;
public bool enableRagdollOnDeath = true;
public GameObject deathGorePrefab;
public SimpleObjectPool gorePool;
void Awake()
{
currentHealth = maxHealth;
if (animator == null) animator = GetComponent<Animator>();
if (ragdollBodies != null)
foreach (var rb in ragdollBodies) rb.isKinematic = true; // animation controls bones while alive
}
public virtual void ApplyDamage(float amount, Vector3 hitPoint, Vector3 hitNormal, GameObject source)
{
currentHealth -= amount;
if (currentHealth <= 0f)
Die(hitPoint, hitNormal, source);
else
{
if (animator) animator.SetTrigger("Hit");
// Optional: spawn small blood VFX using gorePool
}
}
protected virtual void Die(Vector3 hitPoint, Vector3 hitNormal, GameObject source)
{
if (enableRagdollOnDeath) EnableRagdoll(hitPoint, hitNormal);
if (deathGorePrefab != null)
{
if (gorePool != null) gorePool.Get(hitPoint, Quaternion.LookRotation(hitNormal));
else Instantiate(deathGorePrefab, hitPoint, Quaternion.LookRotation(hitNormal));
}
// Default cleanup: disable AI or other scripts (your AI should check for isDead)
// Example: Destroy after a while
Destroy(gameObject, 12f);
}
public void EnableRagdoll(Vector3 hitPoint, Vector3 hitNormal)
{
if (animator) animator.enabled = false;
if (ragdollBodies != null)
{
foreach (var rb in ragdollBodies)
{
rb.isKinematic = false;
rb.AddExplosionForce(220f, hitPoint, 1.2f);
}
}
}
}
Explanation — DamageableBase
- currentHealth: starts at maxHealth. When ≤ 0 we call
Die
. - EnableRagdoll: disables the Animator and sets limb rigidbodies to non-kinematic so physics simulates dead body.
- deathGorePrefab: optional big gore spawn at death (pooled if you supplied gorePool).
Q: Animator still pushes bones after enabling ragdoll.
A: Ensure you disable the Animator component (we do) and that ragdoll bones are separate Rigidbodies with colliders assigned in the inspector.
Code — BreakableLimb (dismemberment on damage)
Place this on each limb GameObject. It tracks accumulated damage and removes the joint once damage passes breakThreshold
. It also exposes Detach()
which can be triggered by grab logic.
using UnityEngine;
[RequireComponent(typeof(Rigidbody))]
public class BreakableLimb : MonoBehaviour, IDamageable
{
public float breakThreshold = 40f;
public GameObject bloodBurstPrefab;
public SimpleObjectPool bloodPool;
private float accumulated = 0f;
private Joint joint;
private bool detached = false;
void Start()
{
joint = GetComponent<Joint>(); // CharacterJoint or ConfigurableJoint
}
// IDamageable implementation
public void ApplyDamage(float amount, Vector3 hitPoint, Vector3 hitNormal, GameObject source)
{
if (detached) return;
accumulated += amount;
if (accumulated >= breakThreshold) Detach(hitPoint, hitNormal, source);
}
// Manual detach (can be called from LimbGrabTear)
public void Detach(Vector3 hitPoint, Vector3 hitNormal, GameObject source)
{
if (detached) return;
detached = true;
// spawn blood
if (bloodPool != null && bloodBurstPrefab != null)
bloodPool.Get(hitPoint, Quaternion.LookRotation(hitNormal));
else if (bloodBurstPrefab != null)
Instantiate(bloodBurstPrefab, hitPoint, Quaternion.LookRotation(hitNormal));
if (joint != null) Destroy(joint);
transform.parent = null;
var rb = GetComponent<Rigidbody>();
if (rb != null)
{
rb.isKinematic = false;
rb.AddExplosionForce(300f, hitPoint, 1f);
}
// Optionally disable this component so it no longer receives damage
Destroy(this);
}
}
Explanation — BreakableLimb
- accumulated: sums incoming damage; on surpassing threshold the limb detaches.
- Detach: removes the joint, makes the limb a free Rigidbody, spawns blood VFX, and optionally destroys this script component to stop further damage processing on that limb.
Q: Seam or hole appears where limb detached.
A: Use a connector / gore prefab at the socket (small mesh) to hide seams; or swap SkinnedMeshRenderer for a pre-cut mesh variant (advanced).
Code — LimbGrabTear (grab & yank to rip)
Attach to limbs alongside BreakableLimb & XRGrabInteractable. This component measures how hard the player pulls (positional delta), and if it exceeds a threshold it triggers a detach.
using UnityEngine;
using UnityEngine.XR.Interaction.Toolkit;
[RequireComponent(typeof(XRGrabInteractable))]
[RequireComponent(typeof(Rigidbody))]
public class LimbGrabTear : MonoBehaviour
{
public float tearForceThreshold = 25f;
XRGrabInteractable grab;
Rigidbody rb;
Joint joint;
Vector3 lastWorldPos;
void Awake()
{
grab = GetComponent<XRGrabInteractable>();
rb = GetComponent<Rigidbody>();
joint = GetComponent<Joint>();
lastWorldPos = transform.position;
}
void FixedUpdate()
{
if (grab.isSelected)
{
Vector3 worldVel = (transform.position - lastWorldPos) / Time.fixedDeltaTime;
float pulledForce = worldVel.magnitude * rb.mass;
if (pulledForce >= tearForceThreshold)
{
// call BreakableLimb.Detach for safe cleanup
var br = GetComponent<BreakableLimb>();
if (br != null) br.Detach(transform.position, -transform.forward, gameObject);
else
{
if (joint != null) Destroy(joint);
transform.parent = null;
rb.isKinematic = false;
rb.AddForce(worldVel * 2f, ForceMode.Impulse);
}
}
lastWorldPos = transform.position;
}
else lastWorldPos = transform.position;
}
}
Explanation — LimbGrabTear
- worldVel: uses positional delta in FixedUpdate for stable physics sampling.
- pulledForce: approximates the instantaneous yank impulse using mass & velocity magnitude.
- Detach logic: tries to call
BreakableLimb.Detach
first so pooled blood and cleanup run uniformly.
Q: Yank force seems too weak.
A: Tune tearForceThreshold
and limb masses. Increasing limb Rigidbody.mass or lowering threshold increases chance of ripping.
Code — ZombieAI (NavMeshAgent + Attack)
Simple follower + melee attack. Attach to ZombieRoot. Ensure the scene has a baked NavMesh and assign the player's transform as target
.
using UnityEngine;
using UnityEngine.AI;
[RequireComponent(typeof(NavMeshAgent))]
public class ZombieAI : MonoBehaviour
{
public Transform target;
public float attackRange = 1.6f;
public float attackDamage = 10f;
public float attackCooldown = 2f;
public Animator animator;
NavMeshAgent agent;
float lastAttack = -999f;
DamageableBase damageableBase;
bool isDead = false;
void Awake()
{
agent = GetComponent<NavMeshAgent>();
damageableBase = GetComponent<DamageableBase>();
if (animator == null) animator = GetComponentInChildren<Animator>();
}
void Update()
{
if (isDead || target == null) return;
float dist = Vector3.Distance(transform.position, target.position);
if (dist > attackRange)
{
agent.isStopped = false;
agent.SetDestination(target.position);
if (animator) animator.SetFloat("speed", agent.velocity.magnitude);
}
else
{
agent.isStopped = true;
if (Time.time - lastAttack >= attackCooldown)
{
lastAttack = Time.time;
if (animator) animator.SetTrigger("Attack");
// Apply damage to player if present
var player = target.GetComponent<PlayerHealth>();
if (player != null) player.ApplyDamage(attackDamage);
}
}
}
public void DisableAI()
{
isDead = true;
agent.isStopped = true;
}
}
Q: Zombies get stuck on props.
A: Tweak NavMesh bake, agent radius and step height. Use NavMeshObstacle carving for dynamic objects.
Code — ZombieSpawner (optional pooling)
Spawner that uses SimpleObjectPool if provided, or falls back to Instantiate. Assign spawn points in inspector.
using UnityEngine;
using System.Collections;
public class ZombieSpawner : MonoBehaviour
{
public GameObject zombiePrefab;
public Transform[] spawnPoints;
public float spawnInterval = 6f;
public int initialPool = 6;
SimpleObjectPool pool;
void Start()
{
if (zombiePrefab == null) return;
var poolGO = new GameObject("ZombiePool");
poolGO.transform.SetParent(transform);
pool = poolGO.AddComponent<SimpleObjectPool>();
pool.prefab = zombiePrefab;
pool.initial = initialPool;
pool.Init();
StartCoroutine(SpawnLoop());
}
IEnumerator SpawnLoop()
{
while (true)
{
Spawn();
yield return new WaitForSeconds(spawnInterval);
}
}
void Spawn()
{
if (spawnPoints.Length == 0) return;
var p = spawnPoints[Random.Range(0, spawnPoints.Length)];
var go = pool.Get(p.position, p.rotation);
// Reset components if necessary (animator, health, AI target)
var dmg = go.GetComponentInChildren<DamageableBase>();
if (dmg != null) dmg.currentHealth = dmg.maxHealth;
var ai = go.GetComponent<ZombieAI>();
if (ai != null) ai.target = /*your player transform reference here*/ ai.target;
}
}
Q: How do I return dead zombies to the pool?
A: Instead of Destroy in DamageableBase.Die, call pool.Return(gameObject) after disabling AI & colliders. Implement a pooled zombie lifecycle if you want recycling.
Code — PlayerHealth
Basic player health object. Attach to XR Origin or player root and wire references for UI/haptics as needed.
using UnityEngine;
public class PlayerHealth : MonoBehaviour
{
public float maxHealth = 100f;
public float currentHealth;
public UnityEngine.UI.Slider healthSlider; // optional
void Start()
{
currentHealth = maxHealth;
if (healthSlider != null) healthSlider.maxValue = maxHealth;
}
public void ApplyDamage(float amount)
{
currentHealth -= amount;
if (healthSlider != null) healthSlider.value = currentHealth;
if (currentHealth <= 0) Die();
}
void Die()
{
Debug.Log("Player died - implement respawn or game over.");
// e.g., disable input, fade screen, respawn
}
}
Q: How to avoid instant-death glass hits?
A: Reduce zombie attack damage and/or add stun/knockback instead of instant damage when closer to death.
Assembly checklist — Put it together
- Create folder structure:
/Prefabs
,/Scripts
,/VFX
. - Create zombie prefab following the visual diagram. Set CharacterJoint connectedBody to Torso Rigidbody for each limb.
- Assign all limb Rigidbodies to the Torso's
DamageableBase.ragdollBodies
array. - On each limb: add
BreakableLimb
,XRGrabInteractable
(if grabable),LimbGrabTear
. - Weapon prefab: add
MeleeWeapon
,Rigidbody
,Collider
, andWeaponGrabBinder
. - Create particle prefabs and set up a
SimpleObjectPool
for blood; assign toMeleeWeapon.particlePool
andBreakableLimb.bloodPool
. - Set
MeleeWeapon.damageLayers
to include "Enemy". - Bake NavMesh; place spawn points and attach
ZombieSpawner
.
Conclusion & Next steps
You now have a complete, working foundation for a VR zombie melee game with stabbing, ragdoll, and grab+tear dismemberment. Next steps you might want:
- Add audio SFX (impact, scream, detach)
- Create seam-hider gore meshes for detached sockets
- Add pooling for zombies and implement an efficient return-to-pool lifecycle
- Polish hit detection to avoid multi-hit spam (small per-swing cooldown)