Campfire Cryptid
Survival Horror Minigame Collection
The Winning Experience
Campfire Cryptid took 1st place at UCF's Game Developers' Knights Summer Game Jam, competing against dozens of talented teams. This was my first game jam ever, and winning validated everything I'd learned about rapid prototyping, team coordination, and delivering under pressure. The experience was transformative: 48 hours of intense focus, creative problem-solving, and watching players genuinely enjoy something we'd built from scratch.
Project Overview
In Campfire Cryptid, you're stranded in the woods at night, desperately trying to survive until dawn. Your only lifeline is the campfire, and it's dying. Players must complete various survival minigames (fishing for food, gathering firewood, building protective effigies) while managing a constantly-depleting fire timer. If the fire goes out, the cryptid attacks! The game features escalating difficulty, time pressure mechanics, and a climactic shooting sequence when things go wrong.
As Lead Programmer, I architected the entire game's code structure, implementing the Scriptable Object-based global state system, all minigame mechanics, dynamic day/night cycles with escalating difficulty, and the transition/lose conditions that tied everything together. This jam taught me invaluable lessons about time-constrained development, feature prioritization, and rapid prototyping: skills that have become foundational to my workflow.
Technical Highlights
Scriptable Object Architecture
Centralized game state management across all scenes
Dual Timer System
Simultaneous tracking of day time and fire life
Multi-Minigame Framework
Fishing, wood gathering, effigy building, and cryptid defense
Progressive Difficulty
Days get shorter and fire depletes faster over time
Visual Feedback Systems
Dynamic UI colors, screen shake, static transitions
Scene Management
Seamless transitions between hub and minigames
Code Showcase
Centralized Game State with Scriptable Objects
The GlobalData Scriptable Object serves as the single source of truth for all game state. This architecture allows seamless data persistence across scene transitions without using DontDestroyOnLoad or singletons. Tracks dual timers (day time and fire life), minigame completion, day progression, and game over states. This pattern was crucial for a jam game with multiple scenes and no time for complex save systems!
GlobalData. cs - Scriptable Object State Management
using Unity.VisualScripting;
using UnityEngine;
[CreateAssetMenu(fileName = "GlobalData", menuName = "Scriptable Objects/GlobalData")]
public class GlobalData : ScriptableObject
{
// ===== TIME MANAGEMENT =====
public float secondsLeftBeforeNight = 1200f; // 20 minutes to start
public float secondsLeftBeforeFireDies = 120f; // 2 minutes of fire life
// ===== MINIGAME TRACKING =====
public bool[] minigamesCompleted = new bool[2];
public int[] minigameProgress = new int[2];
/*
* [0] = Fishing (must catch fish to survive)
* [1] = Effigies (protective totems)
*/
// ===== DAY PROGRESSION =====
public int currDay = 0;
public bool gameOver = false;
public bool fireOut = false;
// ===== AUDIO TOGGLE =====
public bool hasAudio = false;
// ===== FRAME-BY-FRAME TIMER UPDATES =====
public void TimeSetDown(float currentTime)
{
secondsLeftBeforeNight -= currentTime;
// Victory condition: survived the night with all tasks done
if (secondsLeftBeforeNight <= 0f &&
minigamesCompleted[0] &&
minigamesCompleted[1])
{
secondsLeftBeforeNight = 0f;
currDay++;
NextDay(); // Prepare for the next night
}
// Loss condition: time ran out without completing tasks
else if (secondsLeftBeforeNight <= 0)
{
Debug.LogWarning("Time ran out! Game Over.");
gameOver = true;
}
}
public void FireSetDown(float currentTime)
{
// Fire dies slowly (0.3x speed for tension)
if (secondsLeftBeforeFireDies > 0)
secondsLeftBeforeFireDies -= currentTime * 0.3f;
else
{
secondsLeftBeforeFireDies = 0f;
fireOut = true; // Trigger cryptid attack!
}
}
// ===== GETTERS =====
public int GetMinigamesCompletedCount()
{
int count = 0;
foreach (bool completed in minigamesCompleted)
{
if (completed) count++;
}
return count;
}
public bool IsMinigameCompleted(int index)
{
if (index < 0 || index >= minigamesCompleted.Length)
return false;
return minigamesCompleted[index];
}
// ===== SETTERS =====
public void SetMinigameCompleted(int index, bool completed)
{
if (index >= 0 && index < minigamesCompleted.Length)
minigamesCompleted[index] = completed;
}
// ===== DAY PROGRESSION LOGIC =====
public void NextDay()
{
// Days get progressively shorter (2 minutes less each night)
secondsLeftBeforeNight = 1200f - (currDay * 120f);
// Minimum 1 minute per night for extreme difficulty
if (secondsLeftBeforeNight < 60f)
{
secondsLeftBeforeNight = 60f;
}
currDay++; // Survived another night!
secondsLeftBeforeFireDies = 100f; // Fresh fire for new night
gameOver = false;
fireOut = false;
// Reset all minigame progress and completion
for (int i = 0; i < minigameProgress.Length; i++)
{
minigameProgress[i] = 0;
}
for (int i = 0; i < minigamesCompleted.Length; i++)
{
minigamesCompleted[i] = false;
}
}
}
Hub System with Minigame Transitions
The Lobby (hub scene) manages all player interactions and scene transitions. Uses raycasting to detect clicks on interactive objects (fishing bobber, campfire, effigies), dynamically updates fire UI with color gradients and scale animations, and handles win/loss conditions. Automatically advances to the next day when both minigames are completed, creating a satisfying progression loop.
Lobby.cs - Hub Scene Manager
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine. SceneManagement;
using UnityEngine.UI;
public class Lobby : MonoBehaviour
{
public GlobalData globalData;
public GameObject gameOverScreen;
public GameObject starveScreen;
[Header("Fire UI Elements")]
public Slider fireSlider;
public Image fireSliderFill;
public RectTransform fireSliderHandle;
void Update()
{
// Update both timers every frame
globalData.TimeSetDown(Time.deltaTime);
globalData.FireSetDown(Time.deltaTime);
// ===== DYNAMIC FIRE UI =====
fireSlider.value = globalData.secondsLeftBeforeFireDies;
// Smooth color transition: yellow (healthy) → red (dying)
float t = Mathf.Clamp01(globalData.secondsLeftBeforeFireDies / 100f);
fireSliderFill.color = Color.Lerp(Color.red, Color.yellow, t);
// Handle grows/shrinks with fire health for extra emphasis
fireSliderHandle.localScale = Vector3.Lerp(
new Vector3(0.66f, 0.66f, 0.66f), // Small when dying
new Vector3(2.6f, 2.6f, 2.6f), // Large when healthy
t
);
// ===== GAME OVER CONDITIONS =====
if (globalData.gameOver)
{
// Different screens based on how you failed
if (! globalData.IsMinigameCompleted(0))
starveScreen.SetActive(true); // Didn't catch fish
else
gameOverScreen.SetActive(true); // Ran out of time
Time.timeScale = 0f; // Freeze game
this.enabled = false; // Stop updates
}
// ===== FIRE OUT = CRYPTID ATTACK =====
if (globalData. fireOut)
{
SceneManager.LoadScene("ShootingEyes"); // Defense minigame!
}
// ===== AUTO-ADVANCE DAY =====
if (globalData.GetMinigamesCompletedCount() >= 2)
{
globalData.NextDay(); // Survived the night!
}
// ===== INTERACTIVE OBJECT CLICKING =====
if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
{
Vector2 mousePosition = Mouse.current.position.ReadValue();
Ray ray = Camera.main.ScreenPointToRay(mousePosition);
if (Physics. Raycast(ray, out RaycastHit hit))
{
Debug.Log("Hit: " + hit.collider.name);
// Route to appropriate minigame based on clicked object
if (hit.collider.CompareTag("Effigy"))
{
SceneManager.LoadScene("EffigyRemover");
}
else if (hit.collider.CompareTag("Bobber"))
{
SceneManager.LoadScene("Fishing");
}
else if (hit.collider.CompareTag("Fire"))
{
SceneManager.LoadScene("FireTending");
}
}
}
}
}
Fire Tending Minigame
The fire tending minigame requires players to quickly collect scattered sticks before the fire dies completely. Features procedural stick spawning in a radius, collection tracking, and a dramatic screen-shake + static effect when successfully completed. The static transition effect uses shader animation to create an unsettling atmosphere when returning to the hub.
FireTendingManager.cs - Collection Minigame
using UnityEngine;
using System.Collections;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
public class FireTendingManager : MonoBehaviour
{
public GlobalData globalData;
private Camera mainCam;
[Header("Fire Tending Settings")]
[SerializeField] private int numSticksToSpawn = 10;
[SerializeField] private GameObject stickPrefab;
[SerializeField] private Vector2 spawnRadius = new Vector2(5f, 5f);
[SerializeField] private int numSticksToCollect = 5;
[Header("Fire UI")]
public Slider fireSlider;
public Image fireSliderFill;
public RectTransform fireSliderHandle;
[Header("Post-Processing Effect")]
[SerializeField] private MeshRenderer staticEffect;
private int SticksCollected = 0;
private bool transitioning = false;
private float effectAmount = 0f;
void Start()
{
SticksCollected = 0;
SpawnSticks(numSticksToSpawn);
mainCam = Camera.main;
if (mainCam == null)
{
Debug.LogError("Main camera not found!");
}
}
void Update()
{
// Update fire state
globalData.FireSetDown(Time.deltaTime);
// Update fire UI
fireSlider.value = globalData.secondsLeftBeforeFireDies;
float t = Mathf.Clamp01(globalData.secondsLeftBeforeFireDies / 100f);
fireSliderFill.color = Color. Lerp(Color.red, Color.yellow, t);
fireSliderHandle.localScale = Vector3.Lerp(
new Vector3(0.66f, 0.66f, 0.66f),
new Vector3(2.6f, 2.6f, 2.6f),
t
);
// Click detection for stick collection
if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame)
{
Vector2 mousePosition = Mouse.current.position.ReadValue();
Ray ray = mainCam. ScreenPointToRay(mousePosition);
if (Physics.Raycast(ray, out RaycastHit hit))
{
if (hit.collider.CompareTag("Stick"))
{
SticksCollected++;
Destroy(hit.collider.gameObject);
// Collected enough sticks!
if (SticksCollected >= numSticksToCollect)
{
// Restore fire health
globalData. secondsLeftBeforeFireDies = 100f;
// Dramatic camera shake
StartCoroutine(ShakeCamera(mainCam));
// Return to hub with static effect
Vector3 newPosition = mainCam.transform.position +
new Vector3(0, 5f, 2f);
StartCoroutine(TransitionRoutine(mainCam, newPosition));
}
}
}
}
}
void SpawnSticks(int count)
{
for (int i = 0; i < count; i++)
{
// Random position within spawn radius
Vector3 randomPosition = new Vector3(
Random.Range(-spawnRadius.x, spawnRadius.x),
0. 5f, // Slightly above ground
Random.Range(-spawnRadius.y, spawnRadius.y)
);
Instantiate(stickPrefab, randomPosition, Quaternion.identity);
}
}
IEnumerator ShakeCamera(Camera cam)
{
Vector3 originalPosition = cam.transform.position;
float elapsed = 0f;
while (elapsed < 0.1f) // 100ms shake
{
// Random offset for shake effect
float x = Random.Range(-1f, 1f) * 0.2f;
float y = Random.Range(-1f, 1f) * 0.2f;
cam.transform.position = originalPosition + new Vector3(x, y, 0);
elapsed += Time.deltaTime;
yield return null;
}
cam.transform.position = originalPosition;
}
IEnumerator TransitionRoutine(Camera cam, Vector3 newPosition)
{
if (transitioning) yield break;
staticEffect.gameObject.SetActive(true);
transitioning = true;
// Fade IN static effect
for (float t = 0; t < 0.5f; t += Time. deltaTime)
{
effectAmount = Mathf.Lerp(0, 1, t / 0.5f);
staticEffect.material.SetFloat("_StaticStrength", effectAmount);
yield return null;
}
// Move camera (hidden by static)
cam.transform.position = newPosition;
// Fade OUT static effect
for (float t = 0; t < 0.5f; t += Time.deltaTime)
{
effectAmount = Mathf.Lerp(1, 0, t / 0.5f);
staticEffect.material.SetFloat("_StaticStrength", effectAmount);
yield return null;
}
staticEffect.gameObject.SetActive(false);
transitioning = false;
// Return to hub
SceneManager.LoadScene("Main");
}
}
Cryptid Attack Defense Minigame
When the fire goes out, players enter a frantic defense sequence where they must shoot glowing cryptid eyes before their health depletes. Features WASD crosshair movement, procedural eye spawning, health depletion mechanics, and victory/defeat conditions. This minigame creates the most intense moment of the game. Players must fend off the cryptid until they can reignite the fire!
ShootingEyes.cs - Cryptid Defense System
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
public class ShootingEyes : MonoBehaviour
{
public RectTransform gunCrosshair;
public GameObject[] eyePrefabs;
public GlobalData globalData;
// Spawn boundaries for eyes
public float eyeSpawnUpperBound = 5. 5f;
public float eyeSpawnLowerBound = -3.5f;
public float eyeSpawnLeftBound = -8. 5f;
public float eyeSpawnRightBound = 8.5f;
// Crosshair movement boundaries
public float crosshairUpperBound = 177. 0f;
public float crosshairLowerBound = -176.0f;
public float crosshairLeftBound = -353.0f;
public float crosshairRightBound = 356.0f;
public Canvas canvas;
public AudioClip shotgunSound;
public Slider health;
private AudioSource audioSource;
private float spawnTimer = 0f;
private float healthTimer = 0f;
void Start()
{
audioSource = GetComponent();
if (audioSource == null)
{
audioSource = gameObject.AddComponent();
}
// Start at half health (desperate situation!)
if (health != null)
{
health.value = health.maxValue * 0.5f;
}
}
void Update()
{
// Continue tracking timers
globalData.TimeSetDown(Time. deltaTime);
globalData. FireSetDown(Time.deltaTime);
// ===== CROSSHAIR MOVEMENT (WASD) =====
float moveX = Keyboard.current.aKey.isPressed ? -1 :
Keyboard.current. dKey.isPressed ? 1 : 0;
float moveY = Keyboard.current.sKey.isPressed ? -1 :
Keyboard.current.wKey.isPressed ? 1 : 0;
float speed = 500f;
Vector2 movement = new Vector2(moveX, moveY) * speed * Time.deltaTime;
gunCrosshair.anchoredPosition += movement;
// Clamp crosshair within screen bounds
gunCrosshair.anchoredPosition = new Vector2(
Mathf.Clamp(gunCrosshair.anchoredPosition.x, crosshairLeftBound, crosshairRightBound),
Mathf.Clamp(gunCrosshair.anchoredPosition.y, crosshairLowerBound, crosshairUpperBound)
);
// ===== EYE SPAWNING (Every 0.5 seconds) =====
spawnTimer += Time.deltaTime;
if (spawnTimer >= 0.5f)
{
SpawnEyes();
spawnTimer = 0f;
}
// ===== HEALTH DEPLETION (Every 1. 5 seconds) =====
healthTimer += Time.deltaTime;
if (healthTimer >= 1.5f)
{
if (health != null)
{
health.value = Mathf.Max(health.minValue, health.value - 10f);
}
healthTimer = 0f;
}
// ===== SHOOTING (Spacebar) =====
if (Keyboard.current.spaceKey.wasPressedThisFrame)
{
ShootEyes();
}
// ===== VICTORY CONDITION =====
if (health.value >= 100)
{
// Survived long enough to reignite fire!
globalData.secondsLeftBeforeFireDies = 100f;
SceneManager.LoadScene("Lobby");
}
// ===== DEFEAT CONDITION =====
if (health.value <= 0)
{
Debug.Log("The cryptid got you! Game Over.");
// Trigger game over screen
globalData.gameOver = true;
SceneManager.LoadScene("Lobby");
}
}
public void SpawnEyes()
{
// Random eye variant
int randomIndex = Random.Range(0, eyePrefabs.Length);
GameObject selectedEye = eyePrefabs[randomIndex];
GameObject newEyes = Instantiate(selectedEye, canvas.transform);
// Random position within bounds
RectTransform eyeRect = newEyes.GetComponent();
eyeRect.anchoredPosition = new Vector2(
Random.Range(eyeSpawnLeftBound, eyeSpawnRightBound),
Random.Range(eyeSpawnLowerBound, eyeSpawnUpperBound)
);
}
public void ShootEyes()
{
// Play shotgun sound
if (audioSource != null && shotgunSound != null)
{
audioSource.PlayOneShot(shotgunSound);
}
// Raycast from crosshair position
Vector2 crosshairWorldPos = gunCrosshair.position;
Ray ray = Camera. main.ScreenPointToRay(crosshairWorldPos);
if (Physics.Raycast(ray, out RaycastHit hit))
{
if (hit.collider.CompareTag("Eye"))
{
// HIT! Destroy eye and gain health
Destroy(hit. collider.gameObject);
if (health != null)
{
health.value = Mathf.Min(health.maxValue, health.value + 15f);
}
}
}
}
}
Dynamic Timer Display
Simple but essential UI component that displays the remaining time before nightfall. Updates every frame with single decimal precision to create urgency. This countdown creates constant tension as players scramble to complete tasks before time runs out.
ShowTimer.cs - Time Display UI
using UnityEngine;
using UnityEngine.UI;
using TMPro;
public class ShowTimer : MonoBehaviour
{
public GlobalData globalData;
public TMPro.TextMeshProUGUI timerTextTMP;
void Update()
{
// Update timer every frame
globalData.TimeSetDown(Time.deltaTime);
// Format: "123.4" (1 decimal place for precision)
timerTextTMP.text = $"{globalData.secondsLeftBeforeNight:F1}";
}
}
First Game Jam Lessons
Time Management
What I Learned: 48 hours sounds like a lot until you start building. I learned to ruthlessly prioritize features, cut scope early, and focus on making one cohesive experience rather than many half-finished ideas. The Scriptable Object pattern saved us hours by eliminating complex save systems.
Rapid Prototyping
What I Learned: Perfect is the enemy of done. I built placeholder systems that "just worked" first, then polished what mattered most. The fire UI started as a simple number. Adding color lerps and scale animations in the final hour made it visceral.
Team Coordination
What I Learned: Clear communication saved our jam. I architected code to be designer-friendly (Scriptable Objects, tagged objects, public variables in Inspector), allowing artists and designers to iterate independently while I focused on core mechanics.
Competitive Development
What I Learned: Winning validated our "less is more" approach. Judges valued tight, polished mechanics over feature bloat. The dual-timer system and escalating difficulty created natural tension that resonated with players. Presentation matters!
Winning Team Credits
Developer Reflection
Winning my first game jam as both Producer and Lead Programmer was surreal. We went into UCF GDK's Summer Jam with no expectations; I just wanted to finish something playable in 48 hours. But managing the team while simultaneously architecting core systems taught me that great games come from clear communication and trust. I learned to delegate art and design decisions to our talented team while focusing on building flexible systems they could work with. Seeing players genuinely stressed about the fire dying, frantically collecting sticks, and cheering when they survived another night... that feeling is why I make games. This jam taught me that constraints breed creativity: the dual- timer system emerged from "how do we create constant tension?" and the Scriptable Object architecture came from "we have no time for save systems." Taking 1st place validated everything I'd learned about clean code, rapid iteration, player-first design, and team leadership. It proved that with the right team, clear communication, and relentless focus, you can build something special in just two days. This experience fundamentally changed how I approach both programming and production!