Abilities and Capybara Stacking Behavior
Abilities and Capybara Stacking Behavior
Abilities and Capybara Stacking Behavior
CHALLENGE
The core mechanic (multiple capybaras physically stacking into a single moving “tower”) is deceptively tricky because it combines: runtime joining/leaving, animation timing, preventing collider/rigidbody chaos, keeping the stack visually aligned to the rig, and making “stack health / damage” feel consistent. The biggest technical challenge was: when a capybara collides to join the stack, it can easily clip through the stack, jitter, or knock physics bodies around unless the transition is tightly controlled.
THOUGHT PROCESS
I treated stacking like a controlled state transition: the incoming capybara temporarily becomes “non-physical” (kinematic + collider off), plays a stack animation trigger, then I manually move it into place using a motion curve that avoids intersecting the existing vertical column. After it settles, it becomes a member of the stack data structure and inherits stack rules (movement lock, shared damage routing, etc.). I also wanted stack health and damage to be deterministic and tunable.
SOLUTION
A) The stack is an explicit Stack<CapybaraController> with deterministic ordering, and I compute total stack HP each frame:
public Stack<CapybaraController> capybarasStack = new Stack<CapybaraController>(3);
void Update()
{
UpdateStackHP();
UpdateStackPosition();
capybaraArray = capybarasStack.ToArray().Reverse().ToArray();
}
void UpdateStackHP()
{
stackHP = capybarasStack.Aggregate(0f, (acc, capybara) => acc + capybara.hp
B) Every stacked capybara is positioned relative to a specific rig bone on the capybara model (so the tower follows animation correctly), and movement input is disabled for non-bottom members:
void UpdateStackPosition()
{
for (int i = 1; i < capybaraArray.Length; i++)
{
capybaraArray[i].transform.position = (
capybaraArray[i - 1]
.transform.Find("Model/riggedCapybara/spine/spine.001/spine.002/spine.004")
.position
+ Vector3.up * stackOffsetY
);
capybaraArray[i].canMove = false
C) Joining the stack uses a coroutine-driven parabolic/Bezier-style motion path specifically engineered to avoid clipping: it adds clearance based on current stack height, approaches from a lateral offset (so you don’t intersect the stack’s vertical column), applies easing for “burst then settle”, and snaps into the exact final pose at the end.
void AddCapybara(CapybaraController newCapybara)
{
bottomCapybara.canMove = false;
newCapybara.canMove = false;
newCapybara.GetComponent<Rigidbody>().isKinematic = true;
newCapybara.GetComponent<Collider>().enabled = false;
audioSource.PlayOneShot(addCapybaraAudio);
newCapybara._animator.SetTrigger("StackTrigger");
StartCoroutine(AddCapybaraCoroutine());
IEnumerator AddCapybaraCoroutine()
{
yield return new WaitForSeconds(0.32f);
Vector3 startPos = newCapybara.transform.position;
Vector3 baseTargetPos =
capybarasStack
.Peek()
.transform.Find("Model/riggedCapybara/spine/spine.001/spine.002/spine.004")
.position
+ Vector3.up * stackOffsetY;
float clearance = Mathf.Max(2f, 1.5f * capybarasStack.Count + 1f);
Vector3 approachDir = startPos - baseTargetPos;
approachDir.y = 0f;
if (approachDir.sqrMagnitude < 0.01f)
{
approachDir = Vector3.Cross(Vector3.up, transform.forward).normalized;
}
else
{
approachDir = approachDir.normalized;
}
float lateralOffset = Mathf.Clamp(1.0f + capybarasStack.Count * 0.25f, 0.8f, 2.5f);
Vector3 offsetTargetPos = baseTargetPos + approachDir * lateralOffset;
float elapsedTime = 0f;
float duration = 0.63f;
Vector3 midPoint = (startPos + offsetTargetPos) * 0.5f + Vector3.up * clearance;
while (elapsedTime < duration)
{
elapsedTime += Time.deltaTime;
float rawT = Mathf.Clamp01(elapsedTime / duration);
float burstBias = Mathf.Pow(rawT, 0.6f);
float eased = Mathf.SmoothStep(0f, 1f, burstBias);
Vector3 a = Vector3.Lerp(startPos, midPoint, eased);
Vector3 b = Vector3.Lerp(midPoint, offsetTargetPos, eased);
Vector3 currentPos = Vector3.Lerp(a, b, eased);
if (rawT > 0.85f)
{
float settleT = Mathf.InverseLerp(0.85f, 1f, rawT);
settleT = Mathf.Pow(settleT, 2.5f);
currentPos = Vector3.Lerp(currentPos, baseTargetPos, settleT);
}
newCapybara.transform.position = currentPos;
newCapybara.transform.rotation = Quaternion.Slerp(
newCapybara.transform.rotation,
transform.rotation,
0.18f
);
yield return null;
}
newCapybara.transform.position = baseTargetPos;
newCapybara.transform.rotation = transform.rotation;
capybarasStack.Push(newCapybara);
newCapybara.stackController = this;
newCapybara.ability.SetOnStack(true);
bottomCapybara.ability.SetOnStack(true);
bottomCapybara.canMove = true
D) Removing a capybara from the stack is also a controlled transition: pop from the stack, toggle ability “on stack” flags, then propel them away with an impulse while temporarily disabling collider to prevent immediate re-collision/jank.
public void RemoveCapybara(Vector3 direction)
{
if (capybarasStack.Count <= 1) return;
CapybaraController topCapybara = capybarasStack.Pop();
topCapybara.stackController = null;
topCapybara.ability.SetOnStack(false);
if (capybarasStack.Count == 1)
{
bottomCapybara.ability.SetOnStack(false);
}
PropellCapybaraFromStack(topCapybara, direction);
}
void PropellCapybaraFromStack(CapybaraController capybara, Vector3 direction)
{
StartCoroutine(PropellCoroutine(capybara));
IEnumerator PropellCoroutine(CapybaraController capybara)
{
capybara.GetComponent<Rigidbody>().isKinematic = false;
capybara.GetComponent<Collider>().enabled = false;
capybara.GetComponent<Rigidbody>().AddForce(
direction.normalized * capybara.GetComponent<Rigidbody>().mass * 30f,
ForceMode.Impulse
);
yield return new WaitForSeconds(0.2f);
capybara.GetComponent<Collider>().enabled = true;
capybara.canMove = true
E) Stack damage supports both “split among stack” and “only top takes it” modes, with scaling based on stack size. I also force a de-stack reaction on damage to keep the mechanic dynamic and readable.
public void TakeDamage(float damage, bool splitDamageAmongStack = true)
{
if (splitDamageAmongStack)
{
if (capybarasStack.Count == 2) damage *= 0.75f;
else if (capybarasStack.Count == 3) damage *= 0.5f;
foreach (CapybaraController capybara in capybarasStack)
{
capybara.TakeDamage(damage);
}
}
else
{
capybarasStack.Peek().TakeDamage(damage);
}
UpdateStackHP();
RemoveCapybara(transform.forward + transform.up);
foreach (CapybaraController capybara in capybarasStack)
{
if (capybara.hp <= 0f)
{
RemoveCapybara(transform.forward + transform.up
Projectiles
CHALLENGE
Projectiles in this game aren’t symmetric. A capybara spell hitting granny applies a status effect (including a stronger debuff if the capybara is stacked), while granny missiles hitting capybaras have to handle both solo and stacked targets. The tricky part is making all these outcomes consistent, while also supporting a defensive parry ability that can negate impacts — including the edge case where a missile hits any capybara in a stack but another capybara in the same stack is currently parrying.
THOUGHT PROCESS
I wanted collision resolution to be centralized in the projectile and route to the correct gameplay systems: granny debuff logic, stack controller damage logic, or individual capybara damage. The important part was building the “parry short-circuit” so it’s both correct and cheap: first check direct shield state, then check stack membership and scan only that stack.
SOLUTION
A) Capybara projectile hitting granny: spawn hit VFX, optionally spawn a scaled debuff VFX if stacked, and route to granny’s spell-hit handler.
if (isCapybaraProjectile)
{
if (collision.transform.root.TryGetComponent(out GrannyController grannyController))
{
Destroy(
Instantiate(
hitEffectPrefab,
collision.GetContact(0).point,
Quaternion.identity
),
5f
);
if (isCapybaraStacked)
{
GameObject debuffEffect = Instantiate(
debuffEffectPrefab,
collision.transform.root.position,
Quaternion.identity
);
debuffEffect.transform.localScale *= 2f;
Destroy(debuffEffect, 5f);
}
grannyController.HitBySpell(isCapybaraStacked);
Destroy(gameObject);
return
B) Granny projectile hitting capybara: early-out if parried, including “anyone in the stack is parrying”, then route damage to stack controller (top-only) or individual capybara, then apply impulse forces across all rigidbodies involved to sell impact.
if (collision.transform.root.TryGetComponent(out CapybaraController capybaraController))
{
if (
capybaraController.TryGetComponent(out CapybaraShield shield)
&& shield.isParrying
|| (
capybaraController.stackController != null
&& capybaraController.stackController.capybarasStack.Any(capybara =>
capybara.TryGetComponent(out CapybaraShield stackShield)
&& stackShield.isParrying
)
)
)
return;
else
{
List<Rigidbody> rigidbodies = new List<Rigidbody>();
if (capybaraController.stackController != null)
{
capybaraController.stackController.TakeDamage(damage, false);
foreach (
CapybaraController capy in capybaraController
.stackController
.capybarasStack
)
{
rigidbodies.AddRange(capy.GetComponentsInChildren<Rigidbody>());
}
}
else
{
capybaraController.TakeDamage(damage);
rigidbodies.AddRange(
capybaraController.GetComponentsInChildren<Rigidbody>()
);
}
foreach (Rigidbody rb in rigidbodies)
{
rb.AddForce(
(-collision.GetContact(0).normal.normalized + Vector3.up).normalized
* (rb.mass * 20f),
ForceMode.Impulse
);
}
MissileExploded(collision.GetContact(0).point
B) Granny projectile hitting capybara: early-out if parried, including “anyone in the stack is parrying”, then route damage to stack controller (top-only) or individual capybara, then apply impulse forces across all rigidbodies involved to sell impact.
if (collision.transform.root.TryGetComponent(out CapybaraController capybaraController))
{
if (
capybaraController.TryGetComponent(out CapybaraShield shield)
&& shield.isParrying
|| (
capybaraController.stackController != null
&& capybaraController.stackController.capybarasStack.Any(capybara =>
capybara.TryGetComponent(out CapybaraShield stackShield)
&& stackShield.isParrying
)
)
)
return;
else
{
List<Rigidbody> rigidbodies = new List<Rigidbody>();
if (capybaraController.stackController != null)
{
capybaraController.stackController.TakeDamage(damage, false);
foreach (
CapybaraController capy in capybaraController
.stackController
.capybarasStack
)
{
rigidbodies.AddRange(capy.GetComponentsInChildren<Rigidbody>());
}
}
else
{
capybaraController.TakeDamage(damage);
rigidbodies.AddRange(
capybaraController.GetComponentsInChildren<Rigidbody>()
);
}
foreach (Rigidbody rb in rigidbodies)
{
rb.AddForce(
(-collision.GetContact(0).normal.normalized + Vector3.up).normalized
* (rb.mass * 20f),
ForceMode.Impulse
);
}
MissileExploded(collision.GetContact(0).point
Destructible environment
CHALLENGE
The destructible walls are not just “when hit, destroy.” They needed to react to: granny missiles, granny dashes, and the special case of a capybara stack where only the bottom capybara is driving movement but a dash state might exist inside the stack. On top of that, the destruction needed to look good (break VFX), propagate to nearby destructibles (chain reaction), become physics objects temporarily (fall/explode), then clean themselves up (disable collider, re-kinematic, sink, destroy) to avoid clutter and performance issues.
THOUGHT PROCESS
I designed destructible as a stateful object that transitions from static/kinematic to dynamic physics on demand, and I use a proximity query to “wake up” nearby destructibles for a satisfying cascading break. I also needed to ensure collision triggers don’t re-fire infinitely, and that broken pieces don’t keep blocking gameplay forever.
SOLUTION
A) OnCollisionStay routes multiple triggers into a shared “explode” function:
Missile tag
Capybara stack dash (explicitly checking bottom capybara’s ability cast to CapybaraDash and reading isDashing)
Granny dash via GetIsDashing()
void OnCollisionStay(Collision collision) {
if (!rb.isKinematic)
return;
if (collision.gameObject.CompareTag("Missile"))
{
ExplodeNearbyDestructibles(collision, destroyInitialAcceleration);
Destroy(collision.gameObject);
}
if (collision.transform.root.TryGetComponent(out CapybaraController capybaraController))
{
if (
capybaraController.stackController != null
&& capybaraController.stackController.capybarasStack.Count > 1
&& (
(CapybaraDash)capybaraController.stackController.bottomCapybara.ability
).isDashing
)
{
ExplodeNearbyDestructibles(collision, destroyInitialAcceleration);
}
}
if (collision.transform.root.TryGetComponent(out GrannyController grannyController))
{
if (grannyController.GetIsDashing())
{
ExplodeNearbyDestructibles(collision, destroyInitialAcceleration
B) The explosion function does several “production-grade” steps:
Plays audio + spawns VFX at contact point
OverlapSphere to gather nearby destructibles (layer filtered)
Detaches them from parents (so wall chunks can fall independently)
Converts MeshCollider to convex so physics works correctly when dynamic
Applies impulse forces
Starts a cleanup coroutine for each chunk (layer swap, collider disable, sink, destroy)
private void ExplodeNearbyDestructibles(Collision collision, float initialAcceleration)
{
audioSource.PlayOneShot(destroyAudio);
Destroy(Instantiate(wallBreakVFX, collision.GetContact(0).point, Quaternion.identity), 5f);
Collider[] hitColliders = Physics.OverlapSphere(
GetComponent<Collider>().bounds.center,
4f,
LayerMask.GetMask("Water")
);
foreach (var hitCollider in hitColliders)
{
if (hitCollider != null && hitCollider.TryGetComponent(out Destructible destructible))
{
destructible.transform.SetParent(null);
destructible.rb.isKinematic = false;
destructible.GetComponent<MeshCollider>().convex = true;
}
}
foreach (var hitCollider in hitColliders)
{
if (hitCollider != null && hitCollider.TryGetComponent(out Destructible destructible))
{
destructible.rb.AddForce(
destructible.rb.mass
* initialAcceleration
* collision.GetContact(0).normal.normalized,
ForceMode.Impulse
);
destructible.StartCoroutine(DestroyWall(destructible.gameObject
C) Cleanup lifecycle prevents broken debris from turning into permanent blockers and keeps performance stable:
IEnumerator DestroyWall(GameObject target)
{
yield return new WaitForSeconds(0.1f);
target.layer = LayerMask.NameToLayer("Destroyed");
yield return new WaitForSeconds(5f);
target.GetComponent<Collider>().enabled = false;
target.GetComponent<Rigidbody>().isKinematic = true;
float elapsed = 0f;
float duration = 12f;
Vector3 initialPosition = target.transform.position;
Vector3 targetPosition = initialPosition + Vector3.down * 12f;
while (elapsed < duration)
{
target.transform.position = Vector3.Lerp(
initialPosition,
targetPosition,
elapsed / duration
);
elapsed += Time.deltaTime;
yield return null;
}
Destroy(target