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
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();
}
}
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);
}
}