← All work

Gameplay Engineering · Multiplayer Survival

Project-M

Cooperative reactor repair built around shared interactions and unpredictable failure states.

Project-M gameplay
Role
Gameplay Engineer Intern
Tools
Unity (C#), GitHub
Team
Team of 11
Duration
5 Months

The challenge

How do several players repair the same reactor without fighting over shared interactables?

My contribution

  • Shared ownership and locking state machine
  • Randomized reactor-component failures and tool-gated repair
  • ScriptableObject item and interaction-prompt definitions

Overview

Project background

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.

MoreLess

Contributions

All project contributions

  • 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

Deep dive 1Preventing race-condition when multiple players reach for the same interactable

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.

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.

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

Deep dive 2Breaking reactor interactables randomly

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.

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.

Implementation code
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 3Slowly rewind a cancelled hold-interaction instead of snapping

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.

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.

Implementation code
_becomingTrueState.AddTransition(_trueCancelledState, HasCancelledHold, true);
_trueCancelledState.AddTransition(_falseState, HasFullyCancelled, true
Implementation code
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