🎮 JamSepticEye Game Jam 2025

Deadtective

Spirit Detective Puzzle Game Built in 3 Days

Game Jam Experience

Deadtective was created for the JackSepticEye Game Jam (Jamsepticeye), one of the largest and most prestigious community game jams, with thousands of participants worldwide competing to create games around Jack's signature humor and energy. Participating in this massive event was an incredible opportunity to showcase our skills, work under intense time pressure, and be part of a global creative community.

In this supernatural puzzle game, you play as a dead detective investigating your own murder. Toggle between the living and spirit worlds to uncover clues, solve puzzles, and piece together what happened. As Lead Programmer, I architected the entire code architecture, implementing the dual-world mechanic, inventory system, drag-and-drop interactions, and puzzle logic, all within the 3-day time constraint.

Technical Highlights

Dual-World System

Toggle between living and spirit realms with different interactions

Dynamic Inventory

Drag-and-drop item management with UI integration

Object-Oriented Design

Inheritance-based interactable system (Draggable, Toggleable)

Puzzle Logic System

Multi-clue win condition with progress tracking

Advanced Input Handling

Unity's new Input System with raycast-based interactions

3-Day Development

Rapid prototyping and agile feature implementation

Code Showcase

Spirit World Toggle System

The core mechanic of Deadtective: switching between living and spirit worlds. The SpiritSwitch class manages multiple room states, dynamically shows/hides objects based on the current world, tracks the limited number of switches allowed, and updates the environment (skybox, lighting, available interactions). This system creates strategic depth as players must carefully choose when to switch realms.

SpiritSwitch. cs - Dual World Management

using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;

public class SpiritSwitch : MonoBehaviour
{
    [HideInInspector]
    public int numSwitches = 0;
    public int maxSwitches = 7;  // Limited switches adds strategic depth

    public SpriteRenderer backgroundPanel;

    // Different skyboxes for each room in each world
    public Sprite bedroomLiving;
    public Sprite bedroomSpirit;
    public Sprite kitchenLiving;
    public Sprite kitchenSpirit;
    public Sprite bathroomLiving;
    public Sprite bathroomSpirit;
    public Sprite livingRoomLiving;
    public Sprite livingRoomSpirit;

    public GameObject examineButton;
    public GameObject losePanel;

    private Sprite[][] rooms;

    // Separate lists for living and spirit world objects per room
    public List livingRoomItems;
    public List livingRoomSpiritItems;
    public List kitchenItems;
    public List kitchenSpiritItems;
    public List bedroomItems;
    public List bedroomSpiritItems;
    public List bathroomItems;
    public List bathroomSpiritItems;

    private int currentRoomIndex = 0;
    private bool inSpiritWorld = false;

    void Start()
    {
        // Initialize 2D array of room sprites [room][world]
        rooms = new Sprite[][]
        {
            new Sprite[] { livingRoomLiving, livingRoomSpirit },
            new Sprite[] { kitchenLiving, kitchenSpirit },
            new Sprite[] { bedroomLiving, bedroomSpirit },
            new Sprite[] { bathroomLiving, bathroomSpirit }
        };
        
        // Start in living world
        SetRoom(currentRoomIndex);
    }

    public void SwitchWorlds()
    {
        if (numSwitches >= maxSwitches)
        {
            LoseGame();  // Ran out of switches! 
            return;
        }

        inSpiritWorld = !inSpiritWorld;
        numSwitches++;

        // Update skybox for current room and world state
        backgroundPanel.sprite = rooms[currentRoomIndex][inSpiritWorld ? 1 : 0];

        // Show/hide appropriate objects for this world
        UpdateVisibleObjects();
    }

    public void SetRoom(int roomIndex)
    {
        currentRoomIndex = roomIndex;
        
        // Update background for new room
        backgroundPanel.sprite = rooms[currentRoomIndex][inSpiritWorld ? 1 : 0];
        
        UpdateVisibleObjects();
    }

    private void UpdateVisibleObjects()
    {
        // Hide all room objects first
        HideAllRooms();

        // Show only the current room's appropriate world objects
        switch (currentRoomIndex)
        {
            case 0: // Living Room
                SetActiveItems(inSpiritWorld ? livingRoomSpiritItems : livingRoomItems, true);
                break;
            case 1: // Kitchen
                SetActiveItems(inSpiritWorld ? kitchenSpiritItems : kitchenItems, true);
                break;
            case 2: // Bedroom
                SetActiveItems(inSpiritWorld ? bedroomSpiritItems : bedroomItems, true);
                break;
            case 3: // Bathroom
                SetActiveItems(inSpiritWorld ? bathroomSpiritItems : bathroomItems, true);
                break;
        }
    }

    private void HideAllRooms()
    {
        SetActiveItems(livingRoomItems, false);
        SetActiveItems(livingRoomSpiritItems, false);
        SetActiveItems(kitchenItems, false);
        SetActiveItems(kitchenSpiritItems, false);
        SetActiveItems(bedroomItems, false);
        SetActiveItems(bedroomSpiritItems, false);
        SetActiveItems(bathroomItems, false);
        SetActiveItems(bathroomSpiritItems, false);
    }

    private void LoseGame()
    {
        Time.timeScale = 0;
        losePanel.SetActive(true);
    }

    private void SetActiveItems(List items, bool isActive)
    {
        if (items == null) return;
        foreach (var item in items)
        {
            if (item != null)
                item.SetActive(isActive);
        }
    }

    public void RemoveItemFromList(GameObject item)
    {
        livingRoomItems?. Remove(item);
        livingRoomSpiritItems?.Remove(item);
        kitchenItems?.Remove(item);
        kitchenSpiritItems?.Remove(item);
        bedroomItems?.Remove(item);
        bedroomSpiritItems?. Remove(item);
        bathroomItems?.Remove(item);
        bathroomSpiritItems?.Remove(item);
    }

    public void AddItemToList(GameObject item, bool isSpiritItem)
    {
        // Re-add item to appropriate list when dropped back in world
        List targetList = null;
        
        switch (currentRoomIndex)
        {
            case 0:
                targetList = isSpiritItem ? livingRoomSpiritItems : livingRoomItems;
                break;
            case 1:
                targetList = isSpiritItem ? kitchenSpiritItems : kitchenItems;
                break;
            case 2:
                targetList = isSpiritItem ? bedroomSpiritItems : bedroomItems;
                break;
            case 3:
                targetList = isSpiritItem ? bathroomSpiritItems : bathroomItems;
                break;
        }
        
        targetList?.Add(item);
    }
}
                        

Dynamic Inventory System

Manages collected items with automatic UI updates. Items are scaled when added to the inventory panel, removed from world object lists, and restored when dropped back into the world. Integrates with the Spirit Switch system to track items across both realms.

Inventory.cs - Item Management

using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;

public class Inventory : MonoBehaviour
{
    public List items;

    [Header("UI Elements")]
    public HorizontalLayoutGroup inventoryUI;
    public float itemScale = 50f;

    private SpiritSwitch spiritSwitch;

    private void Awake()
    {
        spiritSwitch = FindAnyObjectByType();
    }

    public bool HasItem(GameObject item)
    {
        return items.Contains(item);
    }

    public void AddItem(GameObject item)
    {
        items.Add(item);

        // Remove from world object lists
        spiritSwitch. RemoveItemFromList(item);

        // Add the item to the inventory UI
        if (inventoryUI != null)
        {
            // Set as child of inventory panel
            item.transform.SetParent(inventoryUI.transform, false);

            // Scale down to fit in UI
            item.transform.localScale *= itemScale;
        }
    }

    public void RemoveItem(GameObject item)
    {
        items.Remove(item);
        
        // Remove from inventory UI
        if (inventoryUI != null)
        {
            item.transform.SetParent(null);

            // Reset scale when returned to world
            item.transform. localScale /= itemScale;
        }
    }
}
                        

Object-Oriented Interactable Architecture

Built a flexible parent-child class hierarchy for all interactable objects. DraggableObject provides drag-and-drop functionality with collision detection and interaction logic. Child classes (Key, Knife, Battery, etc.) override AdjustableFunction() to implement specific behaviors. This OOP design enabled rapid iteration; we could create new puzzle objects in minutes during the jam!

DraggableObject.cs - Base Draggable Class

using UnityEngine;
using System.Collections.Generic;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class DraggableObject : MonoBehaviour
{
    [Header("Tags of Interactable Objects")]
    public List interactableObjectTags = new List();

    private bool isDragging = false;
    private GameObject interactingObject = null;
    private Inventory inventory;
    private GraphicRaycaster uiRaycaster;
    private ClickController clickController;

    void Awake()
    {
        inventory = FindAnyObjectByType();
        uiRaycaster = FindAnyObjectByType();
        interactableObjectTags.Add("Inventory");
        clickController = FindAnyObjectByType();   
    }
        
    void Start()
    {
        if (inventory. HasItem(gameObject))
        {
            inventory.RemoveItem(gameObject);
        }
    }

    void OnBeginDrag()
    {
        isDragging = true;

        if (interactingObject != null)
        {
            interactingObject = null;
        }
        if (inventory.HasItem(gameObject))
        {
            inventory.RemoveItem(gameObject);
        }
    }

    public virtual void OnEndDrag()
    {
        isDragging = false;

        if (interactingObject != null)
        {
            if (interactingObject.CompareTag("Inventory"))
            {
                if (! inventory.HasItem(gameObject))
                {
                    inventory. AddItem(gameObject);
                }
                return;
            }
            // Call specific interaction for this object type
            AdjustableFunction(interactingObject);
        }
        else
        {
            // Check if dropped on UI element
            var results = new List();
            uiRaycaster. Raycast(new PointerEventData(EventSystem. current) 
                { position = clickController.currentMousePosition }, results);
            
            foreach (var result in results)
            {
                if (interactableObjectTags.Contains(result.gameObject.tag))
                {
                   AdjustableFunction(result.gameObject);
                   break;
                }
            }
            
            // Default:  add back to inventory
            if (! inventory.HasItem(gameObject))
            {
                inventory.AddItem(gameObject);
            }
        }
        interactingObject = null;
    }

    public virtual void OnTriggerEnter2D(Collider2D other)
    {
        if (CanInteractWith(other. gameObject))
        {
            interactingObject = other.gameObject;
        }
    }

    public virtual bool CanInteractWith(GameObject other)
    {
        return isDragging && interactableObjectTags.Contains(other. tag);
    }

    public virtual void OnCollisionExit2D(Collision2D other)
    {
        if (other.gameObject == interactingObject)
        {
            interactingObject = null;
        }
    }

    // Override this in child classes for specific interactions
    public virtual void AdjustableFunction(GameObject other)
    {
        // Base implementation does nothing
    }
}
                        
Key.cs - Specific Draggable Implementation

using UnityEngine;

public class Key : DraggableObject
{
    // Use with Drawer object
    public override void AdjustableFunction(GameObject other)
    {
        var drawer = other. GetComponent();
        drawer?.Unlock();
        Destroy(gameObject);  // Key is consumed
    }
}
                        
Knife.cs - Multi-Purpose Draggable

using UnityEngine;

public class Knife : DraggableObject
{
    public bool isExamined;
    
    public override void AdjustableFunction(GameObject other)
    {
        // Can be examined or used on body
        if (other.CompareTag("ExamineButton"))
        {
            isExamined = true;
        }
        
        var body = other.GetComponent();
        if (body != null)
        {
            body. Unlock();
            body.Toggle();
            Destroy(gameObject);
        }
    }
    
    public void Examine() => isExamined = true;
}
                        
Battery.cs - Item Combination Logic

using UnityEngine;

public class Battery : DraggableObject
{
    // Use on Remote to power it
    public override void AdjustableFunction(GameObject other)
    {
        other.GetComponent()?.TurnOn();
        Destroy(gameObject);
    }
}
                        

Toggleable Object System

Parallel hierarchy for objects that can be clicked to toggle states (doors, drawers, etc.). Supports locked states with visual feedback, sprite swapping on toggle, and unlock conditions. Child classes like Shelf add custom logic (e.g., requiring 3 books before revealing a clue).

ToggleableObject.cs - Base Toggleable Class

using UnityEngine;

public class ToggleableObject :  MonoBehaviour
{
    [Header("Toggle Settings")]
    public bool isLocked;
    public bool isToggled = false;

    [Header("Sprites")]
    public Sprite toggledSprite;
    public Sprite untoggledSprite;

    [Header("Locked Indicator")]
    public GameObject lockedIndicator;
    public float errorDuration = 0.5f;
    
    private SpriteRenderer spriteRenderer;

    public void Start()
    {
        spriteRenderer = GetComponent();
        if (spriteRenderer != null)
            spriteRenderer.sprite = isToggled ? toggledSprite :  untoggledSprite;
    }

    virtual public void Toggle()
    {
        if (! isLocked)
        {
            isToggled = !isToggled;
            if (spriteRenderer != null)
                spriteRenderer.sprite = isToggled ? toggledSprite : untoggledSprite;
        }
        else
        {
            // Flash locked indicator for feedback
            if (lockedIndicator != null)
            {
                lockedIndicator.SetActive(true);
                Invoke("HideLockedIndicator", errorDuration);
            }
        }
    }

    private void HideLockedIndicator()
    {
        if (lockedIndicator != null)
        {
            lockedIndicator. SetActive(false);
        }
    }

    virtual public void Unlock()
    {
        isLocked = false;
    }
}
                        
Shelf. cs - Custom Toggle Logic

using UnityEngine;
using System.Collections.Generic;

public class Shelf : ToggleableObject
{
    private List books = new List();
    public bool isFilled;

    public void AddBook(GameObject other)
    {
        books. Add(other);

        // Requires all 3 books to reveal hidden clue
        if (books. Count == 3 && !isFilled)
        {
            isFilled = true;
            Toggle();  // Reveal the hidden message! 
        }
    }
}
                        

Puzzle Win Condition System

The GameManager tracks progress across multiple clues and puzzle objects. The CheckWin() function ensures players have examined all critical evidence before revealing the solution. This creates a satisfying "aha!" moment when all pieces come together.

GameManager.cs - Win Condition Tracking

using UnityEngine;

public class GameManager : MonoBehaviour
{
    public GameObject winPanel;
    
    // References to all puzzle objects
    private Shelf shelf;
    private Sheets sheets;
    private Body body;
    private Knife knife;
    private Window window;
    private TV tv;
    private Rug rug;
    private Cabinet cabinet;
    private Fridge fridge;
    private Tub tub;

    private void Awake()
    {
        // Cache references to all interactable objects
        shelf = FindAnyObjectByType();
        sheets = FindAnyObjectByType();
        body = FindAnyObjectByType();
        knife = FindAnyObjectByType();
        window = FindAnyObjectByType();
        tv = FindAnyObjectByType();
        rug = FindAnyObjectByType();
        cabinet = FindAnyObjectByType();
        fridge = FindAnyObjectByType();
        tub = FindAnyObjectByType();
    }

    public void CheckWin()
    {
        // All clues must be discovered to win
        if (shelf.isFilled && 
            sheets.isToggled && 
            body.isToggled && 
            knife. isExamined && 
            window.isToggled && 
            tv.isToggled && 
            rug. isToggled && 
            cabinet.isToggled && 
            fridge.isToggled && 
            tub.isToggled)
        {
            winPanel.SetActive(true);
            Time.timeScale = 0f;  // Pause game to show victory! 
        }
    }
}
                        

Advanced Input System

Uses Unity's new Input System with raycasting for both world objects and UI elements. Handles drag offsets, layer masks for clickable objects, and distinguishes between draggable vs. toggleable interactions. This system was crucial for supporting both desktop and mobile platforms during the jam.

ClickController.cs - Input & Raycast Management

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections.Generic;

public class ClickController : MonoBehaviour
{
    private LayerMask clickableLayer;
    private Camera mainCamera;
    private GameObject draggedObject;
    private Vector3 offset;

    [HideInInspector]
    public Vector2 currentMousePosition;
    private bool isDragging = false;

    private Inventory inventory;
    private GraphicRaycaster uiRaycaster;

    void Awake()
    {
        mainCamera = Camera.main;
        inventory = FindAnyObjectByType();
    }

    void Start()
    {
        clickableLayer = LayerMask.GetMask("DraggableObjects");
        uiRaycaster = FindAnyObjectByType();
    }

    // Called by Unity's Input System
    public void OnPoint(UnityEngine.InputSystem.InputValue value)
    {
        currentMousePosition = value.Get();
    }

    // Called by Unity's Input System
    public void OnClick(UnityEngine.InputSystem. InputValue value)
    {
        bool pressed = value.isPressed;
        if (pressed)
        {
            TryBeginDrag();
        }
        else
        {
            EndDrag();
        }
    }

    void Update()
    {
        // Update dragged object position every frame
        if (isDragging && draggedObject != null)
        {
            Vector3 mouseWorld = mainCamera.ScreenToWorldPoint(currentMousePosition);
            mouseWorld.z = draggedObject.transform.position.z;
            draggedObject.transform.position = mouseWorld + offset;
        }
    }

    void TryBeginDrag()
    {
        // Raycast to detect clicked objects
        Vector3 mouseWorld = mainCamera. ScreenToWorldPoint(currentMousePosition);
        Vector2 mouseWorld2D = new Vector2(mouseWorld.x, mouseWorld.y);

        RaycastHit2D hit = Physics2D.Raycast(mouseWorld2D, Vector2.zero, 
                                              Mathf.Infinity, clickableLayer);
        
        if (hit.collider != null)
        {
            // Handle toggleable vs draggable objects
            if (hit.collider.gameObject.GetComponent() == null && 
                hit.collider. gameObject.GetComponent() != null)
            {
                hit.collider.gameObject.GetComponent().Toggle();
                return;
            }

            // Begin dragging
            draggedObject = hit.collider.gameObject;
            draggedObject.SendMessage("OnBeginDrag", SendMessageOptions.DontRequireReceiver);
            
            offset = draggedObject.transform.position - 
                     mainCamera.ScreenToWorldPoint(new Vector3(currentMousePosition.x, 
                                                               currentMousePosition.y, 
                                                               mainCamera.WorldToScreenPoint(
                                                                   draggedObject.transform.position).z));
            isDragging = true;
        }
        else
        {
            // Check UI elements
            List results = new List();
            uiRaycaster. Raycast(new PointerEventData(EventSystem.current) 
                { position = currentMousePosition }, results);

            foreach (var result in results)
            {
                if (result.gameObject.GetComponent() != null)
                {
                    draggedObject = result.gameObject;
                    draggedObject. SendMessage("OnBeginDrag", SendMessageOptions.DontRequireReceiver);
                    offset = Vector3.zero;
                    isDragging = true;
                    return;
                }
            }
        }
    }

    void EndDrag()
    {
        if (draggedObject != null)
        {
            draggedObject.SendMessage("OnEndDrag", SendMessageOptions.DontRequireReceiver);
        }
        draggedObject = null;
        isDragging = false;
    }
}
                        

Team Credits

🎬
Jadyn Englett Producer
🎨
Olin Britt-Tores Character & Map Art
🖌️
Ash Cho Chia Yuen UI Art
🧩
Samuel Drastal Level Design & Camera Programming
🏰
Wesley Yates Level Design

Developer Reflection

Participating in the JackSepticEye Game Jam was a career highlight: competing alongside thousands of developers worldwide pushed us to deliver our absolute best under intense pressure. The 3-day constraint forced me to architect clean, extensible systems from the start. The dual-world mechanic became the core of everything: every decision had to account for two parallel realities. Looking back, I'm incredibly proud of how we transformed a simple concept into a polished puzzle experience with strategic depth. This jam taught me that great code architecture isn't about complexity; it's about building flexible foundations that let designers create magic!