This phase focuses on the technical design and systems programming side of the project.
I built this project as a technical design and systems-programming exercise centered on one question:
Can I build a gameplay framework that is extensible enough for new mechanics and content, while keeping the systems understandable and safe for other programmers to work with?
Rather than building each feature directly around Unity components, I designed a layer of gameplay objects and systems responsible for the actual game rules, with Unity-specific presentation and object management handled separately.
The result is a framework built around reusable abstractions, explicit APIs, event-driven interactions, centralized content definitions, and clear ownership of game state.
The first phase of the project was deliberately focused on the framework rather than the individual game content.
I wanted gameplay classes to describe what they do, without requiring every new plant, zombie, or projectile to understand how the board, entities, projectiles, or Unity objects are implemented internally.
A plant should be able to express:
if (AnyZombiesInFront())
ShootProjectile(ProjectileType.Pea, ActorFace.PlantShooterMouth);
rather than manually searching lanes, inspecting entity collections, calculating positions, spawning objects, and communicating with Unity.
That distinction became the basis for much of the architecture.
One of the core design decisions was to separate gameplay objects from their Unity representations.
Plant, Zombie, and Projectile are gameplay classes. Their Unity-side representation is handled through EntityActor and its concrete implementations.
For example, creating a zombie consists conceptually of two separate operations:
Zombie newZombie = ZombieRegistry.Create(type, row);
ZombieActor actor = GameManager.Instance.SpawnZombiePrefab(type, row);
newZombie.SetActor(actor);
The gameplay object owns the game state and behaviour. The Actor owns the Unity representation: sprites, transforms, visual effects, sorting layers, and other presentation concerns.
This means the gameplay model does not need to inherit from MonoBehaviour or structure its behaviour around Unity's component lifecycle.
The intended boundary was that the gameplay framework could remain largely intact if the engine-facing layer were replaced. In practice, the current project still contains some framework-to-framework coupling that I would restructure in a second iteration, but the distinction between gameplay representation and engine representation is fundamental to the design.
Early in development, Plant, Zombie, and Projectile shared enough functionality that I repeatedly found myself reworking the same systems in three different places.
Instead of continuing to duplicate those behaviours, I introduced an abstract Entity base class.
It provides common functionality such as:
health and damage handling,
death and lifecycle hooks,
status effects,
team relationships and targeting,
equipment,
timing and updates,
projectile creation,
communication with the entity Actor.
The important part was not simply reducing code duplication.
Once these objects shared a meaningful abstraction, higher-level systems could reason about them consistently. Systems such as damage, effects, equipment, targeting, and lifecycle management no longer needed to know whether they were dealing with a plant, zombie, or projectile.
I found this to be one of the most valuable architectural changes in the project because it turned several separate implementations into a coherent gameplay model.
The framework is intended to be used by the programmers implementing individual game content.
That made the API exposed to subclasses just as important as the underlying architecture.
A concrete plant should not need deep knowledge of the framework in order to implement its behaviour.
For example, the Repeater's behaviour can be expressed almost entirely in terms of gameplay intent:
protected override void BehaviourPerTick()
{
if (firing)
{
firing = false;
ShootProjectile(ProjectileType.Pea, ActorFace.PlantShooterMouth);
}
else if (AnyZombiesInFront())
{
ShootProjectile(ProjectileType.Pea, ActorFace.PlantShooterMouth);
SetTimeBeforeNextTick(0.2f);
firing = true;
}
}
The plant does not manually traverse the board or manage projectile creation.
Instead, the framework exposes operations such as:
AnyZombiesInFront()
ZombiesInRange()
ZombiesInArea()
ShootProjectile()
SummonSun()
AttemptMovingPlant()
ModifyTileObject()
and similar helpers.
This effectively creates a small gameplay language for content programmers.
The abstraction is useful not because the underlying implementation is hidden for its own sake, but because the API exposes the level of information a content author actually needs.
That was an important part of the technical-design goal: programmers working on individual game elements should be able to implement features without having to understand the entire framework first.
Plants frequently need to react to zombies entering or leaving relevant areas.
One approach would have been to have every plant continuously search the board for zombies during every update.
Instead, TileData supports subscriptions to zombie movement.
A plant identifies the tiles it is interested in, subscribes to them, and receives callbacks when zombies enter or leave those tiles.
This changes the relationship from:
Plant repeatedly asks the board what is happening
to:
Board notifies the plant when relevant state changes
The same system also supports higher-level queries such as ZombiesInFront, ZombiesInArea, and ZombiesInRadius.
The result is a combination of event-driven notifications for persistent spatial relationships and query APIs for behaviours that need an immediate answer.
It also keeps the implementation of spatial bookkeeping inside the board framework rather than forcing every plant to reimplement it.
Another major architectural change came from how I initially approached zombie armour.
I originally considered armour to be a property of a specialized zombie subclass, such as an ArmoredZombie.
That became increasingly restrictive as soon as different entities could have different kinds of equipment or protections.
Some protection behaves like a directional shield. Other equipment may behave differently, may be removable, or may interact with other systems.
I replaced the specialized inheritance approach with an Equipment abstraction that any Entity can own.
Armor is then one implementation of equipment.
This allows behaviour to be composed onto entities instead of continually expanding the inheritance hierarchy.
The pattern became useful beyond armour because it established a general rule:
If a behaviour describes something an entity can possess rather than what that entity fundamentally is, it is a candidate for composition rather than inheritance.
I applied a similar idea to plants.
I initially considered dividing plants into specialized behavioural categories such as attacking plants, sun producers, or walls.
That would have made the hierarchy increasingly restrictive once plants began combining behaviours.
Instead, plants share one common Plant abstraction and use independent tags/capabilities to describe properties such as:
secondary plants,
base plants,
plants that can grow on water,
plants that can be placed on existing plants,
upgrades,
sun producers,
mushrooms,
instant-use plants,
and other placement or interaction rules.
This allows a plant to combine capabilities without requiring a new branch of the inheritance hierarchy for every combination.
Content creation originally used simple factory-style mappings.
As the number of things the rest of the framework needed to know about each plant increased, I expanded that idea into PlantRegistry.
The registry centralizes:
construction,
gameplay statistics,
prefabs,
seed packet sprites,
main sprites,
available plant types.
Adding a plant therefore becomes a single registry definition rather than requiring the same information to be manually synchronized across several systems.
For the framework's intended users, this creates a predictable workflow: register the content once, provide its definition, and the rest of the systems can retrieve what they need through the registry.
The same pattern was later applied to other entity types.
This was intentionally not designed as an elaborate data-driven content pipeline. It was a pragmatic way to create one authoritative boundary for content definitions and reduce the number of places that a programmer could accidentally forget to update.
Entity removal exposed one of the more interesting problems in the framework.
Originally, an entity could remove itself from the game's collections immediately.
That created a classic iteration problem: while one entity was being updated, another entity could be killed and removed from the same collection currently being traversed.
The first solution was to defer the removal until the end of the update.
That solved the collection mutation problem, but introduced another one: the entity still existed in the game's collections for the rest of the frame and could therefore remain visible to lookups performed by other entities.
The final solution separated the two requirements.
Before updating, the current entity collections are copied into update buffers.
Entities can then be removed from the actual game state immediately while the update traversal continues over the stable buffer.
This means:
logical existence and iteration safety are handled independently.
That was a useful architectural lesson for the project: solving a technical problem is not necessarily the same as satisfying the underlying gameplay requirement.
The framework also separates reusable world behaviour from individual level definitions.
World defines shared world-level properties such as:
board geometry,
level count,
coordinate conversion,
world-wide tags,
level generation,
zombie pools,
wave definitions,
rewards,
world-specific lifecycle hooks,
ambush behaviour.
Individual worlds override only what differs.
For example, World_Night defines its own geometry, night-specific tags, zombie progression, wave counts, level rewards, grave generation, and ambush positions without duplicating the common world framework.
This allows level-specific content to live within a predictable structure while keeping world-wide rules reusable.
It also creates a useful distinction between:
the framework that defines how a world works
and
the content that defines what a particular world contains.
The final structure was not designed perfectly from the beginning.
Several of the project's strongest architectural decisions came from encountering limitations in earlier versions.
The evolution looked roughly like this:
One central manager → split game-wide and level-wide responsibilities
Repeated entity implementations → common Entity abstraction
Specialized behaviour hierarchies → composition and capability tags
Armored zombie inheritance → generic Equipment
Basic factory → centralized content registry
Deferred death → update buffers
Continuous spatial searching → tile subscriptions and spatial query APIs
This is an important part of the project for me.
The framework was not an exercise in designing an ideal architecture on paper. It was an iterative process of identifying where abstractions stopped fitting the systems I was building, then changing the architecture rather than continually forcing new features into a flawed structure.
The framework works, but there are several areas where I would design the second iteration differently.
Both managers accumulated more responsibilities than they should have.
GameManager currently combines game progression, persistent state, scene transitions, prefab creation, UI coordination, rewards, and other application-level concerns.
LevelManager similarly handles simulation updates, entity lifecycle, spatial queries, economy, tile manipulation, projectiles, and level flow.
The next iteration would split these responsibilities into smaller systems, with a clearer boundary between game state, simulation systems, and engine communication.
The original spatial model made Lane the primary owner of zombies and TileData the primary owner of plants.
As the mechanics expanded, tiles also needed to know about zombies for enter/leave events and plant interactions.
In hindsight, I would likely make TileData the more fundamental spatial representation and treat a zombie's continuous X position as its relationship to the tiles it is crossing.
The current implementation performs well enough at the scale of this game, so this is primarily an architectural hindsight rather than a performance problem.
Some classes became broader as the framework matured.
The project demonstrates that the abstractions solved real problems, but it also showed me where “one convenient central API” can eventually become too large.
That is one of the main reasons the next iteration would focus more strongly on separating responsibilities rather than simply continuing to add capabilities to existing managers.
I keep these issues deliberately visible because they are more useful to document than to hide. They show where the framework succeeded, where it became strained, and what I learned from building it.
By the end of Phase 1, I had a reusable gameplay framework rather than a collection of individual mechanics.
New content can interact with the framework through a defined set of APIs instead of directly manipulating the underlying implementation.
Gameplay entities are represented separately from their Unity-facing Actors.
Common functionality is shared through meaningful abstractions.
Optional behaviours use composition and capabilities where inheritance became restrictive.
Content is registered through centralized definitions.
Spatial interactions can be event-driven.
And the simulation has explicit rules for safely updating and removing entities.
Most importantly, the framework gave me something I could test as a technical-design problem:
Could another programmer understand it well enough to use it without needing to understand its internal implementation first?
That question became the focus of Phase 3, where I planned to hand the framework and its documentation to programmers with little or no Unity experience and observe where the architecture, APIs, or documentation failed to communicate their intended use.
Phase 1 therefore established the framework.
Phase 2 used it to build a new game design on top of it.
Phase 3 would test whether the framework actually succeeded as a developer-facing tool.