EtherLocked

First-person 3D Puzzle
Timeline 2024 – 2025
My role Developer, UI/UX Designer
Team Rebeka Forgach, Mate Forgach, Eliza Drazniece

Overview

EtherLocked was our first major game development project, created over a year by a team of four. As the developer, I managed the Unity and GitHub setup, implemented core gameplay mechanics, and maintained the project's technical foundation. The project received an Honorable Mention at our university's Demo Day, recognizing our teamwork and effort.

Narrative

Illustration of Orion's body lying still while astral projecting

Orion, a 17-year-old fascinated by dreams and the paranormal, experiments with astral projection after frequent episodes of sleep paralysis. During one attempt, he successfully leaves his body – but can't return.

As panic sets in, his once-familiar room twists into a sinister place filled with shadowy entities drawn to his defenseless body. To survive, Orion must uncover clues hidden within the distorted space and confront the fears and self-doubt keeping him trapped.

Hand holding a polaroid photo of two friends

Gameplay loop

Players continuously explore, investigate their surroundings, and solve puzzles to progress through the story. Each puzzle provides new clues that lead to further exploration until the final objective is reached.

Gameplay loop diagram — Exploration, Finding clues, Solving puzzles

Controls

The player system is built using Unity's Starter Assets FirstPersonController and Cinemachine for movement and camera control. World interaction is based on a center-screen raycast system, allowing players to interact with doors, drawers, mirrors, and other objects.

Input Name Action
WASD keys or arrow keys WASD or arrows Move around
Mouse Mouse Look around / rotate camera
Left mouse button Left mouse button Object interaction
Space bar Space Toggle rotation mode, when holding an object. Player movement stops, cursor unlocks, and the object can be freely rotated. Press Space again to exit.
Left mouse button and drag Left mouse button and drag Rotates the object, when holding it
Escape key Escape Toggle pause menu

Puzzles

Rather than existing independently, each puzzle builds upon the previous one. Players use information gathered from earlier challenges to unlock new puzzles, creating a clear sense of progression throughout the game.

Puzzles diagram

Diary

The diary is the game's main storytelling and progression system. Players unlock four memories containing clues about specific times and locations, while collecting tickets that track their puzzle progress.

EtherLocked diary annotated

Structure flow

Diary system structure flow diagram

Diary.cs – main controller

Diary.cs serves as the central manager for the diary system. It maintains references to all diary pages and ticket indicators through two Inspector-assigned List<GameObject> collections:

  • pages – one GameObject for each diary page.
  • tickets – one GameObject for each ticket indicator, mapped to the corresponding page by index.

The system uses a fixed page index (0–3) to identify each memory. References are configured manually in the Unity Inspector rather than through a separate data model.

Runtime state

The controller maintains the following runtime state:

  • currentPageIndex – currently active diary page.
  • isDiaryOpen – current diary visibility state.
  • newTickets (HashSet<int>) – stores unlocked pages that have not yet been viewed, enabling unread ticket indicators.

Core functionality

The controller exposes the following core methods:

  • UnlockTicket(int pageIndex) Activates the corresponding ticket, registers it as unread, and enables its notification state.
  • ToggleDiary() Opens or closes the diary UI, manages cursor visibility and locking, pauses/resumes gameplay (Time.timeScale), temporarily disables player interaction systems, and restores the previously viewed page.
  • GoToPage(int pageIndex) Switches the active diary page, clears the unread state for that page, disables its notification effect, and updates saved progress.

Memory unlock flow

Each wall clock puzzle contains a ClockControl component with an assigned ticketPageIndex.

Once the puzzle reaches its correct solution:

  • the completion sequence is triggered,
  • success feedback is played,
  • Diary.UnlockTicket(ticketPageIndex) is invoked to unlock the associated diary page.

The project also contains an additional unlock call inside Clock.OnPuzzleCompleted(). In the current implementation this field remains at its default value, making the call redundant without affecting gameplay.

Diary interaction

Player interaction with the diary is handled by ClickDiary.cs.

The script continuously performs a center-screen raycast to detect the diary object, validates interaction distance, and controls the outline and interaction prompt. When the player interacts with the diary, the request is forwarded to Diary.ToggleDiary(), provided the pause menu is not currently active.

Navigation between diary pages is handled through UI buttons, each storing a page index and invoking GoToPage() when selected.

Visual feedback

Unread ticket notifications are driven by the reusable BlinkEffect component.

Rather than containing diary-specific logic, the component simply animates either a UI element or a renderer. Diary.cs controls when the effect is enabled or disabled based on each ticket's read state.

Persistence

The diary uses Unity's PlayerPrefs for lightweight persistence.

The following values are stored:

  • LastDiaryPage – restores the previously opened page when the diary is reopened.
  • NewDiaryTickets – stores unread ticket indices as a serialized comma-separated string, which is reconstructed into a HashSet<int> during initialization.

Clocks

Players solve clock puzzles by matching the correct hour and minute based on clues discovered in the diary. Successfully solving a clock rewards progression by unlocking a diary ticket.

EtherLocked clock puzzle annotated

Interaction with clock

State & fields

VARIABLE(S) TYPE PURPOSE
playerCamera, interactRange Transform float Used for raycast interaction and interactive distance
outline Outline Highlights the clock when the player is looking at it
clockAnimator Animator Controls transitions into and out of the clock puzzle view
defaultUI, crosshairUI, lockButtonUI GameObject Manages gameplay UI during interaction transitions
diaryTicketPageUnlock int Specifies which diary ticket is unlocked after solving the puzzle
playSuccessSfx, successSfxPlayed bool Controls one-time playback of the completion sound
isLookingAtClock, isInteractingWithClock bool Tracks player focus and interaction state

HandleRaycastInteraction()

Looking at the clock
  1. Detect if the player is looking at the clock.
  2. Check that the player is close enough to interact.
  3. Highlight the clock with an outline.
  4. Disable all blinking visual cues.
Interacting with the clock
  1. Enter puzzle mode.
  2. Play the clock zoom/open animation.
  3. Hide the gameplay UI and disable player movement.
  4. Unlock the mouse cursor for interaction.
Looking away
  1. Disable the outline.
  2. Reset the "looking at clock" state.

OnBackButtonPressed()

  1. Exit puzzle mode.
  2. Play the closing animation.
  3. Hide the back button and crosshair.
  4. Restore player controls.

OnPuzzleCompleted()

  1. Play the success sound (only once).
  2. Unlock the puzzle's diary ticket.
  3. Exit puzzle mode.

Clock system

State & fields

VARIABLE(S) TYPE PURPOSE
correctHour, correctMinute int Correct solution for the puzzle
rotated[] int[] Stores the player's selected hour and minute
correctCombination[] int[] Stores the target solution
clockScript Clock Controls puzzle interaction and completion
clockAnimator Animator Plays the completion animation
diary, ticketPageIndex Diary int Unlock the activated diary ticket
objectToReveal GameObject Reveals the next progression object
clockID string Unique identifier for the clock

Start() – when game starts

  1. Clear any previous clock values.
  2. Set the correct hour and minute.
  3. Initialize the clock so it's ready to be solved.

UnlockClock()

  1. Play the completion animation.
  2. Mark the clock as solved.
  3. Disable further interaction with the clock.
  4. Restore the gameplay UI and mouse cursor.
  5. Unlock the corresponding diary ticket.

OnArmRotated()

  1. Update the selected hour or minute.
  2. Compare the current time with the correct solution.
  3. If the time is correct, complete the puzzle.

Clock arm

State & fields

VARIABLE(S) TYPE PURPOSE
pivotRoot Transform Rotates the hour hand around its pivot
clockControl ClockControl Sends the selected value to the puzzle controller
rotationAnglePerClick float Rotation applied for each click
totalSteps int Number of available positions
initialValue int Starting hour value
outline Outline Highlights the hand when hovered
constrainedRotation bool Prevents multiple rotations at once
numberShown int Current hour selection

Awake()

  1. Disable the outline.
  2. Set the initial hour value.

OnMouseEnter()

  1. Highlight the hour hand.

OnMouseExit()

  1. Remove the highlight.

OnMouseDown()

  1. Start the rotation animation.

OnMouseUp()

  1. Rotate the hour hand.
  2. Update the selected value.
  3. Notify ClockControl of the new hour.

Padlock

The padlock combines the solutions from all four clock puzzles into a single digit code. Once the correct combination is entered the drawer can be opened.

EtherLocked padlock puzzle annotated

Padlock

State & fields

VARIABLE(S) TYPE PURPOSE
playerCamera, interactRange Transform float Detect player interaction using raycasts
outline Outline Highlights the padlock when it can be interacted with
padlockAnimator Animator Controls transitions into and out of puzzle mode
defaultCrosshairUI, backButtonUI GameObject Switch between gameplay UI and puzzle UI
isLookingAtPadlock, isInteractingWithPadlock bool Track the current interaction state
IsPadlockInteractionActive static bool Global flag indicating that the padlock puzzle is currently active

HandleRaycastInteraction()

Hovering
  1. Detect the padlock.
  2. Highlight it with an outline.
Interaction
  1. Enter padlock mode.
  2. Play the opening animation.
  3. Hide the gameplay UI, display back button and cursor.
Exit hover
  1. Remove the outline.
  2. Return the padlock to its default state.

OnBackButtonPressed()

  1. Exit padlock mode.
  2. Play the closing animation.
  3. Restore the gameplay UI.
  4. Restore player controls.

Rotate wheels

State & fields

VARIABLE(S) TYPE PURPOSE
couroutineAllowed bool Prevents the wheel from rotating multiple times simultaneously
numberShown int Stores the currently selected digit
outline Outline Highlights the wheel when hovered
Rotated Action<string, int> Broadcasts the updated wheel value to the lock controller

RotateWheel()

  1. Prevent additional rotations while the animation is playing.
  2. Rotate the wheel to the next number.
  3. Update the displayed digit.
  4. Wrap from 9 → 0 when necessary.
  5. Notify the padlock controller of the new value.

Mirror

The mirror acts as the player's primary goal. As mirror pieces are discovered, players return to the mirror to place them into the correct slots, gradually rebuilding it until the final scene is unlocked.

EtherLocked mirror puzzle annotated

Missing mirror piece

State & fields

VARIABLE(S) TYPE PURPOSE
outline Outline Highlights the socket when the mirror is targeted.
holdPos Transform Reference point for snapping objects.
correctHeldObject GameObject The mirror piece accepted by this socket.
Manager MissingMirrorManager Reference to the central mirror manager.

TrySnapHeldObject()

  1. Check if the player is holding a mirror piece.
  2. Attempt to match it with the correct socket.
  3. If valid, snap the piece into place.

SnapThisHere()

  1. Remove the mirror piece from the player's hand.
  2. Snap it into its correct position.
  3. Disable physics and further interaction.
  4. Notify the manager that the piece has been placed.
  5. Remove the empty socket placeholder.

Mirror

State & fields

VARIABLE(S) TYPE PURPOSE
AllPieces List<MissingMirrorPiece> Stores every remaining mirror socket.
mirrorPieces List<MissingMirrorPiece> Tracks pieces still required to complete the mirror.
byCorrect Dictionary<GameObject, MissingMirrorPiece> Maps each mirror piece to its correct socket.
playerHold Transform Reference to the player's held object.
cam, interactDistance Camera float Detect interaction with the mirror.
interactKey KeyCode Input used to place a mirror piece.
endCreditsSceneName string Scene loaded after all pieces are placed.

Awake()

  1. Initialize the mirror manager.
  2. Find every mirror socket in the scene.
  3. Build the mirror piece lookup table.

Update()

Hovering
  1. Detect when the player is looking at the mirror.
  2. Highlight all available mirror sockets.
Interaction
  1. Check if the player is holding a mirror piece.
  2. Attempt to place it into the correct socket.
Exit Hover
  1. Remove all socket highlights.

RefreshPiecesAndMap()

  1. Find every mirror socket.
  2. Register each socket with the manager.
  3. Create a lookup between mirror pieces and sockets.

TrySnapFromAnyHeld()

  1. Check whether the player is holding a mirror piece.
  2. Attempt to place the held object.

TrySnapSpecific()

  1. Find the correct socket for the held mirror piece.
  2. Place it if a valid match exists.

NotifyPiecePlaced()

  1. Remove the completed socket from the active list.
  2. Update the remaining piece count.
  3. Load the ending scene once all pieces have been placed.

Next project

Mystical playing cards