FourSight

FourSight is a roguelike deck-building tower defense game built in Unity. Players defend a central core by drawing tower and spell cards each round, placing defenses, and managing resources under pressure. Enemies navigate the board dynamically using a custom flow field pathfinding system, rerouting around towers in real time. Each run is procedurally generated with branching paths through battles, shops, and events, drawing inspiration from Slay the Spire's progression loop. Players unlock new cards and grow stronger across runs, pushing for deeper clears.
Technical highlights:
• Built enemy movement and tower simulation on Unity DOTS (ECS) for high-performance entity processing
• Implemented a flow field combined with break-cost scoring so enemies intelligently route through or around tower clusters
• Data-driven design using ScriptableObjects for towers, enemies, cards, and keywords, so the game can be tuned without code changes
Tools: Unity 6, C#, Photoshop, Aseprite, GitHub

Technical Systems

Flow Field Pathfinding

Enemy navigation in FourSight is driven by a backward Dijkstra flow field computed from the nexus outward every frame. Instead of each enemy running its own pathfinding query, a single global grid stores the optimal move direction and cost-to-goal for every cell. Enemies read the direction from the cell they occupy and step toward the lowest-cost neighbor. This scales to hundreds of enemies at once without any per-enemy pathfinding cost.
The more interesting design problem was towers. Treating a placed tower as a hard wall forces enemies into long, predictable detours and removes meaningful player decisions about where to place. Instead, each tower is a traversable obstacle with a dynamic break cost. The base cost to walk through a tower is roughly 1.5 normal steps, but drops by 30% for each enemy already attacking it. That discount creates group behavior on its own. The third enemy approaching a tower finds it cheaper to join the attack than to walk around it. When a tower hits its attacker cap, its cost spikes to 200, routing all new enemies away.
Two additional layers shape cell selection without any flocking logic: an occupancy penalty (live enemy count in a candidate cell raises its cost in real time) and path stamps (a one-frame-delayed cost overlay on recently-traveled cells). Neither system knows about lanes. The spreading and lane formation fall out of each enemy greedily avoiding cells that others have just committed to.

Card Feel & Placement

Cards are standard Unity UI objects, but I put a lot of work into making them feel good to handle, not just functional. Dragging a card above a vertical screen threshold triggers a mode shift: the card locks to a canvas arc position, a placement ghost spawns on the grid using the tower's actual visual prefab, and AOE preview tiles appear color-scaled by damage value. The ghost tints red or green to communicate placement validity in the same frame the check runs. Pressing Q or E rotates the ghost and live-rotates the AOE overlay in sync.
Placement verification runs in two stages: energy check first, then a call to the DOTS bridge that validates against edge-zone restrictions, existing tower footprints, and the nexus cell. On a valid drop, energy deducts immediately, the card destroys itself, and the tower entity begins operating before the placement animation finishes. Cards returned to hand from invalid drops or explicit cancels lerp back with an ease-out-back tween to reinforce the feel of a physical card snapping into place.
Rotation state (0 to 3 clockwise 90-degree steps) is carried through the entire pipeline: from the ghost preview, to the DOTS bridge call, to the baked entity component. All AOE shape offsets are rotated at query time, so the tower that fires matches exactly what the ghost showed at placement.

Data-Driven Keyword System

Card effects in FourSight are not hardcoded. Each CardInfo ScriptableObject carries a list of keyword entries, pairs of a KeywordDefinition asset (a tag and default parameter) with an optional per-card override. A card that draws two cards and then heals the nexus for 20 HP is two entries stacked in the Inspector. No code changes are required for new card combinations.
At runtime, KeywordExecutor runs the chain as a coroutine. It builds a context object that resolves and injects references each handler might need: draw pile, discard pile, hand, energy manager, the DOTS EntityManager, and the target grid cell. Then it iterates the keyword list, yielding each handler as a nested coroutine. Adding a new keyword type means writing one class implementing IKeywordHandler and registering it by tag. The rest of the pipeline is unchanged.
Cancellation took some extra design work. The Discard keyword is asynchronous: the player clicks cards to discard and can press Escape to abort. When a handler sets a cancel flag on the context, the executor breaks the chain and fires an onCancelled callback, which refunds energy and returns the card to hand. No handler knows what comes before or after it in the chain. Since the keyword data, the execution order, and the behavior all live in separate places, new cards can be added without editing any existing handler code.

ECS / DOTS Simulation Core

FourSight's simulation layer, including enemies, towers, pathfinding, combat, and health, runs on Unity's Entity Component System (DOTS). I went with DOTS because a wave of 80 to 100 enemies alongside 20+ active towers generates thousands of component queries per frame, and DOTS's cache-friendly data layout keeps those queries fast without low-level manual optimization.
All component types are defined in a single file, Components.cs. I kept it that way so the whole data schema stays visible in one place instead of being scattered across feature folders. The 26+ ECS systems run in an explicitly declared execution order. As one example: all spawn systems are ordered after EnemyCleanupSystem so that dead entities are already removed from queries before wave-completion checks fire. Damage is always deferred: systems write DamageEvent structs to per-entity dynamic buffers, and health systems drain those buffers later in the same frame, avoiding any ordering race conditions.
ECS systems communicate to the MonoBehaviour layer through static C# events rather than managed object references inside components, keeping Burst compilation available on the simulation hot path. The SimulationAnimationEvents hub raises six typed events (attacked, hurt, and died, for both towers and enemies) that visual sync systems subscribe to. No ECS system knows anything about GameObjects, renderers, or animations, and I was strict about keeping that boundary in place.