Skip to content
Go back

How to use animated sprites and animations with Excalibur and the LDtk level editor

I have been experimenting with game development recently. Excalibur seems like a great 2D game engine for Typescript and LDtk is a very intuitive level editor that is compatible.

Sadly, it does not have support for configuring animated sprites, and it seems like at least more complex player animations are unlikely to be on the roadmap.

I wanted to share the solution I chose, in case it helps someone. With this, you can configure animations directly in LDtk and visually select and see the tiles from the tileset in the level editor (however, sadly not the animation itself).

To do so, we set up an entity and add a custom Array<Tile> field for each animation, with a number of values equal to the animation length.

Custom Array<Tile> field for an entity with animations
Custom Array<Tile> field for an entity with animations

Now, we can configure these animations directly in the editor after placing an entity. If an animation is mission, errors will be shown and selecting sprites for the animation is intuitive because you can see the tile you have selected :).

Characters can be configured with animations based on the custom tile field
Characters can be configured with animations based on the custom tile field

When loading an entity from the exported level, we can create animations based on the information in the fields (but have to manually map the animation names to the field names):

import type { LdtkEntityInstance } from "@excaliburjs/plugin-ldtk";
import * as ex from "excalibur";

interface LdtkFieldInstance {
  __identifier: string;
  __value?: unknown;
}

interface LdtkTileRect {
  x: number;
  y: number;
  w?: number;
  h?: number;
}

// Helper to create animation from LDTK Tile field
const createAnimation = async (
  imageSource: string,
  entity: LdtkEntityInstance,
  fieldName: string,
  animationFrameDuration: number,
  isIdle: boolean
): Promise<ex.Animation> => {
  const tilesField = entity
    .fieldInstances
    .find((f: LdtkFieldInstance) => f.__identifier === fieldName);

  const tiles = tilesField?.__value as LdtkTileRect[] | null;

  if (tiles && tiles.length > 0) {
    // Create frames from tile data
    const frames: ex.Frame[] = [];
    for (const tile of tiles) {
      const sprite = new ex.Sprite({
        image: imageSource,
        sourceView: {
          x: tile.x,
          y: tile.y,
          width: tile.w || 16,
          height: tile.h || 16,
        },
        destSize: {
          width: tile.w || 16,
          height: tile.h || 16,
        },
      });
      frames.push({
        graphic: sprite,
        duration: animationFrameDuration,
      });
    }

    return new ex.Animation({
      frames,
      strategy: isIdle ? ex.AnimationStrategy.Freeze : ex.AnimationStrategy.Loop,
    });
  }

  // Fallback: use the entity's main tile as a single-frame animation
  const entityTile = entity.__tile;
  if (entityTile) {
    const sprite = new ex.Sprite({
      image: imageSource,
      sourceView: {
        x: entityTile.x,
        y: entityTile.y,
        width: entityTile.w || 16,
        height: entityTile.h || 16,
      },
      destSize: {
        width: entityTile.w || 16,
        height: entityTile.h || 16,
      },
    });

    return new ex.Animation({
      frames: [{
        graphic: sprite,
        duration: animationFrameDuration,
      }],
      strategy: ex.AnimationStrategy.Freeze,
    });
  }
  
  throw new Error(`No animation data found for ${fieldName}`);
};

About Me

I research open data and collaborative data engineering. In another life, I build custom software and consult on data science and software engineering. Sometimes, I create (mostly digital) projects for fun.
For freelance work, project ideas or feedback, email me: philip@heltweg.org.
Subscribe for more writing like this:

Powered by Buttondown ↗


Share this post on:

Next Post
Can a domain-specific language improve program structure comprehension of data pipelines? A mixed-methods study