top of page

Rogue of Gems

Ludum Dare 58 Game Jam 2025, 2D top-down roguelike game Team Project (2 people)

Rogue of Gems (Ludum Dare 58 Game Jam 2025, Theme: Collector), a 2D top-down roguelike game where you fight randomly generated monsters on a procedurally generated map, search for treasure chests, collect different types of magical gems, and ultimately defeat the boss to bring those gems back to your collection cabinet.


Link to Web Play:



Link to Game Jam:

https://ldjam.com/events/ludum-dare/58/rogue-of-gems



Team & Responsibilities:

Weiqi Liu — Lead & Sole Programmer

Jiawei Zhang — Artist


Develop Tools:

C#, Unity


🎬 Gameplay Video



🌌 Background Story


Long ago, there was a mysterious figure known only as The Collector —a wanderer obsessed with the magical gems scattered across forgotten realms.Each gem held a unique power — fire that burns through darkness, ice that freezes time, lightning that splits the sky.

The Collector dreamed of mastering them all.But one day, he vanished deep within the ruins, leaving behind only his Exhibition Hall,a place filled with empty glass cases waiting to be filled again.

Now, a new adventurer (you) steps into his footsteps.Guided by curiosity and greed alike, you venture into the ever-shifting dungeons,seeking the same gems that once drove The Collector mad.


🔮 Gameplay Description


Every run begins at the Exhibition Hall,where you can view the magical gems you’ve collected so far — your growing legacy.Before each new expedition, you may choose one collected gem to carry with you.Each gem grants a different magical ability — maybe a fire blast, a healing aura, or a lightning dash.

Then, you descend into a procedurally generated world filled with monsters, traps, and treasure chests.Your goal:

  • Find treasure chests hidden throughout the dungeon.

  • Collect new magical gems that grant powerful abilities.

  • Fight through random encounters and mini-bosses.

  • Finally, defeat the main Boss guarding the dungeon’s core gem.

When you return victorious, you place your newly acquired gem into the Exhibition Hall. It becomes a permanent trophy,and also a new power you can select for your next adventure.

The more you collect, the stronger and more creative your future runs become —but beware: every journey resets,and death means losing everything except your collected gems.


🔁 Game Flow


ree
Each room is procedurally generated, with an algorithm ensuring there are no obstructions to the portals to the next room, and the entire room corresponds to a minimap.


ree
Branching room network, green for current room, yellow for treasure room, red for boss room. You can know whether the rooms you connect to next are treasure room.


ree

You will receive a boost every time you enter a new room.

ree
Treasure chests may drop random gems (weapons).

ree

Go to the next room


ree
Weapons with enhanced power have high knockback and recoil.

ree

Boss charge attack

ree
Boss vertical railgun attack

ree

The boss launches missile attacks in rounds.

ree
The boss launched a series of missile attacks.

ree
The boss generates obstacles.

ree
Try to collect all the gems in the Exhibition Hall!

ree

💡Design Innovation: Slot-Based Enhancement System



Problem:

Traditional roguelike enhancement systems require:

  • Gem/rune decomposition mechanics

  • Enhancement transfer UI between weapons

  • Complex weapon upgrade trees

  • ~1000+ LOC overhead

Challenge: 3-day game jam timeline







Implementation

csharp

// Enhancements persist in slots, not weapons
private EnhancementData leftHandEnhancement;  
// Survives weapon swaps
private EnhancementData rightHandEnhancement;

// Applied dynamically when firing
public override void Use(Vector3 direction, Vector3 origin)
{
    EnhancementData e = EnhancementManager.Instance.GetEnhancement(slotIndex);
    ApplyEnhancementToBullet(bullet, e); // Real-time application
}

ree


Solution:

Enhancements attach to hand slots, not weapons

Traditional:  [Weapon A + Enhancement X] → Swap → [Weapon B] (loses Enhancement X)

Our Design:   [Left Slot: Enhancement X] + [Any Weapon] → Swap → [New Weapon] (keeps Enhancement X)

Benefits

✅ Frictionless weapon swapping

  • Replace weapons without losing power progression

  • Match weapon types to slot specialization (e.g., shotgun → AoE slot, sniper → single-target slot)

✅ Eliminated decomposition subsystem

  • No gem extraction mechanics needed

  • Saved ~500 LOC development time

✅ Deep build diversity

  • 9 enhancement types with dual-hand selection per room

  • 5+ rooms per round × 3 rounds = 30+ enhancement decisions

  • Specialized builds: Left hand (Multi-shot + Bounce + Explosion) for mob clear, Right hand (Damage + Fire Rate) for boss DPS

  • Weapon flexibility: Swap shotgun to AoE-enhanced left hand, swap rifle to single-target right hand

✅ Minimal implementation

  • Complete system in ~200 LOC

  • New enhancement = 1 switch case




Result: Production-ready roguelike lite with rich endgame build diversity, achieved under extreme time constraint through architectural innovation rather than feature cutting.





⚙️ Technical Showcase


1. Weapon System


Dual-wielding equipment with interface-driven architecture(csharp)
// IEquippable.cspublic interface IEquippable
{
    string EquipmentName { get; }
    Sprite Icon { get; }
    void Use(Vector3 direction, Vector3 origin);
    bool CanUse();
}

// EquipmentManager.cspublic class EquipmentManager : MonoBehaviour
{
    private IEquippable leftHandEquipment;
    private IEquippable rightHandEquipment;
    private GameObject leftHandPrefab;  // Store for swap/drop
    private GameObject rightHandPrefab;
    
    public void EquipToSlot(GameObject equipmentPrefab, int slot)
    {
        GameObject equipmentObj = Instantiate(equipmentPrefab, firePoint);
        IEquippable equipment = equipmentObj.GetComponent<IEquippable>();
        
        if (slot == 0) {
            leftHandEquipment = equipment;
            leftHandPrefab = equipmentPrefab;
        } else {
            rightHandEquipment = equipment;
            rightHandPrefab = equipmentPrefab;
        }
    }
}
ree

2. Enhancement System

Slot-based enhancement with runtime bullet modification(csharp)
// EnhancementData.cs
[System.Serializable]
public class EnhancementData
{
    public float damageMultiplier = 1f;
    public int bonusBounces = 0;
    public float fireRateMultiplier = 1f;
    public int bulletsPerShotBonus = 0;
    public float slowMultiplierBonus = 0f;
    public float bulletSpeedMultiplier = 1f;
    public bool enableHoming = false;
    public bool enableExplosion = false;
}

// EnhancementManager.cspublic class EnhancementManager : MonoBehaviour
{
    private EnhancementData leftHandEnhancement;
    private EnhancementData rightHandEnhancement;
    
    public void AddWeaponEnhancement(int slot, string type, float value)
    {
        EnhancementData e = GetEnhancement(slot);
        
        switch (type)
        {
            case "Damage": e.damageMultiplier *= value; break;
            case "FireRate": e.fireRateMultiplier *= value; break;
            case "BulletsPerShot": e.bulletsPerShotBonus += (int)value; break;
            case "Bounce": e.bonusBounces += (int)value; break;
            case "SlowEffect": e.slowMultiplierBonus += value; break;
            case "BulletSpeed": e.bulletSpeedMultiplier *= value; break;
            case "Homing": e.enableHoming = true; break;
            case "Explosion": e.enableExplosion = true; break;
        }
    }
}

// Gun.cs - Apply enhancements at runtimepublic override void Use(Vector3 direction, Vector3 origin)
{
    EnhancementData e = EnhancementManager.Instance.GetEnhancement(slotIndex);
    
    float enhancedDamage = damage * e.damageMultiplier;
    int enhancedBullets = bulletsPerShot + e.bulletsPerShotBonus;
    float enhancedSpeed = bulletSpeed * e.bulletSpeedMultiplier;
    
    for (int i = 0; i < enhancedBullets; i++)
    {
        GameObject bullet = Instantiate(bulletPrefab);
        bullet.GetComponent<Bullet>().Initialize(direction, enhancedSpeed, enhancedDamage);
        ApplyEnhancementToBullet(bullet, e);
    }
}

void ApplyEnhancementToBullet(Bullet bullet, EnhancementData e)
{
    if (e.enableExplosion) 
        SetField(bullet, "isExplosive", true);
    
    if (e.bonusBounces > 0) {
        SetField(bullet, "isBouncy", true);
        int baseBounces = GetField<int>(bullet, "maxBounces");
        SetField(bullet, "maxBounces", baseBounces + e.bonusBounces);
    }
    
    if (e.enableHoming) 
        SetField(bullet, "isHoming", true);
    
    if (e.slowMultiplierBonus > 0) {
        SetField(bullet, "hasSlowEffect", true);
        float baseSlow = GetField<float>(bullet, "slowMultiplier");
        SetField(bullet, "slowMultiplier", baseSlow * (1f - e.slowMultiplierBonus));
    }
}
ree

3. Procedural Generation

QuadTree subdivision + A pathfinding validation*(csharp)
// RoomGenerator.cs - QuadTree spatial partitioningvoid BuildQuadTree(QuadTreeNode node, int depth)
{
    if (depth >= maxDepth || node.bounds.width < minRegionSize 
        || Random.value > splitChance)
    {
        node.isLeaf = true;
        node.isWalkable = Random.value > obstacleDensity;
        return;
    }
    
    node.children = new QuadTreeNode[4];
    float halfW = node.bounds.width / 2f;
    float halfH = node.bounds.height / 2f;
    
    node.children[0] = new QuadTreeNode(
        new Rect(node.bounds.x, node.bounds.y, halfW, halfH));
    // ... create other 3 quadrants
    
    foreach (var child in node.children)
        BuildQuadTree(child, depth + 1);
}

// A* pathfinding ensures reachabilityList<Vector2Int> FindPath(Vector2Int start, Vector2Int end)
{
    List<PathNode> openList = new List<PathNode>();
    HashSet<Vector2Int> closedSet = new HashSet<Vector2Int>();
    
    openList.Add(new PathNode { 
        position = start, 
        gCost = 0, 
        hCost = Vector2Int.Distance(start, end) 
    });
    
    while (openList.Count > 0)
    {
        PathNode current = openList.OrderBy(n => n.fCost).First();
        if (current.position == end) 
            return ReconstructPath(current);
        
        openList.Remove(current);
        closedSet.Add(current.position);
        
        foreach (Vector2Int neighbor in GetNeighbors(current.position))
        {
            if (closedSet.Contains(neighbor) || !IsWalkable(neighbor)) 
                continue;
            
            float tentativeG = current.gCost + 
                Vector2Int.Distance(current.position, neighbor);
            
            PathNode neighborNode = openList
                .FirstOrDefault(n => n.position == neighbor);
            
            if (neighborNode == null)
            {
                openList.Add(new PathNode {
                    position = neighbor,
                    gCost = tentativeG,
                    hCost = Vector2Int.Distance(neighbor, end),
                    parent = current
                });
            }
        }
    }
    return null;
}

// Guarantee all exits are reachablevoid EnsurePathsReachable()
{
    Vector2Int spawnGrid = WorldToGrid(playerSpawnPosition);
    
    foreach (var door in exitDoors)
    {
        Vector2Int doorGrid = WorldToGrid(door.position);
        List<Vector2Int> path = FindPath(spawnGrid, doorGrid);
        
        if (path == null || path.Count == 0)
            path = CreateForcedPath(spawnGrid, doorGrid);
        
        foreach (var tile in path)
            WidenPath(tile, pathWidth);
    }
}

Branching room network(csharp)
// RoomMapSystem.csvoid GenerateMap()
{
    // Start: 1 room
    List<Room> startColumn = new List<Room> { 
        new Room(0, RoomType.Normal, 0, 0) 
    };
    roomColumns.Add(startColumn);
    
    // Expansion phase: 1→2→3→4 rooms
    for (int i = 1; i <= maxRoomHalf; i++)
    {
        int nextCount = previousColumn.Count + Random.Range(1, 3);
        List<Room> newColumn = GenerateRooms(i, nextCount);
        ConnectRoomsExpanding(previousColumn, newColumn);
        roomColumns.Add(newColumn);
    }
    
    // Convergence phase: 4→2→1 Boss
    while (previousColumn.Count > 3)
    {
        int nextCount = previousColumn.Count / 2;
        List<Room> newColumn = GenerateRooms(currentColumn, nextCount);
        ConnectRoomsConverging(previousColumn, newColumn);
        roomColumns.Add(newColumn);
    }
    
    // Final Boss room
    Room bossRoom = new Room(roomIdCounter++, RoomType.Boss, finalColumn, 0);
    foreach (Room room in previousColumn)
        room.connectedRooms.Add(bossRoom);
}

ree


ree



Project Gallery

bottom of page