unity · c# · co-op

Deep Dive Repair

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.

Unity C# Local co-op Game feel
GDD (Game Design Document) GOD (Game Overview Document)
01

Character controller

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.

Controller mapping for two local players

Input · New Input System

Two players, hot-pluggable controllers

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.

  • Clean separation between input and gameplay logic through action maps and callbacks
  • Each player is paired to a specific device, avoiding any input mix-up between the two robots
  • Controller reconnection: a pad unplugged mid-game is detected and re-paired to the same player, so a disconnection never breaks the run

Physics · Jump

A jump that feels right

Rather than relying on Unity's default rigidbody behaviour, the jump is driven by hand-tuned physics to get a precise, satisfying arc.

  • Controlled rise and fall, tuned by hand for good game feel
  • Apex and landing weighted so the jump reads clearly and stays easy to aim
  • Consistent height regardless of frame rate thanks to fixed-step physics

Physics · Jump forgiveness

Coyote time & jump buffering

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.

  • Coyote time: a short grace window after leaving a ledge during which the jump still registers, so a slightly late press never feels punishing
  • Jump buffering: a press made just before landing is remembered and fires the instant the robot touches the ground
  • Both windows tuned to stay invisible — they forgive the player without making the controller feel loose

Physics · Throw

Throwing your partner

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.

  • Launch direction and force derived from aim and charge for predictable arcs
  • Thrown body handed back to the physics simulation cleanly, with the right velocity
  • Tuned to be useful for traversal without feeling floaty or random

Physics · Throw

Preventing unrealistic throws

Making sure throws follow believable physics and can't take impossible trajectories — no clipping through thin walls or glitching into geometry.

  • Check the trajectory with a raycast from the player to their reticle, over the distance between them: if a wall sits in between, the throw is blocked, preventing the thrown item from glitching into nearby geometry or being thrown out of bounds
See the code · grab.cs (aiming)C#
Every frame, a raycast goes from the player to their aiming reticle: if a wall sits in between, the throw is blocked.
// 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

Custom 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.

  • Per-character gravity tuned independently from the rest of the physics world
  • Stronger fall gravity for a snappier, less floaty descent
  • Single shared knob to balance jump, throw, and fall together as one coherent feel
02

The Task system

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.

See the code · task architectureC#
Each task registers with the TaskManager; the player always interacts with the nearest task, and every task type overrides two methods.
// 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);
    }
}
LevelZone

System · Task spawning

Managing 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.

  • Zone boundaries: each task is assigned a zone from its on-screen position
  • Player detection: the system tracks which zone the players are not in
  • Targeted spawn: a new task appears in a free spot, forcing the players to move to reach it
  • Twofold pressure: constant movement, plus the submarine's health gauge creeping toward zero
See the code · zone-based spawningC#
The screen is split into 12 areas; a sleeping task only spawns outside both players' areas.
// 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

Hammer the button

On reaching the task, the player must repeatedly press the X button to complete it.

  • Mechanic that's easy to reproduce
  • Introduces a rhythm-and-speed challenge tied to the number of presses required
See the code · Task_SpamC#
Each press restarts a short time window: too slow between two presses and the count resets. (The visual and sound effects around it were added by the team.)
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

Two-player sync

This task requires both players to be present at the same time. To complete it, they must press the indicated button simultaneously.

  • The most complex task mechanic to pull off
  • Relies on coordination and communication between players
  • Offers a significant reward on success
See the code · TaskSynchroC#
A shared counter tracks the players engaged; the task only completes at 2.
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

Screw with the joystick

The player must perform a rotating motion with the joystick to simulate the act of screwing.

  • Intuitive mechanic that's easy to pick up
  • Introduces a speed challenge based on how fast the motion is performed
See the code · Task_RouletteC#
The stick angle is converted to degrees and accumulated frame by frame, so only real full turns in the right direction count. (The screw animation and effects around it were added by the team.)
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

Hit at the right moment

The player must press the button at the exact moment a visual or audio cue indicates, in order to complete the task.

  • Simple mechanic that's easy to reproduce
  • Introduces a skill dimension based on timing precision
See the code · HitTiimingTaskC#
A cursor bounces back and forth in the gauge; a second press checks whether it is inside the zone, with a tunable tolerance.
// 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

Aim and recharge

The player must throw a battery and aim correctly to recharge the submarine's power system.

  • Mechanic that's easy to reproduce
  • Introduces a precision challenge through aiming
  • Punishing failure: a significant loss if the throw misses
See the code · Task_PowerC#
While the player holds the interaction a timer runs; after timeRange, every light in the submarine gets its original intensity back.
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

Take aim

The player must throw a nearby object and aim correctly at the pipe to free the submarine crew in danger.

  • Mechanic that's easy to reproduce
  • Introduces a sense of urgency to the tasks

Task · Washing machine

Charge the ball

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.

  • Pays out more, but the ball is locked away while it charges: a real tactical trade-off
  • Tests precision: throwing from afar saves time, at the risk of missing
  • The ball's spin speeds up then slows down, following a sine curve
See the code · Task_CharBallScriptC#
The real ball is hidden and swapped for a spinning copy; when the timer ends, the gauge fills up and the ball is handed back.
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);
    }
}
Mission selection interface In-world computer, powered on

Secondary Task · Menu · Game state

The computer: launching a level

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.

  • Fully controller-driven menu: navigate the level list and confirm a choice entirely with the gamepad, no mouse required
  • Selecting a mission switches the game state — from free-roam exploration to an active level — loading the right scene and setup
  • UI feedback (highlight, selection, confirmation) kept clear and readable so both players always know what's about to launch
03

Around the game

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

Level intro dialogues

At the start of every stage, a short dialogue scene — inspired by classic JRPG dialogue boxes — sets the tone before the players take control.

  • Traditional dialogue box with text printed letter by letter
  • Characters speaking in turn, advanced at the players' own pace
  • Adds context and personality without slowing the co-op action
Victory screen Defeat screen

Game state · Win / loss

Win, loss & instant restart

Each run ends in one of two game states, both resolving into a clear end screen so players immediately understand the outcome.

  • Win: reaching the safe harbour triggers the victory state
  • Loss: the submarine's health gauge reaching zero triggers defeat
  • Instant restart: a single input relaunches the game, straight back into the tutorial

Want to play it?

Grab a partner and dive in: the game is playable on itch.io.

Play / itch.io Other projects