Skip to content

Event unification and touch damage#2144

Merged
IntegratedQuantum merged 21 commits intoPixelGuys:masterfrom
IntegratedQuantum:event
Nov 1, 2025
Merged

Event unification and touch damage#2144
IntegratedQuantum merged 21 commits intoPixelGuys:masterfrom
IntegratedQuantum:event

Conversation

@IntegratedQuantum
Copy link
Member

@IntegratedQuantum IntegratedQuantum commented Oct 27, 2025

Unifies gui opening, tick events and on touch events to use the same system.

This makes it more future proof and establishes a clear pattern to be used for events.

Also while I was at it I decided to implement lava damage.

  • Implement damage for more blocks: Cactus and magma

brings us one step closer to #2083

@IntegratedQuantum
Copy link
Member Author

@Argmaster I would like to hear your opinion on this. Is this in line with what you wanted to have in #2083?

Copy link
Collaborator

@Argmaster Argmaster left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Argmaster I would like to hear your opinion on this. Is this in line with what you wanted to have in #2083?

Partially, yes. I did a rough review for now and I have some thoughts, but I would want to make clear what is a simplification to avoid doing to much at once and what is a design choice. So, are we aiming to have full event loop setup, or are you planning to stop with what we have now (basically callbacks but more consistent)?

}

pub fn touchBlocks(entity: main.server.Entity, hitBox: Box, side: main.utils.Side) void {
pub fn touchBlocks(entity: *main.server.Entity, hitBox: Box, side: main.utils.Side, deltaTime: f64) void {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, a touch happens when you collide with block hitbox, hm?
I "touch" immediately brings "touch with your finger" instead of "touch with your face" idk about you.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sense of touch is present on your whole body.
For colliding into things (as in moving towards them) I would like to have separate functions. This could in theory also allow to move some physics effects like bouncing into the event handler.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, I guess I will let you cook.

return result;
}

pub fn run(self: *@This(), params: main.events.ServerBlockEvent.Params) main.events.EventResult {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this event supposed to work? When I am allowed to use it? Can I use it in WE commands?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use it in any context that uses a ServerBlockEvent, currently it's only used in random tick events, but you could make a world edit command that triggers a ServerBlockEvent based on a user-provided zon if you think that's useful.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any other replace event in blocks category? Why not replace.zig

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could see a replace callback for bigger areas, like spheres or cubes. Like maybe you have a corruption block and want to spread it in all directions on random tick.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, its a valid argument.

fn GenericEvent(_Params: type, list: type) type {
return struct {
data: *anyopaque,
runFunction: *const fn(self: *anyopaque, params: Params) main.events.EventResult,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a well established name for such things: callback

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't callback generally the name of the entire object that holds the function pointer? At least that's how it was in java.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess depends on imagination of the author

In this case I would prefer to stick to definition that pops up if you DuckDuckGo "what is a callback", in my case:

image

So it is easier for uninformed user to learn.
DuckDuckGoing runFunction doesn't yield useful results.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would I call run then? It would seem inconsistent to have it called run in all but this one place.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, with current implementation it is no longer an event, more of an event handler according to definition on Wikipedia. Event is just a piece of data without callback. If we rename this to event handler then run as a function to trigger it makes sense. Additionally I don't see a problem with runing a callback as an interpretation of what is happening.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if maybe we should call the whole thing a Callback then. It's conceptually no different from a lambda callback that also captures extra parameters.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair

@IntegratedQuantum
Copy link
Member Author

So, are we aiming to have full event loop setup, or are you planning to stop with what we have now (basically callbacks but more consistent)?

I'm not sure yet. I would like to avoid an event loop, since it does have a performance overhead (→needs to allocate and store the event data in a data structure), but we might need it anyways.
Either way with a unified callback system it should be easy to add that on top later, by just changing the parameter type to a pointer.

@Argmaster
Copy link
Collaborator

Ok, then I need to gather my thoughts and write them down here, I will do that after work.

.runFunction = &ignoredRun,
};

fn ignoredRun(_: *anyopaque, _: Params) EventResult {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that noop with a fancy name?

Copy link
Collaborator

@Argmaster Argmaster left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, so this is quite different than the direction #2083 is proposing.


TL;DR; Event loop please, so callback is always called the same way.

Current Event is "just" an implementation of a stateful lambda. It unifies the interface of callbacks for blocks, which is great, they really needed that, but at the same time has typical callback issues - those callbacks have no guarantees where they will be called from (as in, which thread they are called from). Thus callee can never be sure what it can and what it cannot do. I best case scenario this means a bunch more mutex locks and unlocks, in worst case it means we won't be able to reuse that code, as depending on context necessary locks exclude each other.
Consider following case: You have an entity in ECS that can place or break a block (eg. upon death). Can you call place block from entity AI thread? Why does entity AI thread lock a chunk mutex? Even worse, why entity AI code has to bother with which replace block to call (since you have separation between server and client implementation). Entity AI would be happy if it would just schedule a block placement and would wait until that blocks appears where it asked it to appear after some time, checking for it every tick.
Same applies to playing sounds, spawning particles etc. Why does X care about action being immediate? Delaying it for 1, 5, 10 frames doesn't bother anyone, even better, it may allow us to optimize things automagically if events can be batched into single thing. We are not talking about actions in a tight loop in the middle of the rendering thread, we are talking about things that happen at human perception speed.


TL;DR; Events and Listeners as a separate system from Block system and storage.

Now back to genericness of the design. Current solution is closely coupled with block system for no reason at all. An alternative implementation would be to define Events to contain no static data (not zon configurable, only Params in current implementation terms) and Listeners to be configured with necessary zon configuration.
Regardless of looped or loop-less implementation, when an event is triggered it would be delivered to a listener dedicated to that event and said listener would check which block was delivered to it and act accordingly. Block would no longer know that it opens a GUI. Instead you would define listener as code and include a zon configuration for it that would contain all the necessary information for it to function, in format preferred by the listener implementation.
This means we can finally stop adding more and more complexity to blocks.zig and distribute it between smaller systems with smaller interfaces. My experience shows that spaghetti factor of a module grows exponentially with its size.


Final notes:

  • what you are implementing is different design pattern called Command (as opposed to Observer I have suggested)
  • I am far from saying PR is all bad, although my comments may have suggest otherwise. In current state its still a step in a good direction, but I think its too small of a step.
  • I am convinced that this is not performance critical code and it should value readability and extensibility over performance. If someone is dedicated enough, this will always be a weak spot, for one reason or another (eg. inventory lock). I do not think we should throw performance out the window with O(n!) complexity, but we should split the responsibilities between different systems and in that case make the system as generic as possible. To my best knowledge it it possible to generalize it so its not directly tied to any of existing systems.
  • I did recently listen to non-pessimisation and "Clean" Code, Horrible Performance and I do see he has a point. There are two "but" I would like point out tho:
    • this is just a guideline. Guidelines should be always tested against priorities of the project and reality. Here reality is that for 99% of the time opening a chest or touching a cactus is not a performance critical path in the code (or rather it shouldn't be!)
    • There is a big difference between using "clean code" practices at high level, when managing big systems and communication between them (like here) where overhead from the cleanness of the code is in single digits of execution time, compared to using vtables in the middle of rendering thread. Again, guidelines should be tested against reality, not followed blindly.

@IntegratedQuantum
Copy link
Member Author

I think you are underestimating the performance impact of this. Yes touching a cactus is rare, but if we have a thousand entities touching the ground, and 10 different listeners that listen to this on-touch event, then that would be quite expensive.
The same is also true for random tick events, we want to process 10000s of these, and if each of them first needs to go through 10 different listeners which all come to the conclusion that no action is needed, then this is a lot of wasted processing power.

I agree that we should be careful about what context an event gets executed.
For that I would like to have assertion for what thread you are one, and if we ever do get into trouble then we can just put events into a queue and execute them elsewhere, like how it's already done for e.g. window open/close operations, client block updates from the server, and the particle command.
There are already events that are executed in a clean context (→random tick) and shouldn't have this extra overhead.

This means we can finally stop adding more and more complexity to blocks.zig and distribute it between smaller systems with smaller interfaces.

I do think the block type is the right place to put block-type specific event data. In my opinion the block should know what window it opens, and the user should be able to specify that in the block's zon file.
I do not see how this adds more complexity to blocks.zig, it's all rather trivial code in there (some simple parsing, some member functions that return an array value, some arrays).

@Argmaster
Copy link
Collaborator

Then I suppose I have nothing else to add.

Comment on lines 66 to 82
pub fn getChild(self: *const ZonElement, key: []const u8) ZonElement {
if(self.* != .object) {
return .null;
} else {
if(self.* == .object) {
if(self.object.get(key)) |elem| {
return elem;
}
}
return .null;
}

pub fn getChildOrNull(self: *const ZonElement, key: []const u8) ?ZonElement {
if(self.* == .object) {
if(self.object.get(key)) |elem| {
return elem;
} else {
return .null;
}
}
return null;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn getChild(self: *const ZonElement, key: []const u8) ZonElement {
if(self.* != .object) {
return .null;
} else {
if(self.* == .object) {
if(self.object.get(key)) |elem| {
return elem;
}
}
return .null;
}
pub fn getChildOrNull(self: *const ZonElement, key: []const u8) ?ZonElement {
if(self.* == .object) {
if(self.object.get(key)) |elem| {
return elem;
} else {
return .null;
}
}
return null;
}
pub fn getChild(self: *const ZonElement, key: []const u8) ZonElement {
return self.getChildOrNull(key) orelse .null;
}
pub fn getChildOrNull(self: *const ZonElement, key: []const u8) ?ZonElement {
if(self.* == .object) {
return self.object.get(key) orelse .null;
}
return null;
}

}

pub fn run(self: *@This(), params: main.events.BlockTouchCallback.Params) main.events.EventResult {
std.debug.assert(params.entity == &main.game.Player.super); // TODO: Implement on the server side
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Todos are forbidden. Make an issue.
I think that for documentation purposes it would be appropriate to link it here in a comment, but that depends on the preference.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I repurposed #1124 for this.

Copy link
Collaborator

@Argmaster Argmaster left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on my current state of knowledge it should be good to merge.

One thing to consider is having a thread local variable that identifies threads as client side and server side, so we can assert that things like server callbacks and client callbacks are invoked only on threads they are allowed to be invoked on.

fn Callback(_Params: type, list: type) type {
return struct {
data: *anyopaque,
runFunction: *const fn(self: *anyopaque, params: Params) Result,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see runFunction persists. I suspect that is your preference, and the decision is final.
If that is the case, I would want to make
a poll on discord to see what ideas wider audience would have (for scientific purposes ofc).

Given code snippet:

fn Callback(_Params: type, list: type) type {
  return struct {
  	data: *anyopaque,
  	<field-name>: *const fn(self: *anyopaque, params: Params) Result,
  	...

What field name would you chose for <field-name>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

go make the poll, we can wait a bit longer on this,

@IntegratedQuantum IntegratedQuantum changed the title Event unification Event unification and touch damage Nov 1, 2025
@IntegratedQuantum IntegratedQuantum merged commit dda5fcb into PixelGuys:master Nov 1, 2025
1 check passed
codemob-dev pushed a commit to codemob-dev/Cubyz that referenced this pull request Nov 4, 2025
Unifies gui opening, tick events and on touch events to use the same
system.

This makes it more future proof and establishes a clear pattern to be
used for events.

Also while I was at it I decided to implement lava damage.

- [x] Implement damage for more blocks: Cactus and magma

brings us one step closer to PixelGuys#2083
Argmaster pushed a commit to Argmaster/Cubyz that referenced this pull request Nov 6, 2025
Unifies gui opening, tick events and on touch events to use the same
system.

This makes it more future proof and establishes a clear pattern to be
used for events.

Also while I was at it I decided to implement lava damage.

- [x] Implement damage for more blocks: Cactus and magma

brings us one step closer to PixelGuys#2083
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants