Hiraishin

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.

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

  2. Target Selection: The system iterates through all teleportable objects, storing the nearest one to the player in a variable called closestTarget.

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

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

  2. Target Selection: The system iterates through all teleportable objects, storing the nearest one to the player in a variable called closestTarget.

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

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

  2. Target Selection: The system iterates through all teleportable objects, storing the nearest one to the player in a variable called closestTarget.

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

Sample Level (Level 4)

Sample Level (Level 4)

Sample Level (Level 4)

Top Down Map

Top Down Map

Top Down Map

Block Out Mesh

Block Out Mesh

Block Out Mesh

Copyright © 2024, Andy Pang. All rights reserved.

Copyright © 2024, Andy Pang. All rights reserved.

Copyright © 2024, Andy Pang. All rights reserved.