Code Monkey home page Code Monkey logo

0g's Introduction

Grant Forrest

I'm constantly making things.

These days I enjoy just tinkering with systems, pushing around the abstractions, playing with different trade-offs and opinions. What if websites were local-only apps and the internet was just the delivery platform? Could you make an ECS with full TypeScript support? That kinda stuff.

I will always be in progress on some game and I will never finish one. But I do finish other projects sometimes, I put them on my website:

Please don't contact me for anything crypto/web3 related. Not interested. Let's grow the set of things that can't be transactionalized.

0g's People

Contributors

a-type avatar github-actions[bot] avatar

Stargazers

 avatar

Watchers

 avatar  avatar

Forkers

hubofvalley

0g's Issues

[ECS inspired] Possible API changes

Remove stores as a concept - return to a simple ducktyped object for entity storage.

This could be convenient, but it does rely more on ducktyping which means conventions for key names like transform etc.

Expand Entity API to allow querying stores by kind

Instead of declaring stores and their keys required, systems would freeform request stores from the entity during run. They could decide on how to handle multiple stores of the same type.

Kind of exclusive with the first idea though.

Move actual Body creation/destruction to a Component

Box2D plugin should export a component which allows seamless body creation / destruction when the parent Entity is mounted / unmounted.

This opposed to managing body in the System, which is both clunkier and tempts toward direct access, which would break data-as-boundary.

Unify identical queries

Lots of wasted work tracking several versions of the same Query. QueryManager should detect an existing Query that matches the same filter and just use that one.

Entities are destroyed before effect cleanup runs

Could be tricky - Effect cleanups are triggered by Query changes, but the actual destruction operation happens before the Query is notified by definition.

The nature of destruction makes a lot of intuitive Effect cleanup redundant anyways - Components / State are already removed and disposed.

But suppose the case where the Entity is not destroyed, it just lapses from the Query. Then we would need the Effect to manually remove those things.

For now I've just no-oped removing a Component from a disposed Entity...

Change detection support

  • Include component
  • Exclude component

These are just basic filters.

  • Entity added to Query
  • Entity removed from Query

The last two are needed for reactive query monitoring in some fashion, but it could be possible to only support them in a limited sense - for example, emit events for entities being added or removed from an archetype included in the query, with an expectation that those events would be processed in a certain order to keep a separate entity list in sync with the query contents.

Scenarios:

Entity added to query

  1. entityArray: []
  2. entityAdded: 1, entityArray: [1]

Entity removed from query

  1. entityArray: [1]
  2. entityRemoved: 1, entityArray: []

Entity moved from one archetype to another in the query

  1. entityArray: [1]
  2. entityRemoved: 1, entityArray: []
  3. entityAdded: 1, entityArray: [1]

The latter, if used in a React integration, would re-render the collection reactive component but would not re-render any of the children if they were properly memoized.

Switch to mobx

This project simply requires too much specialized logic and optimization to use the delightfully simple Valtio beyond prototype phase. MobX supports the kinds of advanced features I want - computed properties, actions, etc.

Store Instances become MobX observables.
Store Definitions (user-created) are factories for those observables.
Components must be made reactive automatically by 0g.
Manual reactions must still be allowed for optimization outside of React lifecycle.
Store serialization / deserializing will probably now need to be a core concept.

Formalize Globals / Resources concept

Presently there's globals, which is just a Record of any.

Users should be able to type globals safely.

Document use cases for globals:

  1. Physics systems
  2. Renderers (ThreeJS scene?)
  3. Timers
  4. Asset caches

Changing Query iteration and System syntax

The query iteration in systems was interesting but it has some obtuse limitations.

Let's go back to a single handler per phase, and you can iterate queries inside that.

Queries should be iterables, not using .forEach which has to allocate an anon function each frame.

Expose `Entity` from Game for prop typing

It's possible that if Entity is exposed from game, it could get strong typing for initial store data - at least, like to validate that it is a valid store of some kind?

Test & verify support for store actions

Now that mobx is in place, stores should support defining actions to modify state semantically, instead of relying on direct assignment.

Verify:

  • Actions work
  • Actions are rehydrated from a loaded scene
  • Actions are not serialized to a saved scene

Rethinking Component Usage

Returning to this take on ECS after building a game for a bit, some ideas on how Components could work...

Separate persistent component data from general class properties.
User is in control of how components are saved.
Getters and setters provide more effective access.

class Transform {
  position = new Vector3();
  quaternion = new Quaternion();

  static serialize(instance) {
    return {
      position: instance.position.toArray(),
      quaternion: instance.quaternion.toArray(),
    };
  }

  static deserialize(instance, serialized) {
    instance.position.set(serialized.position);
    instance.quaternion.set(serialized.quaternion);
  }
}

Entity hierarchy

There are ways to do this purely -

Having, say, a concept of a Child component which stores a reference to a parent ID

Or it could be built into the 0G structure somehow.

State component cleanup

Here's how ecsy does it:

  • On remove, remove all persistent components and mark entity as dead
  • Systems get the chance to cleanup state components now
  • Assuming all systems remove state components...
  • When the last state component is removed (and entity is dead), release the entity

This seems to leave an opportunity for memory leaks if systems aren't diligent about cleanup

Is there any way to encode cleanup into the component?

Or should systems have a hook for when an entity leaves a query, providing the old component values for one last deallocation?

Derived & Non-Serializable State

Provide a way inside the framework to associate ephemeral (game state only) objects with entities (or components?).

This will likely be linked intrinsically with TrackingQuery.

The API should provide a succinct and semantic way to initialize and dispose of resources associated with an entity (or component?) over its lifetime. Inspiration: useEffect.

Question: Resources might sometimes take time to load or initialize. An async workflow would be ideal. But a user might not want to process steps on a query until the resources are ready (i.e. you don't want to do physics simulation until the rigidbody is created). How can that be expressed in the API?

Question: Async resources may depend on each other without strict coupling. How does rigidbody creation wait on world initialization which may be managed by a separate system?

Question: How are resources accessed during frames? How can we safely type their presence and shape for users? I don't want to have to call a function with a generic to access the rigidbody for an entity. I definitely don't want to (can't) wait for an async getter to resolve it during the frame.

Emerging concepts:

  • Resource lifecycle and availability is a first-class blocker for an entity's readiness in a Query. Entities whose resources are not available will not be processed despite meeting component criteria.
  • Resources can be global or entity-scoped. PhysicsWorld vs. RigidBody.
  • Resources are keyed by names - or Symbols?
  • Resources have two access patterns: sync and async.
    • Sync access happens on-frame. It is exposed from EntityImpostor just like Components.
    • Async access happens during initialization. It is exposed from ResourceManager on Game.
    • ResourceManager also exposes sync access if needed external to query iteration.
  • Resource dependency chains are implicitly contained in initialization logic. I.E. loading a RigidBody for an Entity depends on async access to the global PhysicsWorld.
class Sys extends System {
  bodies = this.trackingQuery([Body, ColliderShape]);

  // new: initialize hook for configuration
  initialize() {
    this.bodies.process(async ent => {
      const world = await this.game.resourceManager.awaitGlobal(World);
      const shape = ent.get(ColliderShape);
      const body = ent.get(Body);
      const collider = await loadCollider(shape);
      const rigidbody = world.createBody(body, shape);
      // resolve the resource
      this.game.resourceManager.resolve(ent.id, RigidBody, rigidbody);
      // return a destruct function
      return () => {
        world.destroyBody(rigidbody);
        this.game.resourceManager.remove(ent.id, RigidBody, rigidbody);
      };
    });
  }
}

In the above example we establish a dependency of an entity resource on a global one. We also define a processing pipeline for incoming entities. Until the promise resolves, these entities will not be marked as ready and iterated. Finally, we return a destruct function to be called when the entity no longer matches the query.

Bug: makeEffect entity impostor will get reused while async logic runs

Oops. Need to address this, and it could be tricky.

On one hand, could use an async iterator for effect iteration - but then we'd have to wait on each item before proceeding to the next, which wouldn't be necessary.

The core problem is entity impostor lives at the Archetype level, which works fine for synchronous iteration but once async usage is added it becomes a huge bottleneck to enforce only one iterator is using it at a time.

In fact this also breaks if nested iteration happens!

Best approach is probably proper pooling of EntityImpostors. I will need to identify some way to tell when an impostor can be returned to the pool - i.e. when it's still 'in use,' even in async cases.

Line Rider style game demo

Create a more interesting and interactive game demo, drawing on Line Rider for inspiration. Seems like a fun demo which could push the framework a bit.

Race conditions with Effects

Should have seen this coming - but Effects can result in race conditions as other systems + effects operate on the same entities.

For example, an effect:

const addMeshEffect = makeEffect([Model], async (ent, game) => {
  const scene = game.globals.immediate('scene');
  const mesh = new Mesh();

  scene.add(mesh);

  game.add(ent.id, Mesh, { mesh });

  await doLongTask();

  return () => {
    scene.remove(mesh);
    game.remove(ent.id, Mesh);
  };
});

const recycleModelsEffect = makeEffect([Model], (ent, game) => {
  if (someCondition()) {
    game.remove(ent.id, Model);
  }
});

That's not even the worst that could happen (what if scene got replaced between awaits?) but demonstrates how Model could be removed, but the actual mesh could remain in the scene for multiple frames because the cleanup is blocked behind a long-running task.

I'm thinking perhaps the cleanup and setup should be separated so that cleanup can run immediately without waiting for an async setup. But that doesn't resolve the setup continuing to operate, potentially on invalid resources.

Cancelation could be required here. Obviously async functions are notoriously obnoxious to cancel. I don't want to force the user to check a canceled flag every step.

Generators are often looked to as the antidote here. I avoided generators because they have worse performance, but for effects perf is not so much a problem as they run infrequently. I'm going to look into that.

So the potential new Effect API is:

const addMeshEffect = makeEffect([Model], function* (ent, game) {
  const scene = game.globals.immediate('scene');
  const mesh = new Mesh();

  scene.add(mesh);

  game.add(ent.id, Mesh, { mesh });

  yield doLongTask();
}, function (ent, game) {
  const scene = game.globals.immediate('scene');
  const { mesh } = ent.get(Mesh);
  scene.remove(mesh);
  game.remove(ent.id, Mesh);
});

Less terse, because the closure is lost. That kind of sucks. And actually in this example it just happens to not really help that the first function is a generator, since the final yield is no different than just running the task. Something to think about. At least the cleanup can be run immediately?

But at this point, is this not similar to just writing 2 different effects?

const addMeshEffect = makeEffect([Model], function* (ent, game) {
  const scene = game.globals.immediate('scene');
  const mesh = new Mesh();

  scene.add(mesh);

  game.add(ent.id, Mesh, { mesh });

  yield doLongTask();
});

const removeMeshEffect = makeEffect([not(Model), Mesh], function* (ent, game) {
  const scene = game.globals.immediate('scene');
  const { mesh } = ent.get(Mesh);
  scene.remove(mesh);
  game.remove(ent.id, Mesh);
});

While this is even more code, it's more decomposed and cleaner I think. So perhaps just removing cleanup entirely is the right call here.

Let's think up another example to explore further...

const generateTerrainEffect = makeEffect([TerrainSeed], function* (ent, game) {
  const voxels = yield terrainNoise(ent.get(TerrainSeed).seed);
  const geometry = yield buildGeometry(voxels);
  const scene = game.globals.immediate('scene');
  const mesh = new Mesh(geometry);
  scene.add(mesh);
  game.add(ent.id, Mesh, { mesh });
});

const disposeTerrainEffect = makeEffect([not(TerrainSeed)], function* (ent, game) {
  const scene = game.globals.immediate('scene');
  const mesh = ent.get(Mesh);
  if (mesh) {
    scene.remove(mesh.mesh);
    game.remove(ent.id, Mesh);
  }
});

Oof, that's a tricky one! Because not(TerrainSeed) will match any entity not even related to TerrainSeed (like the player), and now if they have a Mesh it will be removed frame 1 for no reason.

What might be able to solve this issue is introducing a removed filter which mandates that the component was in fact attached to the entity last frame. But powering that might be hard.

Add paused run options for systems

Just because the game is paused doesn't mean everything stops working. Think about ergonomic ways to allow systems to run while the game is paused.

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.