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
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!