Serializing and deserializing an ECS in JavaScript/TypeScript

I've been playing around more and more with some prototypes for my online persistent-universe single/coop player space game. In the last iteration, I wanted to use an ECS for getting a feel of how it might look like whether I am to implement an ECS in the production server code.

Reasons for the ECS research

Using an ECS occurred to me thinking of how might I show the "reasons" behind some resource change. In my case, the "Lifeforce" resource, which can either be increased by lifeforce generation or decreased by mining or population. By creating several LifeforceConsumption components and attaching them to a planet entity for example, I can both calculate and control or show what affects that planet's Lifeforce.

Here's an example from the current prototype I'm working on (ignore graphics, released game will be totally different).

image.png

I didn't want to implement my own ECS because that's boring. I chose This guy's ECS, implemented in TypeScript (github.com/Trixt0r/ecsts).

Hello, let's persist the galaxy

The prototypes I'm working on are eventually React or Svelte Apps (see other prototype post) or whatever that just simulate things to test game design ideas and balance them.

Running on the browser, these prototypes wouldn't be very helpful if they are reset and cleared on a browser refresh, or worse, on a development hot-reload event.

The ECS-based prototype proved to be an interesting challenge for persistence. Taking all entities in my engine, I should somehow serialize each entity's components and deserialize them properly on reload. Properly means that the actual component class instance must be recovered.

This is the interesting part

I came up with something like this. The basic idea is to "prepare" the components for JSON.stringify'ing. The name of the class must be kept as a string and the data is a simple stringify.

export function prepareEntityForSerialization(entity: AbstractEntity): {
  id: ObjectId;
  components: Array<{ name: string; data: Component }>;
} {
  const components = entity.components.reduce<
    Array<{ name: string; data: Component }>
  >((acc, component) => {
    acc.push({ name: component.constructor.name, data: component });
    return acc;
  }, []);
  return { id: entity.id, components };
}

The components' data I write are easily serialized with JSON.stringify, I try to keep them as simple as possible. It's just the component.constructor.name that I need to add.

When recalling the entity, I use the magical Object.setPrototypeOf on a newly created object and set its prototype to the real class component mapped by the name we saved before. I'll show the mapping in a moment. Nice.

export function recallEntityFromSerialization(serializedEntity: {
  id: ObjectId;
  components: Array<{ name: string; data: Component }>;
}): GenericEntity {
  const components = serializedEntity.components?.map(({ name, data }) => {
    const component = data;
    const prototype = nameToComponent[name]?.prototype;
    if (prototype) {
      Object.setPrototypeOf(component, prototype);
    }
    return component;
  });
  const entity = new GenericEntity(components, serializedEntity.id);
  return entity;
}

The mapping I used is a bit of a bummer, because at its current implementation I have to remember to add each component class I create to it. There are smarter ways to do that but I didn't want to invest time on this in prototyping time. I'd be happy to hear suggestions though!

// TODO: create this list by filenames from "components/*" folder?
const supportedComponents = [
  ConsumesLifeforce,
  HasLifeforce,
  HasPosition,
  IsPlanet,
  IsPlanetSlot,
  Colonized,
  Builder,
  HasStockpile,
  IsShip,
  HasDestination,
];

const nameToComponent = supportedComponents.reduce<Record<string, Component>>(
  (acc, componentClass) => {
    acc[componentClass.name] = componentClass;
    return acc;
  },
  {}
);

This is basically it. A last small bit is using this in the serialization method of the Game class:

  serialize(): string {
    return JSON.stringify({
      tickCount: this.tickCount,
      entities: this.engine.entities.map(prepareEntityForSerialization),
    });
  }

Phew. Learning javaScript's prototypal inheritance paid off at the end. What a fruitful day.