Replies: 6 comments 8 replies
-
How can dynamic queries help with reactivity?So, I wanted to talk about dynamic queries just reading the title of the discussion, but after reading it, it became an imperative. So first off:
This is simply false. It is just a current limitation of bevy, that IMO is bound to change very soon. See this writeup. You can build up the query at runtime. If @james-j-obrien is to be believed, you can do this at no performance cost compared to a static query. You can then react to changes with the tick filters ( Then the language expressing dependencies such as:
Is pretty much a tinny abstraction on top of the query language. With dynamic queries, it is even, indeed, conceivable to design a hot reloadable scripting language for this! Now, however, for full reactivity, you still need a dependency graph between various bits of code that behaves in reaction to queries. That does sound like a system schedule doesn't it? I'm not exactly sure, but it might already be possible to build a dynamic system schedule at runtime… So you just need to design a loading mechanism that builds a system schedule right? I'll admit it sounds crazy that such a system completely orthogonal to bevy's design should be so easy to implement, but it sounds about right… What I have in mind right nowWhat I want to do for Why am I excited? Well, I really like the idea of data driven. My take is bevy currently is everything but data driven. Data driven implies that all of the game's behavior is controlled through data, could be changed with a scene update. This is untrue of bevy today. I enjoy using scripting languages, but I think they don't have their place in the core use-case of bevy. With dynamic components, I can express in a scene file reactions to game state. It sounds similar to what numpy or torch is to python. The "scripting" would be a description of query composition, not a formal imperative set of actions. The description would be converted into a dynamic query and then… Eh I don't know… I didn't think that far. But it will kick open a lot of doors! |
Beta Was this translation helpful? Give feedback.
-
First of all it is a nice discussion and I think it's a good way to check how Bevy is supposed to solve some real world game dev problems. I'm way more experienced with OOP than with ECS, so I like to work with use cases, to better illustrate some pitfalls and understand new ways to solve problems, using ECS. So let's use a simple logic which is common used by level designers (or producers) that consists in waiting for 2 or 3 events to happens, like when a player kills a boss, solves some puzzle and stands in front of a door, it must unlock a door to allow player to progress further. In that scenario, the following hypothetical conditions should be met:
As you said, in a common scripting language, this would be something like: boolean PuzzleSolved = false
boolean BossKilled = false
event OnBossKilled(integer id)
if (id == 10)
BossKilled = true
end
end
event OnPuzzleSolved(integer id)
if (id == 33)
PuzzleSolved = true
end
end
event OnTriggerAreaEntered(integer id)
if (id == 5 && PuzzleSolved == true && BossKilled == true)
UnlockDungeonExit()
end
end It's verbose because in order to fulfill the logic needed, the script have to build its own state machine, which may lead to bug and is harder to debug. Using a reactive approach, this would be written in something like this: var targetBoss = GetBossById(10);
var targetPuzzle = GetPuzzleById(33);
var targetTriggerArea = GetTriggerAreaById(5);
// The script reactive runtime will track those three objects and only run this script whenever one of those is changed.
if (targetBoss.IsDead() && targetPuzzle.IsSolved() && targetTriggerArea.IsActivated()) {
UnlockDungeonExit();
} Now using a Bevy ECS there are some possible approaches, which currently all require using Rust code but in a future may be possible using some scripting language:
fn check_unlock_dungeon(
q_boss: Query<(&Id, &BossState)>,
q_puzzle: Query<(&Id, &PuzzeState)>,
q_tg_area: Query<(&Id, &TriggerArea)>,
evt_writer: EventWriter<DoorUnlocked>,
) {
for (id, state) in q_boss.iter() {
if id.0 == 10 && state.is_dead() {
for (id, state) in q_puzzle.iter() {
if id.0 == 33 && state.is_solved() {
for (id, state) in q_tg_area.iter() {
if id.0 == 5 && state.is_activated() {
evt_writer.send(DoorUnlocked);
}
return;
}
}
return;
}
}
return;
}
}
fn check_unlock_dungeon(
door_state_res: ResMut<DoorState>,
evt_boss_killed: EventReader<BossKilled>,
evt_puzzle_solved: EventReader<PuzzleSolved>,
evt_tg_area_activated: EventReader<TriggerAreaActivated>,
evt_writer: EventWriter<DoorUnlocked>,
) {
for ev in evt_boss_killed.iter() {
if ev.0.id == 10 {
door_state_res.0.boss_killed = true;
}
}
for ev in evt_puzzle_solved.iter() {
if ev.0.id == 33 {
door_state_res.0.puzzle_solved = true;
}
}
for ev in evt_tg_area_activated.iter() {
if ev.0.id == 5 {
door_state_res.0.tg_area_activated = true;
}
}
if door_state_res.0.boss_killed && door_state_res.0.puzzle_solved && door_state_res.0.tg_area_activated {
evt_writer.send(DoorUnlocked);
}
} There are another approaches, which can mix and match any of those and more, but currently it isn't possible to have something as simple as the reactive approach as you mentioned. Even when something like Indexes land on Bevy ECS, I don't think it'll be as straightforward as some scripting language, that's why I think Bevy needs to have some scripting language officially supported, since It'll be very hard to non-programmers to build something on top of it. AFAIK, Bevy will support scripting languages by having the building blocks (like reflection, one shot systems, dynamic queries, etc), but let the community create mods which actually implements the support for those scripting languages. So in the end, IMO, a fully reactive and straightforward usage for designers and produces won't be possible using ECS directly, but having some abstraction layer (like a scripting language or DSL) which will abstract this and do the heavy lifting of dealing with ECS. |
Beta Was this translation helpful? Give feedback.
-
Beta Was this translation helpful? Give feedback.
-
Hi, I hit the problem with introducing unacceptable lag with heavy event-driven systems as well. It is inevitable consequence of game tick rate is coupled with rendering frame rate. I found this coupling very limiting especially when I want to design complex logic where events can definitely help to tackle the complexity. IMO having separate schedules for Game Logic / UI / Networking / Rendering running side by side in different pace could solve many of the pain points. There was related discussion in #1343 I wonder whether there is some plan to continue with the effort. |
Beta Was this translation helpful? Give feedback.
-
Diving deeper into reactivityI strongly advise anyone who is interested in Reactive programming to read this series by Ryan Carniato (creator of Solid.js) on Building a Reactive Library from Scratch. However, for the impatient I'll give the TL;DR: fine-grained reactivity (as opposed to coarse-grained, which is what React uses) is organized around signals, where a signal is a writable variable. Signals expose two functions, a getter and a setter. The signal getter reads the value of the signal; it also adds the current tracking context to a set of subscribers. All reactive code must run within a tracking context; the tracking context is how we know what code needs to get run when the signal is updated. The simplest type of tracking context is called an effect, which I'll get to in a moment. The signal setter updates the value of the signal, and then calls each tracking context to "react" to the change by re-running whatever code the tracking context contains. It then erases the subscription list. This last point is key: fine-grained reactivity is based on the idea that both "subscribe" and "unsubscribe" actions are automatic. "Subscribe" happens when you access a variable; "Unsubscribe" happens as a side effect of the reaction - it's a one-shot. This is how we avoid all the tedious mucking about with subscriptions. However, this may be confusing to those who haven't worked with reactivity: most of the time we want subscriptions to be persistent, not auto-erased every reaction. Not to worry - the "reaction" always re-runs the same function that caused the subscription in the first place. By re-running this code, all of the subscriptions are re-established (unless there is conditional logic, in which case different subscriptions might be established). The tracking context is nothing more than a closure, but one that can be added to a subscriber set. An "effect" is a tracking context that runs once, and then re-runs whenever any of it's dependencies changed. It can also (in some libraries) be canceled. However, "effects" are not the only kind of reaction - another type is called a "derived" or "computed" value, one which produces a result which is reactively memoized, meaning that it can be subscribed to (like a signal), but only notifies subscribers when the memoized output actually changes. There are also asynchronous versions of these, as well as other types that would take too much space to list here. It's important to understand that reactions can cascade - for example, updating the value of a signal might run an effect, which updates a different signal, which in turn may run an effect, and so on. Generally the reactive framework keeps cascading until there are no more reactions ("quiescence"). If the reactions never stop, that's a bug (an infinite loop), and generally the framework will detect this and throw an exception or panic. So how does this relate to ECS? Or, more importantly, what's the tension between this sort of reactivity and ECS design principles? In an ECS system, we often track modified / need-to-update / "dirty" states using a marker component: an empty component that indicates that the entity needs to re-run some code. This makes it easy for an update system to query the "needs update" markers. The problem is the cascade: adding a marker doesn't update the current Query, so there's a 1-frame lag - marking an entity as dirty won't take effect until the next time the system runs. If the cascade is N levels deep, then it takes N frames for all the updates to resolve. An alternative approach is to use some other method besides marker components to indicate which entities need updating. The problem here is that you can no longer use Queries to extract which components need updating. In fact, you probably are going to need exclusive world access, because there's no telling which entities are likely to be updated in the cascade. The other problem with the cascade is that the reactions are generally closures, or at least, some kind of ad-hoc function such as a Component (in the React/Solid sense of the word, not the Bevy sense). Most ECS systems are homogenous in execution: you have some algorithm that is uniformly applied across many rows. But the cascade of reactions can invoke many different effect functions which might be spread out all over the code base. Even if this can be made to work, it's not very ECS-like. It's almost as if you had two different execution models, each in their own "bubble" and joined at the hip. One might be tempted to simply throw out the tracking contexts and subscriber lists, and simply run every effect every frame - this is closer in spirit to the ECS way of thinking. That's fine if your effects are only doing lightweight processing. However, it's often the case that these effects are doing rearranging of the DOM or Entity graphs, which is not something to be done lightly. Alternative approachesThe React approach, although less efficient overall than Solid, seems conceptually simpler to match with ECS. React updates are less fine-grained, and depend on the VDOM "diffing" algorithm to allow fine-grained modifications of the view hierarchy. In React, you only need to track whether a "component" is dirty, not individual signals and effects. I'd be interested to know if anyone has tried to implement something like Iced within ECS. This is a different approach to reactivity, which some people like, although others have criticized. |
Beta Was this translation helpful? Give feedback.
-
For anyone interested, I've implemented a very basic check box and text box via fine grain reactivity inside bevy. The textbox: The fine grain reactivity primitives: Creating the root scope and using your components: The API is based on SolidJS. A little ugly at the moment, but works. |
Beta Was this translation helpful? Give feedback.
-
There's been a lot of discussion lately about reactivity in the context of Bevy UI. However, this is not what I want to talk about. Given the level of interest, and the popularity of reactive UI frameworks, I'm sure this is a problem that will get solved one way or another.
What I want to discuss is reactivity for things other than UI - in the context of an ECS universe.
A bit of history: I've been a game developer since 1983, and over the years I've developed a number of scripting languages (SAGA and SAGA 2, both of which are still supported in SCUMM VM), and worked on a few others during my time at PostLinear Entertainment and Maxis.
The benefit of game scripting languages is that they allow semi-technical team members (called "level designers" at most companies, although at Maxis these were called "producers" because they produced game data and also because people liked having that title on their official business cards) to author complex behavioral logic without having to become full-fledged programmers, as well as taking advantage of a very fast iterative development cycle.
However, somewhere around 2005, while working on The Sims 2, I came to the conclusion that there was something wrong with game scripting languages in general; that there ought to be a "better way" of authoring game behavior. Most game scripting languages aren't very innovative as languages go - for the most part they are standard, imperative, OOP-based languages, little different from Visual Basic or Python, with an event-based architecture layered on top.
It wasn't until about 2021 that these nebulous ideas came into focus, partly due to my experience working with React.js, MobX, and later, Solid.js. I realized a "reactive" approach to game behavior made a lot of sense. Most game scripting languages are organized around events, but intuitively what we really care about in most cases are states:
In games like ElderScrolls: Skyrim, Mass Effect Trilogy or Zelda: BotW, quests are stateful objects that alter the world depending on how far the player has progressed along the narrative arc. Quests can affect things like:
...and many other game states.
Similarly, the quests themselves progress based on changes to the world, such as picking up a quest item, choosing a particular dialogue option, or killing a particular type of monster.
In an event-based system, these states are modified by subscribing and unsubscribing to various event sources. If a game state depends on multiple events, then multiple subscriptions are required, and your logic has to handle events occurring out of order.
However, in a reactive system, where subscribing to changes is implicit and hidden, specifying complex game behavior is much simpler, more like a spreadsheet formula. A trap which is disarmed by pressing two toggles can be succinctly expressed by a formula:
self.disarmed = button1.toggled && button2.toggled
; the framework knows that "button1" and "button2" are dependencies and can automatically listen for changes as needed. The framework knows what needs to be recalculated, and when; there's no need of all this "tedious mucking about with subscribing and unsubscribing to event sources".As I experimented more with reactive scripting (I spent 3 years building a game engine in JavaScript to test out these ideas) I realized that even in a very complex game with elaborate quests, most such formula are very simple. The metaphor I like to use is that of a pinball machine: the action of each "bumper" is very simple, but what emerges out of the arrangement of bumpers is a highly complex trajectory of the ball. Only in this case, the "ball" is the player, and the "bumpers" are the various NPCs and objects that they interact with in the world.
So now we come to ECS, to which I'm a relative newcomer. I've looked at Rust reactive frameworks like Leptos, but I haven't quite wrapped my head around how reactivity and ECS can co-exist.
The problem is that ECS is based around the idea that the game state is partitioned into isolatable units, in a way that multiple threads can operate on discrete islands of state. The problem, though, is that reactivity cuts across those boundaries. The whole idea of "signals" and "computations" (to use the Solid nomenclature) is that the lines of dependency are invisible and dynamic - a given value may depend on a quest, a property of an NPC, an inventory item, a trap, or anything else.
With an ECS system, you have to declare up front which "slices" of the world you want to access - but reactive dependencies may reach outside of that slice.
One solution is to de-synchronize reactions by deferring them - that is, if you pick up a quest item, rather than immediately propagating the state changes to all of the derived computations, you instead add the change to a queue, to be processed by some other system. Each derived formula can then execute in its own sandbox without the need to cross system boundaries. The problem with this is that reactive dependencies are often deep: formula A1 depends on formula B1 which depends on C1 and so on. If each derived calculation is deferred you start to introduce lag, and state changes have to wait multiple frames before they are completely resolved.
One can also say, "hey, let's not bother with reactions - the ECS way is to simply re-calculate every formula every frame". This is probably closer to the right answer, but I still have concerns. First, in a large world, you have scaling issues because every interactive game object and every quest stage has formula. Secondly, these "formula" are represented in data, not code. They are, ideally, something you edit in a game editor. You don't create a new quest by writing new Rust code, but rather by instantiating a new
Quest
object and then populating it withQuestStages
; but only in Rust code can you declare a new system that knows which "slice" of the world you want to access.I'm guessing that the eventual answer is going to be some kind of hybrid. But I don't know what that really looks like yet.
Beta Was this translation helpful? Give feedback.
All reactions