๐Ÿš€ FIEA Game Jam 2026

Escape the Dying Star

๐ŸŒŸ Space Survival โ€ข โฑ๏ธ Built in 48 Hours โ€ข ๐ŸŽฎ Programmer & Composer

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

๐ŸŽฎ
Luke Cullen Programming
๐ŸŽจ
Ash Cho Chia Yuen Art & UI
๐Ÿ–Œ๏ธ
Jacob Arbogast Additional Art Assets

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!