No Classes, No `this`, Just Arrays: bitECS + LittleJS
This is the last post in the series, and it's the one for the developers: how td-survivors is actually built under the placeholder skin. The short version is a rule I set on day one and never broke: no classes, no this, no inheritance. The game is a pile of plain functions operating on arrays of numbers. That sentence sounds like a punishment and turned out to be a gift, so let me show you why.
The stack is small on purpose. State lives in bitECS 0.4, an Entity Component System that stores everything as struct-of-arrays. The engine is LittleJS 1.18: game loop, rendering, input, audio, lights, particles. On top of that, Vite, TypeScript, and Vitest. The division of labor is strict: the bitECS world is the single source of truth for all game state, and LittleJS is only the loop, the renderer, the input, and the speakers. (LittleJS objects like particle emitters and lights are allowed, because the library needs its own handles for them, but they never hold gameplay state. If it decides who lives and who leaks, it lives in the ECS.)
Components are just arrays
Here's what "struct-of-arrays" means in practice. A component isn't a class with fields. It's a plain object whose fields are parallel arrays, indexed by entity id (eid):
export const Position = {
x: [] as number[],
y: [] as number[],
}
export const Enemy = {
type: [] as number[], // index into ENEMY_TYPES
pathDist: [] as number[],
hp: [] as number[],
hpMax: [] as number[],
speed: [] as number[],
// ...and about thirty more fields
}
Enemy number 7's health is Enemy.hp[7]. Its position is Position.x[7], Position.y[7]. There is no enemy object anywhere; the "enemy" is just the number 7 plus an agreement about which arrays to read. Towers, projectiles, XP orbs, and traps are the same idea. A Tower has a lastFire field that gets initialized to -Infinity on spawn, which is a small joke that works out perfectly: "how long since this tower last fired?" is now - (-Infinity), which is infinity, which is definitely longer than any cooldown, so a fresh tower can fire on its very first frame without a single special case.
Systems are functions in a fixed order
There are no update methods on objects, because there are no objects. Each system is a plain function, and every frame gameUpdate runs them in a fixed order:
spawnSystem(world)
movementSystem(world, dt)
statusSystem(world, dt)
shootingSystem(world, dt)
projectileSystem(world, dt)
deathSystem(world)
xpOrbSystem(world, dt)
leakSystem(world)
Spawn, move, tick status effects, shoot, fly projectiles, die, hoover XP, count leaks. Order matters, and it's explicit and readable, right there in main.ts. Inside a system, the shape is always the same: query the world for entities that have the components you care about, then loop.
for (const eid of query(world, [Enemy, Position])) {
Position.x[eid] += Math.cos(Enemy.angle[eid]) * Enemy.speed[eid] * dt
// ...
}
One note for anyone coming from older bitECS: 0.4 dropped the defineQuery/defineSystem factory dance from 0.3. You just call query(world, [A, B]) inline, plus addEntity, addComponent, and removeEntity. Fewer moving parts, which suits a codebase that's allergic to ceremony.
The footgun: entities get recycled
Here's the one real trap of struct-of-arrays, and it bit me before I respected it. bitECS reuses eids. Enemy 7 dies, removeEntity frees slot 7, and the next addEntity hands slot 7 straight back to a projectile. But Enemy.hp[7], Enemy.burnRemaining[7], and all the other arrays still hold the dead enemy's stale numbers. Nothing clears them for you.
So the discipline is one line, and it is non-negotiable: initialize every field on spawn. The spawn code writes all thirty-odd Enemy fields every single time, even the ones that are almost always zero:
Enemy.hp[eid] = hp
Enemy.slowRemaining[eid] = 0
Enemy.burnRemaining[eid] = 0
Enemy.poisonStacks[eid] = 0
Enemy.hexRemaining[eid] = 0
// ...every field, no exceptions
Miss one field and you get a genuinely spooky bug: a brand-new enemy that spawns already on fire, inheriting a burn from whoever last owned that slot. Once you internalize "the arrays are always dirty, so overwrite everything," it stops being scary. It's the ECS version of never trusting uninitialized memory.
Everything interesting lives in JSON
Towers, enemies, upgrade cards, and stage layouts are all data, not code. They live in JSON files with thin typed TS wrappers, so towers.json is the roster and towers.ts is just the loader that maps it onto a typed TowerDef[]. Tower behavior is dispatched off a string behavior field: "chain", "beam", "cone", "mortar", "utility", and friends. Adding a new tower archetype is mostly writing a JSON object and, if its behavior is genuinely new, one case in a switch. The sixteen towers from post 05 are far more data than they are code.
Three patterns I'm fond of
Projectile stats are baked at spawn. When a tower fires, it reads its own current level, multiplies in the player's global buffs, and stamps the result onto the projectile:
Projectile.damage[pid] = lvl.damage * w.game.stats.damageMult
Projectile.splashRadius[pid] = (lvl.splashRadius ?? 0) * w.game.stats.splashRadiusMult
From that moment the projectile is on its own. The projectile system reads only the projectile, never the tower that fired it. So a shot in flight when the tower gets sold, relocated, or leveled up still lands for exactly the damage it was born with. Firing and flying are completely decoupled, which kills a whole category of "what if the source changed mid-flight?" bugs before they can exist.
Trap crossing with no raycasts. Traps sit at a fixed distance along the path, and enemies need to trigger them regardless of how fast they're moving. Instead of raycasting or checking overlap, every enemy stores both this frame's pathDist and last frame's prevPathDist. A pure function decides the rest:
export const crossed = (prev: number, next: number, mark: number): boolean =>
prev < mark && next >= mark
Did the enemy pass the mark this frame? Only if it was before it last frame and is at-or-past it now. It handles any speed, any framerate, and a slowed enemy creeping across at 0.15x triggers exactly as reliably as a boss sprinting through. Two subtractions and a comparison, no geometry.
The damage funnel. Every source of damage in the game, projectiles, beams, burns, poison, traps, boss abilities, goes through one impure dealDamage(). That function gathers the messy context (armor, crit rolls, hex debuffs, the boss hit-cap, whether the enemy is shielded) and hands the actual arithmetic to a pure resolveDamage():
export const resolveDamage = (ctx: DamageContext): number => {
let dmg = ctx.pierceArmor ? ctx.base : Math.max(1, ctx.base - (ctx.armor ?? 0))
dmg *= ctx.hexMult ?? 1
dmg *= ctx.critMult ?? 1
// ...armor, shatter, last-stand, hit-cap
return Math.max(0, dmg)
}
Because that's a pure function, the entire combat math is unit-tested in isolation, and it's exactly the kind of thing that gets a real red/green cycle. "A sniper with a 2% hit-cap can't deal more than 18 damage to a 900 HP boss" is a one-line assertion, not a playtest:
expect(resolveDamage({ base: 5000, hitCapPct: 0.02, hpMax: 900 })).toBe(18)
Write that test first, watch it fail, make it pass. No boss required.
Where the tests stop (on purpose)
That last point is the whole philosophy. The pure functions in src/lib/ (targeting, damage, chaining, paths, waves) are unit-tested with Vitest, because they're just inputs and outputs. The systems/, render/, and main.ts layers are deliberately untested: they're impure orchestration, all side effects and engine calls, and testing them would mean mocking half of LittleJS to assert that a particle appeared. Not worth it. So the rule is: if a piece of logic is worth being sure about, it gets pulled out into a pure function and pinned by a test. If it's just wiring, it stays in a system and earns its trust by being boring.
There's a Drummer aura that shows the same instinct. It's a support tower that buffs nearby towers' fire rate, and the naive version has every tower query for nearby Drummers every frame, which is a nightmare of overlapping ranges. Instead the aura is computed once per frame with a simple "best wins, no stacking" rule: gather the aura sources, and for each tower take the single strongest one covering it. That collapses a many-to-many query into a pure auraAt(pos, sources) function, which, of course, has its own test.
Two house rules
Freeze, don't pause. When you level up and the card screen appears, the game "freezes," but it does not call the engine's setPaused. Instead the phase switches away from 'playing', and the big block of simulation systems (spawn, movement, shooting, and the rest) simply doesn't run that frame. What does keep running is everything cosmetic: particles drift, the overlay animates, damage numbers finish floating up. The world holds its breath while the UI stays alive. Pausing the engine would freeze the pretty stuff too, and it looks dead. This looks like a held moment.
The robot doesn't get to play. House rule on this project: the AI assistant only ever runs npm run test and npm run build, and it never launches the actual game. All the playtesting is done by hand, by me. This isn't superstition, it's a direct consequence of everything above: the code is arranged so that the questions worth answering can be answered by the test suite, and the questions that can't ("does hoovering XP with the mouse feel good?") can only be answered by a human with the thing running. So I trust the green checkmarks and I trust my own hands, and I do not trust an agent's report that the vibes seemed fine. The vibes are my job.
That's the series
That's a wrap on td-survivors, at least this pass through it. Across ten posts we've covered the two-genres-in-a-trench-coat loop, the sixteen towers, the enemies that walk the path, the oversized bosses, the eleven cheating stages, the thirty upgrade cards, and now the machine underneath.
One last callback. My other game, Memory Falling Into Place, runs on a completely different stack: Excalibur, actors, scenes, an object-oriented engine, none of this array business. And yet the exact same instinct shows up in both codebases: keep the pure logic separate from the rendering, so the parts you need to be sure about can be proven by a function call instead of by flying a ship into a wall fifty times. Different engine, different genre, same reflex. It's the one habit I'd keep no matter what I built next.
And a reminder, one final time, since I've said it in nearly every post: none of what you've seen is final. The art is Kenney's CC0 tileset, the audio is Juhani Junkala's CC0 packs, the font is "Press Start 2P," and even the name "td-survivors" is a placeholder for a name I still haven't thought of. The point of all of it, this whole series, was the architecture under the placeholder skin.
Next time you see td-survivors, the plan is that the programmer art will finally be gone, the name will be a real one, and instead of code snippets I'll have actual playtest footage to show you. Thanks for reading the whole thing. See you when it looks like a game.