Doug's Game Dev Log
Sobre
EN|PT-BR
2026-07-21

Sem Classes, Sem `this`, Só Arrays: bitECS + LittleJS

Esse é o último post da série, e é o post pros devs: como o td-survivors é montado de verdade embaixo da casca de placeholder. A versão curta é uma regra que eu botei no primeiro dia e nunca quebrei: sem classes, sem this, sem herança. O jogo é uma pilha de funções simples mexendo em arrays de números. Essa frase soa como castigo e acabou virando presente, então deixa eu te mostrar o porquê.

A stack é pequena de propósito. O estado mora no bitECS 0.4, um Entity Component System que guarda tudo como struct-of-arrays. A engine é a LittleJS 1.18: game loop, renderização, input, áudio, luzes, partículas. Em cima disso, Vite, TypeScript e Vitest. A divisão de trabalho é rígida: o world do bitECS é a única fonte da verdade pra todo o estado de jogo, e a LittleJS é o loop, o renderizador, o input e as caixas de som. (Objetos da LittleJS tipo emissores de partícula e luzes são permitidos, porque a biblioteca precisa dos handles dela, mas eles nunca guardam estado de gameplay. Se aquilo decide quem vive e quem vaza, mora no ECS.)

Componentes são só arrays

Aqui vai o que "struct-of-arrays" quer dizer na prática. Um componente não é uma classe com campos. É um objeto simples cujos campos são arrays paralelos, indexados pelo id da entidade (eid):

export const Position = {
  x: [] as number[],
  y: [] as number[],
}

export const Enemy = {
  type: [] as number[],   // índice em ENEMY_TYPES
  pathDist: [] as number[],
  hp: [] as number[],
  hpMax: [] as number[],
  speed: [] as number[],
  // ...e mais uns trinta campos
}

A vida do inimigo número 7 é Enemy.hp[7]. A posição dele é Position.x[7], Position.y[7]. Não existe nenhum objeto enemy em lugar nenhum; o "inimigo" é só o número 7 mais um combinado sobre quais arrays ler. Torres, projéteis, orbes de XP e armadilhas seguem a mesma ideia. Uma Tower tem um campo lastFire que é inicializado com -Infinity no spawn, que é uma piadinha que dá certo perfeitamente: "quanto tempo desde que essa torre atirou pela última vez?" é now - (-Infinity), que é infinito, que é definitivamente mais do que qualquer cooldown, então uma torre novinha já pode atirar no primeiríssimo frame sem nenhum caso especial.

Sistemas são funções numa ordem fixa

Não tem método update em objeto, porque não tem objeto. Cada sistema é uma função simples, e todo frame o gameUpdate roda eles numa ordem fixa:

spawnSystem(world)
movementSystem(world, dt)
statusSystem(world, dt)
shootingSystem(world, dt)
projectileSystem(world, dt)
deathSystem(world)
xpOrbSystem(world, dt)
leakSystem(world)

Spawnar, mover, tickar efeitos de status, atirar, voar projéteis, morrer, aspirar XP, contar vazamentos. A ordem importa, e ela é explícita e legível, ali no main.ts. Dentro de um sistema o formato é sempre o mesmo: pergunta pro world quais entidades têm os componentes que você quer, e faz o loop.

for (const eid of query(world, [Enemy, Position])) {
  Position.x[eid] += Math.cos(Enemy.angle[eid]) * Enemy.speed[eid] * dt
  // ...
}

Um aviso pra quem vem do bitECS antigo: a 0.4 largou aquela dança de factory defineQuery/defineSystem da 0.3. Você só chama query(world, [A, B]) inline, mais addEntity, addComponent e removeEntity. Menos peça pra dar errado, o que combina com um código que tem alergia a cerimônia.

O footgun: as entidades são recicladas

Aqui vai a única cilada de verdade do struct-of-arrays, e ela me mordeu antes de eu respeitar ela. O bitECS reaproveita eids. O inimigo 7 morre, o removeEntity libera o slot 7, e o próximo addEntity entrega o slot 7 na lata pra um projétil. Só que Enemy.hp[7], Enemy.burnRemaining[7] e todos os outros arrays ainda guardam os números velhos do inimigo morto. Nada limpa eles pra você.

Então a disciplina é uma linha só, e ela não é negociável: inicialize todo campo no spawn. O código de spawn escreve todos os trinta e tantos campos de Enemy toda santa vez, até os que são quase sempre zero:

Enemy.hp[eid] = hp
Enemy.slowRemaining[eid] = 0
Enemy.burnRemaining[eid] = 0
Enemy.poisonStacks[eid] = 0
Enemy.hexRemaining[eid] = 0
// ...todo campo, sem exceção

Esquece um campo e você ganha um bug genuinamente assombrado: um inimigo recém-nascido que já spawna pegando fogo, herdando um burn de quem foi o último dono daquele slot. Depois que você internaliza "os arrays estão sempre sujos, então sobrescreve tudo", para de ser assustador. É a versão ECS de nunca confiar em memória não inicializada.

Tudo que interessa mora em JSON

Torres, inimigos, cartas de upgrade e layouts de fase são todos dados, não código. Eles moram em arquivos JSON com wrappers fininhos e tipados em TS, então towers.json é o elenco e towers.ts é só o loader que mapeia ele num TowerDef[] tipado. O comportamento das torres é despachado por um campo string behavior: "chain", "beam", "cone", "mortar", "utility" e cia. Adicionar um arquétipo novo de torre é, na maior parte, escrever um objeto JSON e, se o comportamento for realmente novo, um case num switch. As dezesseis torres do post 05 são muito mais dado do que código.

Três padrões que eu curto

As stats do projétil são assadas no spawn. Quando uma torre atira, ela lê o próprio nível atual, multiplica pelos buffs globais do jogador, e carimba o resultado no projétil:

Projectile.damage[pid] = lvl.damage * w.game.stats.damageMult
Projectile.splashRadius[pid] = (lvl.splashRadius ?? 0) * w.game.stats.splashRadiusMult

A partir daquele momento o projétil tá por conta própria. O sistema de projéteis lê só o projétil, nunca a torre que atirou. Então um tiro no ar quando a torre é vendida, realocada ou upada ainda acerta exatamente pelo dano com que nasceu. Atirar e voar ficam completamente desacoplados, o que mata uma categoria inteira de bug do tipo "e se a fonte mudar no meio do voo?" antes dela conseguir existir.

Cruzamento de armadilha sem raycast. Armadilhas ficam numa distância fixa ao longo do caminho, e os inimigos precisam ativar elas não importa a velocidade que estejam. Em vez de raycast ou checagem de overlap, todo inimigo guarda tanto o pathDist desse frame quanto o prevPathDist do frame passado. Uma função pura decide o resto:

export const crossed = (prev: number, next: number, mark: number): boolean =>
  prev < mark && next >= mark

O inimigo passou da marca nesse frame? Só se ele estava antes dela no frame passado e está na marca ou depois agora. Isso aguenta qualquer velocidade, qualquer framerate, e um inimigo lento se arrastando a 0,15x ativa com a mesma confiança que um boss passando voando. Duas subtrações e uma comparação, zero geometria.

O funil de dano. Toda fonte de dano do jogo, projéteis, feixes, burns, poison, armadilhas, habilidades de boss, passa por um único dealDamage() impuro. Essa função junta o contexto bagunçado (armadura, rolagem de crit, debuffs de hex, o hit-cap de boss, se o inimigo tá com escudo) e entrega a aritmética de verdade pra um resolveDamage() puro:

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)
}

Como isso é uma função pura, toda a matemática de combate é testada em unidade isoladamente, e é exatamente o tipo de coisa que ganha um ciclo red/green de verdade. "Um sniper com hit-cap de 2% não pode dar mais de 18 de dano num boss de 900 de HP" é uma asserção de uma linha, não um playtest:

expect(resolveDamage({ base: 5000, hitCapPct: 0.02, hpMax: 900 })).toBe(18)

Escreve esse teste primeiro, vê ele falhar, faz ele passar. Sem precisar de boss nenhum.

Onde os testes param (de propósito)

Esse último ponto é a filosofia inteira. As funções puras em src/lib/ (targeting, dano, chaining, paths, waves) são testadas em unidade com Vitest, porque são só entrada e saída. As camadas systems/, render/ e main.ts são deliberadamente sem teste: são orquestração impura, só efeito colateral e chamada de engine, e testar elas ia significar mockar metade da LittleJS pra afirmar que uma partícula apareceu. Não vale a pena. Então a regra é: se um pedaço de lógica merece confiança, ele é puxado pra uma função pura e preso por um teste. Se é só fiação, fica num sistema e ganha confiança sendo sem graça.

Tem uma aura de Drummer que mostra o mesmo instinto. É uma torre de suporte que buffa a cadência das torres vizinhas, e a versão ingênua tem toda torre perguntando por Drummers por perto todo frame, o que é um pesadelo de alcances sobrepostos. Em vez disso a aura é calculada uma vez por frame com uma regra simples de "o melhor vence, sem stack": junta as fontes de aura, e pra cada torre pega a única mais forte que cobre ela. Isso colapsa uma query muitos-para-muitos numa função pura auraAt(pos, sources), que, claro, tem o próprio teste.

Duas regras da casa

Congela, não pausa. Quando você sobe de nível e a tela de cartas aparece, o jogo "congela", mas ele não chama o setPaused da engine. Em vez disso a fase muda de 'playing' pra outra, e o bloco grandão de sistemas de simulação (spawn, movimento, tiro e o resto) simplesmente não roda naquele frame. O que continua rodando é tudo que é cosmético: partículas flutuam, o overlay anima, os números de dano terminam de subir. O mundo prende a respiração enquanto a UI continua viva. Pausar a engine ia congelar as coisas bonitas junto, e fica com cara de jogo travado. Assim fica com cara de momento suspenso.

O robô não pode jogar. Regra da casa nesse projeto: o assistente de IA só roda npm run test e npm run build, e nunca abre o jogo de verdade. Todo o playtest é feito na mão, por mim. Isso não é superstição, é consequência direta de tudo lá em cima: o código é arrumado pra que as perguntas que valem a pena responder sejam respondidas pela suíte de testes, e as que não dão ("aspirar XP com o mouse é gostoso?") só podem ser respondidas por um humano com a coisa rodando. Então eu confio nos check verdes e confio nas minhas próprias mãos, e não confio no relatório de um agente dizendo que a vibe tava boa. A vibe é comigo.

É isso, a série

É o fim do td-survivors, pelo menos nessa passada por ele. Ao longo de dez posts a gente cobriu o loop dois-gêneros-num-sobretudo, as dezesseis torres, os inimigos que andam no caminho, os bosses tamanho família, as onze fases trapaceiras, as trinta cartas de upgrade, e agora a máquina embaixo de tudo.

Um último callback. O meu outro jogo, o Memory Falling Into Place, roda numa stack completamente diferente: Excalibur, actors, scenes, uma engine orientada a objetos, nada dessa história de array. E mesmo assim o mesmíssimo instinto aparece nos dois códigos: manter a lógica pura separada da renderização, pra que as partes que você precisa ter certeza possam ser provadas por uma chamada de função em vez de pilotar uma nave contra a parede cinquenta vezes. Engine diferente, gênero diferente, mesmo reflexo. É o único hábito que eu levaria comigo não importa o que eu fosse construir depois.

E um lembrete, uma última vez, já que eu falei isso em quase todo post: nada do que você viu é final. A arte é o tileset CC0 do Kenney, o áudio são os pacotes CC0 do Juhani Junkala, a fonte é a "Press Start 2P", e até o nome "td-survivors" é um placeholder pra um nome que eu ainda não pensei. O ponto de tudo isso, dessa série inteira, era a arquitetura embaixo da casca de placeholder.

Da próxima vez que você ver o td-survivors, o plano é que a programmer art finalmente tenha ido embora, o nome seja um de verdade, e no lugar de trechos de código eu tenha footage de playtest pra te mostrar. Valeu por ler até o fim. A gente se vê quando isso parecer um jogo.