
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
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-togglingDesigned 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 codeDesigned
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 syncImplemented 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-togglingDesigned 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 codeDesigned
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 syncImplemented 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-togglingDesigned 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 codeDesigned
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 syncImplemented 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