// part 2 of 3

Sprites, Anchors & Painted Art

How does a zombie go from “artwork” to “a moving thing with a health bar”? This part follows the art through the whole pipeline — and ends with the best kind of lesson: a redesign that shipped without touching the engine.

Two kinds of “UI element” — keep them separate in your head

Game-world elementsHUD elements
Exampleszombies, towers, projectiles, the gate, the keep, the placement ghostgold/lives counters, build cards, upgrade popup, pause overlay
Built withauthored SVG art, positioned imperatively by the game loopnormal React JSX + CSS
Changesevery single frame (position, facing, HP bar width)only when a value a human would notice changes

This part is about the first column — the actual game art, where the question "what format is this?" has an interesting answer.

The format: SVG markup, stored as JavaScript strings

Open td-sprites.js and there are no image files in sight. Each sprite is a complete SVG document as a string — literal <path> and <rect> markup, generated from a design tool and committed like a build artifact (the header says: do not hand-edit, regenerate instead). Next to the art sits the metadata that makes it usable:

td-sprites.js — three exports
export const PALETTE = { outline: "#1c1510", boardGrass: "#3b3a28", /* … */ }

export const META = {
  "zombie-shambler": { w: 100, h: 112, ax: 46, ay: 103 },  // size + anchor
  // …
}

export const SPRITES = {
  "zombie-shambler": "<svg viewBox=\"0 0 100 112\" …>…paths, rects…</svg>",
  // …
}

SVG (Scalable Vector Graphics) describes art as shapes and coordinates rather than a grid of pixels — so it stays crisp at any zoom, which is why the game's fullscreen mode costs nothing. When a zombie spawns, makeEl() turns its string into a real DOM node once; from then on, moving it is a single attribute write per frame:

ZombieTowerDefense.js — one write per entity per frame
z.el.setAttribute('transform',
  `translate(${z.x} ${z.y}) rotate(${bob}) scale(${dir * s} ${s}) translate(${-m.ax} ${-m.ay})`)

The anchor point — the one idea worth really understanding

A sprite is authored as a little rectangle of art with its own top-left as (0,0). But "put this zombie at position (x, y)" doesn't mean its top-left corner — it means its feet should stand at that spot on the road. The pixel inside the sprite that should land exactly on the world position is the anchor point, stored as ax, ay in META. Read the transform above right-to-left: slide the sprite back by its anchor (translate(-ax -ay)), scale and rotate around that point, then place that point at the world position (translate(x y)).

A sprite here is not a picture — it's a queryable DOM tree

Because sprites are live SVG elements rather than flattened images, the engine can reach inside one. Health bars are the clearest payoff: they aren't an overlay drawn on top of zombies — they are <rect> elements baked into each zombie's own artwork, found by CSS selector and driven directly:

ZombieTowerDefense.js — the HP bar is part of the zombie
function hpRefs(g) {
  const frame = g.querySelector('rect[fill="#141210"]')                 // bar frame
  const fills = [...g.querySelectorAll('rect[fill="#7ac74f"]')]         // green fill
  return { frame, fills }
}
// every frame: fills[0].setAttribute('width', hpFraction * fullWidth)

A raster image could never offer this. Notice the contract hiding in it, though: every zombie sprite must contain rects with exactly those fill colors, or hpRefs() comes back empty. Implicit contracts like this are the price of clever tricks — part of why the painted-art upgrade below had to be done carefully.

The painted-art era: upgrading the art without touching the engine

The game shipped with pure vector art. Later, the main actors — all four towers at every level, the keep, the crypt gate, several zombies — were repainted as AI-generated illustrations (1024² PNG renders with transparent backgrounds). Here is the pipeline that got them into the game:

generate
AI image model paints each structure on a transparent background
trim
crop each PNG to its visible pixels — ship no wasted bytes
describe
an ART table entry per sprite: image URL + world footprint + anchor
override
registerElementArt() swaps entries into SPRITES/META at startup
td-sprites-elements.js — one painted-art entry
'tower-watch-1': {
  url: '/games/td/tower-watch-1.png',
  aw: 702, ah: 961,   // trimmed PNG pixels (aspect ratio only)
  h: 124, ay: 107,    // world-unit height + ground anchor — the size dial
  label: 'watchtower level 1',
},

The trick making this safe: each override keeps the same contract as the sprite it replaces — same META-style footprint and anchor, the ground shadow, and for zombies the same baked HP-bar rects. The engine literally cannot tell the art changed. The PNG is wrapped in an SVG <image> element, so the whole render path — makeEl(), the anchor transform, the depth sort — runs unmodified.

What other formats could this have been?

FormatVerdict for this engine
Inline SVG strings (used)The baseline: live, addressable, crisp at any zoom, zero asset loading.
PNG via SVG <image> (also used)The painted-art layer — pixels for richness, wrapped in SVG so the engine contract holds.
External .svg files in <img>Partial fit: an <C>&lt;img&gt;</C>-loaded SVG is opaque — JS can’t reach inside, so the HP-bar trick dies.
Raster sprite sheet / texture atlasNot a format swap — it implies a canvas or GPU renderer. That’s part 3’s territory.
Lottie / animation JSONGreat for one polished pre-baked flourish; wrong tool for hundreds of game-logic-driven entities.
glTF / 3D formatsDifferent dimension, literally. Only relevant if the game went 3D, which means a WebGL renderer first.

The honest summary: nothing else is a drop-in swap, because this renderer isn't "a thing that displays pictures" — it's a thing that queries and mutates live SVG DOM. Any format that isn't itself addressable DOM needs a different rendering strategy. Which is exactly the question part 3 answers.