Skip to content

Manual physics stepping#116230

Open
ashtonmeuser wants to merge 5 commits intogodotengine:masterfrom
ashtonmeuser:physics-iteration
Open

Manual physics stepping#116230
ashtonmeuser wants to merge 5 commits intogodotengine:masterfrom
ashtonmeuser:physics-iteration

Conversation

@ashtonmeuser
Copy link
Copy Markdown
Contributor

@ashtonmeuser ashtonmeuser commented Feb 13, 2026

Closes godotengine/godot-proposals#2821.

This PR presents two user-facing changes:

  1. A Physics → Common → Use Builtin Physics Stepping setting that defaults to true.
  2. The Engine.physics_iteration(delta: float) -> bool method of the Engine global.

I hope that this is a less impactful change than #76462 which, without a Godot physics lead, the maintainers have been understandably cautious of merging. The changes herein are purely internal to the engine, use existing behaviour wherever possible, and have no effect on the external PhysicsServer2/3D API, thereby avoiding the need for external adoption.

Note

This PR does not, on its own, enable physics rollback. That is a complex and nuanced operation. Refer to the conversation in #76462 for more context.

Implementation

The bool Main::iteration(double delta) method has been split in two. All physics-related logic is now performed in bool Main::physics_iteration(double delta). Determined by the Physics → Common → Use Builtin Physics Stepping setting, the main loop Main::iteration calls the physics loop Main::physics_iteration. As this setting defaults to true, default behaviour remains unchanged.

The Engine global now registers a physics_iteration_callback function pointer property that can be invoked, if set, using bool Engine::physics_iteration(double p_delta). This callback is set by the main loop in Main::setup and points to Main::physics_iteration.

The Engine global exposes its physics_iteration method in core_bind, following the pattern set by other global singleton methods. This can be invoked in GDScript via Engine.physics_iteration(delta: float).

Alternatives

This exposes similar functionality to the contentious PhysicsServer2/3D::space_step() PR#76462. However, it differs in several key ways. I'll include some criticisms of the aforementioned PR below, although I believe @Daylily-Zeleen has done an incredible job and has dutifully updated the PR over many years 👏👏👏

The key differences between this PR and PR#76462 are:

  1. This PR does not enable manually stepping a single physics space.
  2. This PR allows for the complete disabling of main loop physics iteration. With PR#76462, the physics server is still stepped each typical physics frame.
  3. This PR triggers all side effects typically triggered by Godot. This includes the SceneTree.physics_frame signal being fired and Node.physics_process methods being called.

With the utmost respect, I believe PR#76462 is lacking in the following ways:

  1. PR#76462 requires the cooperation of all physics server implementations while this PR is more encapsulated, albeit higher level, as it targets the core engine loop rather than PhysicsServer2/3D.
  2. PR#76462 does not align with intuition in that physics_process no longer reflects when the processing of physics has occurred. This PR rights that in an attempt to be more intuitive.
  3. Documentation and comments in PR#76462 are lacking and, where present, lack clarity. I've strived to include adequate documentation in this PR.
  4. PR#76462 exposes PhysicsServer2/3D.space_flush_queries to the user. This is an implementation detail that, unfortunately, has been exposed by the PR and is required, depending on the situation, to receive physics notifications e.g. Area2D.body_entered when manually stepping physics. The details of flushing queries should be the concern of physics server implementors and the ambiguity of when it's called should be the concern of the engine maintainers. This ambiguity is reflected in the documentation of PhysicsServer2/3D.space_flush_queries and PhysicsServer2/3D.space_step, can be seen in many comments on the PR, and has made it's way to other libraries including the excellent Rapier Godot plugin. The Godot engine calls flush_queries before step while many examples of manual stepping reverse this order. I believe the exposure of such a method is a footgun.
  5. Finally, it seems counter-intuitive in PR#76462 that e.g. PhysicsServer2D.step does not call PhysicsServer2D.space_step. In my opinion, stepping the server should merely iterate all active spaces and call space_step. This decoupling may result in unexpected discrepancies in behaviour between two seemingly related methods.

Questions & Concerns

Feedback very welcome on the following:

  • When Physics → Common → Use Builtin Physics Stepping is disabled, it effectively renders Physics → Common → Physics Ticks per Second moot. Perhaps the behaviour of this PR when builtin physics stepping is disabled should be triggered when physics frequency is set to 0.
  • This has implications for profiling physics timing. The main loop uses a simple 1 / physics_ticks_per_second that's passed to EngineDebugger::get_singleton()->iteration (source). Untested and I'm not sure what the solution is here.
  • This has small implications for MainTimerSync which is passed a physics step in its advance method. Ultimately, the calculated physics frame count passed back (in a MainFrameTime struct) is ignored if Physics → Common → Use Builtin Physics Stepping is disabled, so this may be a nonissue.
  • How does this play with a physics server running on its own thread? I haven't the foggiest and have yet to test. That said, the engines default locking of the physics server (via e.g. PhysicsServer2D::sync) remains unchanged, so this may be a nonissue.
  • How does this interact with internal tracking of the current physics frame? For example, how does Input.is_action_just_pressed behave?

@Clonkex
Copy link
Copy Markdown

Clonkex commented Feb 13, 2026

This is a great step towards a properly thought-out solution, and I completely agree with the thought process. We shouldn't have to care about implementation details, especially as low-level as calling space_flush_queries. I also think this will be a heck of a lot easier to work with when it comes to implementing network rollback since we won't need to manually assign our rigidbodies to a separate physics space.

I believe the only real issue people might face now when trying to implement network rollback would be that if an object has entered a trigger volume and then you run a rollback, moving the object to a historical position would trigger the "leave trigger" callback, whatever that is. That's not automatically an issue, though, and for cases where it might be, it's a very solvable problem.

If I find time to test this PR I'll let you know how it goes.

@ashtonmeuser
Copy link
Copy Markdown
Contributor Author

ashtonmeuser commented Feb 14, 2026

Here is a breakdown of my interpretation of and questions about the physics portion of the main loop iteration. This was all carried over to Main::physics_iteration with minimal changes but I'd be lying if I claimed to understand each part.

Further, I've highlighted some concerns about portions of the main loop iteration logic that I do understand e.g. the effects of manual iteration on input events.

I'd love some insight from those more familiar with this part of the codebase.

Breakdown
GodotProfileZone("Physics Step");
GodotProfileZoneGroupedFirst(_physics_zone, "setup");

Setup profiling zones. I am not sure exactly how these work or how they'll work with GodotProfileZoneGrouped(_profile_zone, "physics"); that is called before the for loop encapsulating physics iteration and is thus not called when manually calling physics iteration from elsewhere.

if (Input::get_singleton()->is_agile_input_event_flushing()) {
  Input::get_singleton()->flush_buffered_events();
}

This condition is only satisfied if Settings → Input Devices → Buffering → Agile Event Flushing is enabled. Seeing as this setting 1) defaults to false, and 2) claims to only be supported on Android, I think it's safe to ignore this condition or consider it unsupported when manually stepping physics.

Engine::get_singleton()->_in_physics = true;
Engine::get_singleton()->_physics_frames++;

[!CAUTION]
Affects typical engine behavior when manually iterating physics.

Here, we're signaling that we're in a physics frame (which determines the return value of Engine.is_in_physics_frame).

These two calls have a large influence on input.

First, Engine.is_in_physics_frame determines whether Input.is_action_just_pressed compares the current frame with the input frame via event.pressed_physics_frame == Engine.get_physics_frames() and event.pressed_process_frame == Engine.get_process_frames() for physics and process frames, respectively (source).

Second, the physics frame we're comparing our input event to must match the current physics frame. Manually running many physics frames will increment _physics_frames, thus making any gathered input appear old. Input.is_action_just_pressed will rarely be asserted because we've increment the physics frame count before considering the input event.

Perhaps an argument could be supplied to Engine.physics_iteration that skips physics frame incrementing and thus considers input events "current" for future physics frames. I'm not sure what this may affect elsewhere in the engine.

uint64_t physics_begin = OS::get_singleton()->get_ticks_usec();

Simply getting the start time for profiling metrics.

// Prepare the fixed timestep interpolated nodes BEFORE they are updated
// by the physics server, otherwise the current and previous transforms
// may be the same, and no interpolation takes place.
GodotProfileZoneGrouped(_physics_zone, "main loop iteration prepare");
OS::get_singleton()->get_main_loop()->iteration_prepare();

Oof, this is a tough one. I'm not sure how interpolation would/should work with manual physics stepping. This behavior may work for free, or may be catastrophically broken. Perhaps is best to consider physics interpolation unsupported in cases of manual physics iteration.

#ifndef PHYSICS_3D_DISABLED
  GodotProfileZoneGrouped(_physics_zone, "PhysicsServer3D::sync");
  PhysicsServer3D::get_singleton()->sync();
  PhysicsServer3D::get_singleton()->flush_queries();
#endif // PHYSICS_3D_DISABLED

#ifndef PHYSICS_2D_DISABLED
  GodotProfileZoneGrouped(_physics_zone, "PhysicsServer2D::sync");
  PhysicsServer2D::get_singleton()->sync();
  PhysicsServer2D::get_singleton()->flush_queries();
#endif // PHYSICS_2D_DISABLED

PhysicsServer.sync simply tells the server that we're in a physics frame. In the Godot physics server implementations, this seems to only be considered when the physics server is on its own thread. If threaded and the server is not being synced, an error is thrown when accessing direct physics state e.g. GodotPhysicsServer2D.space_get_direct_state.

PhysicsServer.flush_queries updates Godot nodes with state from the physics server. This triggers notifications for area entered, collisions, etc. It may be desirable to skip this step e.g. simulating a projectile and getting only its position after N physics ticks. Note that PR#76462 handles this a different way and exposes the PhysicsServer.space_flush_queries method which must be called to achieve normal behavior (e.g. physics notifications) when manually stepping a physics space.

GodotProfileZoneGrouped(_physics_zone, "physics_process");
if (OS::get_singleton()->get_main_loop()->physics_process(physics_step * time_scale)) {
#ifndef PHYSICS_3D_DISABLED
  PhysicsServer3D::get_singleton()->end_sync();
#endif // PHYSICS_3D_DISABLED
#ifndef PHYSICS_2D_DISABLED
  PhysicsServer2D::get_singleton()->end_sync();
#endif // PHYSICS_2D_DISABLED

  Engine::get_singleton()->_in_physics = false;
  exit = true;
  break;
}

Here, we're calling MainLoop.physics_process which is responsible for calling the Node._physics_process method on all nodes in the scene tree. We can effectively ignore the conditional as this method only returns true in the case of a catastrophic failure which ends the main loop. Without a custom main loop implementation, this seems to be effectively never the case.

#if !defined(NAVIGATION_2D_DISABLED) || !defined(NAVIGATION_3D_DISABLED)
  uint64_t navigation_begin = OS::get_singleton()->get_ticks_usec();

#ifndef NAVIGATION_2D_DISABLED
  GodotProfileZoneGrouped(_profile_zone, "NavigationServer2D::physics_process");
  NavigationServer2D::get_singleton()->physics_process(physics_step * time_scale);
#endif // NAVIGATION_2D_DISABLED
#ifndef NAVIGATION_3D_DISABLED
  GodotProfileZoneGrouped(_profile_zone, "NavigationServer3D::physics_process");
  NavigationServer3D::get_singleton()->physics_process(physics_step * time_scale);
#endif // NAVIGATION_3D_DISABLED

  navigation_process_ticks = MAX(navigation_process_ticks, OS::get_singleton()->get_ticks_usec() - navigation_begin); // keep the largest one for reference
  navigation_process_max = MAX(OS::get_singleton()->get_ticks_usec() - navigation_begin, navigation_process_max);

  message_queue->flush();
#endif // !defined(NAVIGATION_2D_DISABLED) || !defined(NAVIGATION_3D_DISABLED)

Navigation logic only runs during a physics frame.

I'm unfamiliar with what the call to message_queue->flush() is doing or the implications manual physics iteration may have on this.

#ifndef PHYSICS_3D_DISABLED
  GodotProfileZoneGrouped(_profile_zone, "3D physics");
  PhysicsServer3D::get_singleton()->end_sync();
  PhysicsServer3D::get_singleton()->step(physics_step * time_scale);
#endif // PHYSICS_3D_DISABLED

#ifndef PHYSICS_2D_DISABLED
  GodotProfileZoneGrouped(_profile_zone, "2D physics");
  PhysicsServer2D::get_singleton()->end_sync();
  PhysicsServer2D::get_singleton()->step(physics_step * time_scale);
#endif // PHYSICS_2D_DISABLED

Here's the meat of the physics iteration. We're ending sync (see above) before calling PhysicsServer.step. This advances the physics simulation by the provided step time in seconds.

message_queue->flush();

Once again, I'm not sure what this is doing. Presumably, both navigation and physics servers add messages to the queue that are used by Godot to notify other parts of the engine of events. Flushing this queue may alert Godot that a particular step is complete e.g. physics iteration and that notification can be forwarded onto the end recipients.

GodotProfileZoneGrouped(_profile_zone, "main loop iteration end");
OS::get_singleton()->get_main_loop()->iteration_end();

This likely has to do with physics interpolation (see above). Unsure.

physics_process_ticks = MAX(physics_process_ticks, OS::get_singleton()->get_ticks_usec() - physics_begin); // keep the largest one for reference
physics_process_max = MAX(OS::get_singleton()->get_ticks_usec() - physics_begin, physics_process_max);

Profiling data.

Engine::get_singleton()->_in_physics = false;

We're now out of the physics frame. Engine.is_in_physics_frame will be false.

@Ivorforce Ivorforce requested a review from a team February 15, 2026 18:29
@ashtonmeuser
Copy link
Copy Markdown
Contributor Author

After some experimentation, it seems further API is useful. See #116510 for a deeper exposure of the main loop physics iteration via Engine::physics_iteration(double p_delta, bool p_increment_frames = true, FlushQueriesOrder p_flush_queries_order = FLUSH_QUERIES_BEFORE_STEP). This allows for practical re-simulation of physics frames.

This new PR goes against some of my criticisms of #76462 e.g. exposing PhysicsServer2/3D.flush_queries to the user. However, Godot's physics implementation simply did not consider physics frames being "out of order" and/or re-simulation, and thus, practically, the main loop internals must be configurable.

Very open to feedback once again. #116510 is opened as a draft as it does not fully consider all implications of such a change (see questions above). Perhaps #116230 should also be a draft.

@KeyboardDanni
Copy link
Copy Markdown
Contributor

I'm a fan of this general approach, because usually you want both the rigid body sim and the physics step gameplay code to run at the same time. For determinism, you want to encapsulate all gameplay logic within a single atomic operation, and this stays closer to that ideal.

I have some concerns about how the timing of manual stepping is handled, though. The purpose of the automatic physics step is so that the game runs at the same pace regardless of rendering speed. When you allow (or require) users to disable this mechanism in order to fast-forward, for example, you risk breaking this important property.

Personally, I would like a way to "request" an additional _physics_process iteration - i.e. call a function like Engine.queue_physics_process(), and after the current _physics_process step is finished, it immediately performs another one. Either that, or have a function that sets how many _physics_process steps occur per frame. This would be a great alternative to setting the time scale because all code will receive the same deltas and thus remain deterministic.

How does this interact with internal tracking of the current physics frame? For example, how does Input.is_action_just_pressed behave?

In terms of API expectations, I would expect Input.is_action_just_pressed to return true for the first _physics_process call and false for all subsequent calls. I.e. it should only act as if the game's target framerate suddenly shifted upward.

@ashtonmeuser
Copy link
Copy Markdown
Contributor Author

Personally, I would like a way to "request" an additional _physics_process iteration - i.e. call a function like Engine.queue_physics_process()

Huh, I like this idea! It may be a little awkward considering currently, the engine may advance more than a single physics frame depending on what MainFrameTime has to say.

In terms of API expectations, I would expect Input.is_action_just_pressed to return true for the first _physics_process call and false for all subsequent calls

Yes, this is how things currently work. However, this makes implementing a rollback system (a popular topic in PR#76462) difficult as executing N rollback physics ticks before executing the current physics tick (for which we want input info) essential consume or invalidate inputs.

For example, our rollback system is on tick 10 and wants to rollback one tick. It resets state (transforms, velocity, etc.) and re-simulates the previous physics tick using previously gathered input. This increments the engines internal physics tick counter and makes input gathered since the previous "real" (not re-simulated) tick appear old. When we simulate the current tick 10, Input.is_action_just_pressed is false while it was in fact just pressed. It was true during the re-simulation.

Further confounding things is the fact that the input may be gathered during the rollback.

To avoid these issues, in #116510, I simply added a flag to avoid incrementing the internal tick counter thus making all input gathered before and during a rollback appear current so that it can be used when executing the current, non-rollback tick.

@KeyboardDanni
Copy link
Copy Markdown
Contributor

I guess what I'm looking for from manual physics stepping is different from what people doing rollback want. My use case is more along the lines of a fast forward button like in Lemmings or Bloons. In that sense, I would expect input to continue being processed as normal, and for the game to maintain the usual tick rate. For rollback use cases though, you probably want something different then.

I think it'd be good to have manual physics stepping include an option on how to process the input, and include clear documentation on when you would want to process input normally versus doing it in a rollback-friendly way.

For example, our rollback system is on tick 10 and wants to rollback one tick. It resets state (transforms, velocity, etc.) and re-simulates the previous physics tick using previously gathered input. This increments the engines internal physics tick counter and makes input gathered since the previous "real" (not re-simulated) tick appear old. When we simulate the current tick 10, Input.is_action_just_pressed is false while it was in fact just pressed. It was true during the re-simulation.

I think this is a good use case for virtualizing input. If you use a custom state object for managing input state, you can replay the input yourself. The question that remains is how this should be handled in the core engine.

@Clonkex
Copy link
Copy Markdown

Clonkex commented Feb 22, 2026

Hmm good points. For rollback you'd want to skip processing inputs because the inputs your entities see should be set from historical values and you wouldn't want to consume the real inputs from the engine.

@Gabooot
Copy link
Copy Markdown

Gabooot commented Mar 4, 2026

I think that calling Node._physics_process as part of manual physics stepping is a mistake.
Node._physics_process provides the fixed time step that we use to ensure that physics objects update consistently, however physics is not the only use for a fixed time step update and users also update non-physics simulations and collect input under _physics_process. It is relatively easy to move gameplay code that needs to be rolled back into a custom signal or function call loop but it would be confusing to implement logic differentiating between a "real" and a "fake" _physics_process call.

@KeyboardDanni
Copy link
Copy Markdown
Contributor

@Gabooot I'm not sure I follow your objection. Ideally you want to put all gameplay logic in _physics_process (I've long been an advocate for this), and when replaying or fast-forwarding, you usually want to run all your logic at once. That's exactly what this PR aims to achieve.

@Clonkex
Copy link
Copy Markdown

Clonkex commented Mar 4, 2026

I think I'm with @KeyboardDanni on this one. _physics_process happens every time the physics engine steps, so it makes sense that it should also happen for manual stepping. That said, while it makes perfect sense thinking in terms of network rollback or a fast-forward button, if your game only needs manual stepping for predicting future physics states (a billiards game, for instance), then it might be confusing for beginners if _physics_process also happens multiple times. But... I don't know if that warrants different behaviour between automatic and manual stepping. I think I'd rather it be the same by default but perhaps manual stepping could include a parameter to not call _physics_process (otherwise, it's still pretty trivial to solve manually, just a bit of boilerplate).

@Gabooot
Copy link
Copy Markdown

Gabooot commented Mar 5, 2026

@KeyboardDanni Sorry that wasn't clear. It's good practice to put gameplay logic under _physics_process because it runs at a consistent rate. By running _physics_process whenever we update the physics simulation, we change _physics_process from a method that gets called at a consistent rate to a method that gets called whenever the physics simulation is updated.

Currently, those are equivalent methods but I think that if the physics simulation is no longer updated at a consistent rate then _physics_process should be untangled from the physics simulation and should be used to trigger logic that is updated regularly.

@GreenCrowDev
Copy link
Copy Markdown
Contributor

Hello, and thanks to everyone for the discussions.

Last night I started looking into whether Godot exposes APIs that support rollback in the context of client-side prediction and server reconciliation, so I first found #76462 and then this PR. Obviously godotengine/godot-proposals#2821 represents a common need that many of us would like to see addressed.

As far as I understood from the discussion in the PhysicsServer2/3D::space_step() PR, implementation details can become very opinionated. @ashtonmeuser also mentioned that exposing too much to the developer might be a footgun.

I'm not a physics expert, and I'm mostly reading and following along because I'm interested in implementing these concepts in future projects. Given that this feature would ideally be merged for everyone, and not maintained as a custom fork, nailing the API and UX around simulation control seems key.

To achieve this, we probably need both solid default behavior and deeper customization options depending on the user’s goal.

By default behavior, a good reference might be what @KeyboardDanni mentioned:

My use case is more along the lines of a fast forward button like in Lemmings or Bloons. In that sense, I would expect input to continue being processed as normal, and for the game to maintain the usual tick rate. For rollback use cases though, you probably want something different then.

Here it’s also acknowledged that networking and rollback use cases may require something different.

I see there is ongoing discussion about how to handle input, or more generally any behavior executed inside _physics_process().

I find the considerations from @Gabooot very interesting, especially the point that _physics_process() may contain logic not strictly related to physics, and that manual stepping could re-trigger that logic in ways that are unexpected. I think this is something we should keep in mind when exposing APIs for manual simulation control.

Since there are very different use cases that all rely on manually stepping the simulation, perhaps the goal should be a clean API that lets beginners do the minimum, while still allowing advanced developers to control what is processed and what is not.

One idea that comes to mind (and this may be completely off base) would be allowing some form of flags or configuration when manually stepping physics, so specific behaviors (for example, input-related handling) could optionally be enabled or disabled. This might help accommodate both fast-forward and rollback scenarios without forcing a single rigid behavior.

Curious to hear what others think.

Thank you all for your contributions.

@albertok
Copy link
Copy Markdown

albertok commented Mar 8, 2026

I've made a few games based on the #76462 PR and think there's some assumptions here that I can add context to as to why they are good features of the OG stepping PR.

  1. The existing PR does let you pick which space to step and quite intuitively by using different Worlds, as it should. It doesn't force you to step a single space which is really nice to have, particularly when working with different Viewports.

This PR allows for the complete disabling of main loop physics iteration. With PR#76462, the physics server is still stepped each typical physics frame.

in the OG PR you generally call the following to stop the automatic stepping, then take over.

var physics_space = get_viewport().world_3d.space
PhysicsServer3D.space_set_active(physics_space, false)

We do this in the netfox netcode addon so everything is nicely in step with the network ticks that everyone is using as a focal reference point.

Documentation and comments in PR#76462 are lacking and, where present, lack clarity. I've strived to include adequate documentation in this PR.

The docs in the existing PR were fine. The api is two methods which were straight forward, didn't impact anything existing and were what you expect if you came from other engines.

Furthermore the author provided examples of three genuine use cases which could have been tutorials in Godot docs.

PR#76462 exposes PhysicsServer2/3D.space_flush_queries to the user. This is an implementation detail that, unfortunately, has been exposed by the PR and is required, depending on the situation, to receive physics notifications e.g. Area2D.body_entered when manually stepping physics. The details of flushing queries should be the concern of physics server implementors and the ambiguity of when it's called should be the concern of the engine maintainers

You want flushing exposed. It's extremely useful if you need to push stepping to do a high number of steps without thrashing the engine. The rapier addon provides this also:

https://godot.rapier.rs/docs/documentation/performance#extra-performance,

You don't necessarily want 'all side effects typically triggered by Godot'. As touched on in the rapier docs above it gives you performance control. You might be doing a prediction like in Peggle and only care about the trajectory path. You might have a high object count and need to skip updating the Godot nodes to keep the frame rate up.

You always have the option of querying the physics engine directly to get the information you need.

@KeyboardDanni
Copy link
Copy Markdown
Contributor

I've made a few games based on the #76462 PR and think there's some assumptions here that I can add context to as to why they are good features of the OG stepping PR.

  1. The existing PR does let you pick which space to step and quite intuitively by using different Worlds, as it should. It doesn't force you to step a single space which is really nice to have, particularly when working with different Viewports.

This PR allows for the complete disabling of main loop physics iteration. With PR#76462, the physics server is still stepped each typical physics frame.

This is all based on the assumption that the rigid body physics sim is the only thing that drives the gameplay sim. The reality is that there's much more that touches gameplay, so the physics space is the wrong place to establish that boundary. There are many things that step outside of the physics space step, including character controllers and any logic that affects those. The moment you step some things and not other things is the moment you open yourself up to desync issues. Basically:

GameplaySimAssumption GameplaySimReality

During discussion of the OG PR, we've established that in order to have reliable replay netcode, the gameplay sim must play out the same for the original inputs. Currently, Godot's physics server and related APIs do not make any guarantees about physics state determinism and do not provide a way to restore the physics state in a way that all collisions will process in the same order and with the same results. The OG PR also makes no such guarantees. In fact, stepping the rigid body sim and not the other code that could touch the sim is only going to open up more problems.

The docs in the existing PR were fine. The api is two methods which were straight forward, didn't impact anything existing and were what you expect if you came from other engines.

Good documentation needs to warn users of potential pitfalls when using lower level functionality like this. Otherwise the Godot project maintainers bear the burden of users filing bug reports when features are used in unexpected ways.

You want flushing exposed. It's extremely useful if you need to push stepping to do a high number of steps without thrashing the engine.

I agree that this should be exposed. I've run into this myself trying to write manual stepping code. In this case though, manual flushing is orthogonal to what this PR achieves, and with this PR you often don't need to flush queries. IMO, it should be implemented but it belongs in a separate PR.

You don't necessarily want 'all side effects typically triggered by Godot'. As touched on in the rapier docs above it gives you performance control.

Often times you do want side effects though. Some examples:

  • Character controllers (since they don't step in the physics space but can still affect the simulation)
  • Replayed inputs affecting the physics sim (turning, jumping, boosting)
  • A pressure plate firing a signal that activates a boulder trap

By simply rerunning _physics_process you can resimulate all of this reliably and without having to write custom code to handle special cases.

And in general, my stance is that performance should never come before the gameplay simulation running correctly.

You might be doing a prediction like in Peggle and only care about the trajectory path.

This is a good use case, but again I don't think the physics space is the right place to draw the line. I think a better system would be "process groups" where you can assign nodes to only process as part of specific groups, so that when you do a manual step only those nodes will step. The physics space step could also be assigned to certain groups as well.

It would have other benefits too, like doing a "partial pause" of the world. You can see many examples of this in Kirby Superstar, where everything except Kirby pauses to demonstrate a new ability.

I'm not sure how signals would tie into this, but from the perspective of "run some things but not other things", this is where I would start personally.

You might have a high object count and need to skip updating the Godot nodes to keep the frame rate up.

If the nodes are gameplay-related at all, this will cause a gameplay desync.

@Clonkex
Copy link
Copy Markdown

Clonkex commented Mar 8, 2026

During discussion of the OG PR, we've established that in order to have reliable replay netcode, the gameplay sim must play out the same for the original inputs. Currently, Godot's physics server and related APIs do not make any guarantees about physics state determinism and do not provide a way to restore the physics state in a way that all collisions will process in the same order and with the same results.

Depending on what you mean by "replay", this isn't true. Perfect determinism is infeasible. Instead of setting up the simulation to the state at the beginning of the replay and then only replaying inputs, a replay usually just replays state. If you're talking about rollback, then generally yes, for stable gameplay you want as much consistency as possible, but even then it's still really not necessary. A good rollback system will always be "eventually consistent". You just might see the occasional glitchy movement or broken animation if the client doesn't process its collision events in the same order as the server, if or triggers are not properly fired again during rollback.

That said, all of my comments are based on a deep understanding of networking theory and experience building a complete networking solution in Godot, but no practical experience in building real games.

@KeyboardDanni
Copy link
Copy Markdown
Contributor

KeyboardDanni commented Mar 8, 2026

Depending on what you mean by "replay", this isn't true. Perfect determinism is infeasible.

For netcode, yes, you'll need to come to terms with the fact that in a lot of games, you can't perfectly reproduce state. For other games, especially ones that are played offline or are online purely through leaderboards or non-interaction between players (think Trackmania), determinism is perfectly feasible (and in fact, vital) with a well-designed system. Any manual stepping system should work well in all of these scenarios.

Instead of setting up the simulation to the state at the beginning of the replay and then only replaying inputs, a replay usually just replays state.

I was under the impression that the purpose of manual physics stepping was to rerun the simulation. You don't need manual physics stepping to just restore state.

If you're talking about rollback, then generally yes, for stable gameplay you want as much consistency as possible, but even then it's still really not necessary.

Again, this depends on the game. In terms of offline or leaderboard-based examples you have games like Super Mario World, Celeste, The Incredible Machine, Trackmania, and pretty much any game with a speedrun timer. Any form of gameplay divergence in a precision or sim-based game can be catastrophic.

Many things in gamedev aren't strictly necessary, yet can mean the difference between "playable" and "unplayable".

A good rollback system will always be "eventually consistent". You just might see the occasional glitchy movement or broken animation if the client doesn't process its collision events in the same order as the server, if or triggers are not properly fired again during rollback.

Gameplay desyncs don't converge, they diverge unboundedly unless there's a system to prevent that. Restoring state is one way to do this, but if you're not restoring the entire state carefully, you may end up with a loop where the client repeatedly hits the same desync over and over, each time attempting to rewind but always ending up with a different result from what the host sees.

Edit: Basically, my point is: if we're adding manual physics stepping in order to replay state, we should be doing the whole thing, not half. We shouldn't be just replaying the rigid body sim but the character controllers and gameplay-related signals as well. If exact state reproduction isn't important, then what's the manual stepping for?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ability to simulate physics manually/multiple times at once

7 participants