Changelog 31
We have a tiny update for you this week, with just a single change:
- Card events are now first broadcasted to other revealed cards in the same location. Only after they are processed by other cards are they sent to the card's location.
This single, arguably obscure, change is an experiment which we hope will inform a big design discussion currently in progress. Let's take a closer look at what we've been discussing this week!
Resolution order
The trigger for this discussion was a bug report by Adrian, who posted it in our Discord. Adrian noticed something weird happening when four Cannibals are played to the "Fill with clones" location: after all their effects apply, they should all have the same Power — and yet, one of them received +3 extra Power!
A screenshot shared by Adrian in our Discord. There are four Cannibals in the left location, each of them adding +1 Power to all other cards. Yet, the first Cannibal has 9 Power, while the three other have 6 Power each.
When we debugged it, it turned out that the root cause was the way card events are processed by the engine. Card events are messages that the game sends between game objects to inform them about actions that take place during the battle. In the build that Adrian was playing, when the original Cannibal was revealed, a message called CardEntersTable was broadcasted:
- First, to the card being revealed itself.
- Second, to the location in which the card was played.
- Third, to other revealed cards in the same location.
- Fourth, to other revealed cards in other locations.
The location's effect triggers second, which immediately spawns the three clones and starts their reveals to be processed — all before we go back to point 3 and 4 on the list. That's because broadcasting and event processing are currently implemented as a stack. When a new event happens, we push it onto the stack to process it right away, and we return to the original resolution flow only once the event has been completely processed.
Let's break this down:
- First, the original Cannibal is revealed. It adds +1 Power to all other revealed cards and sets itself as revealed.
- Next, the location receives the CardEntersTable message about the original and handles it by spawning three Cannibal clones, and then revealing them one by one.
-
The first clone is revealed; it adds +1 Power to all other revealed cards (including the
original Cannibal).
-
A CardEntersTable message is broadcasted about the first clone.
- The location receives the message but it's already full.
- The original Cannibal adds +1 Power to the first clone.
-
A CardEntersTable message is broadcasted about the first clone.
-
The second clone is revealed; it adds +1 Power to all other revealed cards (including the
original and the first clone).
-
A CardEntersTable message is broadcasted about the second clone.
- The location receives the message but it's already full.
- The original Cannibal adds +1 Power to the second clone.
- The first clone adds +1 Power to the second clone.
-
A CardEntersTable message is broadcasted about the second clone.
-
The third clone is revealed; it adds +1 Power to all other revealed cards (including the
original, the first clone, and the second clone).
-
A CardEntersTable message is broadcasted about the third clone.
- The location receives the message but it's already full.
- The original Cannibal adds +1 Power to the third clone.
- The first clone adds +1 Power to the third clone.
- The second clone adds +1 Power to the third clone.
-
A CardEntersTable message is broadcasted about the third clone.
At this point, all is good. Each Cannibal affects each other the same way: each adds +1 to every other, and each receives +3 in total from the three others. However, and this is crucial, at this point we're only done with the second step of the resolution order of the original Cannibal!
- Next, other revealed cards in the same location receive the CardEntersTable message about the original Cannibal. Those are the three clones which at this point have already been fully revealed. Each of them adds +1 Power to the original Cannibal, despite the fact that they already added Power to it as part of their reveal.
- Lastly, all other cards in other locations receive the message, but we can ignore this step in this analysis.
What can we do about it?
Clearly, the stack-based approach to resolving card effects has limitations. We've considered a number of solutions, but we need more research and playtesting to make an informed decision.
The first solution that we discussed was to change Cannibal, and all other cards with ongoing effects, such that they add their modifiers to other cards once and once only. Modifiers store which card created them, so we'd only need to check if the target card already has a modifier created by the source card. This feels like patching an edge-case, but perhaps it's really not. Semantically, the ongoing effects are unique. For instance, when a card transforms into another card, it should not receive a second +1 power modifier from Cannibal. Or, when a card with an existing +1 power modifier from Cannibal is cloned, the clone should again not end up with two modifiers.
Alternatively, we could refactor the resolution order of effects as a queue rather than a stack. This would require a larger refactor, but it's possible that it would also solve a few other design issues. Today, when the stack grows deeply nested due to complex interactions, and when it finally pops back to the card that started it all, the state on the table can be radically different than it used to be when that original card was first played. In fact, that card may not even be on the table anymore, or may have transformed to another card, or changed owners. Switching to a queue and a breadth-first resolution order could help narrow these gaps. We're going to prototype it to assess the potential benefits.
We also considered refactoring the ongoing effects to be "pull" rather than "push." In the pull model (also known as the "immediate" model), each card would dynamically query the state of the table to check if there are any effects which modify its cost, power, or abilities. This querying would only be performed when needed, for instance when the card needs to display its power or when it needs to decide that it's allowed to move to another location. This approach seemed promising and would likely simplify a lot of our code which currently must deal with state management due to the push model. However, the pull model doesn't play well with our requirement to log all changes to cards in the battle log. We don't want to log that a card has +1 power from another card every time the game displays its power value! The push model (also known as the "retained" model), while a bit more complex on the coding side, corresponds one-to-one with this requirement.
The last solution we considered was a simple change to the resolution order outlined at the beginning of this post. By broadcasting card messages to other cards first, before broadcasting to the location, we prioritize card interactions over card-location interactions. This is the approach we decided to go with for now, as an experiment. In this model:
- The original Cannibal is revealed, and adds +1 power to other cards.
- It broadcasts CardEntersTable to other cards. At this point, there no other Cannibals in play, so nothing happens.
- It broadcasts CardEntersTable to the location, which clones it three times, and reveals the clones one by one.
- The first clone is revealed; it adds +1 Power to all other revealed cards (including the original Cannibal).
- The original Cannibal adds +1 Power to the first clone.
- The location sees the first clone but it's already full.
- The second clone is revealed; it adds +1 Power to all other revealed cards (including the original and the first clone).
- The original Cannibal adds +1 Power to the second clone.
- The first clone adds +1 Power to the second clone.
- The location sees the second clone but it's already full.
- The third clone is revealed; it adds +1 Power to all other revealed cards (including the original, the first clone, and the second clone).
- The original Cannibal adds +1 Power to the third clone.
- The first clone adds +1 Power to the third clone.
- The second clone adds +1 Power to the third clone.
- The location sees the third clone but it's already full.
- That's it.
With this change, we've fixed the bug reported by Adrian. We also think that this order makes sense, but we need more playtesting and feedback to make sure we've not regressed some other interactions between other cards and locations. We don't have all the answers yet, and we may switch to another solution in the future.
* * *
That's it for this week. In this update we mostly wanted to share what has been on our minds this past week. Feel free to share your thoughts in the comments. And as always: thanks for playing Millennials! For suggestions, bug reports or ideas please contact us either here in the Community section, or join our Discord. See you next week!
Files
Millennials
Create powerful synergies with only 12 cards in your deck.
Status | In development |
Author | Piesku.com |
Genre | Card Game |
Tags | Deck Building, Roguelike |
Languages | English |
More posts
- Weeknotes 3219 days ago
- Card-a-Monday, Episode 2: Magic Ginger39 days ago
- Changelog: Week 3045 days ago
- Card-a-Monday, Episode 1: Johny Alpha46 days ago
- Changelog: Week 2951 days ago
- js13k part 2: Game Design51 days ago
- js13k part 1: Introduction52 days ago
Comments
Log in with itch.io to leave a comment.
Thanks for fixing that bug! Sorry that I opened this Pandora’s box! :D
The way I handled this problem for Souls (don’t look it up, it’s nowhere public, but Stas knows about it) was to have local and global modifiers. And instead of applying them once, I computed the actual power of cards each time a change happened.
Basically, each card had a state with its base power, then a list of modifiers. After each user input, I refreshed the computed power of the card by resetting it to its base value, then applying all the known modifiers to it. Local modifiers are only ever applied to the card, while global modifiers are stuff that impact a list of cards. Like you mentioned, modifiers are linked to a source, and they have conditions and triggers, so they can be temporary (like stuff that last until end of turn). And when a card that created a modifier is removed, I could just look at all the modifiers and remove the ones that were added by that card.
The benefits of this approach is that it’s super easy to remove modifiers, stack them, etc. It’s possible to add stuff like priorities to modifiers to change the order they’re applied in. It’s the most flexible system I could think of for a game that’s as complex as Magic.
Hope that helps! :)