Role: Programmer, Game Designer, Level Designer
Tool: Unity, Mixamo
Team: Solo Project
Timeline: 7 months
Completion Date: November 30th, 2024
Role: Programmer, Game Designer, Level Designer
Tool: Unity, Mixamo
Team: Solo Project
Timeline: 7 months
Completion Date: November 30th, 2024
Role: Programmer, Game Designer, Level Designer
Tool: Unity, Mixamo
Team: Solo Project
Timeline: 7 months
Completion Date: November 30th, 2024
COMING SOON …
COMING SOON …
COMING SOON …
GAME DESIGN
GAME DESIGN
GAME DESIGN
design pillars
design pillars
design pillars
Versatile Challenges
Versatile Challenges
Versatile Challenges
Players can approach obstacles through stealth, agility, or combat, with every encounter offering multiple viable solutions.
Players can approach obstacles through stealth, agility, or combat, with every encounter offering multiple viable solutions.
Players can approach obstacles through stealth, agility, or combat, with every encounter offering multiple viable solutions.
Evolving Progression
Evolving Progression
Evolving Progression
Unlock new abilities like teleportation and bullet-time while mastering mechanics through increasingly complex challenges.
Unlock new abilities like teleportation and bullet-time while mastering mechanics through increasingly complex challenges.
Unlock new abilities like teleportation and bullet-time while mastering mechanics through increasingly complex challenges.
Fluid Controls
Fluid Controls
Fluid Controls
Seamless transitions between mechanics like teleportation, wall-running, and combat ensure a responsive and empowering gameplay experience.
Seamless transitions between mechanics like teleportation, wall-running, and combat ensure a responsive and empowering gameplay experience.
Seamless transitions between mechanics like teleportation, wall-running, and combat ensure a responsive and empowering gameplay experience.
mechanics
mechanics
mechanics
PROGRAMMING
PROGRAMMING
PROGRAMMING
Teleportation
Teleportation
Teleportation
The teleportation system is divided into three steps: detecting teleportable objects within the crosshair, identifying the closest teleportable, and executing the teleportation based on the object's type.
Detection: The crosshair’s size and center are recalculated to account for potential resolution changes. World positions of teleportable objects are mapped to screen space, and their proximity to the crosshair radius is checked.
Target Selection: The system iterates through all teleportable objects, storing the nearest one to the player in a variable called
closestTarget
.Action Execution: Based on the target’s layer, specific actions are triggered. For kunai, the
Teleport()
coroutine is called. For player-tagged objects or enemies,SwapLocations()
is executed.
The crosshair color updates dynamically to indicate the presence of valid teleportable objects.
The teleportation system is divided into three steps: detecting teleportable objects within the crosshair, identifying the closest teleportable, and executing the teleportation based on the object's type.
Detection: The crosshair’s size and center are recalculated to account for potential resolution changes. World positions of teleportable objects are mapped to screen space, and their proximity to the crosshair radius is checked.
Target Selection: The system iterates through all teleportable objects, storing the nearest one to the player in a variable called
closestTarget
.Action Execution: Based on the target’s layer, specific actions are triggered. For kunai, the
Teleport()
coroutine is called. For player-tagged objects or enemies,SwapLocations()
is executed.
The crosshair color updates dynamically to indicate the presence of valid teleportable objects.
The teleportation system is divided into three steps: detecting teleportable objects within the crosshair, identifying the closest teleportable, and executing the teleportation based on the object's type.
Detection: The crosshair’s size and center are recalculated to account for potential resolution changes. World positions of teleportable objects are mapped to screen space, and their proximity to the crosshair radius is checked.
Target Selection: The system iterates through all teleportable objects, storing the nearest one to the player in a variable called
closestTarget
.Action Execution: Based on the target’s layer, specific actions are triggered. For kunai, the
Teleport()
coroutine is called. For player-tagged objects or enemies,SwapLocations()
is executed.
The crosshair color updates dynamically to indicate the presence of valid teleportable objects.
void Update()
{
// Calculate the radius crosshair and centerpoint of the screen
detectionRadius = crosshairRectTransform.rect.height / 2f * (Screen.height / canvasScaler.referenceResolution.y);
centerPoint = new Vector2(Screen.width / 2, Screen.height / 2);
GameObject closestTarget = null;
// Iterate through all teleportables
foreach (GameObject target in teleportables) {
if (target == playerPickup.heldObj || target == null) {
continue;
}
// Convert target's world position to screen position
Vector2 screenPointPos = Camera.main.WorldToScreenPoint(target.transform.position);
// If the target position is within the crosshair radius and within
if ((Vector2.SqrMagnitude(screenPointPos - centerPoint) <= Mathf.Pow(detectionRadius, 2)
&& Vector3.SqrMagnitude(target.transform.position - transform.position) <= Mathf.Pow(detectionDistance, 2))
// Alternate check for larger objects/close proximity
|| target.GetComponentsInChildren<Collider>().
Aggregate(false, (bool sum, Collider collider) => collider.Raycast(new Ray(Camera.main.transform.position, Camera.main.transform.forward), out _, detectionDistance) || sum))
{
if (closestTarget == null) {
closestTarget = target;
}
// If the new target is closer
else if (Vector3.SqrMagnitude(target.transform.position - transform.position)
< Vector3.SqrMagnitude(closestTarget.transform.position - transform.position)) {
closestTarget = target;
}
}
}
if (closestTarget != null) {
teleportImage.color = detectedColor;
throwImage.color = detectedColor;
// When player wants to teleport
if (InputController.Instance.GetTeleportDown() && teleportReady) {
teleportReady = false;
if (closestTarget.layer == LayerMask.NameToLayer("Kunai")) {
StartCoroutine(Teleport(closestTarget));
}
else if (closestTarget.layer == LayerMask.NameToLayer("Tagged")) {
GameObject temp = new GameObject();
temp.transform.position = gameObject.transform.position;
temp.transform.rotation = gameObject.transform.rotation;
temp.transform.localScale = gameObject.transform.localScale;
StartCoroutine(SwapLocations(closestTarget, temp));
}
Invoke(nameof(ResetTeleport), teleportCD);
}
}
else {
teleportImage.color = defaultColor;
throwImage.color = defaultColor
The Teleport()
and SwapLocations()
functions are coroutines to accommodate the lens distortion effect, processed concurrently. The sequence involves amplifying the lens distortion for a fisheye effect, executing the teleportation, and then reverting the distortion to normal.
Teleportation involves swapping the positions and rotations of the source and target objects. However, numerous edge cases add complexity, requiring checks based on the types of the source and target objects, with tailored handling for each combination due to unique setups.
For instance:
Enemy Handling: Enemies have their center at their feet, unlike players or most objects, which have a central origin. Raycasts on the target’s top and bottom ensure valid teleportation, such as into a tunnel.
Physics and NavMesh: Position changes use
Rigidbody.MovePosition()
to avoid issues from direct Transform manipulation. For enemies, the closest NavMesh point is sampled to prevent deactivation. If no valid NavMesh point exists, the enemy is deactivated.
The Teleport()
and SwapLocations()
functions are coroutines to accommodate the lens distortion effect, processed concurrently. The sequence involves amplifying the lens distortion for a fisheye effect, executing the teleportation, and then reverting the distortion to normal.
Teleportation involves swapping the positions and rotations of the source and target objects. However, numerous edge cases add complexity, requiring checks based on the types of the source and target objects, with tailored handling for each combination due to unique setups.
For instance:
Enemy Handling: Enemies have their center at their feet, unlike players or most objects, which have a central origin. Raycasts on the target’s top and bottom ensure valid teleportation, such as into a tunnel.
Physics and NavMesh: Position changes use
Rigidbody.MovePosition()
to avoid issues from direct Transform manipulation. For enemies, the closest NavMesh point is sampled to prevent deactivation. If no valid NavMesh point exists, the enemy is deactivated.
The Teleport()
and SwapLocations()
functions are coroutines to accommodate the lens distortion effect, processed concurrently. The sequence involves amplifying the lens distortion for a fisheye effect, executing the teleportation, and then reverting the distortion to normal.
Teleportation involves swapping the positions and rotations of the source and target objects. However, numerous edge cases add complexity, requiring checks based on the types of the source and target objects, with tailored handling for each combination due to unique setups.
For instance:
Enemy Handling: Enemies have their center at their feet, unlike players or most objects, which have a central origin. Raycasts on the target’s top and bottom ensure valid teleportation, such as into a tunnel.
Physics and NavMesh: Position changes use
Rigidbody.MovePosition()
to avoid issues from direct Transform manipulation. For enemies, the closest NavMesh point is sampled to prevent deactivation. If no valid NavMesh point exists, the enemy is deactivated.
void TeleportObjects(GameObject source, GameObject target) {
// If the source object has a rigidbody (player, other interactables)
if (source.TryGetComponent(out Rigidbody sourceRB)) {
if (target.CompareTag("Enemy")) {
sourceRB.MovePosition(target.transform.position + Vector3.up);
}
else if (target.CompareTag("Player") && !source.CompareTag("Player")) {
sourceRB.MovePosition(target.transform.position + Vector3.down);
}
else {
// Crouch if teleporting into a tunnel
if (sourceRB.TryGetComponent(out PlayerMovement playerMovement)
&& Physics.Raycast(target.transform.position, Vector3.up, playerMovement.playerHeight * 0.5f + 0.2f)
&& Physics.Raycast(target.transform.position, Vector3.down, playerMovement.playerHeight * 0.5f + 0.2f)) {
playerMovement.Crouch();
playerMovement.movementState = PlayerMovement.MovementState.CROUCH;
}
sourceRB.MovePosition(target.transform.position);
}
// Inherit target object velocity
if (target.TryGetComponent(out Rigidbody targetRB)) {
sourceRB.velocity = new Vector3(targetRB.velocity.x, 0, targetRB.velocity.z);
}
}
// If the source object is an enemy
else if (source.TryGetComponent(out NavMeshAgent agent)) {
agent.enabled = false;
source.transform.position = target.transform.position + Vector3.down;
if (NavMesh.SamplePosition(source.transform.position, out _, 1f, NavMesh.AllAreas)) {
agent.enabled = true;
}
else {
teleportables.Remove(source);
source.layer = LayerMask.NameToLayer("Enemy");
}
}
}
IEnumerator Teleport(GameObject closestTarget) {
while (lensDistortion.intensity.value < maxLensDistortion) {
lensDistortion.intensity.value += Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null;
}
UpdateRotation(closestTarget);
TeleportObjects(gameObject, closestTarget);
if (recycleKunaiEnabled){
GetComponent<PlayerThrow>().AddKunaiCount(1);
}
teleportables.Remove(closestTarget);
Destroy(closestTarget);
while (lensDistortion.intensity.value > 0) {
lensDistortion.intensity.value -= Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null;
}
}
IEnumerator SwapLocations(GameObject closestTarget, GameObject temp) {
while (lensDistortion.intensity.value < maxLensDistortion) {
lensDistortion.intensity.value += Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null;
}
UpdateRotation(closestTarget);
TeleportObjects(gameObject, closestTarget);
TeleportObjects(closestTarget, temp);
Destroy(temp);
while (lensDistortion.intensity.value > 0) {
lensDistortion.intensity.value -= Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null
void TeleportObjects(GameObject source, GameObject target) {
// If the source object has a rigidbody (player, other interactables)
if (source.TryGetComponent(out Rigidbody sourceRB)) {
if (target.CompareTag("Enemy")) {
sourceRB.MovePosition(target.transform.position + Vector3.up);
}
else if (target.CompareTag("Player") && !source.CompareTag("Player")) {
sourceRB.MovePosition(target.transform.position + Vector3.down);
}
else {
// Crouch if teleporting into a tunnel
if (sourceRB.TryGetComponent(out PlayerMovement playerMovement)
&& Physics.Raycast(target.transform.position, Vector3.up, playerMovement.playerHeight * 0.5f + 0.2f)
&& Physics.Raycast(target.transform.position, Vector3.down, playerMovement.playerHeight * 0.5f + 0.2f)) {
playerMovement.Crouch();
playerMovement.movementState = PlayerMovement.MovementState.CROUCH;
}
sourceRB.MovePosition(target.transform.position);
}
// Inherit target object velocity
if (target.TryGetComponent(out Rigidbody targetRB)) {
sourceRB.velocity = new Vector3(targetRB.velocity.x, 0, targetRB.velocity.z);
}
}
// If the source object is an enemy
else if (source.TryGetComponent(out NavMeshAgent agent)) {
agent.enabled = false;
source.transform.position = target.transform.position + Vector3.down;
if (NavMesh.SamplePosition(source.transform.position, out _, 1f, NavMesh.AllAreas)) {
agent.enabled = true;
}
else {
teleportables.Remove(source);
source.layer = LayerMask.NameToLayer("Enemy");
}
}
}
IEnumerator Teleport(GameObject closestTarget) {
while (lensDistortion.intensity.value < maxLensDistortion) {
lensDistortion.intensity.value += Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null;
}
UpdateRotation(closestTarget);
TeleportObjects(gameObject, closestTarget);
if (recycleKunaiEnabled){
GetComponent<PlayerThrow>().AddKunaiCount(1);
}
teleportables.Remove(closestTarget);
Destroy(closestTarget);
while (lensDistortion.intensity.value > 0) {
lensDistortion.intensity.value -= Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null;
}
}
IEnumerator SwapLocations(GameObject closestTarget, GameObject temp) {
while (lensDistortion.intensity.value < maxLensDistortion) {
lensDistortion.intensity.value += Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null;
}
UpdateRotation(closestTarget);
TeleportObjects(gameObject, closestTarget);
TeleportObjects(closestTarget, temp);
Destroy(temp);
while (lensDistortion.intensity.value > 0) {
lensDistortion.intensity.value -= Time.deltaTime / Time.timeScale * distortionSpeed;
yield return null
Dismemberment
Dismemberment
Dismemberment
Enemies can be dismembered when sliced at designated points, such as major limbs and the head. The system supports four default slice angles (0°, 45°, 90°, 135°) for both visual effects (VFX) and the slice trigger box.
When an enemy, its body parts, or a bullet contacts the active trigger box, the destruction function is called for the targeted object and potentially its root.
Enemies can be dismembered when sliced at designated points, such as major limbs and the head. The system supports four default slice angles (0°, 45°, 90°, 135°) for both visual effects (VFX) and the slice trigger box.
When an enemy, its body parts, or a bullet contacts the active trigger box, the destruction function is called for the targeted object and potentially its root.
Enemies can be dismembered when sliced at designated points, such as major limbs and the head. The system supports four default slice angles (0°, 45°, 90°, 135°) for both visual effects (VFX) and the slice trigger box.
When an enemy, its body parts, or a bullet contacts the active trigger box, the destruction function is called for the targeted object and potentially its root.
void Attack() {
sliceVFX.Play();
// Get all targets in range
GameObject[] targetsInRange = Physics.BoxCastAll(
attackPoint.position,
attackPoint.localScale / 2,
Camera.main.transform.forward,
attackPoint.rotation,
0.25f,
LayerMask.GetMask("Enemy", "Bullet"))
.Select(hit => hit.collider.gameObject).ToArray();
foreach (GameObject target in targetsInRange)
{
if (target.TryGetComponent(out Bullet _)) {
Destroy(target);
}
else if (target.TryGetComponent(out Limb limb)) {
limb.Dismember();
limb.GetComponent<Rigidbody>().AddExplosionForce(attackForce, limb.transform.position, 1f);
}
else if (target.transform.root.TryGetComponent(out EnemyController enemyController)) {
enemyController.Die
void Attack() {
sliceVFX.Play();
// Get all targets in range
GameObject[] targetsInRange = Physics.BoxCastAll(
attackPoint.position,
attackPoint.localScale / 2,
Camera.main.transform.forward,
attackPoint.rotation,
0.25f,
LayerMask.GetMask("Enemy", "Bullet"))
.Select(hit => hit.collider.gameObject).ToArray();
foreach (GameObject target in targetsInRange)
{
if (target.TryGetComponent(out Bullet _)) {
Destroy(target);
}
else if (target.TryGetComponent(out Limb limb)) {
limb.Dismember();
limb.GetComponent<Rigidbody>().AddExplosionForce(attackForce, limb.transform.position, 1f);
}
else if (target.transform.root.TryGetComponent(out EnemyController enemyController)) {
enemyController.Die
When a limb enters the active trigger box, it is dismembered. If the enemy is still alive, it will be killed. The severed limb's skinned mesh and character joint components are destroyed, and its scale is set to 0 to make it invisible.
A new rigged limb, based on whether it’s a head, arm, or leg, is spawned at the dismemberment site to replace the original. For the current Mixamo default character, these replacement limbs are custom-made in Unity using primitive shapes and stored as prefabs.
When a limb enters the active trigger box, it is dismembered. If the enemy is still alive, it will be killed. The severed limb's skinned mesh and character joint components are destroyed, and its scale is set to 0 to make it invisible.
A new rigged limb, based on whether it’s a head, arm, or leg, is spawned at the dismemberment site to replace the original. For the current Mixamo default character, these replacement limbs are custom-made in Unity using primitive shapes and stored as prefabs.
On top of that, we also have to handle level progression as time passes, and the spawn ratio between the probiotics and the junk food. To better control the level flow and allow players in the game jam to have a better gaming experience in the short run, we decided to fix the spawn ratio to 1 probiotic per 9 junk food after robust playtesting. Level progression is represented by the increasing number of entities that are floating in the petri dish, which is done by decreasing the time interval between spawns. Below is the class EdibleSpawner, a class that handles the spawning rate and adjusts the level difficulty as time passes:
public class Limb : MonoBehaviour
{
public GameObject currentLimb;
public void Dismember() {
if (transform.root.TryGetComponent(out EnemyController enemyController)) {
enemyController.Die();
enemyController.enabled = false;
}
Destroy(GetComponent<CharacterJoint>());
if (currentLimb != null) {
Instantiate(currentLimb, transform.position, transform.rotation);
}
transform.localScale = Vector3.zero;
Destroy(gameObject
public class Limb : MonoBehaviour
{
public GameObject currentLimb;
public void Dismember() {
if (transform.root.TryGetComponent(out EnemyController enemyController)) {
enemyController.Die();
enemyController.enabled = false;
}
Destroy(GetComponent<CharacterJoint>());
if (currentLimb != null) {
Instantiate(currentLimb, transform.position, transform.rotation);
}
transform.localScale = Vector3.zero;
Destroy(gameObject
Each enemy has a default ragdoll setup, inactive by default, which activates upon dismemberment or death. When triggered, the animator and non-physics/collision components are destroyed as they are no longer needed. The ragdoll is then activated, making the dead enemy an interactable game element.
Each enemy has a default ragdoll setup, inactive by default, which activates upon dismemberment or death. When triggered, the animator and non-physics/collision components are destroyed as they are no longer needed. The ragdoll is then activated, making the dead enemy an interactable game element.
On top of that, we also have to handle level progression as time passes, and the spawn ratio between the probiotics and the junk food. To better control the level flow and allow players in the game jam to have a better gaming experience in the short run, we decided to fix the spawn ratio to 1 probiotic per 9 junk food after robust playtesting. Level progression is represented by the increasing number of entities that are floating in the petri dish, which is done by decreasing the time interval between spawns. Below is the class EdibleSpawner, a class that handles the spawning rate and adjusts the level difficulty as time passes:
public void Die() {
ActivateRagdoll();
PlayerTeleport.teleportables.Remove(gameObject);
}
void ActivateRagdoll() {
foreach (Rigidbody rb in new List<Rigidbody>(transform.GetComponentsInChildren<Rigidbody>())) {
rb.useGravity = true;
rb.isKinematic = false;
rb.mass = 0.01f;
}
foreach (var component in GetComponents<Component>()) {
if (component.GetType() == typeof(EnemyController)
|| component.GetType() == typeof(Transform)) {
continue;
}
else if (component.GetType() == typeof(Animator)) {
(component as Animator).enabled = false;
}
else {
Destroy(component);
}
}
}
void DeactivateRagdoll() {
foreach (Rigidbody rb in new List<Rigidbody>(transform.GetComponentsInChildren<Rigidbody>())) {
rb.useGravity = false;
rb.isKinematic = true
public void Die() {
ActivateRagdoll();
PlayerTeleport.teleportables.Remove(gameObject);
}
void ActivateRagdoll() {
foreach (Rigidbody rb in new List<Rigidbody>(transform.GetComponentsInChildren<Rigidbody>())) {
rb.useGravity = true;
rb.isKinematic = false;
rb.mass = 0.01f;
}
foreach (var component in GetComponents<Component>()) {
if (component.GetType() == typeof(EnemyController)
|| component.GetType() == typeof(Transform)) {
continue;
}
else if (component.GetType() == typeof(Animator)) {
(component as Animator).enabled = false;
}
else {
Destroy(component);
}
}
}
void DeactivateRagdoll() {
foreach (Rigidbody rb in new List<Rigidbody>(transform.GetComponentsInChildren<Rigidbody>())) {
rb.useGravity = false;
rb.isKinematic = true
LEVEL DESIGN
LEVEL DESIGN
LEVEL DESIGN
Challenges
Challenges
Challenges
Teleportation-Enabled Navigation
Teleportation-Enabled Navigation
Teleportation-Enabled Navigation
How can levels remain challenging without allowing teleportation to bypass puzzles or encounters?
How can levels remain challenging without allowing teleportation to bypass puzzles or encounters?
How can levels remain challenging without allowing teleportation to bypass puzzles or encounters?
Encouraging Multiple Playstyles
Encouraging Multiple Playstyles
Encouraging Multiple Playstyles
How can levels accommodate stealth, combat, and parkour equally without favoring one?
How can levels accommodate stealth, combat, and parkour equally without favoring one?
How can levels accommodate stealth, combat, and parkour equally without favoring one?
Progression and Increasing Difficulty
Progression and Increasing Difficulty
Progression and Increasing Difficulty
How can levels scale in complexity while ensuring players feel a sense of mastery?
How can levels scale in complexity while ensuring players feel a sense of mastery?
How can levels scale in complexity while ensuring players feel a sense of mastery?
Solutions
Solutions
Solutions
Teleportation-Enabled Navigation
Teleportation-Enabled Navigation
Teleportation-Enabled Navigation
Levels include boundaries and gated areas requiring puzzles, key items, or combat to progress.
Levels include boundaries and gated areas requiring puzzles, key items, or combat to progress.
Levels include boundaries and gated areas requiring puzzles, key items, or combat to progress.
Encouraging Multiple Playstyles
Encouraging Multiple Playstyles
Encouraging Multiple Playstyles
Use branching paths tailored to playstyles or composite designs with scenarios favoring specific approaches.
Use branching paths tailored to playstyles or composite designs with scenarios favoring specific approaches.
Use branching paths tailored to playstyles or composite designs with scenarios favoring specific approaches.
Progression and Increasing Difficulty
Progression and Increasing Difficulty
Progression and Increasing Difficulty
Start with tutorial levels introducing abilities. Later levels grow larger, add diverse enemies, and require skillful use of combined abilities.
Start with tutorial levels introducing abilities. Later levels grow larger, add diverse enemies, and require skillful use of combined abilities.
Start with tutorial levels introducing abilities. Later levels grow larger, add diverse enemies, and require skillful use of combined abilities.