Under Iron Water

My second shipped title on Steam, proving the ability to execute a full production lifecycle from initial concept to final release.

Under Iron Water is a first-person adventure game developed entirely from the ground up in Unity. Serving as the sole programmer and designer, I architected the core gameplay loops, custom physics interactions, and enemy AI, managing the entire production pipeline through to a public launch. The project required balancing scalable, performant systems with responsive game feel, specifically focusing on first-person puzzle platforming and event-driven narrative integration


Core Contributions
  • Centralized First-Person Interaction: Architected a raycast system utilizing calls to safely handle both combat logic and puzzle triggers, combined with dynamic spatial calculations for VFX scaling.
  • Dynamic Event-Driven Dialogue: Engineered a custom narrative delivery coroutine featuring procedural pacing based on string length. Optimized execution using cached references and early-exit guard clauses to prevent garbage collection spikes.
  • World Scale & NavMesh Optimization: Constructed multi-elevation open environments. Managed the memory overhead of massive asset counts to ensure stable framerates and reliable AI pathfinding across first-person topography.

                            IEnumerator ConversationRoutine()
                            {
                                // Early exit guard clause reduces nesting depth
                                if (m_isTalking) yield break; 
                                
                                m_isTalking = true;
                                bool isActiveStoryNode = false;
                                string[] lines = m_talkingLines;

                                // Evaluate narrative state
                                if (m_isStory)
                                {
                                    int currentStoryPoint = GameManager.instance.GetStoryPoint();
                                    if (currentStoryPoint == storyPosition)
                                    {
                                        isActiveStoryNode = true;
                                        lines = m_storyLines;
                                    }
                                    else if (currentStoryPoint > storyPosition)
                                    {
                                        lines = m_afterLines;
                                    }
                                }

                                // Orient NPC to player (locking Y-axis to prevent tilting)
                                Vector3 playerPos = GameManager.instance.m_Player.position;
                                Vector3 lookPosition = new Vector3(playerPos.x, transform.position.y, playerPos.z);
                                transform.LookAt(lookPosition, transform.up);

                                // Utilize cached components instead of inline GetComponent calls for performance
                                m_cachedPlayerMovement.SetInConversation(true);
                                m_cachedIdleController.SetIsTalking(true);

                                if (!m_hadConvo)
                                {
                                    m_hadConvo = true;
                                    foreach (string line in lines)
                                    {
                                        SetCondition(4);
                                        
                                        // Trigger spatial audio with randomized pitch for natural voice variation
                                        Vector3 voiceOrigin = transform.position + new Vector3(0, 1.7f, 0);
                                        AudioManager.instance.PlayAtPosition(m_voice, voiceOrigin, false, AudioManager.instance.GetRandomPitch());
                                        
                                        GameManager.instance.m_GameText.SendText(line, colour);
                                        
                                        // Dynamic wait calculation based on character count
                                        yield return new WaitForSeconds(line.Length * 0.1f);
                                        
                                        SetCondition(0);
                                        yield return new WaitForSeconds(1f);
                                    }
                                    
                                    // Adjust interaction collider post-conversation
                                    UpdateColliderForPassiveState(); 
                                }
                                else
                                {
                                    // Fallback bark logic for repeated interactions
                                    string finalLine = lines[lines.Length - 1];
                                    SetCondition(4);
                                    AudioManager.instance.PlayAtPosition(m_voice, transform.position + new Vector3(0, 1.7f, 0), false, AudioManager.instance.GetRandomPitch());
                                    GameManager.instance.m_GameText.SendText(finalLine, colour);
                                    
                                    yield return new WaitForSeconds(finalLine.Length * 0.1f);
                                    SetCondition(0);
                                }

                                // Cleanup and release state locks
                                m_isTalking = false;
                                m_cachedPlayerMovement.SetInConversation(false);
                                m_cachedIdleController.SetIsTalking(false);

                                if (isActiveStoryNode)
                                {
                                    GameManager.instance.NextStoryPosition();
                                }
                            }
                            
Data Management for Screen Text: A dynamic, event-driven dialogue coroutine utilizing string-length calculation for procedural pacing, optimized with cached references and early-exit guard clauses to ensure zero garbage collection spikes during runtime.

                            void AttackDirection()
                            {
                                // Offset ray origin slightly ahead of the camera to prevent clipping
                                Vector3 rayOrigin = m_mainCamera.transform.position + (m_mainCamera.transform.rotation * new Vector3(0, 0, 2f));

                                if (Physics.Raycast(rayOrigin, m_mainCamera.transform.forward, out RaycastHit hitInfo, 100.0f, m_lightningLayerMask))
                                {
                                    if (!m_hit)
                                    {
                                        // TryGetComponent is the modern standard for avoiding GC spikes and repeated lookups
                                        if (hitInfo.collider.TryGetComponent(out Taglist tagList))
                                        {
                                            if (tagList.GetIsEnemy() && hitInfo.collider.TryGetComponent(out EnemyStats enemy))
                                            {
                                                enemy.TakeDamage(20);
                                            }
                                            else if (tagList.GetIsCrystal() && hitInfo.collider.TryGetComponent(out CrystalActivator crystal))
                                            {
                                                crystal.ActivateCrystal();
                                            }
                                        }
                                        m_hit = true;
                                    }

                                    // Dynamically scale the lightning particle shape to bridge the exact spatial distance
                                    var shape = lightning.shape;
                                    shape.scale = new Vector3(1, 1, Vector3.Distance(hitInfo.point, transform.position));

                                    // Orient the visual effect directly toward the impact normal
                                    transform.LookAt(hitInfo.point);
                                }
                            }
                            
Raycasting for Magic Mechanic: A centralized first-person interaction system utilizing TryGetComponent calls to safely handle both combat damage and puzzle triggers, combined with dynamic spatial calculations to scale particle effects to the raycast impact point.