← back to home

Astral Debt

v1.1

A psychological horror corridor experience.

Engine Unity
Language C#
Platform Windows
Store Itch.io
Genre Horror
Other Playable
Other GMTK26
Released July 2026
PLAYABLE DEMO / MEDIA
ABOUT THIS PROJECT

Astral Debt is a short, atmospheric psychological horror game developed for the **GMTK Game Jam 2026**. You find yourself trapped in a surreal, shifting corridor where reality bends between dimensions. As you push forward, the environment changes, testing your sanity and pushing you toward an inescapable truth. FEATURES Surreal Atmosphere: A claustrophobic hallway where dimensions shift around you. Minimalist UI: Clean, immersive experience with visual environmental cues instead of clutter. Psychological Tension: A horror mechanics that keeps you on edge. CONTROLS [WASD] : Move [Ctrl] : Crouch [Space] : Jump [Right Click] : Zoom [Q] : Switch Dimension [F] : Flashlight [E] : Interact (Can Use On Some Walls For Clear) ---------------------------------------- "Shift to the astral realm to seek the truth; those within will show you the way." CREDITS Developed By Kadir Sayir

SCREENSHOTS
VERSION HISTORY
v1.1 July 2026

First Version

CODE SNIPPETS
DimensionManager.cs
using UnityEngine;
using System.Collections;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.UI;
using TMPro;

public class DimensionManager : MonoBehaviour
{
    public static DimensionManager Instance;

    public DimensionAudioManager audioManager;

    [Header("Parallel Room Parts (Must match in order!)")]
    public GameObject[] realParts;
    public GameObject[] astralParts;

    [Header("Inventory / Keys")]
    public int playerKeys = 0;

    [Header("Dimension Shift Movement")]
    [SerializeField] private float forwardStepAmount = 1.5f;
    [HideInInspector] public bool lockSanityAtZero = false;

    [Header("Transition Settings")]
    public KeyCode switchKey = KeyCode.Q;
    public float whitenDuration = 0.2f;
    public float transitionCooldown = 1.0f;
    private float lastTransitionTime = -999f;
    private bool isTransitioning = false;

    [Header("Corridor Auto-Shift Settings")]
    private Coroutine corridorShiftRoutine = null;

    [Header("UI Warnings")]
    public TextMeshProUGUI warningText;
    private Coroutine hideWarningRoutine;

    [Header("Post-Processing Volumes")]
    public Volume globalVolume;
    private Bloom bloom;
    private Vignette vignette;
    private ColorAdjustments colorAdjustments;

    [Header("References")]
    public Transform playerTransform;
    public Transform playerCameraTransform;
    public GameObject realBodyPrefab;
    private GameObject spawnedRealBody;

    [Header("Sanity Settings")]
    public float maxSanity = 100f;
    public float currentSanity;
    public float sanityDrainRate = 5f;
    public float sanityRefillRate = 30f;
    [HideInInspector] public bool isQInputLocked = false;

    [Header("Color Thresholds")]
    public Color highSanityColor = Color.cyan;
    public Color mediumSanityColor = Color.yellow;
    public Color lowSanityColor = Color.red;

    [Header("UI References")]
    public Slider sanitySlider;
    public TextMeshProUGUI sanityText;

    [Header("Dimension State")]
    public bool isAstral = false;
    public float interactionDistance = 3f;

    private RendererData[] realData;
    private RendererData[] astralData;

    private struct RendererData
    {
        public Renderer renderer;
        public MaterialPropertyBlock propBlock;
    }

    private void Awake()
    {
        if (Instance == null) Instance = this;
        else Destroy(gameObject);
    }

    private void Start()
    {
        currentSanity = maxSanity;

        if (globalVolume != null && globalVolume.profile)
        {
            globalVolume.profile.TryGet(out bloom);
            globalVolume.profile.TryGet(out vignette);
            globalVolume.profile.TryGet(out colorAdjustments);
        }

        CacheRenderers();
        SetAllPartsState(false);

        if (sanitySlider != null)
        {
            sanitySlider.maxValue = maxSanity;
            sanitySlider.value = currentSanity;
        }
    }

    private void Update()
    {
        UpdateSanityUI();
        HandleSwitchInput();
        UpdateSanityDrainOrRefill();

        if (sanitySlider != null)
            sanitySlider.value = currentSanity;
    }

    public bool IsAstral() => isAstral;

    public void ModifySanity(float amount)
    {
        currentSanity = Mathf.Clamp(currentSanity + amount, 0, maxSanity);
    }

    public void ToggleDimension()
    {
        if (isTransitioning) return;

        if (isAstral)
        {
            audioManager.SwitchToNormalDimension();
            StartCoroutine(SmoothReturnToBodyRoutine());
        }
        else
        {
            audioManager.SwitchToAstralDimension();
            StartCoroutine(CombinedTransitionRoutine());
        }
    }

    public void UpdateCorridorShifting(float intensity)
    {
        float targetInterval = Mathf.Lerp(3.0f, 0.2f, intensity);

        if (corridorShiftRoutine == null)
            corridorShiftRoutine = StartCoroutine(CorridorShiftLoop(targetInterval));
    }

    public void StopAutomaticShifting()
    {
        if (corridorShiftRoutine != null)
        {
            StopCoroutine(corridorShiftRoutine);
            corridorShiftRoutine = null;
        }

        ResetToNormalParts();
    }

    public void ShowTimedWarning(string message, float duration)
    {
        if (warningText == null) return;

        if (hideWarningRoutine != null)
        {
            StopCoroutine(hideWarningRoutine);
            hideWarningRoutine = null;
        }

        warningText.gameObject.SetActive(true);
        warningText.text = message;
        hideWarningRoutine = StartCoroutine(HideWarningAfterSeconds(duration));
    }

    private void UpdateSanityUI()
    {
        if (sanityText == null) return;

        sanityText.text = Mathf.RoundToInt(currentSanity).ToString();

        float sanityPercentage = currentSanity / maxSanity;
        if (sanityPercentage > 0.6f) sanityText.color = highSanityColor;
        else if (sanityPercentage > 0.3f) sanityText.color = mediumSanityColor;
        else sanityText.color = lowSanityColor;
    }

    private void HandleSwitchInput()
    {
        if (!Input.GetKeyDown(switchKey)) return;

        if (isQInputLocked)
        {
            ShowTimedWarning("Your mind is paralyzed... You cannot escape.", 1.5f);
            return;
        }

        float checkDistance = forwardStepAmount + 0.5f;
        Vector3 rayOrigin = playerTransform.position + Vector3.up * 1f;
        forwardStepAmount = Physics.Raycast(rayOrigin, playerTransform.forward, out _, checkDistance) ? 0f : 1.5f;

        if (!isAstral && currentSanity < 40f)
        {
            ShowTimedWarning("Low Sanity! Requires at least 40 sanity.", 2f);
            return;
        }

        if (Time.time < lastTransitionTime + transitionCooldown)
        {
            ShowTimedWarning("Too fast! Please wait.", 1.5f);
            return;
        }

        lastTransitionTime = Time.time;
        ToggleDimension();
    }

    private void UpdateSanityDrainOrRefill()
    {
        if (isAstral)
        {
            currentSanity -= sanityDrainRate * Time.deltaTime;
            if (currentSanity <= 0)
            {
                currentSanity = 0;
                ForcedReturnToBody();
            }

            bool shouldShowReturnWarning = currentSanity <= 40f && warningText != null &&
                hideWarningRoutine == null &&
                (string.IsNullOrEmpty(warningText.text) || warningText.text == "Return to Body!");

            if (shouldShowReturnWarning)
            {
                warningText.gameObject.SetActive(true);
                warningText.text = "Return to Body!";
            }
        }
        else
        {
            if (lockSanityAtZero)
            {
                currentSanity = 0f;
            }
            else if (corridorShiftRoutine == null && currentSanity < maxSanity)
            {
                currentSanity = Mathf.Clamp(currentSanity + sanityRefillRate * Time.deltaTime, 0, maxSanity);
            }

            bool shouldHideReturnWarning = warningText != null && warningText.text == "Return to Body!" &&
                (currentSanity > 40f || !isAstral);

            if (shouldHideReturnWarning)
            {
                if (hideWarningRoutine != null)
                {
                    StopCoroutine(hideWarningRoutine);
                    hideWarningRoutine = null;
                }
                warningText.text = "";
                warningText.gameObject.SetActive(false);
            }
        }
    }

    private IEnumerator CorridorShiftLoop(float interval)
    {
        bool toggleState = false;

        while (true)
        {
            yield return new WaitForSeconds(interval);
            toggleState = !toggleState;

            int count = Mathf.Min(realParts.Length, astralParts.Length);
            for (int i = 0; i < count; i++)
            {
                SetRealPartActive(i, !toggleState);
                SetAstralPartActive(i, toggleState);
            }

            float intensity = 1f - (currentSanity / maxSanity);
            interval = Mathf.Lerp(3.0f, 0.2f, intensity);
        }
    }

    private void ResetToNormalParts()
    {
        int count = Mathf.Min(realParts.Length, astralParts.Length);
        for (int i = 0; i < count; i++)
        {
            SetRealPartActive(i, true);
            SetAstralPartActive(i, false);
        }
    }

    private void SetAllPartsState(bool astralState)
    {
        isAstral = astralState;

        int count = Mathf.Min(realParts.Length, astralParts.Length);
        for (int i = 0; i < count; i++)
        {
            SetRealPartActive(i, !astralState);
            SetAstralPartActive(i, astralState);
        }

        ResetColors();
    }

    private void SetRealPartActive(int i, bool showReal)
    {
        if (!realParts[i]) return;

        if (!showReal)
        {
            realParts[i].SetActive(false);
            return;
        }

        InteractableWall wall = realParts[i].GetComponent<InteractableWall>();
        if (wall != null && wall.isBroken)
        {
            realParts[i].SetActive(false);
            if (wall.underlyingWall != null) wall.underlyingWall.SetActive(false);
        }
        else
        {
            realParts[i].SetActive(true);
        }
    }

    private void SetAstralPartActive(int i, bool showAstral)
    {
        if (!astralParts[i]) return;

        if (!showAstral)
        {
            astralParts[i].SetActive(false);
            return;
        }

        bool isBroken = false;
        if (realParts[i] != null)
        {
            InteractableWall wall = realParts[i].GetComponent<InteractableWall>();
            if (wall != null && wall.isBroken)
            {
                isBroken = true;
                if (wall.underlyingWall != null) wall.underlyingWall.SetActive(false);
            }
        }

        astralParts[i].SetActive(!isBroken);
    }

    private IEnumerator HideWarningAfterSeconds(float delay)
    {
        yield return new WaitForSeconds(delay);
        warningText.text = "";
        warningText.gameObject.SetActive(false);
        hideWarningRoutine = null;
    }

    private IEnumerator SmoothReturnToBodyRoutine()
    {
        isTransitioning = true;
        float duration = 0.4f;
        float elapsed = 0f;

        Vector3 startPos = playerTransform.position;
        Quaternion startRot = playerTransform.rotation;

        Vector3 targetPos = spawnedRealBody != null ? spawnedRealBody.transform.position + Vector3.up * 1.6f : startPos;
        Quaternion targetRot = spawnedRealBody != null ? spawnedRealBody.transform.rotation : startRot;

        while (elapsed < duration)
        {
            elapsed += Time.deltaTime;
            float t = Mathf.SmoothStep(0f, 1f, elapsed / duration);

            playerTransform.position = Vector3.Lerp(startPos, targetPos, t);
            playerTransform.rotation = Quaternion.Slerp(startRot, targetRot, t);

            yield return null;
        }

        if (spawnedRealBody != null)
            Destroy(spawnedRealBody);

        isAstral = false;
        ResetToNormalParts();

        if (bloom != null) bloom.intensity.value = 2f;
        if (vignette != null) vignette.intensity.value = 0.2f;
        if (colorAdjustments != null) colorAdjustments.colorFilter.value = Color.white;

        ResetColors();
        isTransitioning = false;
    }

    private void ForcedReturnToBody()
    {
        if (!isTransitioning)
            ToggleDimension();
    }

    private void CacheRenderers()
    {
        realData = new RendererData[realParts.Length];
        for (int i = 0; i < realParts.Length; i++)
        {
            if (realParts[i])
            {
                realData[i] = new RendererData
                {
                    renderer = realParts[i].GetComponentInChildren<Renderer>(),
                    propBlock = new MaterialPropertyBlock()
                };
            }
        }

        astralData = new RendererData[astralParts.Length];
        for (int i = 0; i < astralParts.Length; i++)
        {
            if (astralParts[i])
            {
                astralData[i] = new RendererData
                {
                    renderer = astralParts[i].GetComponentInChildren<Renderer>(),
                    propBlock = new MaterialPropertyBlock()
                };
            }
        }
    }

    private IEnumerator CombinedTransitionRoutine()
    {
        isTransitioning = true;
        int count = Mathf.Min(realParts.Length, astralParts.Length);

        if (!isAstral && realBodyPrefab != null)
        {
            spawnedRealBody = Instantiate(realBodyPrefab, playerTransform.position, playerTransform.rotation);
            Rigidbody rb = spawnedRealBody.GetComponent<Rigidbody>();
            if (rb != null)
            {
                rb.useGravity = true;
                rb.isKinematic = false;
                rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ;
            }
        }

        yield return StepPlayerForward();
        yield return FadeToWhite(count);

        isAstral = !isAstral;
        for (int i = 0; i < count; i++)
        {
            SetRealPartActive(i, !isAstral);
            SetAstralPartActive(i, isAstral);
        }

        yield return FadeToDimensionColors(count);

        ResetColors();
        isTransitioning = false;
    }

    private IEnumerator StepPlayerForward()
    {
        if (playerTransform == null) yield break;

        Vector3 startPos = playerTransform.position;
        Vector3 targetPos = startPos + (playerTransform.forward * forwardStepAmount);
        float stepDuration = 0.2f;
        float stepElapsed = 0f;

        while (stepElapsed < stepDuration)
        {
            stepElapsed += Time.deltaTime;
            float t = Mathf.SmoothStep(0f, 1f, stepElapsed / stepDuration);
            playerTransform.position = Vector3.Lerp(startPos, targetPos, t);
            yield return null;
        }

        playerTransform.position = targetPos;
    }

    private IEnumerator FadeToWhite(int count)
    {
        float timer = 0f;
        while (timer < whitenDuration)
        {
            timer += Time.deltaTime;
            float t = timer / whitenDuration;

            Color pureWhite = Color.Lerp(Color.black, Color.white, t);
            Color emissionBright = pureWhite * 0.5f;

            if (bloom != null) bloom.intensity.value = Mathf.Lerp(2f, 4f, t);

            ApplyColorToActiveParts(count, pureWhite, emissionBright);
            yield return null;
        }
    }

    private IEnumerator FadeToDimensionColors(int count)
    {
        float startBloom = bloom != null ? bloom.intensity.value : 2f;
        float targetBloom = isAstral ? 3.5f : 2f;

        float startVignette = vignette != null ? vignette.intensity.value : 0.2f;
        float targetVignette = isAstral ? 0.45f : 0.2f;

        Color startFilter = colorAdjustments != null ? colorAdjustments.colorFilter.value : Color.white;
        Color targetFilter = isAstral ? new Color(0.8f, 0.9f, 1f) : Color.white;

        Color targetBaseColor = isAstral ? new Color(0.85f, 0.9f, 0.95f) : Color.white;
        Color targetEmissionColor = isAstral ? new Color(0.02f, 0.02f, 0.05f) : Color.black;

        float timer = 0f;
        while (timer < whitenDuration)
        {
            timer += Time.deltaTime;
            float t = timer / whitenDuration;

            if (colorAdjustments != null) colorAdjustments.colorFilter.value = Color.Lerp(startFilter, targetFilter, t);
            if (vignette != null) vignette.intensity.value = Mathf.Lerp(startVignette, targetVignette, t);
            if (bloom != null) bloom.intensity.value = Mathf.Lerp(startBloom, targetBloom, t);

            Color fadeColor = Color.Lerp(Color.white, targetBaseColor, t);
            Color emissionFade = Color.Lerp(Color.white, targetEmissionColor, t);
            ApplyColorToActiveParts(count, fadeColor, emissionFade);

            yield return null;
        }

        if (bloom != null) bloom.intensity.value = targetBloom;
        if (vignette != null) vignette.intensity.value = targetVignette;
    }

    private void ApplyColorToActiveParts(int count, Color baseColor, Color emissionColor)
    {
        for (int i = 0; i < count; i++)
        {
            GameObject activeObj = isAstral ? astralParts[i] : realParts[i];
            if (!activeObj || !activeObj.activeSelf) continue;

            Renderer rend = isAstral ? astralData[i].renderer : realData[i].renderer;
            MaterialPropertyBlock block = isAstral ? astralData[i].propBlock : realData[i].propBlock;
            if (!rend) continue;

            rend.GetPropertyBlock(block);
            block.SetColor("_Color", baseColor);
            block.SetColor("_BaseColor", baseColor);
            block.SetColor("_EmissionColor", emissionColor);
            rend.SetPropertyBlock(block);
        }
    }

    private void ResetColors()
    {
        int count = Mathf.Min(realParts.Length, astralParts.Length);
        Color targetBaseColor = isAstral ? new Color(0.6f, 0.7f, 0.8f) : Color.white;
        Color targetEmissionColor = isAstral ? new Color(0.1f, 0.1f, 0.2f) : Color.black;

        for (int i = 0; i < count; i++)
        {
            ApplyRendererColor(realData, i, targetBaseColor, targetEmissionColor);
            ApplyRendererColor(astralData, i, targetBaseColor, targetEmissionColor);
        }
    }

    private void ApplyRendererColor(RendererData[] data, int i, Color baseColor, Color emissionColor)
    {
        if (!data[i].renderer) return;

        data[i].renderer.GetPropertyBlock(data[i].propBlock);
        data[i].propBlock.SetColor("_Color", baseColor);
        data[i].propBlock.SetColor("_BaseColor", baseColor);
        data[i].propBlock.SetColor("_EmissionColor", emissionColor);
        data[i].renderer.SetPropertyBlock(data[i].propBlock);
    }
}