
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.
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:
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:
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:
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:
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.
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:
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:
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:
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.
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:
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.timeScaleto compensate.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.unscaledDeltaTimeandInvokewithTime.timeScalemultiplication.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.timeScaleand drive coroutines withTime.deltaTime / Time.timeScale.









