A cozy 3D narrative game built in Unity over 12 weeks — a calm, story-driven experience focused on atmosphere and player comfort.
The problem: the game needed many different objects — NPCs, minigame zones, hold-to-use items — to be interactable in different ways (instant vs hold, looked-at vs walked-into), without a central manager hardcoding every type. My approach: I designed a small set of interaction interfaces (IInteractable, IInteractWithE, IInteractWithF, IZoneInteractable). A single InteractionManager raycasts each frame, resolves whatever interface the hit object implements, and dispatches on type — so a new interactable just implements the right interface and works, with zero changes to the manager. Result: a decoupled, extensible interaction system driving dialogue, minigame entry and hold-to-interact pickups, with a filling-ring hold UI and FMOD feedback.
Sole programmer on a 2-person team — I wrote all of the game's code; my teammate handled level design and art in-engine. Beyond programming, I designed the three minigames and owned the audio and UI end-to-end. My systems: player physics, all modular game systems, the three minigames (design + implementation), full UI, audio (FMOD), camera work, and the cloud mount mechanic.
Objects opt into interaction styles by implementing an interface; one manager raycasts and dispatches on type, so a new interactable needs zero manager changes.
public interface IInteractable
{
void Interact();
string GetInteractionKey();
string GetInteractionAction();
bool isInteractable { get; }
float InteractionRange { get; }
}
public interface IInteractWithF { void Interact(); }
public interface IZoneInteractable { void OnZoneEnter(); void OnZoneExit(); }
// In InteractionManager — a single raycast resolves whatever interface the object implements
var interactable = hit.collider.GetComponentInParent<IInteractable>();
if (interactable != null && interactable.isInteractable)
{
currentInteractable = interactable;
SetInteractionPrompt(interactable.GetInteractionKey(),
interactable.GetInteractionAction());
}
Tiles fall along a lerped path between two anchors; the player locks each one, with a punch-scale pop and a completion wave for feel.
// Each row's resting spot is a lerp between two anchor transforms
private Vector3 GetRowLockPosition(int row)
{
float t = (float)row / (totalRows - 1);
return Vector3.Lerp(bridgeStart.position, bridgeEnd.position, t);
}
// Locking a tile: snap it, play SFX, pop it, advance to the next row
private void LockCurrentTile()
{
isWaitingForInput = false;
Destroy(ghostLeft);
SnapTile(activeTileLeft, currentRow);
placedTiles.Add(activeTileLeft);
AudioManager.Instance.PlaySFX(AudioManager.Instance.Events.LockBridge, bridgeStart.position);
StartCoroutine(PunchScale(activeTileLeft));
currentRow++;
SpawnNextPair();
}
// Juice — a quick scale punch that settles back
private IEnumerator PunchScale(GameObject tile)
{
Vector3 original = tile.transform.localScale;
tile.transform.localScale = original * 1.3f;
yield return new WaitForSeconds(0.18f);
tile.transform.localScale = original;
}
Scene environment and lighting
Dialogue interaction system