Purpose: How players and structs are drawn — layered PFP compositing, struct art selection, the Lottie lifecycle, and the queue that keeps combat animations coherent.
A player’s avatar is not an image file. It is five PNG layers composited by index, painted back to front: background, arms, body, neck, head.
// src/js/view_models/components/PfpViewerComponent.js
const layers = [
['background', this.pfp.background],
['arms', this.pfp.arms],
['body', this.pfp.body],
['neck', this.pfp.neck],
['head', this.pfp.head],
];
return layers
.filter(([, index]) => index !== null && index !== undefined)
.map(([part, index]) =>
`<img class="pfp-viewer-layer" src="/img/pfp/${part}/pfp_${part}_${index}.png" alt="">`
)
.join('');
Path convention is /img/pfp/{part}/pfp_{part}_{index}.png. All five layers are absolutely
positioned at 72×72 in the portrait container, so later DOM nodes paint on top.
Indices are 1-based and bounded by:
// src/js/constants/PfpConstants.js
head: 87, neck: 10, body: 57, arms: 34, background: 6
That is 87 × 10 × 57 × 34 × 6 ≈ 10.1 million combinations from 194 assets.
The indices live on the player as pfp_client_render_attributes, parsed from chain data
by PlayerFactory. Note the trap: Player also has a pfp string field that is not
used for rendering. Reading it and expecting an image URL is a natural mistake.
Two fallback behaviours, and one gap:
/img/portrait-placeholder.png.null → that layer is skipped.PFP_PART_COUNTS yourself.Rendering a PFP requires no network call beyond the layer PNGs, which makes it cheap to
show avatars in a long roster. Randomising one at signup is Math.floor(Math.random() *
count) + 1 per layer.
Two rendering modes, and the choice between them is about state, not about quality.
| Mode | Used for |
|---|---|
| Static still — layered PNGs | Resting appearance on a map tile |
| Lottie animation — SVG | Combat, deployment, movement, stealth, destruction, active loops |
StructTypeArtSetBuilder maps each struct type to its PNG layers under /img/structs:
buildCruiser(structType) {
const art = new StructTypeArtSet(structType);
art[STRUCT_STILL_LAYERS.STRUCT_VARIANT_BASE] = this.structImageDir + '/cruiser/cruiser-struct-base.png';
art[STRUCT_STILL_LAYERS.STRUCT_VARIANT_DMG] = this.structImageDir + '/cruiser/cruiser-struct-dmg.png';
art[STRUCT_WEAPON_SYSTEM.PRIMARY_WEAPON] = this.structImageDir + '/cruiser/cruiser-top-weapon-ballistic.png';
art[STRUCT_WEAPON_SYSTEM.SECONDARY_WEAPON] = this.structImageDir + '/cruiser/cruiser-top-weapon-smart.png';
art[STRUCT_WATER_RIPPLE] = this.structImageDir + '/cruiser/cruiser-bottom-ripples.png';
return art;
}
Variant selection is driven by health, with per-type layout differences — a Frigate puts its primary weapon on the bottom detail layer, a Cruiser on top:
renderHTML(currentHealth = -1, structVariant = '') {
if (currentHealth === 0) {
return `<div class="struct-still"></div>`; // destroyed: nothing
}
if (structVariant === '') {
structVariant = (0 < currentHealth && currentHealth < this.structType.max_health)
? STRUCT_STILL_LAYERS.STRUCT_VARIANT_DMG
: STRUCT_STILL_LAYERS.STRUCT_VARIANT_BASE;
}
return `
<div class="struct-still">
${this.renderTopDetailLayers(structVariant)}
${this.renderStructVariant(structVariant)}
${this.renderBottomDetailLayers(structVariant)}
</div>
`;
}
Three z-index bands order the layers: bottom detail 100, body 200, top detail 300.
Stealth is handled two ways. Most types get 50% opacity via struct-stealth-active; a
Submersible swaps to a dedicated STRUCT_VARIANT_HIDDEN sprite, because a half-transparent
submarine is not the same idea as a submerged one.
Note that StructTypeArtSetBuilder has no art set for Continental Power Plant or World
Engine, and will throw if asked for one. Those types presumably never reach a tactical
map, but guard for it if your surface is more general.
lottie-web 5.12.2, loaded from /js/vendor/, always with the SVG renderer. Animation
JSON lives at predictable paths:
/lottie/deployment_{space|air|land|water}/data.json shared by ambit
/lottie/attack_primary_weapon/{snake_case_type}/data.json per struct type
/lottie/stealth_activate/{snake_case_type}/data.json per struct type
The still and the animation are mutually exclusive: playing an animation hides the still container, and structs with an active loop (extractors and refineries while online) hide the still permanently in favour of the loop.
Lottie lifecycle is where the memory leaks are, and the reference client is explicit about it:
/**
* Tear down all lottie players owned by this viewer. Must be called before
* the viewer is dereferenced (e.g. when its tile is cleared or the viewer
* is replaced), otherwise lottie-web's internal animation registry keeps
* the JSON, image bitmaps, rAF subscription, and DOM listeners alive.
*/
Dropping the reference is not enough — lottie-web holds its own registry. The teardown
chain is MapStructViewerComponent.destroy() → lottieCustomPlayer.destroyAll() →
animation.destroy(), and the map layer calls it before overwriting a tile, not
after.
Animations load lazily on first play. During playback the client swaps struct PNGs into
named SVG placeholders (.struct_init, .struct_top_layer_1), using a generation counter
so a stale async swap from a previous animation cannot land on the current one.
A single attack produces a burst of GRASS events: the attacker fires, the defender evades, a point-defence cannon counters, a struct takes damage, a struct is destroyed. Played concurrently, they trample each other’s partial state — health bars and still images end up showing a mixture of moments that never existed.
AnimationEventQueue serialises them globally. One animation plays at a time, and the
next starts only when the previous reports it has ended:
// src/js/data_structures/AnimationEventQueue.js
enqueue(event) {
super.enqueue(event);
if (!this.isPlaying) {
this.playNext();
}
}
playNext() {
if (this.isEmpty()) {
const wasPlaying = this.isPlaying;
this.isPlaying = false;
this.currentEvent = null;
if (wasPlaying) {
window.dispatchEvent(new CustomEvent(EVENTS.ANIMATION_QUEUE_EMPTY));
}
return;
}
this.isPlaying = true;
this.currentEvent = super.dequeue();
window.dispatchEvent(this.currentEvent);
}
StructListener.handleStructAttack expands one struct_attack message into an ordered
list of AnimationEvents and enqueues them in narrative order.
Three consequences ripple outward from this design, and each is worth copying:
Game state and displayed state diverge on purpose. gameState holds the final
post-attack values the moment the event arrives, while animation events carry
options.healthAfter so each step shows the health at that moment. The bars catch up as
the sequence plays.
Other renderers defer to the queue. The HUD layer holds back struct-HUD renders while
an animation is playing and flushes them on ANIMATION_QUEUE_EMPTY:
if (this.gameState.animationEventQueue && this.gameState.animationEventQueue.isPlaying) {
this.deferredHudRenders.set(event.struct.id, event.struct);
return;
}
StructManager likewise skips showing a still while isStructAnimating(struct.id), so a
routine refresh cannot tear down a Lottie mid-play.
Chained side effects hang off onAnimationEnd. A move sequence clears the old tile
and refreshes the struct after the departure animation and before the arrival one, which
is the only way to get that ordering right.
Deployment animations are the exception: they autoplay when a struct first renders after being built, bypassing the queue entirely, because nothing else is competing at that moment.
Verified against structs-webapp 6eec7f7 (2026-07-21):
src/js/view_models/components/PfpViewerComponent.js, StructStillRenderer.js,
MapStructViewerComponent.js, MapStructLottieAnimationSVG.js,
src/js/data_structures/AnimationEventQueue.js.