unity · c# · co-op
Gameplay Programmer · Game Designer · UI Designer
Play co-op as two little robots tasked with rescuing submarines in distress.
Complete a series of small tasks to repair the submarine's various problems, all while keeping its engine running so it can reach a safe harbour.
Use the objects at your disposal… and even your partner! Throw each other around to move through the submarine more easily and overcome obstacles.
Local co-op was a deliberate design choice: sharing the same screen turns coordination into something physical and immediate. Even movement is cooperative — players can throw one another across the submarine, so each robot is both a teammate and a tool.
On the programming side, I built the character controller: the input layer driving two local players, and the custom physics behind everything the robots do — moving, jumping, and throwing. The goal was a controller that felt responsive and predictable, while staying robust enough for a couch session where controllers get unplugged and plugged back in.
Input · New Input System
The controller is built on Unity's New Input System, with a dedicated action map per player so both robots can be driven independently on the same machine.
Physics · Jump
Rather than relying on Unity's default rigidbody behaviour, the jump is driven by hand-tuned physics to get a precise, satisfying arc.
Physics · Jump forgiveness
To keep the jump forgiving and responsive, two small timing windows smooth over the gap between what the player intends and what the physics allows.
Physics · Throw
A key co-op mechanic: a player can grab and throw the other robot (or objects) across the submarine. The throw is fully physics-driven so the trajectory stays readable and repeatable.
Physics · Throw
Making sure throws follow believable physics and can't take impossible trajectories — no clipping through thin walls or glitching into geometry.
// Update(): is there a wall between the player and the reticle?
Vector3 origin = gameObject.transform.position;
Vector3 direction = (visor.transform.position - origin).normalized;
maxDistance = Vector3.Distance(gameObject.transform.position, visor.transform.position);
if (Physics.Raycast(origin, direction, out RaycastHit hitInfo, maxDistance))
{
if (hitInfo.transform.gameObject.layer == 3) // level geometry
{
canThrow = false;
}
}
else
{
Debug.DrawLine(origin, origin + direction * maxDistance, Color.green);
canThrow = true;
}
// … and the throw itself simply refuses to fire
public void Throw()
{
if (!canThrow) return;
// …
}
Physics · Gravity
Each character runs on a custom gravity model instead of Unity's global gravity, giving full control over how the robots fall and how weighty they feel.
The tasks are the core of the gameplay and therefore held a central place in the game's production. They are the player's only means of action, serving both as a progression mechanic and as a survival system.
Their design had to meet several requirements: be quick to understand, play out smoothly, and allow a natural flow in order to maintain a good gameplay pace. Designing each task required particular attention, as it directly shapes player engagement and the overall balance of the experience.
We wanted a repetitive activity that never became boring. Each task therefore had to take the player a little time — never an instant completion — in order to play against the overall match timer that slowly ticks away. We ended up with 7 different tasks and 1 secondary task.
// Task.cs — base class of every task (press, duo, screwdriver, timing…)
public class Task : MonoBehaviour
{
[SerializeField] private float playerCheckDimensions;
[SerializeField] private bool playerInRange;
void Start()
{
TaskManager.GetInstance().taskList.Add(this);
}
public void OnDestroy()
{
TaskManager.GetInstance()?.taskList.Remove(this);
}
public bool playerInRangeMethod(Controller2D player)
{
playerInRange = Vector2.Distance(player.transform.position, transform.position) < playerCheckDimensions;
return playerInRange;
}
public virtual void StartInteractingTask(Controller2D player) { }
public virtual void StopInteractingTask(Controller2D player) { }
}
// TaskManager.cs — returns the nearest task in range of a given player
public Task CheckTaskinList(Controller2D player)
{
Task taskToReturn = null;
float taskDistanceToPlayer = 0;
foreach (Task task in taskList)
{
if (task.playerInRangeMethod(player))
{
float nearestTaskToPlayer = Vector2.Distance(task.transform.position, player.transform.position);
if (taskToReturn == null || nearestTaskToPlayer < taskDistanceToPlayer)
{
taskToReturn = task;
taskDistanceToPlayer = nearestTaskToPlayer;
}
}
}
return taskToReturn;
}
// Controller2D.cs — the interact input just asks the manager
public void Interacting()
{
Interactingtask = TaskManager.GetInstance().CheckTaskinList(this);
if (Interactingtask & isGrounded)
{
Interactingtask.StartInteractingTask(this);
}
}
System · Task spawning
Tasks disappear once completed — the real challenge lay in their respawn. To keep players moving, we limited the number of tasks on screen and spawned them based on the players' positions, splitting the level into zones.
// TaskSpawnerManager.cs — carried by every task and every player
public enum ScreenArea
{
TopLeft, TopMiddleLeft, TopMiddleRight, TopRight,
MiddleLeftTop, MiddleLeftBottom, MiddleRightTop, MiddleRightBottom,
BottomLeft, BottomMiddleLeft, BottomMiddleRight, BottomRight,
disable
};
public ScreenArea currentArea;
// TaskSpawner.cs — wakes up a task far from both players
public void ChooseRandomTask()
{
if (sleepingTask.Count >= 1)
{
if (DialogueCharacters && DialogueCharacters.isFinished)
{
int _index = UnityEngine.Random.Range(0, sleepingTask.Count);
var taskManager = sleepingTask[_index].GetComponent<TaskSpawnerManager>();
if (taskManager.currentArea != player1.currentArea
&& taskManager.currentArea != player2.currentArea)
{
// the top row only opens later in the level
if (!topTaskCanSpawn)
{
if (taskManager.currentArea != TaskSpawnerManager.ScreenArea.TopLeft
&& taskManager.currentArea != TaskSpawnerManager.ScreenArea.TopRight
&& taskManager.currentArea != TaskSpawnerManager.ScreenArea.TopMiddleLeft
&& taskManager.currentArea != TaskSpawnerManager.ScreenArea.TopMiddleRight)
{
sleepingTask[_index].SetActive(true);
onScreenTask.Add(sleepingTask[_index]);
sleepingTask.Remove(sleepingTask[_index]);
}
}
else
{
sleepingTask[_index].SetActive(true);
onScreenTask.Add(sleepingTask[_index]);
sleepingTask.Remove(sleepingTask[_index]);
}
}
}
}
}
Task · Press
On reaching the task, the player must repeatedly press the X button to complete it.
public override void StartInteractingTask(Controller2D player)
{
if (isFinished == false)
{
_player = player;
StartSpamming();
spamPlayer++; // every press counts…
timerSpam = 0; // …and restarts the window
player._isRepairing = true;
player._isActive = false;
}
}
public void Update()
{
// …
if (spamStarted)
{
timerSpam += Time.deltaTime;
}
// too slow between two presses → the count resets
if (timerSpam >= timeRange)
{
spamPlayer = 0;
spamStarted = false;
timerSpam = 0;
_player._isRepairing = false;
_player._isActive = true;
spriteRenderer.sprite = spriteState0;
}
if (spamPlayer >= spamGoal)
{
_isDestroyed = true; // → validation, VFX and gauge refill
_player._isActive = true;
spamPlayer = 0;
}
// …
}
Task · Duo
This task requires both players to be present at the same time. To complete it, they must press the indicated button simultaneously.
public class TaskSynchro : Task
{
public bool isInteracting;
public Slider slider;
public TaskSpawner taskSpawner;
public float _sliderValue;
private void Update()
{
// both players are holding the task → validate it and fill the gauge
if (CountingSynchro.GetInstance().countingSynchro == 2)
{
taskSpawner.DeleteTask(gameObject);
slider.value += _sliderValue;
gameObject.SetActive(false);
}
}
public override void StartInteractingTask(Controller2D player)
{
if (!isInteracting)
{
CountingSynchro.GetInstance().countingSynchro++;
isInteracting = true;
player._isRepairing = true;
player._isActive = false; // the player is locked in place while holding
}
}
public override void StopInteractingTask(Controller2D player)
{
CountingSynchro.GetInstance().countingSynchro--;
isInteracting = false;
player._isRepairing = false;
player._isActive = true;
}
}
Task · Screwdriver
The player must perform a rotating motion with the joystick to simulate the act of screwing.
public override void StartInteractingTask(Controller2D player)
{
isInteracting = true;
Grab = player.GetComponentInChildren<grab>(); // reads the player's aim stick
playerSelected = player;
}
private void Update()
{
if (Grab != null)
{
angleOfAim = Grab.RotateVector2;
currentAngle = Mathf.Atan2(angleOfAim.y, angleOfAim.x) * Mathf.Rad2Deg;
// DeltaAngle handles the -180/180 wrap: a full turn always adds up to 360
float angleDifference = Mathf.DeltaAngle(previousAngle, currentAngle);
totalRotation += angleDifference;
// clockwise is negative: turning the wrong way winds the count back
if (totalRotation <= -360f * TourAttendus)
{
scoreRotation++;
}
// … validation, screw animation, VFX
previousAngle = currentAngle;
}
}
Task · Timing
The player must press the button at the exact moment a visual or audio cue indicates, in order to complete the task.
// Update(): the cursor bounces between both ends of the gauge
if (TaskStarted)
{
timer += Time.deltaTime;
if (timer < timerRange)
{
visor.transform.Translate(Vector3.right * speed * direction * Time.deltaTime);
if (visor.transform.position.x >= maxX)
{
direction = -1; // move left
}
else if (visor.transform.position.x <= minX)
{
direction = 1; // move right
}
}
else
{
EndTask(); // too late: the task closes
}
}
// second press on the task → is the cursor inside the zone?
void CheckAlignment()
{
float visorX = visor.transform.position.x;
float pointX = pointToGet.transform.position.x;
if (timer > 0.35f) // ignores the press that opened the task
{
if (Mathf.Abs(visorX - pointX) <= tolerance)
{
slider.value += _sliderValue;
TaskOnCooldown = true;
EndTask();
TaskStarted = false;
_player._isActive = true;
_player = null;
}
else
{
TaskOnCooldown = true;
EndTask();
TaskStarted = false;
_player._isActive = true;
_player = null;
}
}
}
Task · Battery
The player must throw a battery and aim correctly to recharge the submarine's power system.
public class Task_Power : Task
{
public bool isInteracting;
public float timeRange = 3f;
public float timerPower = 0f;
public GameObject _lights;
private int i;
private void Start()
{
_lights = GameObject.Find("lights");
i = 0;
}
void Update()
{
if (isInteracting)
{
timerPower += Time.deltaTime;
}
if (timerPower >= timeRange)
{
// power is back: restore each light to the intensity stored when it went off
foreach (Transform t in _lights.GetComponentInChildren<Transform>())
{
t.GetComponent<Light>().intensity = _lights.GetComponent<Power_Off>()._LightListIntensity[i];
i++;
}
timerPower = 0f;
}
}
public override void StartInteractingTask(Controller2D player)
{
isInteracting = true;
}
public override void StopInteractingTask(Controller2D player)
{
isInteracting = false;
timerPower = 0; // letting go resets the charge
}
}
Task · 6
The player must throw a nearby object and aim correctly at the pipe to free the submarine crew in danger.
Task · Washing machine
Inspired by a washing machine: the player throws the ball into the machine, where it spins for a few seconds before popping back out. While it charges, the player is free to move around.
private void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Ball"))
{
other.gameObject.SetActive(false); // the real ball is "inside" the machine
isCharging = true;
FakeBall.SetActive(true);
playerBall = other.gameObject;
}
}
private void Update()
{
if (isCharging)
{
timer += Time.deltaTime;
}
if (timer >= timeRange)
{
timer = 0;
sliderGlobal.value += sliderValue;
FakeBall.SetActive(false);
FakeBall.transform.rotation = initialRotation;
playerBall.SetActive(true); // ball handed back
gameObject.SetActive(false);
taskSpawner.DeleteTask(gameObject);
isCharging = false;
}
else if (isCharging)
{
BallSpining();
}
}
public void BallSpining()
{
// elapsed time as a percentage of the total duration
float elapsedTime = timer / timeRange;
// smooth spin curve (ease-in, ease-out)
float spinSpeed = maxSpinSpeed * Mathf.Sin(Mathf.PI * elapsedTime);
if (FakeBall != null)
{
FakeBall.transform.Rotate(Vector3.up, spinSpeed * Time.deltaTime);
}
}
Secondary Task · Menu · Game state
The in-world computer is the player's gateway into a level: they walk up to it, browse the available missions, and select the one they want to play. Beyond the fiction, this was a piece of UI and menu-navigation work tied to a real game-state change.
Beyond the core gameplay loop, a game is also everything that wraps around it: how a run is introduced, how it ends, and what makes players want to start another one. I handled this layer around the run — the narrative framing, the win/loss flow, and the replayability.
Together, these systems give every session a clear beginning, a clear outcome, and a reason to come back for one more attempt.
Narrative · Level intro
At the start of every stage, a short dialogue scene — inspired by classic JRPG dialogue boxes — sets the tone before the players take control.
Game state · Win / loss
Each run ends in one of two game states, both resolving into a clear end screen so players immediately understand the outcome.
Grab a partner and dive in: the game is playable on itch.io.