Giter Club home page Giter Club logo

Comments (7)

pkaminski avatar pkaminski commented on June 9, 2024 1

Cool, that's some great feedback. The main insight appears to be that it's useful to constrain which components can, must, or cannot go together on an object. I need to think about it more but being able to declare some kind of rules, have Becsy validate their consistency, and then check them at runtime (in non-perf mode only!) sounds like a great idea. Would be fun to riff together on a possible design if you're interested!

I think at a glance it has equivalent thread-safety, but once multi-threading is implemented maybe we'd need to copy the queue/array before running execute().

The big problem you'll run into here is that in multi-threaded JavaScript only SharedArrayBuffers are shared between threads; there's no way to share "normal" objects (including systems, arrays, other singletons, etc.). One of the big value-adds of Becsy is that it hides the ugly buffers behind a thin veneer of components and guarantees thread-safe access. I doubt you'd be able to reproduce this (if for no other reason than Becsy not exposing its threading primitives), so you'd effectively need to have all producers and consumers of events in one thread for your approach to work.

I also need the order in which the commands were sent to be respected, is it ensured that this is the case when querying entities?

If you follow the pattern I gave then yes, in practice the command entities will show up in the added array in the order they were created. However, this is more of an artifact of Becsy's log-based design and not yet formally guaranteed. It's also a bit fragile in general, especially for queries that select on multiple component types that may not all be added at the same time.

That said, I think this is a common enough pattern that it's worth supporting in some way, perhaps by having Becsy keep track of entity creation order and supporting an orderByTimeOfEntityCreation modifier on queries. (I was also already planning to have a new create entitlement that would, e.g., allow multiple systems to queue commands in parallel in some situations, which a write entitlement wouldn't.)

from becsy.

pkaminski avatar pkaminski commented on June 9, 2024 1

I push a new release that should address the issues raised here (and changed my mind about the ordering guarantee):

0.12.2

  • Added hasSomeOf, hasAllOf, hasAnyOtherThan, and countHas to Entity.
  • Implemented experimenal global component combination validators. You can add a static validate(entity) method to any component type and check for valid combinations of components using the has collection of methods, throwing an error if a check fails. All validation methods are executed for all entities whose components have changed after each system executes (not just ones that have a component of the method's host type), and the system's read entitlements are bypassed during these checks. Entities are not validated in response to writes, so validators shouldn't look at fields. Entities are not validated at all in the perf build.
  • Added Entity.ordinal.
  • Implemented query result ordering via orderBy. Just pass in a function to transform entities into numeric values and all results will be sorted by the function's output in ascending order. There are some optimizations to avoid unnecessary sorting, especially in the common case of orderBy(entity => entity.ordinal).

from becsy.

pkaminski avatar pkaminski commented on June 9, 2024

Hey Γ“lafur, thanks for the kind words and your interest in Becsy!

The simple answer to your question is that I'd recommend implementing commands using entities, components, and queries. The reason is simple: every feature of Becsy is intended to be threading-friendly and building new thread-safe constructs is hard, so it's better to leverage ones that already exist. Let me sketch out how I'd do it and see if it allays your concerns about this approach.

First, I'd define the component types:

class Command { }  // tag
class SpecificCommand1 { ... fields ... }
class SpecificCommand2 { ... fields ... }
const commandTypes = [SpecificCommand1, SpecificCommand2];

I'd take one of two approaches to injecting commands into the world. If you control the timing and can guarantee that a command will be inserted when a world isn't executing, you can just create it directly:

world.createEntity(Command, SpecificCommand1, {foo: 'bar'});

If, on the other hand, commands are created by async events, you'll want to employ a command bridge pattern (just a sketch, you can of course make it more type-safe, etc.):

const pendingCommands = [];

class CommandBridge extends System {
  private sched = this.schedule(s => s.onMainThread.beforeReadsFrom(Command, ...commandTypes));
  private q = this.query(q => q.using(Command, ...commandTypes).write);

  execute() {
    for (let i = 0; i < pendingCommands.length; i += 2) {
      this.createEntity(Command, pendingCommands[i], pendingCommands[i + 1]);
    }
    pendingCommands.length = 0;
  }
}

// In your listeners, or wherever, as long as it's on the main thread:
pendingCommands.push(SpecificCommand1, {foo: 'bar'});

Reacting to commands in your systems is straightforward:

class Reactor extends System {
  private commands = this.query(q => q.added.with(SpecificCommand1));

  execute() {
    for (const command of this.commands.added) {
      const data = command.read(SpecificCommand1);
      // do something
    }
  }
}

Finally, you'll want to clean up the command entities, either every frame or every so often:

class CommandCleaner extends System {
  private sched = this.schedule(s => s.afterReadsFrom(Command, ...commandTypes));
  private commands = this.query(q => q.all.with(Command).write.using(commandTypes).write);

  execute() {
    for (const command of this.commands.all) command.delete();
  }
}

Obviously I haven't tried running this code but I believe it's directionally correct. πŸ˜„ I don't think this approach increases the complexity of queries or introduces ambiguity about usage patterns. Also, I think it's wrong to think of components as representing only persistent data about domain entities; rather, they're the universal representation for all data in ECS, whether long- or short-lived, and whether part of the domain or the implementation layer.

All that said, if this isn't appealing, I think your approach is fine too though I don't think I'd want to integrate it into Becsy for the reasons above. Passing held entities is cheap, as they're just tiny objects with a numeric ID inside; the only overhead they impose is when deleting entities, so the held proxies can be invalidated. As for cleaning up, take a look at the newly introduced System.finalize method, perhaps that'll help!

from becsy.

olafurkarl avatar olafurkarl commented on June 9, 2024

Hi Piotr, thanks so much for the quick response!

every feature of Becsy is intended to be threading-friendly and building new thread-safe constructs is hard, so it's better to leverage ones that already exist.

Ah I understand, this is why I thought it'd be best to get your opinion. I'm relieved to hear that you think the approach is fine though.

Regarding the ambiguity/complexity motivation, I'd like to elaborate a bit.

On ensuring Commands don't get used as a regular Component:
That is to say having a Command added to an entity that is meant for something other than message-sending. I feel a little uneasy that they rest in the same category, since I've already seen this pattern used both ways in my project (Command components added on entities as a message that involves that entity, and Command components put on "fake" 1 frame entities to signal something).
It's not immediately obvious what can/should be done with such a component, and I fear that to understand this you must understand all systems that make use of the component (breaking separation of concerns, ideally all I need to know is that I'm firing a 'DoSomethingCommand' and systems may or may not be interested in that).
Since the Command category of components has special rules and needs cleanup, I want to make extra sure that there's no mixing between the two.
Example: One might think a 'FocusComponent' is fine to put on an entity that has focus (indicating it has Focus as a state), but it perhaps was intended to be a command that sets focus on something as a 1-time message before destroying the entity.
This can be handled with strict naming, linting or peer review, but it'd be nicer that the mistake can't be made in the first place.

The CommandBridge idea is not too dissimilar to what I have. In the pattern I'm using the data structure is per-system rather than creating entities that get consumed by the system. E.g. the "forking" is essentially done before we hit the ECS, versus it being handled by entities/queries within becsy. I think at a glance it has equivalent thread-safety, but once multi-threading is implemented maybe we'd need to copy the queue/array before running execute(). πŸ€”

I also need the order in which the commands were sent to be respected, is it ensured that this is the case when querying entities?
Example: I have a 'KeyInputCommandComponent' which I send off 5 times to make the message 'H' 'E' 'L' 'L' 'O', would that order be maintained if later I query using this.query((q) => q.with(KeyInputCommandComponent)?

Thanks again. πŸ™

from becsy.

pkaminski avatar pkaminski commented on June 9, 2024

Couldn't stop thinking about the component combination constraints problem. I did some quick research and didn't find any relevant DSLs. I tried coming up with something declarative but it got hairy really quickly, especially as the constraints got more complex. So then I thought, what if you could just write some code like this?

class Command {
  static constrain(entity: Entity): void {
    if (!entity.hasSomeOf(Command, ...commandTypes)) return;
    if (!entity.has(Command)) throw new Error('no Command tag');
    if (!entity.hasSomeOf(...commandTypes)) throw new Error('no specific command');
    if (entity.count(...commandTypes) > 1) throw new Error('multiple commands');
    if (entity.hasAnyOtherThan(Command, ...commandTypes)) {
      throw new Error('other components mixed with command');
    }
  }
}

Any component type could have a static constrain method (they could equally well be free-standing, I guess, but this helps encapsulation a bit and makes it easy to refer to them in error messages). The method would receive an entity to validate and could use any code to introspect it, including a bunch of new has* methods added to the class. A typical pattern would be like the above: first, qualify the entity to see if we should be validating it at all, then check a bunch of conditions and throw a specific error if a check fails.

All constraints would be run after each system execution, on all entities created and modified by the system. I think this would strike a reasonable balance between reporting the error as close to the causing code as possible, while still letting you transform entities through states that may be temporarily invalid (e.g., don't need to add all components at once, or can remove some components then add others).

The only thing I don't like about the above is that most of the has* methods reduce down to a mask check on the shapes table, but we'll be computing the target mask from scratch each time. It would be nice to find some way to precompute or memoize the component type masks while still keeping the code readable.

What do you think?

from becsy.

olafurkarl avatar olafurkarl commented on June 9, 2024

I can see this type of constraint option being very useful, especially as the state of entities starts becomes more complex. It would be great to catch invalid states with this. πŸ‘

Some thoughts:
Does your Command example imply that we use inheritance? I think this would be ideal so that we don't need to re-implement the constraint for every Command type, would the has* helpers handle that case?

I think this would strike a reasonable balance between reporting the error as close to the causing code as possible, while still letting you transform entities through states that may be temporarily invalid

Definitely.
Suggestion: It may be useful if one could specify an array of systems that the constraint waits for until checked, in case multiple systems are manipulating an entity before checking the constraint validity.

multi-threaded JavaScript only SharedArrayBuffers are shared between threads;

Ah yes good point! I definitely don't want this to stop working once Becsy goes multi-threaded! :)
I imagine the previous CommandBridge pattern would run into this as well, if I'm understanding correctly.

The use-case of sending commands from the main thread is quite important for the product I'm working on (maybe less so sending from other systems, but I can imagine it being useful).

from becsy.

pkaminski avatar pkaminski commented on June 9, 2024

Does your Command example imply that we use inheritance? I think this would be ideal so that we don't need to re-implement the constraint for every Command type, would the has* helpers handle that case?

No, Becsy has no notion of component type inheritance (at least not yet). But the example I gave would actually enforce the constraint on all command types as long as you set commandTypes to an array of classes beforehand. It's a bit confusing, because the constrain methods in every class are applied to every entity that changed shape, not just entities with a component of the type where the method happens to be defined. An alternative would be to make constraints a separate "thing" and pass them explicitly into the world's defs, but this would have to be done explicitly as TypeScript decorators can't be applied to free-standing functions (so @constraint function checkCommands(entity) {...} is not feasible), so I'm not very keen on it.

Suggestion: It may be useful if one could specify an array of systems that the constraint waits for until checked, in case multiple systems are manipulating an entity before checking the constraint validity.

I'm considering adding a generic "run these systems sequentially without dropping any locks in between" scheduling directive, and I think extending its meaning to include holding off on constraint validation would be a good fit.

I imagine the previous CommandBridge pattern would run into this as well, if I'm understanding correctly.

Only in the sense that the bridge is pinned to the main thread, so can only be used there. However, the commands themselves will be available to queries on all threads. Systems not running on the main thread can create command entities in the world directly, and if they have their own asynchronous callback mechanisms (perhaps due to networking?) then they'll need to implement their own bridge pattern. I think bridging is nearly always used only on the main thread, though, so that's a reasonable compromise.

BTW, for ordering I decided against an explicit creation order counter and in favor of the following guarantee (added to the relevant query docs):

The order of entities in this list is generally unspecified. The exception is that if your query has no or clauses (currenty unsupported anyway) and you used {@link System.createEntity} to create your entities and never added or removed components from them thereafter, then those entities that match the query will be listed in the order they were created. (Though the ordering of entities created by systems that execute concurrently is still undefined.)

from becsy.

Related Issues (20)

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.