GithubHelp home page GithubHelp logo

nsstc / sim-ecs Goto Github PK

View Code? Open in Web Editor NEW
84.0 5.0 12.0 3.52 MB

Batteries included TypeScript ECS

Home Page: https://nsstc.github.io/sim-ecs/

License: Mozilla Public License 2.0

TypeScript 100.00%
js javascript javascript-library ecs game-development game-engine game-engine-library simulation entity-component-system typescript sim-ecs entities ecs-libraries prefabs

sim-ecs's Introduction

sim-ecs

A type-based, components-first, fully async batteries-included ECS, which is optimized for simulation needs. There's a big emphasis on developer experience, like type-hinting and auto-completions. Sim-ecs will run in NodeJS, Deno, BunJS and the browser.

It can be installed using your favorite package manager, for example:

$ npm install sim-ecs

or used as direct import for Deno:

import * as simEcs from "https://deno.land/x/[email protected]/src/index.ts";

Considerations

This ECS is inspired by SPECS and bevy-ecs (two Rust ECS libraries), however optimized for TypeScript. It is built for easy usage (DX) and high iteration speed. The trade-off is that insertion and deletion are slower, however there are optimizations and opinionations in place to still make it fast. For example, by using commands, these operations are batched and executed when it is safe to perform them.

In order to create optimized simulation runs, the ECS has to be fully specified in the beginning. All components, systems and queries need to be registered at the start.

AoS vs SoA

Sim-ecs uses the AoS (Array of Structs) approach, because it leads to much more control on the library side, and better lends itself to the promises we make about sim-ecs. The result is a polished experience with simple usage.

An SoA (Struct of Arrays) on the other hand is an approach famously used by bitecs to get performance out of low-level mechanics in JS. This means it has overall better raw performance, but puts a lot of responsibilities on the lib-users' side. This leads to more time spend developing ecs features, which already exist in sim-ecs.

Why use sim-ecs

Sim-ecs was created out of the lack of a fast, featured ECS library for TypeScript, which is able to handle the requirements in a big, agile game project. While there are other ECS libraries available, they do not necessarily cater to that goal and will have short-comings in such an environment.

Sim-ecs comes with batteries included to make sure everything fits together and is simple to use. The focus is on developer-friendliness (by supporting full type/intention support in the IDE), support for a variety of scenarios and performance.

Since sim-ecs implements many bevy-ecs RFCs, it is very featured and modern. It can be used inside a generic game engine or for a game directly.

Runtime requirements

If using the prebuilt library, "ES2020" was selected as the build target. Hence, this is the matrix:

App Version Comment
Chrome 80+ Desktop and mobile
Edge 80+
Safari 14.1+ Desktop and mobile
Firefox 80+ Desktop (no info on mobile)
Opera 67+ Desktop
Samsung Internet 13.0+
NodeJS 14+
Deno 1.0+
Bun 0.2.2+

Examples

For quickly seeing the ECS in action, there are several examples available. You can find them in the /examples directory.

Counter

$ npm run example-counter

The counter example is a very small, minimal example to get a quick overview. It increases a number a few times and then terminates.

Events

$ npm run example-events

The events example demonstrates how to use the event bus to write and read events. It will print a message every second.

Pong

Pong can be played with the keyboard and saves on pausing.

$ cd examples/pong && npm install && npm run start

Pong is a full game which can be run in the browser. It demonstrates all features of sim-ecs. It comes with numerous components and systems, handles states and makes use of prefabs and saves. Since it is an ECS demo, other parts of the game code may be minimal, like rendering and sound. It is recommended to use readily available libraries for these parts for any real endeavour, like BabylonJS.

You will need to build Pong from its directory. Then, you can open the index.html in the public folder to run the game.

System Error

On error, detailed information including the system's name can be retrieved

$ npm run example-system-error

Error handling is very simple with sim-ecs. It uses the events system to catch and provide handling opportunities without aborting the execution. The System-Error example demonstrates how error handling works with a simple example.

Where is the Documentation

Anything which is not explained in detail enough in this README can be found in the code. You will notice that there are spec-files. These contain the specification for a certain class, which usually means an interface with comments on what the methods do.

Also, there is a generated API-documentation available!

Creating the ECS and a World

In an ECS, a world is like a container for entities. Sim-ecs comes, by default, with two variants: A prepare-time world and a runtime world.

See "Counter" example  

Prepare-Time World

The prepare-time world is a place which focuses on easily preparing a simulation. That means, this is the place where everything should be defined and readied.

const prepWorld = buildWorld().build();

See IPreptimeWorld, "Counter" example  

Runtime world

After the preparation is done, a runtime world can be forked, which is optimized for executing a simulation. One of the main differences is that this world is not as configurable, in order to optimize for what was set up in the prep-time world.

const runWorld = await prepWorld.prepareRun();

See IRuntimeWorld, "Counter" example  

Scheduling a run

In sim-ecs, a run has to be planned ahead. This is done by giving a developer the means to put systems into stages and then decide in which order stages should run and if they run in parallel.

One thing to add is that a pipeline, which contains the entire program order, is made up of "Sync Points". These constructs allow for hooking into the plan in a non-destructive way. For example third-party code (like plugins) can make use of such a feature to add their own Systems at the right place in the program chronology. If that's not necessary, sim-ecs will work fine with just the root Sync Point.

const prepWorld = buildWorld()
    .withDefaultScheduling(root => root
        .addNewStage(stage => stage
            .addSystem(CounterSystem)
        )
    )
    .build();

Since this is a very verbose way, sim-ecs also adds a data-driven approach, which enables schedules to be stored as simple arrays which can even be loaded without logic. The Pong example demonstrates this by providing several schedules, stored as separate data. In short:

import {buildWorld, ISyncPointPrefab} from "sim-ecs";

const gameSchedule: ISyncPointPrefab = {
    stages: [
        // Stages are executed sequentially (order guaranteed!)
        [BeforeStepSystem],
        [InputSystem],
        [
            // Systems inside a stage are executed in parallel, if possible (no order guaranteed!)
            MenuSystem,
            PaddleSystem,
            PauseSystem,
        ],
        [CollisionSystem],
        [BallSystem],
        [AnimationSystem],
        [
            RenderGameSystem,
            RenderUISystem,
        ],
        [ErrorSystem],
    ],
};

const prepWorld = buildWorld()
    .withDefaultScheduling(root => root.fromPrefab(gameSchedule))
    .build();

See "Counter" example, "Pong" example  

Setting Resources

Resources are objects, which can hold certain data, like the start DateTime.

// this call implicitely creates a new object of type Date. You can also pass an instance instead.
// you can pass arguments to the constructor by passing them as additional parameters here
prepWorld.addResource(Date);
console.log(world.getResource(Date).getDate());

See addResource(), getResource(), getResources()  

Defining Systems

Systems are the logic, which operates on data sets (components). They are logic building blocks which separate concerns and make the world move.

const CountSystem = createSystem({
    query: queryComponents({counterObj: Write(Counter)}),
})
    // this function is called every time the world needs to be updated. Put your logic in there
    .withRunFunction(({query}) =>
        query.execute(({counterObj}) => console.log(++counterObj.a))
    )
    .build();

See createSystem(), ISystemBuilder, "Counter" example, "Pong" example  

System Parameter Types

A system can request different types of parameter:

const CountSystem = createSystem({
    // Queries are most obvious, since they allow access to stored data
    // All parameters form the query, and only entities which match all criteria will be picked
    query: queryComponents({
        // Access the entity matching this query
        entity: ReadEntity(),
        // Access to a component
        counterObjR: Read(Counter),
        // This component may or may not exist, but if it does, it's readonly
        counterObjRO: ReadOptional(Counter),
        // Access a component in a mutable way
        counterObjW: Write(Counter),
        // This component may or may not exist, but if it does, it's mutable
        counterObjWO: WriteOptional(Counter),
        // If the component itself doesn't matter, but it must exist on the entity, this is the way!
        _counterObjWith: With(Counter),
        // It's also possible to require tags to be present. There's no value.
        _tag1: WithTag(ATag),
        // If the component itself doesn't matter, but it must _not_ exist on the entity, this is the way!
        _counterObjWithout: WithOut(Counter),
        // It's also possible to require that the entity does _not_ have a tag
        _tag2: WithoutTag(ATag),
    }),
    // As a way to pass information between systems and event the outside,
    // sim-ecs provides an event bus. It can be accessed easily from systems:
    eventReader: ReadEvents(MyEvent),
    eventWriter: WriteEvents(MyEvent),
    // If it's necessary to mutate the runnning world, "system actions" can be accessed!
    actions: Actions,
    // World-global resources can also be easily be added. Their access is cached, which is a performance boost
    resourceR: ReadResource(Date),
    resourceW: WriteResource(Date),
    // Last but not least, systems also allow for local variables,
    // which are unique to that system instance within a run.
    // Please prefer them over putting variables into the module's scope!
    // They can be declared using a generic (if needed) and initialized in the parameter 
    systemStorage1: Storage({ foo: 42, bar: 'Hello!' }),
    systemStorage2: Storage({ data: [1,2,3] }),
}).build();

See createSystem()  

Hot Reloading Systems

Systems can be hot-reloaded. The Pong example shows that off nicely. In order to enable HMR, the following is suggested:

  1. Make sure that you put every system into its own module (file), from which it's exported.
  2. Every system must be named.
  3. Implement your dev server's HMR strategy and on accept call hmrSwapSystem(). It takes the new system as its parameter, which you should get from the new module's exports.

For example, a system module may look like this:

import {createSystem, hmrSwapSystem, ISystem, queryComponents, Read, ReadResource, Write} from "sim-ecs";
import {Position} from "../components/position.ts";
import {Velocity} from "../components/velocity.ts";
import {GameStore} from "../models/game-store.ts";


// The System must be exported (of course)
export const AnimationSystem = createSystem({
    gameStore: ReadResource(GameStore),
    query: queryComponents({
        pos: Write(Position),
        vel: Read(Velocity),
    }),
})
    .withName('AnimationSystem')                                                  // It's important to name the System!!
    .withRunFunction(({gameStore, query}) => {
        const k = gameStore.lastFrameDeltaTime / 10;
        return query.execute(({pos, vel}) => {
            pos.x += vel.x * k;
            pos.y += vel.y * k;
        });
    })
    .build();

// using the dev server's HMR strategy...
// @ts-ignore
hmr:if (import.meta.hot) {
    // @ts-ignore
    import.meta.hot.accept(mod => 
        hmrSwapSystem(mod[Object.getOwnPropertyNames(mod)[0]] as AnimationSystem) // pass the System from the new Module
    );
}

Note: The label hmr was chosen as an easy and reliable way to remove this code block from a prod build.

See "Pong" example  

Defining Components

Components are needed to define data on which the whole system can operate. You can think of them like columns in a database. Any serialize-able object may be a component in sim-ecs.

class Counter {
    a = 0;
}

const prepWorld = createWorld().withComponent(Counter).build();
prepWorld.buildEntity().with(Counter).build();

In case you have advanced components, it is possible to pass a serializer and deserializer to the entity builder later on. If you don't do so, it is assumed that the component is a simple data struct. You can also use a default-type de-/serializer on save/load, which allows for a variety of standard types (such as Date) as components.

See IWorldBuilder, buildEntity(), IEntityBuilder  

Adding Entities

Entities are like glue. They define which components belong together and form one data. Entities are automatically added to the world they are built in. You can think of entities like rows in a database.

prepWorld.buildEntity().withComponent(Counter).build();

See buildEntity(), IEntityBuilder  

Working with States (optional)

States allow for splitting up a simulation into different logical parts. In games, that's for example "Menu", "Play" and "Pause". States can be switched using a push-down automaton. States define which systems should run, so that a pause-state can run graphics updates, but not game-logic, for example. If no state is passed to the dispatcher, all systems are run by default.

While the world is running (using run()), the state can be changed using commands. Single calls to step() do not offer the benefits of a PDA.

See "Pong" example  

Update loop

The update loop (for example game loop) is what keeps simulations running. In order to provide an efficient way of driving the ECS, sim-ecs offers its own built-in loop:

runWorld.start() // run() will drive the simulation based on the data provided to set up the world
    .catch(console.error) // this won't catch non-fatal errors, see error example!
    .then(() => console.log('Finished.'));

While this is the recommended way to drive a simulation, sim-ecs also offers a step-wise execution: runWorld.step(). Note, though, that each step will need to run the preparation logic, which introduces overhead!

Commands

Commands, accessible using runWorld.commands and actions.commands in Systems, are a mechanism, which queues certain functionality, like adding entities. The queue is then worked on at certain sync points, usually at the end of every step. This is a safety and comfort mechanism, which guarantees that critical changes can be triggered comfortably, but still only run at times when it is actually safe to do them.

Such sync points include any major transitions in a step's life-cycle, and sim-ecs will always trigger the execution of all queued commands at the end of the step.

See ICommands  

Saving and using Prefabs

Prefabs, short for pre-fabrications, are ready-made files or objects, which can be loaded at runtime to initialize a certain part of the application. In the case of sim-ecs, prefabs can be used to load entities with their components.

All loaded entities are tracked and can be unloaded when not needed anymore. This is thanks to a grouping mechanism, which means that prefabs can be used to design menus, levels, GUIs, etc. which are only loaded when needed and discarded after use. After all, who needs level1 data when they switched over to level2?

The same is true for save games, so that when going back to the menu or loading another save, this can be done cleanly.

Saving and loading save-data works the same in sim-ecs, since they both use a shared mechanism If you wish to work with the raw serializable data instead of writing it as JSON, the SaveFormat extends Array, so it can be used just like an Array<TEntity>.

enum MonsterTypes {
    Duck,
    Lizard,
    Tiger,
}

// loading a prefab, the prefab might be in a different file, even maybe just JSON data!
const prefab = [
    {
        Position: {
            x: 0,
            y: 1,
        } satisfies Position,
        Player: {
            level: 1,
            name: 'Jane',
        } satisfies Player,
        Health: {
            current: 100,
            max: 100,
        } satisfies Health,
    },
    {
        Position: {
            x: 0,
            y: 1,
        } satisfies Position,
        Monster: {
            hostileToPlayer: true,
            type: MonsterTypes.Tiger,
        } satisfies Monster,
        Health: {
            current: 100,
            max: 250,
        } satisfies Health,
    },
];


// to load from JSON, use SerialFormat.fromJSON() instead!
const prefabHandle = prepWorld.load(SerialFormat.fromArray(prefab));

// ...

// unloading is also easily possible to clean up the world
runWorld.unloadPrefab(prefabHandle);
// saving a prefab from the current world. This may be used to write an editor
// or export a PoC for game designers to improve on
const jsonPrefab = runWorld.save().toJSON(4);
saveToFile(jsonPrefab, 'prefab.json');
// filtering what should be saved is also possible,
// so that only certain data is saved and not all data of the whole world
const saveData = runWorld.save(queryEntities(With(Player))).toJSON();
localStorage.setItem('save', saveData);

See load(), save(), "Pong" example  

Syncing instances

In order to keep several instances in sync, sim-ecs provides tooling. Especially when writing networked simulations, it is very important to keep certain entities in sync.

// OPTIONALLY initialize UUID mechanism
import {uuid} from 'your-favorit-uuid-library';
Entity.uuidFn = uuid; // type: () => string

// at the source, entities can be created as normal
const entity = prepWorld.buildEntity().build();

// IDs are created lazily when getting them for the first time
const entityId = entity.id;

// on another instance, you can assign the entity ID on entity creation:
const syncedEntity = prepWorld.buildEntity(entityId).build();

// in order to fetch an entity with a given ID, the ECS's function can be used
const entityFromIdGetter = getEntity(entityId);
// or inside a Query:
const {entityFromIdQuery} = queryComponents({ entityFromIdQuery: ReadEntity(entityId) }).getFirst();

Building for Production

When building for production, it is important to keep class names. Some minimizers need to be adjusted. The Pong example is a good template for a project setup for development and production builds.

Webpack

Webpack must use Terser. The config could look like this:

const TerserPlugin = require("terser-webpack-plugin");

module.exports = {
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
      terserOptions: {
        keep_classnames: true,
      },
    })],
  },
};

Plugins

Here's an overview of known plugins for sim-ecs:

Name Description

Performance

Please take the results with a grain of salt. These are benchmarks, so they are synthetic. An actual application will use a mix out of everything and more, and depending on that may have a different experience.

You can run these benchmarks on your own machine - they are in the examples/bench folder.

The below result compares several AoS-based ECS libraries, which are similar to sim-ecs. The only exception is bitecs, which is a SoA-based ECS library, for its usage in Phaser.

The Benchmarks

These benchmarks are based on the Rust ECS Bench Suite.

Simple Insert

This benchmark is designed to test the base cost of constructing entities and moving components into the ECS. Inserts 1,000 entities, each with 4 components.

Simple Iter

This benchmark is designed to test the core overheads involved in component iteration in best-case conditions.

Dataset: 1,000 entities, each with 4 components.

Test: Iterate through all entities with Position and Velocity, and add velocity onto position.

System Scheduling

This benchmark is designed to test how efficiently the ECS can schedule multiple independent systems. This is primarily an outer-parallelism test.

Dataset:

  • 10,000 entities with (A, B) components.
  • 10,000 entities with (A, B, C) components.
  • 10,000 entities with (A, B, C, D) components.
  • 10,000 entities with (A, B, C, E) components.

Test: Three systems accessing the following components mutably, where each system swaps the values stored in each component:

  • (A, B)
  • (C, D)
  • (C, E)

Serialize

This benchmark is designed to test how quickly the ECS can serialize and deserialize its entities in JSON.

Dataset: 1,000 entities, each with 4 components.

Test: Serialize all entities to JSON in-memory. Then deserialize back into the ECS.

The Result

--------------------------------------------------------------------------------
TypeScript ECS Bench
--------------------------------------------------------------------------------

22nd May 2024

Platform: Windows_NT win32 x64 v10.0.22631
CPU: AMD Ryzen 7 3700X 8-Core Processor@3600MHz
NodeJS: v21.1.0

Bench           v0.3.0
TypeScript      v5.4.5
TS-Lib          v2.6.2
TSX             v4.10.5

Ape-ECS         v1.3.1
bitecs          v0.3.40
Javelin         v1.0.0-alpha.13
sim-ecs         v0.6.5
tick-knock      v4.2.0

Measured in "points" for comparison. More is better!

Default Suite / Simple Insert

Library Points Deviation Comment
Ape-ECS 767 ± 1.1%
bitecs 11218 ± 0.41%
javelin 2028 ± 1.6%
sim-ecs 1057 ± 1.1%
tick-knock 7911 ± 0.22%

Default Suite / Simple Iter

Library Points Deviation Comment
Ape-ECS 23170 ± 0.21%
bitecs 119904 ± 0.36%
javelin 19845 ± 0.049%
sim-ecs 924 ± 0.23%
tick-knock 11038 ± 0.085%

Default Suite / Schedule

Library Points Deviation Comment
Ape-ECS 110 ± 0.13%
bitecs 7288 ± 0.19%
javelin 101 ± 0.071%
sim-ecs 244 ± 0.29%
tick-knock 55 ± 0.18%

Default Suite / Serialize

Library Points Deviation Comment
Ape-ECS 64 ± 1.4% file size: 417.3427734375 KB
Javelin 557 ± 1.3% file size: 31.1455078125 KB
sim-ecs 113 ± 1.6% file size: 92.677734375 KB

sim-ecs's People

Contributors

dependabot[bot] avatar dhruvdh avatar minecrawler avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

sim-ecs's Issues

Update Benchmarks

The bench suite needs to be current state of the art, and add more benchmarks. It's based on an old version of the rust ecs benchmark...

At the same time, sim-ecs should now be integratable into other benchmarks, which work step-by-step (which is synthetic and has nothing to do with real applications..... but whatever), so maybe sim-ecs could be part of other benchmark suites as well and get more visibility from there.

Fix WebPack release builds

When building the release version of Pong, it fails at runtime with

Uncaught (in promise) Error: Component  was already registered!
    registerComponent http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:16
    withComponent http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:31
    <anonymous> http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:16
    <anonymous> http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:16
    n http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:1
    <anonymous> http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:16
    n http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:1
    <anonymous> http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:1
    <anonymous> http://localhost:63342/sim-ecs/examples/pong/public/bundle.js:1
bundle.js:16:24187

This needs further analysis, however it should be possible to build release-versions using sim-ecs, so we need urgent fixing here.

improve execution-pipeline-caching

While there is caching for execution-pipelines (the order in which systems run, one pipeline per state), it is not used optimally. The cache is tested on every state push, but run(), for example, starts a new build without checking the cache. Also, there is no way to prepare the cache for certain states (or all of them) at a user-defined point, which means that a state-push may mean hidden, unexpected costs. I imagine a public method, which takes a state and then prepares and caches a pipeline.

System / Data API suggestion

Had an idea for a potential syntax that might be able to simplify how you access components. Essentially the idea was to allow a system to process components as parameters to some sort of callback function. Hopped on the Typescript discord and they came up with this snippet. You can see the usage at the very bottom, and the stuff above that is an example of how you would implement the types. Think this or something like it might be a reasonable direction to go in instead of creating a separate data object like in the main readme example for Systems.

class Foo { name = "foo" }
class Bar { age = 5 }

type Ctor = new () => object

type ToInstances<T extends Ctor[]> = {
    [K in keyof T]: T[K] extends Ctor ? InstanceType<T[K]> : T[K]
}

function makeReadonly<T extends Ctor>(arg: T): new () => Readonly<InstanceType<T>> {
    return arg as any;
}

function createQuery<T extends Ctor[]>(...args: T) {
    return {
        execute<U>(fn: (...args: ToInstances<T>) => U) {
            return fn(...args.map(x => new x()) as any)
        }
    }
}

const query = createQuery(Foo, makeReadonly(Bar));

query.execute((foo, bar) => {
    console.log(foo.name);
    console.log(bar.age);
});

Implement HMR functionality

It would be good to have at least some Hot Module Replacement functionality. Mostly, in order to have a quick feedback loop for changes to systems. But I'd also like to include states and schedules, maybe even components and prefabs.

Ideally, the system-builder should take care of it, but that needs more research. So, the minimalistic approach could be

import {MySystem1} from './systems/my-system1.ts';
import {MySystem2} from './systems/my-system2.ts';
import {MySystem3} from './systems/my-system3.ts';

const prepWorld = ...;
const runWorld = ...;

module.hot?.accept('./systems/my-system1.ts', () => {
  runWorld.hmrReplaceSystem(MySystem1);
});

module.hot?.accept('./systems/my-system2.ts', () => {
  runWorld.hmrReplaceSystem(MySystem2);
});

module.hot?.accept('./systems/my-system3.ts', () => {
  runWorld.hmrReplaceSystem(MySystem3);
});

Maybe, and I haven't tested that yet, it would be possible to even go a little simper...

import {MySystem1} from './systems/my-system1.ts';
import {MySystem2} from './systems/my-system2.ts';
import {MySystem3} from './systems/my-system3.ts';

const prepWorld = ...;
const runWorld = ...;

runWorld.enableSystemHMR([
  [MySystem1, './systems/my-system1.ts'],
  [MySystem2, './systems/my-system2.ts'],
  [MySystem3, './systems/my-system3.ts'],
]);

// or even just - using default exports:

runWorld.enableSystemHMR([
  './systems/my-system1.ts',
  './systems/my-system2.ts',
  './systems/my-system3.ts',
]);

The implementation will be able to match the class name to replace the system inside the scheduler.


Step 1 and the highest prio are systems, since they are the ECS core and I dread most about them not having hot replacement. It would be cool to support different setups, but since I use Webpack exclusively (and it's a standard), HMR for that would come first. I plan to look into different bundlers, though, since they mostly work the same with minor differences.

Improve system ordering

in world.ts, we have the following code, which is in need of more cleverness. It will group most systems by themselves, as it is now, which is very bad, especially once we figure out how to go multi-threaded.

The objective of this story is to get rid of the below mentioned todos.

    // todo: improve logic which sets up the groups
    protected prepareExecutionPipeline(state: IState): Set<TSystemInfo<any>>[] {
        // todo: this could be further optimized by allowing systems with dependencies to run in parallel
        //    if all of their dependencies already ran

        // todo: also, if two systems depend on the same components, they may run in parallel
        //    if they only require READ access
        const result: Set<TSystemInfo<any>>[] = [];
        const stateSystems = Array.from(state.systems);
        let executionGroup: Set<TSystemInfo<any>> = new Set();
        let shouldRunSystem;
        let systemInfo: TSystemInfo<any>;

        if (!this.sortedSystems || this.dirty) {
            // this line is purely to satisfy my IDE
            this.sortedSystems = [];
            this.maintain();
        }

        for (systemInfo of this.sortedSystems) {
            shouldRunSystem = !!stateSystems.find(stateSys => stateSys.constructor.name === systemInfo.system.constructor.name);

            if (shouldRunSystem) {
                if (systemInfo.dependencies.size > 0) {
                    result.push(executionGroup);
                    executionGroup = new Set<any>();
                }

                executionGroup.add(systemInfo);
            }
        }

        result.push(executionGroup);
        return result;
    }

Implement groupings

And allow groups to be sub-dispatched.

TODO:

  • what if a system in a group depends on systems outside the group?

add world-merge functionality

This allows us to create a game world and then load a saved game later on, or load stuff in the background and then add it to the current running world on the fly.

function-based systems

  • less boilerplate
  • how to manage system life-cycle? Do we even still need this now that we have actions inside runners?
  • depends on centralized run-criteria

Improve running systems declaration

Currently, each state defines which systems will be running
As soon as you reach 50+ systems it gets really annoying to keep your eye on both GameState and createWorld() function

I really wish there were a more versatile way of assigning systems to state, not the vice versa
for example, using decorators

@RunsInStates([ GameState, MenuState ]) export class MenuSystem extends System { readonly query = new Query({ uiItem: Write(UIItem) });

or simply a readonly array

export class MenuSystem extends System { readonly _states = [ GameState, MenuState ]

I know it can also become messy if one wants to program your world in a state-first way, not the system-first one
But then there should be an "all systems" mechanism in states, which would work by reading all the unique systems from ECS world

Investigate preformance regression on master

The iter benchmark regressed by 330% between d2de6f8 and f11e943. The logic is the same, and the only difference I can think of is switching from arrays to Sets for entity storage - which does incur a performance overhead, but I can't believe that it's that heavy! Sets are better in this case since they protect against having the same entity several times.

Sync-Point ergonomics

Sync-Points should be

  • easy to create and extend (with other sync-points) 50c66d9 2839b08
  • able to have labels, which allow arbitrary insertion of systems before or after them af4e9bb
  • serializable as prefabs, so a complex pipeline can be created separately, stored and loaded and have a very simple syntax 50c66d9
  • safe to use - we need checks for loops! bceb421
  • execute stuff on point (commands.flush(), world.maintain(), custom logic like "extract to other thread") bb7ecae

prefab handle + save file = undefined

Given a State, which is possible to create at the moment, which manages its data with a prefab, there currently is no way to clean up prefab-bound entities if the data was loaded from a save.

export class GameState extends State {
    _systems = [...];
    prefabHandle?: TPrefabHandle;

    create(actions: ITransitionActions) {
        if (actions.getResource(MenuSelection).continueLastGame) {
            // no way to fill the prefab handle here!
            loadGameFromSave(actions);
        } else {
            this.prefabHandle = createNewGameFromPrefab(actions);
        }

        actions.maintain();
    }

    destroy(actions: ITransitionActions) {
        // target: remove the `if`, we should always clean up!
        if (this.prefabHandle) {
            actions.unloadPrefab(this.prefabHandle);
        }
    }
}

One idea might be to pass a unique ID to the loadPrefab() method, so it can mark entities and find them when loading a save, but that sounds complex for users. Maybe the ID could be a hash (of the prefab) and a new method (const prefabHandle = world.linkPrefab(prefabObj)) could be used to retrieve the handle, however that sounds bad when it comes to using updated prefabs on an old save. Then there also must be a method to upgrade the prefab handle to the new prefab version.

A completely different way to handle this might be to make it explicit which data is saved, always require a prefab to be loaded, and have saved data overwrite the prefab data. This means a lot more syncing, though, since then a prefab needs to include the necessary information to do such a sync, or the ecs needs an algorithm which can bind the data, which, just like before, sounds like a problem when changing the prefab slightly for a version update.

One more way would be to chunk the data in the save to make extraction possible per chunk, where a chunk could be tied to a state, or anything a user may want, really.

I won't stall v0.3.0 for a fix for this problem, though, since progress is slow as is.

Add benchmarks

While I don't think that there are a lot of ECS libraries with a similar feature set, it is important to know where we are at performance-wise. A bulk of features is useless if we perform horribly!

Problems:

  • There are no stable TS-only ECS libraries
  • JS ECS libs are sparse
  • Comparing this implementation to a compiled one (like SPECS) is unfair, however intriguing.

Provide default de-/serializers

Many built-in Prototypes might work just by themselves, however providing a default handler will stabilize the process and take away boilerplate, so that users only need to implement what matters to them.

On that note, the default one might even be helper, which lets the user register components and how to handle them, which will probably make the code a lot nicer...

de-/serializers should be able to handle (references to) entities

De-/serializers must be able to handle Entity fields. One common case where this becomes important is when creating a hierarchy. The usual strategy is to use a Parent component, which is a wrapper with a reference to an entity in the same world (see Amethyst and Bevy for reference). So, sim-ecs needs to store that reference and re-create it on load.

// example of a Parent component
class Parent {
  constructor(public entity: IEntity) {}
}

My current idea is to give each entity an id (could be a UUID or counter) which enables the implementation of such a feature and leave the implementation to the user, since they will likely create their custom components with entity fields.

I am open to opinions, though.

v0.3.0

The last version was released over half a year ago, and a ton of new features and fixes landed, so it is more than time to publish a new version. I want to get the following things done and then release.

  • Create a demo game (Pong)
    • Create a PoC to work with 5ac17ee
    • Show off sim-ecs basics (working with the Es, Cs and Ss) 5ac17ee
    • Show off states 5ac17ee
    • Show off prefabs 5ac17ee
    • Show off save/load 877a156
    • Make sure the code is in good shape to actually show off 386a208
  • Fix findings from creating the demo game
    • Fix bugs 9803f66
    • Have world.run options take a constructor for the initial state 3954768
    • Polish prefab handling e99bc4a, c2097f0
    • Have the PDA take constructors for push and pop methods 1bac088
    • Throw if a system is required to run a state, but was not registered 774ceab
    • Parse component constructor parameters for required fields in prefabs and throw if they are not available (optional)
  • Make sure all tests pass

image

Test WASM optimization

Maybe by putting the sorting algorithm into a wasm file, we can improve its perf, however this needs profiling!

Write better docs

The README is a little outdated and not very detailed. I want to have a book, which goes over different scenarios, but also works as a quick guide for developers to get started.

Implement support for Array-Components

The trick is to create an Object known to the system which contains an array of a certain component. Whenever sim-ecs is confronted with this type or object, it knows how to handlw it. The user API could look like this:

createWorld().withComponent(Array(Component)).build();

Update the docs and the examples

Cloned version: 0.3.0
Issue: can't build the examples due to dramatic changes in API
it looks like you got rid of Query, WithTag, and even commands in actions. A brief look into the code didn't give me answers so please, update your piece of great work!

Improve coverage to 80%+

I am already working on this. Coverage is important to find errors early and display that the library is well tested.

All new features after getting the coverage must keep the coverage above 80%

Add a per-iteration state-object

I want to have a defined object, which contains a certain state each system may use during its run. The state object should be generated before any system runs and destroyed after all systems are finished.

In order to handle the generation, a custom function may be supplied, which receives the old state object. If no handler is supplied, the state object will contain a value passed to the run() or displatch() methods (for example undefined).

The state object should be frozen, so that systems don't accidentally change it, which would lead to different behavior based on system order, which spells chaos in capital letters.

The motivation for this is to allow things like a central simulation-state, frame-based events (input), etc. to hook directly into the ECS and be synchronized.

"Without" does not seem to work properly

In system.ts there is this code:

if (!entity.hasComponent(accessStruct[access].component) || accessStruct[access].type == EAccess.UNSET) {
    return false;
}

But should this not be:

if (accessStruct[access].type == EAccess.UNSET) {
    if (entity.hasComponent(accessStruct[access].component)) {
        return false;
    }
} else {
    if (!entity.hasComponent(accessStruct[access].component)) {
        return false;
    }
}

Or the shorter:

if (entity.hasComponent(accessStruct[access].component) == (accessStruct[access].type == EAccess.UNSET)) {
    return false;
}

Since as things stand right now it will always check if it has the component and then return false if it does not.

Add convencience methods to Query, like `filter`, `sort`,...

These convenience methods should be part of the query interface and run whenever the query result cache is updated.

Some of them are purely for edge-cases where it makes sense to manipulate the results in a usually bad way.

filter

filter((data: PDESC) => boolean): IXXXQuery

Go over each result and decide if it should stay in the cache or not.

push

push(data: PDESC): IXXXQuery

Add a new result to the end of the results cache. This is discouraged (because the result obviously is not part of a real component)

reverse

reverse(): IXXXQuery

Reverse the results' order in cache

shift

shift(data: PDESC): IXXXQuery

Add a new result to the start of the results cache. This is discouraged (because the result obviously is not part of a real component)

sort

sort((data: PDESC) => number): IXXXQuery

Works just like Array.sort(), but on the results.

Create shorthand aliases for long `with...()` methods

There are a lot of methods which start with with and end up having long names. Especially when creating e.g. many systems, having such long names becomes a burden, so there should be short aliases available:

ISystemBuilder

Original name Alias
withName name
withRunFunction run
withSetupFunction setup

IWorldBuilder

Original name Alias
withComponent c
withComponent component
withComponents components
withDefaultScheduler don't change it for clarity
withName name
withDefaultScheduling don't change it for clarity
withStateScheduler don't change it for clarity
withStateScheduling don't change it for clarity

There are with() and withAll() methods, too, but it does not make sense to further shorten them imo...

Implement saving / loading

There should be a way to save worlds and load them later on. This would allow a simple way to save a game, create a world in a separate tool, etc.

Entity Creation API seems difficult to use

This project looks super promising! I've been looking around for ECS implementations for the web, and so far none of them seemed to expose components first, as opposed to returning entire entity references from any given query. Excited to see where this goes.

Only feedback I had initially was that it seems like it might be a bit clunky to manually create entities. From a collaboration perspective, you'll frequently have designers with a weak technical background creating enemies or powerups for a game, and they need to be able to quickly throw together a new entity without code-heavy syntax. Granted, this feedback is just going off of example entity creation you have in your readme, so there might be some undocumented ways to create entities that I'm just missing. Just as an idea, I think you might want to have something like an in-line deserializer that lets a designer create an entity with an object literal or a piece of json.

For instance:

createEntity({
    "components": ["background", "glyph", "mineable", "name", "position_2d", "collidable", "block_light"],
    "background": { "color": [ 64, 63, 67 ] },
    "glyph": { "color": [ 217, 144, 88 ],  "id": 211, "tile_type": 0, "tiles": [ 0 ] },
    "mineable": { "amount": 50, "resource": 148, "time": 30.0 },
    "name": "Coal vein"
});

Or even something like

createEntityFromComponents({
  background: {},
  glyph: {},
  mineable: {},
  name: {},
  position_2d: {}
});

Both of these examples might be awful though, ultimately the goal is to create an API that is approachable for non-technical contributors to a game.

Implement threading

Browsers can use WebWorkers and NodJS can use a Cluster.

Ideas:

  • cache all data and logic in the workers, however that would mean that all components have to be synchronized after every batch of systems is finished
  • upload the whole world to every worker whenever it is run, while simple it means a lot of overhead

Implement serializer

Imagine having a component like this:

class MyComponent {
  health: number
  target: IEntity
}

When running the ECS and adding such components to entities, everything will work as expected. However, as soon as save() is called, the target field will be serialized as independent entity, even though it really should reference another living entity. In order to handle it correctly, we need a serializer, which knows how to serialize the reference. We already have a deserializer, which can provide logic to reverse the process :)

I wonder if an entity ID might make sense, or if I should leave it to the user to add one (via component). Not every application needs to reference entities, and for those, having an ID is overhead.

Improve archetypes

Instead of assigning entities to a system, they can be grouped by tag. Grouped entities should have similar components. Systems should then be assigned tags which they can iterate over.

This idea is taken from Legion, which chunks entities that way. Using tags will have the benefit that doing loose queries is way faster (as they query is compared against tags instead of iterating over all entities). In addition, systems might share tags, so that adding/removing components results in fewer query-comarisons.

The iteration-overhead will be slightly increased for systems, but I'd say that's minimal.

Systems cannot access World safely

Implement a new SystemWorld type, which strips dangerous methods from World (like maintain()) and will be used for passing World into a System.

run() at fixed timestep intervals

Since world.run() is the core of the whole game loop, it should support features which are expected of any good game loop executing logic, and I think one of the missing pieces is the ability to run the logic at fixed intervals. Usually, that means at 60 steps per second.

I think using setInterval() would be a good fit for this instead of coming up with custom timing logic - but we'll have to see how it works out, especially with browsers currently scaling down precision because of security concerns.

I think, making run() use fixed steps should be configured via the configuration parameter - there could be a field which sets the frequency. At the same time, logic has to be in place which makes sure that every step waits for the previous step to finish first and then execute, so that things don't go to undefined land at every hickup. Plus this has to be logged, because it might mean that the logic is too slow and developers have to optimize the code to run at the target frequency, or decrease the frequency.

implement way to clone entities

I do have some ideas where this is handy. A entity-clone method would need to clone all components, too, though, so we don't accidentally end up with several entities referencing the same component instance.

Hence, the idea is that IEntity exposes a clone() method, which returns a new entity with cloned components.

Investigate Deno (again)

Deno libraries and tsc are still unsure about a way to move forward, however more tooling is emerging to solve the at least some problems through build steps.

Add resources to save file

When saving a world, there should be a way to include all resources, so saving and loading a world is always restoring the whole state, not only entities.

I imagine that this can be disabled using an option, in case the resources should be freshly created.

improve world.maintain()

Currently, when calling world.maintain(), the whole world is re-evaluated and sorted, which means a lot of overhead in general.

A first measure would be to only work on the current state instead of everything, which would also mean that on each state change the state would have to be maintained.

Another remedy would be to add changes to a queue and work on the queue when maintain() is called, so that only real changes are worked on.

I think bringing both these changes together can bring down the maintain-cost by a lot and distribute it to several points in time, which reduces stutters and hick-ups, especially when doing lots of entity adds and removes.

The system dispatching is not component-query aware

At the moment, any two systems without system-dependencies may run in parallel, even when both access the same components in WRITE mode. While for simple use-cases this may not be an issue, it is a hard to debug problem.

One solution may be that before dispatching systems, they are ordered. For world.dispatch() there wouldn't be a big benefit, however pre-scheduling dependencies like that will remove one conditional from world.run() for an added benefit.

Add snapshots

Description

Snapshots work like a time-machine. They record every change since a certain point and allow to do or undo them (rewind to the snapshot's creation time). Since they store deltas, they are more space-efficient than saving.

Use-Case

Snapshots should be save-able (world.save()) and can be used for simple game saves (e.g. load world from Prefab and apply snapshot)

Hints

Depends on #9

The implementation should be time-efficient as well, e.g. they should not care about the exact history. If a component was changed several times, the snapshot should only record the initial and most recent value.

Make components Objects-of-Arrays (like BitECS)

This should be transparent, so that the current API does not change - ideally. Also, a way to provide explicit types should be provided, so more optimal storage types can be used.

The translation from array-of-objects components to objects-of-arrays can happen on maintain(). Query caches should still remain an array-of-objects, though, because that's how they are used.

Add Madge as post-build-step

Madge is a tool to detect circular dependencies. It is important to find them as they may randomly break applications. sim-ecs is of a size which makes it hard to find them manually, so this tool will help and make sure the output is clean :)

The process should exit with an error code if there are errors in the JS artifacts, but only warn about errors found in the TS files.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.