Escape the Dying Star
Gameplay Preview
Watch the frantic escape from Icarus-XIII as scavengers pursue through procedurally generated sectors
Game Jam Experience
Solar Scavenger was created for the FIEA Game Jam 2026, an intense 48-hour development challenge where our team of 4 built a complete space survival experience from scratch. The game centers around a desperate escape from the dying Icarus-XIII star, with players navigating through procedurally generated space sectors while being pursued by hostile scavengers and pirates.
One of the most memorable aspects of this jam was the incredible support from the judges. Their constructive feedback during playtesting helped us refine our momentum-based controls and balance the resource management systems. Their insights on pacing and difficulty scaling were invaluable in making the final hours of development count. The judges' enthusiasm for our procedural generation approach and dynamic difficulty systems validated our technical decisions and gave us confidence in the game's potential.
As Programmer & Composer, I designed and implemented the procedural chunk-based generation system, resource management mechanics, and momentum-based controls that define the core gameplay. I also composed the adaptive music system that responds to game state, intensifying as danger increases. The experience taught me the value of modular systems and performance optimization in real-time procedural generation.
Technical Highlights
Procedural Generation
Chunk-based infinite space exploration with seamless loading
Object Pooling
Performance-optimized enemy and resource spawning system
Resource Management
Strategic scavenging mechanics with fuel and supplies
Momentum Controls
Physics-based movement for realistic space navigation
Dynamic Difficulty
Adaptive challenge scaling responsive to player progress
Adaptive Systems
Enemy spawn rates adjust based on performance
Progressive Music
Soundtrack intensity responds to danger levels
Dynamic Lighting
Visual atmosphere tied to star's dying state
Development Insights
Procedural Generation
What I Learned: Building a chunk-based procedural generation system taught me about spatial hashing, efficient neighbor lookups, and seamless world streaming. The challenge was ensuring smooth performance while generating infinite space in real-time. Implementing adaptive chunk loading based on player velocity significantly improved the experience.
Performance Optimization
What I Learned: Object pooling became essential for maintaining 60fps with hundreds of asteroids, enemies, and collectibles. Rather than instantiating/destroying objects constantly, we reused them strategically. This pattern reduced garbage collection and kept the game buttery smooth even in intense combat scenarios.
Momentum-Based Physics
What I Learned: Creating satisfying momentum controls required careful tuning of acceleration curves, drag coefficients, and rotation speeds. The key was making the ship feel heavy and realistic while remaining responsive enough for evasive maneuvers. Playtesting feedback helped us find the perfect balance.
Adaptive Game Systems
What I Learned: Dynamic difficulty scaling based on player performance created a more engaging experience. Tracking metrics like survival time, resources collected, and damage taken allowed us to adjust spawn rates and enemy behavior in real-time, keeping players in the "flow state" regardless of skill level.
Code Showcase
Procedural Sector Generation System
Developed an infinite procedural world generation system that creates sectors on-demand based on player position. The system calculates distance from the sun to determine resource density and threat levels, creating a natural difficulty curve. Sectors are tracked in a dictionary for efficient retrieval and memory management.
SectorFactory.cs - On-Demand World Generation
using UnityEngine;
public class SectorFactory : MonoBehaviour
{
public static SectorFactory Instance { get; private set; }
const int SectorSize = 100;
public Vector2Int sunPosition;
public int sunDistance = 1000;
public GameObject sectorPrefab;
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
// Randomize sun position for replayability
switch(UnityEngine.Random.Range(0,4))
{
case 0: sunPosition = Vector2Int.up; break;
case 1: sunPosition = Vector2Int.right; break;
case 2: sunPosition = Vector2Int.down; break;
case 3: sunPosition = Vector2Int.left; break;
}
}
// Called when a new sector needs to be generated
public Sector GenerateSector(Vector2Int sectorGridPosition)
{
// Instantiate sector at grid-based world position
GameObject sectorObject = Instantiate(
sectorPrefab,
new Vector3(sectorGridPosition.x * SectorSize, sectorGridPosition.y * SectorSize, 0f),
Quaternion.identity
);
sectorObject.transform.SetParent(transform);
sectorObject.name = $"Sector({sectorGridPosition.x},{sectorGridPosition.y})";
// Calculate distance from sun for difficulty scaling
float distanceFromSun = Vector2.Distance(
sectorGridPosition * 100 * sunPosition,
sunPosition * sunDistance
);
Sector sector = sectorObject.GetComponent<Sector>();
sector.ReceivePackage(new FactoryPackaging(distanceFromSun, sectorGridPosition));
// Register with sector manager
SectorManager.Instance.StoreNewSector(sectorGridPosition, ref sector);
// Subscribe to player enter event for dynamic loading
sector.OnPlayerEnter += (s) => {
SectorManager.Instance.ShiftPlayerSector(sectorGridPosition);
};
return sector;
}
}
Dynamic Sector Loading with Memory Management
Implemented a sliding window algorithm that loads/unloads sectors as the player moves through the world. This system maintains a 5x5 grid of active sectors around the player, disabling sectors outside the range and reactivating cached sectors when revisiting areas. This approach enables infinite world exploration without performance degradation.
SectorManager.cs - Efficient World Streaming
public class SectorManager : MonoBehaviour
{
public static SectorManager Instance { get; private set; }
const int MaxSectors = 25; // 5x5 grid
const int midPoint = 2; // Center of grid
private Sector[][] currLoadedSectors;
private Vector2Int playerSectorPosition;
private Dictionary<Vector2Int, Sector> prevLoadedSectors; // Cached sectors
public void ShiftPlayerSector(Vector2Int newMidSectorGridPosition)
{
Vector2Int shiftdir = newMidSectorGridPosition - playerSectorPosition;
// Create new sector array
Sector[][] tempSectors = new Sector[5][];
for (int i = 0; i < tempSectors.Length; i++)
tempSectors[i] = new Sector[5];
// Shift existing sectors in opposite direction of player movement
for (int i = 0; i < currLoadedSectors.Length; i++)
{
for (int j = 0; j < currLoadedSectors[i].Length; j++)
{
int x = i - shiftdir.x;
int y = j - shiftdir.y;
if (x >= 0 && x < tempSectors.Length && y >= 0 && y < tempSectors[x].Length)
{
// Sector stays in range, keep it active
tempSectors[x][y] = currLoadedSectors[i][j];
}
else if (currLoadedSectors[i][j] != null)
{
// Sector leaving range, disable but keep cached
currLoadedSectors[i][j].gameObject.SetActive(false);
}
}
}
currLoadedSectors = tempSectors;
// Load new sectors in direction of movement
Vector2Int newSectorGridOffset;
Vector2Int sectorGridPosToBeLoaded;
switch (shiftdir)
{
case var v when v.x > 0: // Moving right
for (int i = 0; i < currLoadedSectors[0].Length; i++)
{
newSectorGridOffset = new Vector2Int(2, i - midPoint);
sectorGridPosToBeLoaded = newMidSectorGridPosition + newSectorGridOffset;
// Try to retrieve cached sector, otherwise generate new
Sector sector = GetPreviousSector(sectorGridPosToBeLoaded);
if (sector == null)
sector = SectorFactory.Instance.GenerateSector(sectorGridPosToBeLoaded);
else
sector.gameObject.SetActive(true);
currLoadedSectors[currLoadedSectors.Length - 1][i] = sector;
}
break;
// ... similar cases for left, up, down
}
playerSectorPosition = newMidSectorGridPosition;
}
public void StoreNewSector(Vector2Int sectorPosition, ref Sector sector)
{
prevLoadedSectors[sectorPosition] = sector;
}
public Sector GetPreviousSector(Vector2Int sectorPosition)
{
return prevLoadedSectors.GetValueOrDefault(sectorPosition);
}
}
State Machine AI with Inheritance Hierarchy
Architected a flexible enemy AI system using a state machine pattern with virtual methods for behavior customization. The BaseEnemy class handles common logic (pursuit, attacking), while derived classes override specific behaviors. This OOP design enabled rapid creation of diverse enemy types with unique mechanics.
BaseEnemy.cs - Extensible AI Foundation
public class BaseEnemy : Asteroid
{
protected Player player;
protected Rigidbody2D rb;
protected enum EnemyState
{
DORMANT, // Passive behavior when player is far
PURSUIT, // Chase player
ATTACK1, // Close-range attack
ATTACK2, // Mid-range attack
ATTACK3 // Special attack
}
protected EnemyState currentState;
public float forwardSpeed = 0.25f;
public float forwardSpeedLimit = 6f;
public float steerSpeed = 2f;
public float pursuitDistance = 15f;
public float attack1Distance = 3f;
void Update()
{
switch(currentState)
{
case EnemyState.DORMANT:
DormantTick();
break;
case EnemyState.PURSUIT:
PursuitTick();
break;
case EnemyState.ATTACK1:
Attack1Tick();
break;
// ... other states
}
}
protected virtual void DormantTick()
{
if(Vector2.Distance(player.transform.position, transform.position) <= pursuitDistance)
currentState = EnemyState.PURSUIT;
}
protected virtual void PursuitTick()
{
if(Vector2.Distance(player.transform.position, transform.position) > pursuitDistance)
currentState = EnemyState.DORMANT;
else if(Vector2.Distance(player.transform.position, transform.position) <= attack1Distance)
currentState = EnemyState.ATTACK1;
else
{
// Navigate toward player
Vector2 wish = (player.transform.position - transform.position).normalized;
TurnTo(wish, 0.1f);
if(rb.linearVelocity.magnitude <= forwardSpeedLimit)
rb.AddForce(GetForwardV() * forwardSpeed);
}
}
protected virtual bool TurnTo(Vector2 wish, float threshold=0.05f)
{
Vector2 shipDir = GetForwardV();
float angle = Mathf.Atan2(shipDir.y, shipDir.x) - Mathf.Atan2(wish.y, wish.x);
// Normalize angle to -ฯ to ฯ
if (angle > Mathf.PI) angle -= 2f * Mathf.PI;
else if (angle <= -Mathf.PI) angle += 2f * Mathf.PI;
if (Mathf.Abs(angle) > threshold)
{
int dir = angle > 0 ? 1 : -1;
rb.angularVelocity = dir * steerSpeed * 50f;
return true;
}
rb.angularVelocity = 0;
return false;
}
}
Scavenger.cs - Sun-Fleeing Enemy Behavior
public class Scavenger : BaseEnemy
{
Vector2Int sunPosition;
public float dormantSpeedLimit = 4f;
protected override void subStart()
{
sunPosition = SectorFactory.Instance.sunPosition;
}
// Override dormant behavior to flee from sun
protected override void DormantTick()
{
if(Vector2.Distance(player.transform.position, transform.position) <= pursuitDistance)
currentState = EnemyState.PURSUIT;
else
{
// Flee away from sun when not pursuing player
TurnTo(-sunPosition);
if(rb.linearVelocity.magnitude <= dormantSpeedLimit)
rb.AddForce(GetForwardV() * forwardSpeed);
}
}
}
Predictive Targeting AI System
Implemented advanced AI targeting that predicts player movement based on velocity and distance. The BeltDweller enemy calculates where the player will be when the projectile arrives, creating challenging combat encounters that require evasive maneuvering rather than simple dodging.
BeltDweller.cs - Predictive Harpoon Targeting
public class BeltDweller : BaseEnemy
{
public GameObject harpoon;
protected override void PursuitTick()
{
float distance = Vector2.Distance(player.transform.position, transform.position);
if(distance > pursuitDistance)
{
currentState = EnemyState.DORMANT;
GetComponent<Rigidbody2D>().angularVelocity = 0;
}
else if(distance <= attack2Distance)
{
currentState = EnemyState.ATTACK2;
}
else
{
// PREDICTIVE TARGETING: Calculate where player will be
// distance/30f estimates projectile travel time
Vector2 predict = (Vector2)player.transform.position +
(player.GetComponent<Rigidbody2D>().linearVelocity * distance/30f);
Vector2 wish = (predict - (Vector2)transform.position).normalized;
bool turning = TurnTo(wish);
// Only fire when aimed at predicted position
if(!turning && lastAttack1 + attack1Cooldown < Time.time)
currentState = EnemyState.ATTACK1;
}
}
protected override void Attack1Tick()
{
if(lastAttack1 + attack1Cooldown < Time.time)
{
// Fire harpoon at predicted position
GameObject newHarp = Instantiate(harpoon);
newHarp.transform.position = transform.position;
newHarp.transform.eulerAngles = transform.eulerAngles;
newHarp.GetComponent<Rigidbody2D>().AddForce(
GetForwardV() * newHarp.GetComponent<Harpoon>().impulse
);
lastAttack1 = Time.time;
}
currentState = EnemyState.PURSUIT;
}
}
Physics-Based Player Movement System
Created a momentum-based spaceship control system with three movement states (idle, normal thrust, boost). The system features velocity limiting, resource management (fuel tank), and health-drain mechanics that force strategic movement decisions. Movement feels weighty and realistic while remaining responsive.
Player.cs - Multi-State Movement System
public class Player : MonoBehaviour
{
Rigidbody2D rb;
public float startingHealth = 10f;
float health;
public float startHealthDrain = 0.1f; // Cost of movement
float currHealthDrain;
public float forwardSpeed = 0.25f;
public float boostSpeed = 1f;
public float boostImpulse = 150f;
public float forwardSpeedLimit = 5f; // Max normal velocity
public float boostSpeedLimit = 10f; // Max boost velocity
enum Forward { NONE, NORMAL, BOOST }
Forward forwardState = Forward.NONE;
public float maxTank = 100f;
float tank;
public float thrustCost = 20f;
public float tankCooldown = 1f;
float lastThrust = -1f;
float lastTank = -1f;
void Update()
{
switch(forwardState)
{
case Forward.BOOST:
if(tank <= 0f)
{
forwardState = Forward.NONE;
break;
}
lastThrust = Time.time; // Delay tank regeneration
if(rb.linearVelocity.magnitude <= boostSpeedLimit)
rb.AddForce(GetForwardV() * boostSpeed);
tank = Mathf.Max(tank - thrustDrain, 0f);
boostSlider.value = tank/maxTank;
break;
case Forward.NORMAL:
health -= Time.deltaTime * currHealthDrain; // Movement costs health
if(rb.linearVelocity.magnitude <= forwardSpeedLimit)
rb.AddForce(GetForwardV() * forwardSpeed);
break;
case Forward.NONE:
// Natural momentum decay
break;
}
// Tank regeneration after cooldown
if(lastTank + tankCooldown <= Time.time)
{
tank = Mathf.Min(tank + tankRegen * Time.deltaTime, maxTank);
boostSlider.value = tank/maxTank;
}
// Steering
if(steer != 0)
rb.AddTorque(-steer * steerSpeed);
// Gravity pull from nearby planets
if(currentPlanet != null)
{
Vector2 direction = (currentPlanet.transform.position - transform.position).normalized;
float distance = Vector2.Distance(transform.position, currentPlanet.transform.position);
float gravityForce = currentPlanet.gravityStrength / (distance * distance);
rb.AddForce(direction * gravityForce);
}
}
public void OnForward(InputAction.CallbackContext context)
{
forwardState = context.performed ? Forward.NORMAL : Forward.NONE;
}
public void OnThrust(InputAction.CallbackContext context)
{
if(context.performed && tank >= thrustCost && lastThrust + thrustCooldown < Time.time)
{
tank -= thrustCost;
rb.AddForce(GetForwardV() * boostImpulse); // Instant boost
lastThrust = Time.time;
lastTank = Time.time;
}
}
}
Upgrade System with Stat Modifications
Designed a flexible upgrade system that allows players to modify core stats (health, fuel capacity, health drain rate, weapon cost). Upgrades are purchased at space stations using salvaged resources, creating a risk/reward loop where players must venture into dangerous sectors to afford improvements.
Player.cs - Dynamic Stat Modification System
// Resource management
public List<SalvageItem> salvageInv = new List<SalvageItem>();
public int maxSalvage = 5;
public bool AddSalvage(Salvage add)
{
if(salvageInv.Count >= maxSalvage) return false;
salvageInv.Add(add.data);
salvageText.text = salvageInv.Count + "/" + maxSalvage;
return true;
}
// Upgrade methods called by shop system
public void DecreaseHealthDrainUpgrade(float amount)
{
currHealthDrain = Mathf.Max(0f, currHealthDrain - amount);
}
public void IncreaseMaxHealthUpgrade(float amount)
{
startingHealth += amount;
health += amount; // Also heal current health
healthSlider.value = health/startingHealth;
}
public void IncreaseTankUpgrade(float amount)
{
maxTank += amount;
tank += amount; // Also restore tank
boostSlider.value = tank/maxTank;
}
public void DecreaseWeaponCostUpgrade(float amount)
{
weaponCost = Mathf.Max(0f, weaponCost - amount);
}
Station.cs - Salvage Trading System
public class Station : MonoBehaviour
{
void OnTriggerEnter2D(Collider2D other)
{
if(other.CompareTag("Player"))
{
Debug.Log("Docked at Station!");
Player pl = other.gameObject.GetComponent<Player>();
// Calculate total salvage value
int sale = 0;
foreach(SalvageItem s in pl.salvageInv)
sale += s.value;
// Clear inventory and give credits
pl.salvageInv.Clear();
pl.salvageText.text = pl.salvageInv.Count+"/"+pl.maxSalvage;
GameManager.instance.GiveCredits(sale);
// Open shop UI
CanvasController.instance.setShop(true);
ShopUI shop = CanvasController.instance.shopLayer.GetComponent<ShopUI>();
shop.enterShop();
}
}
}
The Future: Goblin Candy Studios
We're incredibly excited to announce that we're continuing development on Solar Scavenger! The positive reception at FIEA Game Jam 2026 and the encouraging feedback from judges has inspired us to expand the game into a full release. We've formed Goblin Candy Studios (temporary name!) to take this project to the next level.
Our post-jam roadmap includes expanded procedural generation systems, additional enemy types, a more robust upgrade system for the player's ship, and narrative elements that flesh out the lore of the dying Icarus-XIII star. We're planning deeper resource management mechanics with ship customization options and dynamic story events that respond to player choices.
The judges' feedback on improving the readability of resource states and enhancing the visual distinction between enemy types has already influenced our development priorities. We're grateful for the game jam experience and can't wait to share the expanded version of Solar Scavenger with the gaming community!
Team Credits
Developer Reflection
Building Solar Scavenger in 48 hours was an exhilarating challenge that pushed our technical skills and creativity to the limit. The procedural generation system became the backbone of the entire experience, and watching infinite space unfold in real-time as players desperately fled the dying star was incredibly rewarding. The judges' enthusiasm and constructive feedback made this jam experience truly specialโthey didn't just evaluate our work, they invested in helping us make it better. Their insights on balancing challenge and accessibility shaped our dynamic difficulty system in ways we hadn't considered. Forming Goblin Candy Studios to continue this project feels like a natural next step, and we're thrilled to take the momentum from this jam and transform Solar Scavenger into something even more ambitious. This game proved that with the right team, tight constraints breed incredible creativity. I can't wait to see where we take this project next!