PROJECT-M

ROLE

Gameplay Engineer Intern

ROLE

Gameplay Engineer Intern

ROLE

Gameplay Engineer Intern

TOOL

Unity (C#), GitHub

TOOL

Unity (C#), GitHub

TOOL

Unity (C#), GitHub

TEAM

Team of 11

TEAM

Team of 11

TEAM

Team of 11

TIMELINE

5 Months

TIMELINE

5 Months

TIMELINE

5 Months

OVERVIEW

OVERVIEW

Project-M is a multiplayer, cooperative, first-person game in which players must prevent a nuclear reactor from melting down. Players explore the facility, uncovering locked hallways, while repairing reactor components to stop the meltdown and prevent radiation from spreading, all while coordinating with teammates in real time.

Project-M is a multiplayer, cooperative, first-person game in which players must prevent a nuclear reactor from melting down. Players explore the facility, uncovering locked hallways, while repairing reactor components to stop the meltdown and prevent radiation from spreading, all while coordinating with teammates in real time.

Project-M is a multiplayer, cooperative, first-person game in which players must prevent a nuclear reactor from melting down. Players explore the facility, uncovering locked hallways, while repairing reactor components to stop the meltdown and prevent radiation from spreading, all while coordinating with teammates in real time.

CONTRIBUTIONS

CONTRIBUTIONS

OVERVIEW

OVERVIEW

OVERVIEW

  • Built a shared ownership/locking state machine (ReactorInteractableBase) so multiple players can't fight over the same interactable, with minimum-ownership and lockout-cooldown timers to prevent instant re-toggling

  • Designed a reactor-component randomizer that periodically breaks interactables (pipe, valve, or fuse) while correctly handling Photon Fusion's state-authority model, including a timeout/re-roll fallback for authority requests that never resolve

  • Implemented tool-gated repair logic (crowbar-required pipe fixes, stuck-valve failure states) built on top of shared toggle/hold-button state machines rather than one-off code per interactable

  • Fixed a jarring hold-cancellation bug by adding a dedicated cancelled state that rewinds progress proportionally to how far the hold had progressed, instead of snapping instantly back to closed

  • Built a ScriptableObject-driven item system (ItemDefinitionSO, InteractionPromptDefinitionSO) letting non-programmers define new items and interaction prompts without touching code

  • Designed WorldItem's state model (World/Held/OnSurface/Inventory) as a single source of truth for pickup physics and collider behavior, keeping visual and physics state from drifting out of sync

  • Implemented Display Settings (Vsync, fullscreen, resolution), Randomize Interactables State, and the in-game Interaction HUD

  • Built a shared ownership/locking state machine (ReactorInteractableBase) so multiple players can't fight over the same interactable, with minimum-ownership and lockout-cooldown timers to prevent instant re-toggling

  • Designed a reactor-component randomizer that periodically breaks interactables (pipe, valve, or fuse) while correctly handling Photon Fusion's state-authority model, including a timeout/re-roll fallback for authority requests that never resolve

  • Implemented tool-gated repair logic (crowbar-required pipe fixes, stuck-valve failure states) built on top of shared toggle/hold-button state machines rather than one-off code per interactable

  • Fixed a jarring hold-cancellation bug by adding a dedicated cancelled state that rewinds progress proportionally to how far the hold had progressed, instead of snapping instantly back to closed

  • Built a ScriptableObject-driven item system (ItemDefinitionSO, InteractionPromptDefinitionSO) letting non-programmers define new items and interaction prompts without touching code

  • Designed WorldItem's state model (World/Held/OnSurface/Inventory) as a single source of truth for pickup physics and collider behavior, keeping visual and physics state from drifting out of sync

  • Implemented Display Settings (Vsync, fullscreen, resolution), Randomize Interactables State, and the in-game Interaction HUD

  • Built a shared ownership/locking state machine (ReactorInteractableBase) so multiple players can't fight over the same interactable, with minimum-ownership and lockout-cooldown timers to prevent instant re-toggling

  • Designed a reactor-component randomizer that periodically breaks interactables (pipe, valve, or fuse) while correctly handling Photon Fusion's state-authority model, including a timeout/re-roll fallback for authority requests that never resolve

  • Implemented tool-gated repair logic (crowbar-required pipe fixes, stuck-valve failure states) built on top of shared toggle/hold-button state machines rather than one-off code per interactable

  • Fixed a jarring hold-cancellation bug by adding a dedicated cancelled state that rewinds progress proportionally to how far the hold had progressed, instead of snapping instantly back to closed

  • Built a ScriptableObject-driven item system (ItemDefinitionSO, InteractionPromptDefinitionSO) letting non-programmers define new items and interaction prompts without touching code

  • Designed WorldItem's state model (World/Held/OnSurface/Inventory) as a single source of truth for pickup physics and collider behavior, keeping visual and physics state from drifting out of sync

  • Implemented Display Settings (Vsync, fullscreen, resolution), Randomize Interactables State, and the in-game Interaction HUD

CONTRIBUTIONS DEEP DIVE

CONTRIBUTIONS DEEP DIVE

CONTRIBUTIONS DEEP DIVE

DEEP DIVE 1: PREVENTING RACE-CONDITION WHEN MULTIPLE PLAYERS REACH FOR THE SAME INTERACTABLE

DEEP DIVE 1: PREVENTING RACE-CONDITION WHEN MULTIPLE PLAYERS REACH FOR THE SAME INTERACTABLE

Problem

Problem

Problem

In a cooperative multiplayer game, nothing stops two players from reaching for the same valve or pipe at the same moment. Without explicit handling, this risks desynced state, both players' clients believing they're interacting, or a player yanking control away from someone mid-repair.

In a cooperative multiplayer game, nothing stops two players from reaching for the same valve or pipe at the same moment. Without explicit handling, this risks desynced state, both players' clients believing they're interacting, or a player yanking control away from someone mid-repair.

In a cooperative multiplayer game, nothing stops two players from reaching for the same valve or pipe at the same moment. Without explicit handling, this risks desynced state, both players' clients believing they're interacting, or a player yanking control away from someone mid-repair.

Solution

Solution

Solution

Built a shared ownership state machine (ReactorInteractableBase) with four states, Idle, Acquiring, Owned, Releasing, that every reactor interactable inherits. A player can only begin interacting if the object is Idle; once acquired, it's locked to that player (LockedBy) until they release it. A minimum ownership duration (_minimumOwnershipSeconds) prevents a lock from being immediately released and re-grabbed the same tick, and a lockout cooldown after release (_lockoutCooldownSeconds) prevents instant re-toggling once free.

Built a shared ownership state machine (ReactorInteractableBase) with four states, Idle, Acquiring, Owned, Releasing, that every reactor interactable inherits. A player can only begin interacting if the object is Idle; once acquired, it's locked to that player (LockedBy) until they release it. A minimum ownership duration (_minimumOwnershipSeconds) prevents a lock from being immediately released and re-grabbed the same tick, and a lockout cooldown after release (_lockoutCooldownSeconds) prevents instant re-toggling once free.

Built a shared ownership state machine (ReactorInteractableBase) with four states, Idle, Acquiring, Owned, Releasing, that every reactor interactable inherits. A player can only begin interacting if the object is Idle; once acquired, it's locked to that player (LockedBy) until they release it. A minimum ownership duration (_minimumOwnershipSeconds) prevents a lock from being immediately released and re-grabbed the same tick, and a lockout cooldown after release (_lockoutCooldownSeconds) prevents instant re-toggling once free.

public virtual bool Interact(PlayerRef player, PlayerItemHandler itemHandler)
{
    if (!HasStateAuthority || !CanInteract(player, itemHandler)) return false;
    LockedBy = player;
    return _ownershipMachine.TryActivateState<OwnershipAcquiringState

public virtual bool Interact(PlayerRef player, PlayerItemHandler itemHandler)
{
    if (!HasStateAuthority || !CanInteract(player, itemHandler)) return false;
    LockedBy = player;
    return _ownershipMachine.TryActivateState<OwnershipAcquiringState

DEEP DIVE 2: BREAKING REACTOR INTERACTABLES RANDOMLY

DEEP DIVE 2: BREAKING REACTOR INTERACTABLES RANDOMLY

Problem

Problem

Problem

Reactor components need to break periodically to create ongoing pressure, but Fusion requires state authority (only one peer can modify a networked object at a time) to actually force an interactable into a broken state. If the randomizer doesn't already have authority over the chosen object, it can't just break it, it has to request authority first, wait, and handle the case where that request never resolves.

Reactor components need to break periodically to create ongoing pressure, but Fusion requires state authority (only one peer can modify a networked object at a time) to actually force an interactable into a broken state. If the randomizer doesn't already have authority over the chosen object, it can't just break it, it has to request authority first, wait, and handle the case where that request never resolves.

Reactor components need to break periodically to create ongoing pressure, but Fusion requires state authority (only one peer can modify a networked object at a time) to actually force an interactable into a broken state. If the randomizer doesn't already have authority over the chosen object, it can't just break it, it has to request authority first, wait, and handle the case where that request never resolves.

Solution

ReactorInteractablesRandomizer picks a random interactable (pipe, valve, or an available fuse slot), requests state authority on it, and re-checks on subsequent ticks whether authority was actually granted before calling ForceBreak(). If authority isn't granted within a timeout window, it gives up and re-rolls a different interactable rather than hanging indefinitely.

ReactorInteractablesRandomizer picks a random interactable (pipe, valve, or an available fuse slot), requests state authority on it, and re-checks on subsequent ticks whether authority was actually granted before calling ForceBreak(). If authority isn't granted within a timeout window, it gives up and re-rolls a different interactable rather than hanging indefinitely.

ReactorInteractablesRandomizer picks a random interactable (pipe, valve, or an available fuse slot), requests state authority on it, and re-checks on subsequent ticks whether authority was actually granted before calling ForceBreak(). If authority isn't granted within a timeout window, it gives up and re-rolls a different interactable rather than hanging indefinitely.

private void BreakRandomInteractable()
    {
        if (InteractableChosen == null)
        {
            // TODO: Maybe manually drag in all slots in the inspector into a list of reactor interactables instead
            switch (Random.Range(0, _totalInteractables))
            {
                case 0:
                    InteractableChosen = _brokenPipe;
                    break;
                case 1:
                    InteractableChosen = _reactorValve;
                    break;
                case 2:
                    InteractableChosen = _fuseBox.GetRandomWorkingSlot();

                    // If there are no eligible slots, get a new random interactable
                    if (!InteractableChosen)
                    {
                        Debug.LogWarning(
                            $"[{GetType()}] Can't break fusebox — no eligible slots. Re-rolling interactable.");
                        return;
                    }

                    break;
            }

            Debug.Log(
                $"[{GetType()}] Requesting Authority on {InteractableChosen.name}. Outputted at: {Runner.SimulationTime}");
            _hasRequestedInteractableAuthority = false;
            InteractableChosen.Object.RequestStateAuthority();
            AuthorityRequestStartedAt = Runner.SimulationTime;
            // Return to this function the very next frame when it does have state authority
            return;
        }

        if (InteractableChosen.HasStateAuthority)
        {
            Debug.Log(
                $"[{GetType()}] Has State Authority. Breaking {InteractableChosen.name}. Outputted at: {Runner.SimulationTime}");
            InteractableChosen.ForceBreak();
            IsInteractableBroken = true;
            InteractableChosen = null;
            return;
        }

        // Don't have authority yet — re-request, or give up and re-roll.
        if (Runner.SimulationTime - AuthorityRequestStartedAt > _authorityTimeout)
        {
            Debug.LogWarning(
                $"[{GetType()}] Timed out waiting for authority on {InteractableChosen.name}, re-rolling.");
            InteractableChosen = null;
            return;
        }

        InteractableChosen.Object.RequestStateAuthority

private void BreakRandomInteractable()
    {
        if (InteractableChosen == null)
        {
            // TODO: Maybe manually drag in all slots in the inspector into a list of reactor interactables instead
            switch (Random.Range(0, _totalInteractables))
            {
                case 0:
                    InteractableChosen = _brokenPipe;
                    break;
                case 1:
                    InteractableChosen = _reactorValve;
                    break;
                case 2:
                    InteractableChosen = _fuseBox.GetRandomWorkingSlot();

                    // If there are no eligible slots, get a new random interactable
                    if (!InteractableChosen)
                    {
                        Debug.LogWarning(
                            $"[{GetType()}] Can't break fusebox — no eligible slots. Re-rolling interactable.");
                        return;
                    }

                    break;
            }

            Debug.Log(
                $"[{GetType()}] Requesting Authority on {InteractableChosen.name}. Outputted at: {Runner.SimulationTime}");
            _hasRequestedInteractableAuthority = false;
            InteractableChosen.Object.RequestStateAuthority();
            AuthorityRequestStartedAt = Runner.SimulationTime;
            // Return to this function the very next frame when it does have state authority
            return;
        }

        if (InteractableChosen.HasStateAuthority)
        {
            Debug.Log(
                $"[{GetType()}] Has State Authority. Breaking {InteractableChosen.name}. Outputted at: {Runner.SimulationTime}");
            InteractableChosen.ForceBreak();
            IsInteractableBroken = true;
            InteractableChosen = null;
            return;
        }

        // Don't have authority yet — re-request, or give up and re-roll.
        if (Runner.SimulationTime - AuthorityRequestStartedAt > _authorityTimeout)
        {
            Debug.LogWarning(
                $"[{GetType()}] Timed out waiting for authority on {InteractableChosen.name}, re-rolling.");
            InteractableChosen = null;
            return;
        }

        InteractableChosen.Object.RequestStateAuthority

DEEP DIVE 3: SLOWLY REWIND A CANCELLED HOLD-INTERACTION INSTEAD OF SNAPPING

DEEP DIVE 3: SLOWLY REWIND A CANCELLED HOLD-INTERACTION INSTEAD OF SNAPPING

Problem

Problem

Problem

For hold-to-interact actions (like the reactor valve), a player can release early. A naive implementation would either finish the action anyway or snap instantly back to the start, both feel wrong, the player should see the valve visually rewind proportional to how far they got.

For hold-to-interact actions (like the reactor valve), a player can release early. A naive implementation would either finish the action anyway or snap instantly back to the start, both feel wrong, the player should see the valve visually rewind proportional to how far they got.

For hold-to-interact actions (like the reactor valve), a player can release early. A naive implementation would either finish the action anyway or snap instantly back to the start, both feel wrong, the player should see the valve visually rewind proportional to how far they got.

Solution

Added a dedicated ButtonTrueCancelledState to the state machine, sitting between the in-progress hold and the fully-closed state. On cancellation, the exact elapsed hold time is snapshotted (_cancelledAtTime = _holdDuration - HoldTimeRemaining), and the cancelled state uses that value to drive a proportional rewind, a player who cancelled near the end sees a short rewind, one who cancelled early sees a longer one, rather than every cancellation looking identical regardless of progress.

Added a dedicated ButtonTrueCancelledState to the state machine, sitting between the in-progress hold and the fully-closed state. On cancellation, the exact elapsed hold time is snapshotted (_cancelledAtTime = _holdDuration - HoldTimeRemaining), and the cancelled state uses that value to drive a proportional rewind, a player who cancelled near the end sees a short rewind, one who cancelled early sees a longer one, rather than every cancellation looking identical regardless of progress.

Added a dedicated ButtonTrueCancelledState to the state machine, sitting between the in-progress hold and the fully-closed state. On cancellation, the exact elapsed hold time is snapshotted (_cancelledAtTime = _holdDuration - HoldTimeRemaining), and the cancelled state uses that value to drive a proportional rewind, a player who cancelled near the end sees a short rewind, one who cancelled early sees a longer one, rather than every cancellation looking identical regardless of progress.

_becomingTrueState.AddTransition(_trueCancelledState, HasCancelledHold, true);
_trueCancelledState.AddTransition(_falseState, HasFullyCancelled, true

_becomingTrueState.AddTransition(_trueCancelledState, HasCancelledHold, true);
_trueCancelledState.AddTransition(_falseState, HasFullyCancelled, true

protected bool HasCancelledHold(StateBehaviour from, StateBehaviour to)
{
    if (LockedBy == PlayerRef.None)
    {
        return false;
    }

    if (
        !IsLocalPlayerInRange()
        || !Runner.TryGetInputForPlayer(LockedBy, out NetInput holdInput)
        || !holdInput.Buttons.IsSet(NetInputButton.Interact)
    )
    {
        // Snapshot elapsed hold time before Release, which triggers FixedUpdateNetwork
        // to zero HoldTimeRemaining on the next tick.
        _cancelledAtTime = _holdDuration - HoldTimeRemaining;
        Release(LockedBy);
        return true;
    }

    return false

protected bool HasCancelledHold(StateBehaviour from, StateBehaviour to)
{
    if (LockedBy == PlayerRef.None)
    {
        return false;
    }

    if (
        !IsLocalPlayerInRange()
        || !Runner.TryGetInputForPlayer(LockedBy, out NetInput holdInput)
        || !holdInput.Buttons.IsSet(NetInputButton.Interact)
    )
    {
        // Snapshot elapsed hold time before Release, which triggers FixedUpdateNetwork
        // to zero HoldTimeRemaining on the next tick.
        _cancelledAtTime = _holdDuration - HoldTimeRemaining;
        Release(LockedBy);
        return true;
    }

    return false

Copyright © 2026, Andy Pang. All rights reserved.

Copyright © 2026, Andy Pang. All rights reserved.

Copyright © 2026, Andy Pang. All rights reserved.