Hiraishin

Role: Programmer, Game Designer, Level Designer


Tool: Unity (C#), GitHub, ProBuilder, Mixamo


Team: Solo Project


Timeline: 7 months


Completion Date: November, 2024

Role: Programmer, Game Designer, Level Designer


Tool: Unity (C#), GitHub, ProBuilder, Mixamo


Team: Solo Project


Timeline: 7 months


Completion Date: November, 2024

Role: Programmer, Game Designer, Level Designer


Tool: Unity (C#), GitHub, ProBuilder, Mixamo


Team: Solo Project


Timeline: 7 months


Completion Date: November, 2024

Play ->

Play ->

Play ->

GAME DESIGN

GAME DESIGN

GAME DESIGN

Hiraishin is a first-person action game built around instantaneous teleportation and time manipulation. Players reposition rapidly across the battlefield, using teleportation to evade attacks, control space, and outmaneuver enemies in high-speed combat scenarios. The core gameplay rewards spatial awareness, anticipation, and creative use of movement to dominate encounters.

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.

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.

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.

mechanics

mechanics

mechanics

TECHNICAL CHALLENGES

TECHNICAL CHALLENGES

TECHNICAL CHALLENGES

Teleportation System

Teleportation System

Teleportation System

CHALLENGE

The teleportation system has to handle two fundamentally different operations — teleporting to a kunai (one-way travel) and swapping positions with a tagged enemy or throwable — while sharing the same crosshair detection, the same lens distortion feedback, and the same cooldown. The detection problem alone is tricky: using a simple screen-space circle fails on large objects or objects very close to the player, and using a pure raycast fails on anything not precisely center-screen. On top of that, the swap operation has to correctly handle enemies (NavMesh agents with non-center origins), inheriting velocity, preserving camera orientation, and cleaning up tagged layers — all before the lens distortion finishes playing out.

THOUGHT PROCESS

I split the problem into three distinct layers: detection, execution, and feedback.

For detection, I recognized that neither screen-space proximity nor a single raycast is sufficient alone. Large objects and close-range targets need a fallback that accounts for their actual collider volume, not just their transform pivot. I combined both: screen-space distance check for normal cases, and a raycast-against-each-collider fallback using LINQ aggregation for edge cases.

For execution, I separated TeleportObjects() (the raw position logic) from the coroutines (Teleport() and SwapLocations()) that wrap it with lens distortion timing. This meant the teleport logic is testable and reusable without being entangled with visual feedback. Enemy handling needed specific offsets because NavMesh agents have their origin at their feet, not their center, and repositioning them without disabling the agent first causes the NavMesh to fight the position change. I also sample the NavMesh after repositioning and deactivate the enemy entirely if no valid point exists — otherwise the agent snaps back.

For the tag subsystem feeding into teleportation: rather than maintaining a separate data structure for tagged objects, I used Unity's layer system as the state. An object is "tagged" when its layer is Tagged; the teleportation system branches on layer to decide which operation to run. This keeps the two systems loosely coupled — PlayerTag doesn't know anything about PlayerTeleport except the shared static teleportables list.

SOLUTION

A) Dual-mode crosshair detection — screen-space proximity check as the primary path, with a per-collider raycast aggregate as the fallback for large or close objects:

Vector2 screenPointPos = Camera.main.WorldToScreenPoint(target.transform.position);

if ((Vector2.SqrMagnitude(screenPointPos - centerPoint) <= Mathf.Pow(detectionRadius, 2)
    && Vector3.SqrMagnitude(target.transform.position - transform.position) <= Mathf.Pow(detectionDistance, 2))

|| target.GetComponentsInChildren<Collider>()
    .Aggregate(false, (bool sum, Collider collider) =>
        collider.Raycast(new Ray(Camera.main.transform.position, Camera.main.transform.forward), out _, detectionDistance) || sum))
{
    // candidate for closest target

The SqrMagnitude comparisons throughout are intentional — avoiding Mathf.Sqrt on every frame across every teleportable in the list.

B) Branching execution on layer — the crosshair detection resolves a single closestTarget, then the layer determines which operation fires:

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;
    StartCoroutine(SwapLocations(closestTarget, temp

The temp GameObject in SwapLocations is a lightweight anchor — it captures the player's pre-swap position and rotation so TeleportObjects can move the target to where the player was without the positions having already changed.

C) Enemy-aware position resolution in TeleportObjects — NavMesh agents require disabling before repositioning, and the new position is validated against the NavMesh before re-enabling:

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"

If no valid NavMesh point exists (e.g. the player swapped the enemy into an off-mesh region), the enemy is removed from the teleportables list and its layer is reset rather than leaving it in a broken agent state.

D) Lens distortion gating — both coroutines use the same distortion ramp pattern: crank up to maxLensDistortion before executing the teleport, execute, then ramp back down. The Time.timeScale division keeps the ramp speed consistent whether bullet time is active or not:

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

The kunai recycle option is gated here rather than in the throw system, since the teleport coroutine is the only place that knows a kunai-teleport just completed.

Bullet Time

Bullet Time

Bullet Time

CHALLENGE

A naive Time.timeScale implementation breaks almost everything downstream: cooldown timers fire at the wrong rate, physics forces become inconsistent, and UI feedback is useless if the slider itself slows down. The bigger design problem is that bullet time shouldn't have a fixed-length cooldown — a player who uses it for 0.1 seconds shouldn't be punished the same as one who uses it for the full duration. The cooldown needs to scale proportionally to how much of the resource was actually consumed.

THOUGHT PROCESS

I separated wall-clock time from game time throughout the system. Any counter that the player perceives — the duration bar draining, the cooldown bar recovering — runs on Time.unscaledDeltaTime. Any counter that should respect the game world (e.g. ability cooldowns, throw resets) runs on Time.deltaTime, which is already scaled. The bullet time slider needed to do double duty: show drain during active use, then immediately switch to showing cooldown recovery, which meant its maxValue had to be reassigned dynamically rather than fixed.

For the proportional cooldown, the key insight is that durationCounter / bulletTimeDuration gives a normalized 0–1 value representing how much of the resource was used. Multiplying bulletTimeCD by that ratio gives a cooldown proportional to consumption.

SOLUTION

A) Duration tracking on unscaled time, with dynamic slider repurposing:

if (inBulletTime && durationCounter < bulletTimeDuration) {
    durationCounter += Time.unscaledDeltaTime;
    bulletTimeSlider.value = bulletTimeDuration - durationCounter;

    Time.timeScale = dilutedTimeScale;
    Time.fixedDeltaTime = defaultDeltaTime * dilutedTimeScale

Time.fixedDeltaTime must track Time.timeScale manually — Unity does not do this automatically. Failing to update it causes FixedUpdate to run at the wrong frequency relative to scaled time, which breaks physics force magnitudes.

B) Proportional cooldown on exit:

private void StartCoolDown() {
    if (!startCooldown) {
        inBulletTime = false;
        startCooldown = true;
        colorAdjustments.active = false;

        cooldownCounter = bulletTimeCD * (durationCounter / bulletTimeDuration);
        bulletTimeSlider.maxValue = bulletTimeCD

durationCounter / bulletTimeDuration is the fraction of bullet time consumed. A player who used 25% of the duration pays 25% of the maximum cooldown. The slider's maxValue is reassigned to bulletTimeCD here so the same UI element transitions cleanly from "time remaining" to "cooldown remaining" without a visual jump.

C) Toggle behavior with early exit:

if (InputController.Instance.GetBulletTimeDown()) {
    if (cooldownCounter <= 0 && !inBulletTime) {
        inBulletTime = true;
        startCooldown = false;
        durationCounter = 0;
        bulletTimeSlider.maxValue = bulletTimeDuration;
    }
    else if (inBulletTime) {
        StartCoolDown

The ability is toggle-based rather than hold-based, and pressing the key while active early-exits into cooldown immediately. The startCooldown bool acts as a one-shot guard so StartCoolDown() can be called from multiple paths (toggle exit, duration expiry) without triggering the reset twice.

Bullet Time × System Integration

Bullet Time × System Integration

Bullet Time × System Integration

CHALLENGE

Time.timeScale is a global. Every system in the game that touches time — throw forces, attack cooldowns, movement speed, wall run speed, VFX playback rate — is affected the moment you change it. The risk is that some systems become too slow-motion (forces applied during bullet time hit like wet cardboard) while others break entirely (cooldowns that use Invoke or Time.deltaTime drain faster or slower than intended relative to what the player sees). Getting all of these consistent requires explicit, case-by-case decisions rather than assuming the engine handles it.

THOUGHT PROCESS

I categorized every time-sensitive system into one of three groups:

  1. Perceived by the player in world-space (throwing a kunai, movement speed, wall run) — these should feel the same in bullet time. A thrown kunai should still arc the same way visually; the player shouldn't feel like they're wading through water. Solution: divide forces by Time.timeScale to compensate.

  2. Perceived by the player in UI / real time (cooldown timers, duration bars) — these should drain at wall-clock speed so the player has accurate information. Solution: use Time.unscaledDeltaTime and Invoke with Time.timeScale multiplication.

  3. Tied to a specific animation or VFX (slice VFX, lens distortion ramp) — these need to play at the correct perceptual speed regardless of timescale. Solution: explicitly set playRate = 1 / Time.timeScale and drive coroutines with Time.deltaTime / Time.timeScale.


SOLUTION

A) Throw force compensation — kunai and throwable forces are divided by Time.timeScale so the projectile travels the same speed in slow-mo as at full speed from the player's perspective:

kunai.GetComponent<Rigidbody>().AddForce(
    (kunaiThrowForce * forceDirection + kunaiUpwardForce * transform.up) / Time.timeScale,
    ForceMode.Impulse

Without this division, AddForce during bullet time applies the same world-space impulse over a much longer frame window, making projectiles feel drastically underpowered during slow-motion.

B) Cooldown timer compensationInvoke delays are multiplied by Time.timeScale so they fire after the correct real duration rather than the scaled duration:

// PlayerAttack.cs
attackReady = false;
Invoke(nameof(ResetAttackReady), attackCD * Time.timeScale);

// PlayerThrow.cs
Invoke(nameof(ResetThrow), throwCD * Time.timeScale);

// PlayerTeleport.cs
Invoke(nameof(ResetTeleport), teleportCD * Time.timeScale

Invoke uses scaled time internally. If Time.timeScale is 0.2 and you Invoke with a 1-second delay, it fires after 5 real-world seconds — far too long. Multiplying by Time.timeScale corrects this: 1.0 * 0.2 = 0.2 scaled seconds, which is 1 real second.

C) Movement speed compensation — wall run speed and move speed are divided by Time.timeScale in the movement state handler so the player moves at a consistent perceived speed:

moveSpeed = wallRunSpeed / Time.timeScale;
moveSpeed = sprintSpeed / Time.timeScale

D) VFX playback rate correction — the slice VFX playRate is explicitly set before playing so the animation runs at wall-clock speed regardless of timescale:

sliceVFX.playRate = 1 / Time.timeScale;
sliceVFX.Play

Without this, the slash animation plays in slow-motion alongside everything else, which looks wrong — the visual effect should register as instant from the player's perspective even in bullet time.

Copyright © 2026, Andy Pang. All rights reserved.

Copyright © 2026, Andy Pang. All rights reserved.

Copyright © 2026, Andy Pang. All rights reserved.