roblox8 min read|2026-06-07

My ridiculous optimization ventures taken to make the smoothest tower defense.

The server and client tricks that let my tower defense stay smooth with thousands of enemies, lots of units, and a lot happening at once.

robloxtower-defenseoptimizationecsarchitecture
A pastel desktop scene with a character beside an old computer
Game systems have their own rhythm.

Where the problem actually was

Tower defense performance gets weird pretty quickly because the game looks simple from the outside, but the actual workload is brutal.

You have a large number of enemies moving at once. Every unit has to decide what is in range. Some enemies are slowed, some are knocked back, some are split across different paths or waves, and all of it still has to feel perfectly synced on the client. Then you still need the actual game to render well while a player has a whole board of units placed and firing.

That was the real problem I cared about. I did not want a game that felt okay at a few hundred enemies and then quietly fell apart once the screen got busy. I wanted something that could handle absurd counts and still feel clean.

The server shape

The server is built around a pretty simple idea. Keep the authoritative simulation small, predictable, and cheap to query.

Enemies and units both live inside the same ECS world. I do not treat them as special object hierarchies with a bunch of bespoke logic hanging off each one. They are mostly just entities with different components. An enemy has things like position, health, speed, progress along a path, and status effects. A unit has things like range, cadence, damage data, and target selection state.

The other piece is relationships. Enemies and units are really only different because of what they carry and what they point at. Enemies have a relationship to a path entity, which means pathing logic stays separate from the enemy record itself. That matters a lot once you want the architecture to scale instead of turning into a giant pile of one-off state.

Path Entitiescurve samplesprogress stateEnemypositionhealth, speed, path linkUnitrangefire cadence, target stateSpatial Hashenemy occupancy by cellunit cell coverage cacheAttack Queryonly touched cellscandidate list stays smallReplicatorstate streamtimestampsrelationshipposition updatescandidate filteringsync packets
The server loop stays small because units only ask for a short list that already survived the spatial hash.

This helped in two ways.

First, iteration stayed fast because the hot data was grouped by component layout instead of by whatever happened to be convenient in gameplay code.

Second, adding new enemy behavior was much less painful. If I wanted a different movement mode, an aura, a burn effect, or something path-related, I could attach or remove components instead of shoving more special cases into a giant class tree.

The spatial hash is what made target queries cheap

The main server-side win was the spatial hash.

Enemy positions are tracked inside a 2D grid. Each grid cell holds the enemies currently occupying that part of the map. That by itself is already useful, but the better trick was doing the same kind of work for units ahead of time.

Each unit caches its attack range and the set of hash cells that range overlaps. That means a unit never asks "which enemies exist right now?" It asks "which enemies are inside these few cells I already know I care about?"

That changes the whole cost profile. Instead of checking every enemy against every unit range test, the query starts with a much smaller candidate set.

Interactive Spatial Hash

Only query the cells that matter

Towers cache the 2D hash cells their range touches. When a tower updates, it only scans enemies that already landed in those cells instead of walking the full enemy set.

Tower

Enemy frame

Touched cells

21

Enemies queried

4

Global enemy count

7

Current query set

e3e4e5e6

This is one of those optimizations that sounds obvious after you have it, but it changes the feeling of the whole system. The server stops doing broad, dumb work and starts doing local, targeted work.

In practice that meant the game could keep scaling enemy count without unit targeting becoming the thing that dragged everything down.

ECS mattered for more than just raw speed

People usually talk about ECS like it is just a performance trick. For me it was also an architecture trick.

The performance part is real. Tight iteration over hot component sets is exactly what this kind of game wants. You are constantly touching enemy movement, path progress, targeting data, and combat state in large batches.

But the scalability part mattered just as much. Tower defense games keep growing sideways. You add more enemy variants, more status effects, more unit behaviors, more map logic, more interactions between systems. If the whole game is built around hard-coded categories, that growth gets annoying fast.

With ECS, enemies and units are just shaped by what they carry. If something needs movement on a path, it has the path relationship. If something can be slowed, it has the right state. If something can attack, it has range and cadence data. That kept the simulation code a lot calmer than it would have been otherwise.

The client had to do just as much work

The server side is only half the story. You can have a beautiful server architecture and still end up with a client that looks awful once the screen fills up.

The client side had to solve three separate problems at once:

  1. stay visually smooth at huge enemy counts
  2. stay locked to the server even during lag, slows, and knockbacks
  3. avoid turning animation and movement into a CPU disaster

That meant the client could not just be a dumb receiver of replicated state. It needed a real rendering pipeline.

Lag compensation and interpolation kept everything honest

One thing I cared about a lot was that the client should look smooth without inventing a fake version of the game state.

The client interpolates between timestamped server snapshots and compensates for network delay, so movement stays smooth even when enemies get slowed, knocked back, or otherwise pushed around in ways that would normally make interpolation ugly.

That part matters more in tower defense than people sometimes expect. Once you have crowd control, irregular motion, different enemy speeds, and huge counts, bad interpolation becomes really obvious. Enemies start feeling mushy or disconnected from what the server is actually doing.

I wanted the opposite feeling. Even if latency exists, the client and server should still feel like they are talking about the same world at all times.

That is why I treat interpolation as part of the simulation presentation layer, not as a random visual extra.

The custom animation solver helped a lot on the client

Another big client win was the custom animation solver.

Instead of leaning on the default stack for everything, the game reads keyframe sequences and solves animation work in parallel. That made a huge difference once lots of enemies were visible at the same time.

At that scale, the question stops being "can this one enemy animate?" and becomes "what happens when a massive crowd is all trying to animate while units are firing, effects are playing, and the board is busy?"

The custom solver gave me much more control over how that cost behaves under load, which is exactly what I needed.

Distance-based throttling kept work where the player actually sees it

Not every enemy deserves the same render or simulation attention on the client.

If an enemy is close to the camera, it gets updated more often. If it is farther away, the render and simulation presentation rate can be throttled.

That sounds small, but it adds up hard in a tower defense game because the camera does not care equally about every enemy on the map. The player is looking at a local area most of the time. Spending the exact same presentation budget on distant enemies is wasteful.

This is one of those things that helps because it respects what the player actually notices instead of treating the whole scene like it needs the same fidelity everywhere.

Motor6D transforms were the biggest visual movement win

The single most satisfying optimization was movement presentation through Motor6D transforms.

A tower defense game gives you a nice advantage here. Enemies mostly need to look like they are moving smoothly through the world. They do not necessarily need their actual model world transform and collider state to be churned every frame the same way a more physics-heavy game might.

So instead of relying on regular movement updates, or even the more common optimized answer people jump to like BulkMoveTo, I used Motor6D transforms for the visual side.

That means the visible motion is handled as a cosmetic transform layer. The original model world CFrame is not constantly being rewritten. Colliders are not getting dragged around for no reason. The visual movement work stays much cheaper.

That ended up being massive.

Once I shifted movement presentation into Motor6D transforms, enemy counts that would normally feel dangerous became comfortable. The game could render huge waves smoothly while units were placed, attacking, and doing all the usual tower defense work on top.

For this kind of game, that trade was perfect.

Why it all stacked so well

None of these changes carried the whole game alone.

The server stayed cheap because ECS kept the hot data sane and the spatial hash made target queries local.

The client stayed smooth because interpolation, distance-based throttling, and the animation solver kept presentation under control.

The movement side stayed surprisingly cheap because Motor6D transforms let me separate visual motion from the heavier parts of the world state.

That is really the whole story. The game runs well because every layer is doing the kind of work it is actually good at doing.

The server decides truth.

The client turns that truth into something smooth.

The targeting path stays local.

The rendering path stays cosmetic when it can.

Once those pieces clicked together, the system stopped feeling fragile. It started feeling like something I could keep pushing harder without immediately paying for it.