Roles: Gameplay Programmer, Programming Lead, Producer
Team Size: 13
Engine/Language: Unity , C#
Bewitched is a single player, dungeon crawling game where you play as a forsaken witch in a grim fantasy setting. Having limited offensive capabilities herself, the witch must posses other enemies to have them do her bidding, clearing out the hordes of danger while staying out of reach while in a weak form.
Along the way, you’ll find upgrades and augments to change how the game functions and influence how each playthrough will feel. Each attempt will be filled with similar layouts and enemies, but will change based on what upgrades you find and can salvage for yourself.
Bewitched was a 16-week project. The goal for my team was to create a small slice of a game in a style as close to AAA as possible. Bewitched is a third-person action game where you play as Eleth, a witch who must use their power of possession to escape the prison they were locked in.
On this project, I served as the producer, the programming lead, and the gameplay programmer. My most significant programming contributions include the player controller and abilities, the animation controllers, and the camera controllers.
Additionally, as the programming lead and producer, I managed tasks, reviewed code, and guided the team on key decisions.
One of my main contributions on Bewitched was building the player controller. This system connected and managed major mechanics such as possession, dynamic combat lock-on, movement, animation transitions, and the full counter/dodge system. Designing the player controller required careful attention to responsiveness, timing, and integration with animations, VFX, and camera systems to achieve the smooth, AAA-style combat experience we were aiming for.
To make combat similar to that of Batman: Arkham, our main inspiration, I implemented a target-enemy function. When the player isn’t providing any directional input, this function targets enemies in front of the camera’s forward direction. However, if the player is inputting a direction, the function performs a spherecast in the input direction relative to the camera to find the correct target.
Vector3 dir = new Vector3(movementInput.x, 0, movementInput.y);
dir = Camera.main.transform.TransformDirection(dir);
if (movementInput.magnitude < 0.001f)
dir = new Vector3(Camera.main.transform.forward.x, 0,
Camera.main.transform.forward.z);
This function sends out a spherecast to gather all enemies in front of the player. Then, using a weighted equation that compares each enemy’s position relative to the player, it determines the most appropriate target. This ensures the system consistently selects the correct enemy.
RaycastHit[] hits = Physics.SphereCastAll(currentCharacter.transform.position + dir * sphereDistance, sphereRadius, dir, 0f, enemyLayerMask);
if (hits.Length > 0)
{
foreach (RaycastHit hit in hits)
{
Enemy enemy = hit.collider.GetComponent<Enemy>();
if (enemy == null) continue;
Vector3 enemyPosNoY = new Vector3(enemy.transform.position.x, currentCharacter.transform.position.y, enemy.transform.position.z);
Vector3 toEnemy = (enemyPosNoY - currentCharacter.transform.position).normalized;
float baseDist = Vector3.Distance(enemyPosNoY, currentCharacter.transform.position) - enemy.sizeRadius - currentCharacter.sizeRadius;
float dot = Vector3.Dot(toEnemy, dir);
float dist = baseDist + (1 - dot) * inFrontWeight; // or adjust sign depending on intent
if (enemy && hit.collider.gameObject != currentCharacter.gameObject && (target == null || dist < targetDistance))
{
target = enemy;
targetDistance = dist;
}
}
}
if (target != currentCharacter)
{
lockedCharacter = target;
}
}
Dodging for the player went through many iterations. At first, countering was only possible through possession, but playtesting showed this approach was ineffective. Dodging was then introduced as a dedicated counter mechanic. Each enemy attack includes a counter window, displayed with a VFX indicator during the enemy’s attack approach. During this window, the player can press the dodge button to evade the attack, teleporting backward and gaining temporary invincibility.
The dodge system performs a raycast behind the player to ensure they don’t teleport into a wall or other obstacle. The player will either move the full dodge distance or only as far as possible without colliding with an object.
RaycastHit hitInfo;
Vector3 moveDist;
if (Physics.Raycast(currentCharacter.transform.position, dodgeDirection, out hitInfo, dodgeDistance, environmentLayer))
{
moveDist = (dodgeDirection.normalized * hitInfo.distance);
}
else
{
moveDist = (dodgeDirection.normalized * dodgeDistance);
}
A lot of polish went into this functionality. The player is rotated to face the direction they dodged away from, positioning them perfectly for a counter possession.
Vector3 enemyNoY = -dodgeDirection + eleth.transform.position;
enemyNoY.y = eleth.transform.position.y;
eleth.transform.DOLookAt(enemyNoY, 0.01f);
Since the dodge distance does not always guarantee the player fully avoids an attack, invincibility frames are applied during the dodge. This ensures a successful dodge with proper timing, regardless of the final dodge distance.
Possession, being the main mechanic of the game, was important to get right. It was implemented using a collider that surrounds the player, which keeps track of all enemies within range that can be possessed. While in this state, the targeting system constantly updates to show the player which enemy they’re aiming at, using a VFX indicator for feedback.
The direction of possession is determined either by the direction the player is aiming relative to the camera, or by the camera’s forward direction if the player is not providing any input.
Vector3 dir = new Vector3(PlayerController.instance.GetMovementInput().x, 0, PlayerController.instance.GetMovementInput().y);
dir = Camera.main.transform.TransformDirection(dir);
if (PlayerController.instance.GetMovementInput().magnitude < 0.001f)
dir = new Vector3(Camera.main.transform.forward.x, 0,
Camera.main.transform.forward.z);
The targeted enemy is chosen based on a defined angle within which the player is allowed to possess. Any enemy outside this angle is ignored. To determine this, I calculate the direction from the player to each enemy and compare it with the player’s current possession direction. By taking the dot product of these two direction vectors and converting it into an angle, I can check whether the enemy falls within the allowed possession cone. If the angle is small enough, meaning the enemy is directly in front of the player, they are considered a valid target.
After confirming the enemy is within the correct angle, I perform a raycast toward them to ensure there is a clear line of sight. If the raycast hits the enemy, I calculate a priority value based on two factors: the enemy’s distance from the player and how closely they align with the player’s forward direction. This priority value is then added to a priority queue, which ensures that the enemy closest to the center of the player’s aim, and with the clearest, shortest path, is selected first.
PriorityQueue<(float, Character)> distances = new PriorityQueue<(float, Character)>();
foreach (Character character in
possessionColliderScript.GetCharactersInPossession())
{
Vector3 toCharacter = new Vector3 (character.transform.position.x, 0, character.transform.position.z) - new Vector3 (currentCharacter.transform.position.x, 0, currentCharacter.transform.position.z);
toCharacter = toCharacter.normalized;
float dotProduct = Vector3.Dot(toCharacter, dir);
float angle = Mathf.Acos(dotProduct);
angle = Mathf.Rad2Deg * angle;
if (angle < currentPossessionAngle / 2.0f)
{
Ray possessionRay = new Ray(currentCharacter.transform.position, toCharacter);
RaycastHit hitInfo;
if (Physics.Raycast(possessionRay, out hitInfo, currentPossesionDistance, possessionMask))
{
if (hitInfo.collider.gameObject.GetComponent<Character>() != null)
{
distances.Enqueue((hitInfo.distance + (1 - dotProduct) * 50, character), Mathf.FloorToInt(hitInfo.distance * 100));
}
}
}
}
Finally, the top of the priority queue is dequeued to retrieve the enemy with the highest priority to possess. This enemy is then saved as the currentPossessableEnemy, which the player will take control of if they choose to possess.
if (distances.Count > 0)
{
(float, Character) characterPair = distances.Dequeue();
currentPossessableEnemy = characterPair.Item2;
possessionState = PossessionStates.canPossess;
}
else
{
possessionState = PossessionStates.canNotPossess;
}
When the player clicks to possess, they take control of the targeted enemy. To add polish, features such as VFX feedback, automatic rotation toward the possession direction, and smooth camera transitions were implemented to make the possession mechanic feel responsive and satisfying.
Another feature I focused on was implementing all of the character animations. This was done using the Animator in Unity, a base class to control general character animations, and a derived class for each individual character. This abstraction helped reduce redundancy and made the animation system easier to maintain.
Enemies had anywhere from two to three attacks in their primary combo cycle. These attacks had to be executed in sequence, with each step requiring the next input to occur within a specific timing window. If the input wasn’t made within that window, the combo would automatically reset to the first attack.
if (currentAnimationState == "PrimaryAttack" && currentPrimaryComboStep != -1 && Time.time - timeLastPrimary >= primaryComboResetTime[currentPrimaryComboStep])
{
character.ResetPrimaryComboStep();
}
animator.SetInteger("PrimaryCombo", currentPrimaryComboStep);
To support this system, I implemented customizable values for both the combo window and the reset time, allowing designers to fine-tune how strict or forgiving the combo timing felt for each enemy. A major challenge was making the combo system flexible without requiring designers to manually adjust multiple separate timing variables. To solve this, I created a single animation speed multiplier. This multiplier controlled the overall speed of attack animations, and every timing reference, including the frames at which hitboxes were spawned, was automatically scaled by this value. Because hitboxes needed to activate at very specific frames, using one consistent speed multiplier kept animations and gameplay perfectly synchronized. This approach allowed designers to adjust just a single variable to speed up or slow down an enemy’s entire attack sequence rather than juggling multiple interdependent values that had to be precisely aligned.
Overall, this system provided designers with a streamlined way to adjust enemy attack behavior while keeping complex timing, animation synchronization, and hitbox logic consistent and easy to maintain.
Another challenge was handling variations of the same attack animation. Some attacks required a windup animation if the enemy needed to move toward the target, while others used a version without a windup. To handle this all character check if they are close enough to their target including the targets radius. If they are far enough away the animator is told and sets that movement to the target is needed starting a windup attack verus a stationary attack.
if (!playerControlling || (lockedCharacter != null && Vector3.Distance(new Vector3(lockedCharacter.transform.position.x, transform.position.y, lockedCharacter.transform.position.z), transform.position) - lockedCharacter.sizeRadius - sizeRadius > moveToTargetDistance))
{
animator.SetPrimaryMovementNeeded(true);
primaryMovementNeeded = true;
}
else
{
animator.SetPrimaryMovementNeeded(false);
primaryMovementNeeded = false;
}
In addition to programming, I implemented and configured all of the animation controllers in Unity, ensuring that each character’s animations were properly structured and responsive. For example, the Goblin animation controller includes a full set of states, transitions, and parameters that manage movement, attacks, hit reactions, and other behaviors, all integrated with the player controller and combat systems to ensure smooth and responsive gameplay.
When implementing the animation transitions for the characters, I needed a system that was flexible and easy to extend. The choice to use a string instead of an enum for the animation states was made to allow easy overrides in derived classes. Using an enum would not permit adding or removing states while still using the same enum. With a HashSet<string> instead, animationStates can have states added or removed easily in subclasses, making it much simpler to manage complex animation transitions.
protected HashSet<string> animationStates = new HashSet<string>
{
"Idle", "Run", "PrimaryAttack", "SecondaryAttack", "Death", "Jump", "Hit", "Overriding"
};
Each animation class includes an override of this function for its specific purposes. To switch animation states in an enemy class, the class calls the SwitchState function with the state it wants to transition to. The animator class then checks conditions such as whether the animation is allowed to change states at that moment or if the enemy is currently dead. If the transition is allowed, the animator class resets all triggers and sets the new state. Additionally, the animator class applies the animation speed as specified by the designers.
public virtual void SwitchState(string newState)
{
if (overriding) return;
if (!animationStates.Contains(newState))
{
Debug.LogWarning("This animation state: " + newState + " does not exist!");
}
if (newState == "PrimaryAttack")
{
ResetAllTriggers();
animator.SetTrigger("PrimaryAttack");
canChange = false;
currentAnimationState = newState;
}
else if(newState == "Death")
{
ResetAllTriggers();
animator.SetTrigger("Death");
canChange = false;
currentAnimationState = newState;
return;
}
if (!canChange || currentAnimationState == "Death" || currentAnimationState == newState)
return;
currentAnimationState = newState;
if (animator == null) return;
ResetAllTriggers();
switch (newState)
{
case "Idle":
animator.SetFloat("IdleSpeedMult", idleSpeedMult);
animator.SetTrigger("Idle");
canChange = true;
break;
case "Run":
animator.SetTrigger("Run");
canChange = true;
break;
case "PrimaryAttack":
animator.SetTrigger("PrimaryAttack");
canChange = false;
break;
case "SecondaryAttack":
animator.SetTrigger("SecondaryAttack");
canChange = false;
break;
case "Death":
animator.SetFloat("DeathSpeedMult", deathSpeedMult);
animator.SetTrigger("Death");
canChange = false;
break;
}
}
Camera control was a key element in this 3D third-person game. To enhance gameplay, it was important to have a camera system that supported the player. To achieve this, a camera controller was created to provide dynamic camera movements based on the player’s position. Additionally, different camera setups were implemented for various game states to ensure smooth and context-appropriate perspectives.
While enemies are present in combat rooms, the camera controller activates the current character’s combat camera. This camera uses dynamic movements to frame the action effectively.
Several situations trigger adjustments to the combat camera. For example, when an enemy hits the player, the camera is notified and calculates the positions of both the enemy and the player. It then rotates to focus on the enemy that delivered the hit, ensuring the action remains clear and engaging.
public IEnumerator RotateToEnemy(GameObject enemy, float duration)
{
Vector3 toEnemy = enemy.transform.position - PlayerController.instance.currentCharacter.transform.position;
toEnemy.y = 0;
float degrees = Vector3.SignedAngle(Vector3.forward, toEnemy, Vector3.up);
if (degrees < 0) degrees += 360;
float dist = toEnemy.magnitude;
dist -= 1;
dist = Mathf.Clamp(dist, 0f, 10f);
yield return StartCoroutine(RotateCamera(degrees, 1f - dist / 10f, duration));
}
When there is no player input, the combat camera automatically frames the enemy considered the “biggest threat.” The camera determines this target using a weighted priority system. Each enemy is evaluated based on two main factors, threat level and distance from the player. Enemies that are actively attacking the player receive an additional threat bonus.
For each enemy, a priority value is calculated as follows, the enemy’s threat level is multiplied by a configurable threatWeight, and the difference between a maximum distance and the enemy’s actual distance is multiplied by a configurable distWeight. The sum of these values gives the enemy’s total priority. A raycast is also performed to ensure the player has a clear line of sight to the enemy, ignoring targets blocked by the environment.
The enemy with the highest resulting priority is selected as the target, and the camera then smoothly rotates over time to frame this enemy both horizontally and vertically. This ensures the player’s attention is focused on the most relevant threat at any moment in combat.
public IEnumerator RotateToBiggestThreat(int threatWeight, int distWeight, float maxDistance, List<Enemy> enemies, Dictionary<Character, int> attackingEnemies)
{
if (!inHitBy && !inOnAttack && !CameraController.instance.GetLooking() && Time.time - timeOverrideEnded > CameraController.instance.GetTimeWaitToPriorityRotate())
{
StopAllRotates();
if (enemies != null)
{
Enemy topPriority = null;
float priority = Mathf.NegativeInfinity;
foreach (Enemy enemy in enemies)
{
float distance = Vector3.Distance(PlayerController.instance.currentCharacter.transform.position, enemy.gameObject.transform.position);
float threat = enemy.priority;
if (attackingEnemies.ContainsKey(enemy))
{
threat += attackingEnemies[enemy];
}
Vector3 direction = (enemy.gameObject.transform.position - PlayerController.instance.currentCharacter.transform.position).normalized;
if (Physics.Raycast (PlayerController.instance.currentCharacter.transform.position + Vector3.up * 1.5f, direction, out RaycastHit hit, distance, LayerMask.GetMask ("Environment", "Character")))
{
if (hit.collider.gameObject != enemy.gameObject)
continue;
}
float currentPriority = (threat * threatWeight) + (maxDistance - distance) * distWeight;
if (topPriority == null || currentPriority > priority)
{
topPriority = enemy;
priority = currentPriority;
}
}
if (topPriority != null)
{
yield return StartCoroutine( RotateToEnemy(topPriority.gameObject, CameraController.instance.GetGeneralPriorityRotationTime()) );
}
}
}
}
The camera controller keeps track of the active cameras. When possession switches, the active cameras are updated accordingly.
private void SwitchCharacter(Character character)
{
transitioning = true;
StartCoroutine(WaitTransitionTime());
combatCam.Priority = 0;
explorationCam.Priority = 0;
currentCharacter = character;
if (!listener.attenuationObject) listener.attenuationObject = currentCharacter.gameObject;
combatCam = character.GetCombatCam();
explorationCam = character.GetExploreCam();
try
{
combatCamScript = combatCam.GetComponent<CombatCam>();
}
catch
{
Debug.LogWarning("No combat cam component found!");
}
if(inCombat)
{
combatCam.Priority = 2;
explorationCam.Priority = 1;
}
else
{
combatCam.Priority = 1;
explorationCam.Priority = 2;
}
}
The camera controller lowers the priority of cameras that are no longer in use and raises the priority of the newly active cameras. Blends are then applied to ensure smooth transitions from one character to another. Through the camera controller, other objects can indicate when they hit the player, allowing the current camera to respond accordingly or to focus on the highest-priority enemy. The camera controller distributes this information across all cameras to ensure cohesive movement and that the correct camera is active at any given time.
public void OnAttack(Vector3 forwardDir, float approachTime)
{
if (combatCamScript == null ) return;
StartCoroutine(combatCamScript.OnAttack(forwardDir, approachTime));
}
When there are no enemies in the room with the player, the camera controller activates the current character’s exploration camera. This camera provides a behind-the-shoulder view. To maintain a consistent perspective regardless of the walking direction, the camera follows a point to the left of the character that rotates around them as they turn. This ensures that, no matter which direction the character is moving, the camera remains on the left side of the screen.
private void Update()
{
lookAtPoint.transform.position = character.transform.position
+ Camera.main.transform.right * xOffset
+ new Vector3(0, yOffset, 0);
}
The aim camera was a mode that was ultimately cut from the game. However, it would have been activated when the player held down the left trigger to aim. The camera controller would then transfer control to the aim camera and smoothly lerp over the player’s shoulder. In this mode, a different movement state would be active, where the player’s rotation is controlled entirely by the camera, and the player can strafe freely in all directions.
cameraPOVComponent.m_VerticalAxis.m_MaxSpeed = 300 * ySensitivity;
if (CameraController.GetIsAiming())
{
yaw += lookDir.x * xSensitivity;
characterToFollow.transform.Rotate(new Vector3(0, yaw - prevYaw, 0));
cameraPOVComponent.m_HorizontalAxis.Value = characterToFollow.transform.rotation.y;
prevYaw = yaw;
}
As the programming lead on this project, I took several precautions to ensure that high-quality code was consistently written and maintained.
The first step I implemented was an automated system to run unit tests on every pull request in GitHub. I also set the standard for programmers to create unit tests for every feature they added. Running tests on each pull request helped ensure that the project remained bug-free before new code was merged into the main branch, keeping it stable and reliable.
Secondly, I set up automatic builds for every pull request. This ensured that the game could always be successfully compiled without errors, which was crucial for meeting milestone deadlines and maintaining a smooth development pipeline.
Finally, I enforced code reviews and standards. I conducted reviews before every pull request was merged into the main branch, verifying that all code adhered to project standards, was well-formatted, and followed best practices.
In addition to these practices, I also organized the project’s branch structure and documented Git workflows for the team. This included guidance on branching, pull requests, and merging strategies, which helped maintain consistency across the team and reduced integration issues.
This was the first project where I implemented automatic builds and automated unit testing, which ended up providing far more support to the project than I initially anticipated. I gained a strong understanding of the importance of automatically running unit tests and builds to ensure project quality and stability at all times. By integrating these systems into our Unity project using YAML and GitHub Actions, I not only streamlined the development workflow but also reduced the risk of breaking the main branch with new code. This experience taught me best practices for continuous integration in game development and gave me practical skills that will be invaluable for maintaining quality and efficiency in future projects.
I was brought onto Bewitched after it had already been developed as a four-week prototype. At the time, the original team had created a low-poly isometric rogue-like, and one of the main challenges I faced was integrating my work while respecting the team’s initial vision. At the same time, the project had shifted toward a 3D, third-person, AAA-style action-adventure, which required expanding the systems, redesigning mechanics, and upgrading technical implementations to support the new vision. Balancing these two directions, honoring the prototype’s foundation while evolving it into a more ambitious, polished game, required careful planning, strong communication, and strategic decision-making.