FROSTFIRE SERPENT

Summary

Role: Game Programmer
Duration: 48 Hours + couple days for bugfixing
Tools & Technologies: Unity, C#, Git
Concepts & Techniques: Graph Theory, Object Pooling
Team Size: 5

A single-player game made for the 2024 GDK Spooky Jam. Play as a fiery skeletal serpent that destroys a snowy town. Touch your own tail to explode anything you've encircled. Pick up bone pieces to grow longer. Serpent can also use abilities to deflect ice projectiles and charge forward, at the cost of bone segments. Avoid/destroy townsfolk that try to break off your tail and stop you with ice magic.

Sections

Overview

FROSTFIRE SERPENT puts you in control of a flaming serpent that is summoned by a witch, seeking vengeance against a snow-covered town.

The goal of the game is to destroy every building in the town. Completing a full loop with the serpent causes anything inside the loop to be destroyed. Additional vertebrae spawn around the map, allowing the player to grow in size.

Enemies will spawn to attack the serpent with icy projectiles. Attacks to the serpent's body will cause its tail to break off from the point hit. The serpent may recollect its fallen pieces for a short duration - otherwise, they're lost forever!

The serpent has two other abilities at its disposal. Each ability consumes vertebrae on use and has a cooldown. The first ability is Flame Parry. The serpent sheathes itself in fire, causing ice projectiles to deflect right off it and back at the attacking villager. Its second ability is a devastating charge attack, where the serpent rushes forward and destroys almost anything it runs into.

BFS Loop Detection

I was responsible for implementing the entirety of the player character. This includes movement, abilities, picking up and dropping segments, and more. Each system presented its own challenges, but the loop detection logic required the most careful consideration.

To detect loops, I considered the snake to be a graph, where each vertebra is a node that is considered adjacent to nearby vertebra. Segments of the serpent that cross over each other are considered adjacent. When the head touches the body, the serpent performs breadth-first search (BFS) starting from the contact point back to the head. Tracing the path of the traversal gives all the segments that form the loop.

After reframing the problem as such, the segments that make up a snake loop can be found simply by performing breadth-first search on the graph, starting from the closing-loop segment and going to the head.

Here's an example of a serpent forming a loop alongside its corresponding directed graph representation. The shaded nodes represent the segments that make up the most recently formed loop.

Snake Loop Filling

After identifying the loop, the next challenge is determining which objects are inside the loop. My first approach involved estimating the loop's shape using raycasts. I averaged the segment positions to approximate a 'center', then cast rays from each segment toward the center.

This worked fairly well, however it only worked for convex loops. For complex concave shapes, the calculated center sometimes fell outside the actual loop. This could result in buildings outside the loop being destroyed, which is unintended.

While researching alternatives, I discovered Unity's Polygon Collider 2D, which defines a polygon from a set of points. This fit the loop detection issue perfectly. By using each segment of the serpent as a point and casting the resulting polygon into the world, I could reliably detect everything enclosed in loops of any shape or size. I also used built-in collider functions and raycasts to approximate a "visual" center of mass to spawn effects like particulars.

Here's an example of a concave shape that was accurately captured by the new polygon collider method. The red square is the calculated "visual center".

Performance & Optimization

Dropping

One of the most satisfying parts of the game is growing your serpent to extraordinary lengths. However, before optimization, the game's performance dropped significantly once the player reached length reached a few hundred segments. This was especially noticeable when the dropping or picking up many segments at once.

The first step into investigating any performance issue is to use the profiler. Using Unity's profiler, I discovered the bottleneck came from Physics Composite Collider calculations. Here's what happened when dropping 1,000 segments at once.

Initially, I added each dropped segment to a composite collider, so that a collision with any segment would trigger all connected segments to be picked up again. While intuitive, this approach was terribly inefficient. I replaced the system with a simpler one where each dropped segment would instead hold a reference to a shared "pickup object". The player colliding with a dropped segment would cause the segment to alert the "pickup object", which would initiate the pickup. This approach was dramatically faster and eliminated the needless recalculations.

Pickup

Similarly, picking up a large number of segments at a time also called heavy frame drops. Profiling quickly revealed this to be from excessive instantiation. The fix here was straightforward: object pooling. By preloading a large number of vertebrae at the start of the game, we can simply reuse instead of instantiating on demand.

Here's a profiler comparison of dropping 1,000 vertebrae. The top shows without object pooling and the bottom shows with object pooling.