diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs
index 7210cc2..47bdf06 100644
--- a/src/Engine.Host/Program.cs
+++ b/src/Engine.Host/Program.cs
@@ -270,9 +270,19 @@ if (windowed)
// before this plugin existed. With one loaded, Update only runs
// while actually Playing: Edit mode still renders the scene every
// frame (so the editor UI stays responsive and the view isn't
- // frozen mid-edit), it just never ticks it.
+ // frozen mid-edit), it just never ticks it. FixedUpdate is gated
+ // the same way and for the same reason — physics shouldn't step
+ // while Edit mode has the world frozen — and only accumulates time
+ // while playing, so Play doesn't open with a catch-up burst of
+ // steps for however long Edit mode had been sitting idle.
if (playMode is null || playMode.IsPlaying)
+ {
+ var fixedSteps = time.ConsumeFixedSteps();
+ for (var step = 0; step < fixedSteps; step++)
+ schedule.RunStage(Stage.FixedUpdate, world);
+
schedule.RunStage(Stage.Update, world);
+ }
schedule.RunStage(Stage.Render, world);
schedule.RunStage(Stage.Present, world);
@@ -313,6 +323,11 @@ else
for (var frame = 0; frame < frames; frame++)
{
time.Tick(headlessDeltaTime);
+
+ var fixedSteps = time.ConsumeFixedSteps();
+ for (var step = 0; step < fixedSteps; step++)
+ schedule.RunStage(Stage.FixedUpdate, world);
+
schedule.RunStage(Stage.Update, world);
}
diff --git a/src/Engine.Kernel/Diagnostics/ITime.cs b/src/Engine.Kernel/Diagnostics/ITime.cs
index 58a2e4c..a44cce7 100644
--- a/src/Engine.Kernel/Diagnostics/ITime.cs
+++ b/src/Engine.Kernel/Diagnostics/ITime.cs
@@ -3,13 +3,12 @@ namespace Engine.Kernel.Diagnostics;
///
/// Frame clock. See docs/kernel-contract.md §2.
///
-/// No fixed-step accumulator yet — that was in the original kernel scope
-/// but nothing consumes it: building it now, with no physics system to
-/// test it against, would be exactly the kind of untested speculative
-/// machinery this project has avoided everywhere else. It arrives with
-/// M4, alongside the Stage.FixedUpdate it would drive — a "FixedUpdate"
-/// stage without a real fixed-timestep accumulator behind it would be
-/// actively misleading, not just incomplete.
+/// M4 adds the fixed-step accumulator this doc comment used to say was
+/// missing: engine.physics needs to step Box3D at a constant rate
+/// regardless of how the real frame rate wobbles, and a "FixedUpdate"
+/// stage without a real accumulator behind it would be actively
+/// misleading, not just incomplete. See Stage.FixedUpdate and
+/// Time.ConsumeFixedSteps for the actual mechanism.
///
public interface ITime
{
@@ -20,4 +19,12 @@ public interface ITime
double ElapsedTime { get; }
int FrameCount { get; }
+
+ ///
+ /// The constant step size Stage.FixedUpdate always runs at, regardless
+ /// of the real frame rate — 1/50s. Not per-project configurable yet:
+ /// nothing has needed a different rate, and a setting nobody's asked
+ /// for is speculative the same way an unconsumed accumulator was.
+ ///
+ float FixedDeltaTime { get; }
}
diff --git a/src/Engine.Kernel/Diagnostics/Time.cs b/src/Engine.Kernel/Diagnostics/Time.cs
index 5dac6f4..5193971 100644
--- a/src/Engine.Kernel/Diagnostics/Time.cs
+++ b/src/Engine.Kernel/Diagnostics/Time.cs
@@ -2,15 +2,26 @@ namespace Engine.Kernel.Diagnostics;
///
/// Read-only to plugins (ITime); advanced by whoever owns the frame loop —
-/// Engine.Host today, headless or windowed — via , which
-/// isn't on the interface. Same split as Schedule/ISchedule: the host gets
-/// the extra, plugin-facing code doesn't.
+/// Engine.Host today, headless or windowed — via and
+/// , neither on the interface. Same split as
+/// Schedule/ISchedule: the host gets the extra, plugin-facing code doesn't.
///
public sealed class Time : ITime
{
+ // Capped so a real stall — a debugger pause, a slow disk load — can't
+ // turn into a catch-up burst of hundreds of physics steps executing
+ // back to back, each one still assuming a normal frame budget around
+ // it. Five steps behind (0.1s at the default rate) already means
+ // something else is wrong; better to visibly fall behind real time
+ // than to freeze the process trying to simulate through it.
+ private const int MaxStepsPerFrame = 5;
+
+ private float _accumulator;
+
public float DeltaTime { get; private set; }
public double ElapsedTime { get; private set; }
public int FrameCount { get; private set; }
+ public float FixedDeltaTime { get; } = 1f / 50f;
public void Tick(float deltaTime)
{
@@ -18,4 +29,25 @@ public sealed class Time : ITime
ElapsedTime += deltaTime;
FrameCount++;
}
+
+ ///
+ /// Adds this frame's DeltaTime to the accumulator and returns how many
+ /// FixedDeltaTime-sized steps it can pay for, having already deducted
+ /// that much. Call once per frame, then run Stage.FixedUpdate that many
+ /// times (zero is normal — most frames are shorter than FixedDeltaTime
+ /// at 50Hz against a 60+fps display).
+ ///
+ public int ConsumeFixedSteps()
+ {
+ _accumulator += DeltaTime;
+
+ var steps = 0;
+ while (_accumulator >= FixedDeltaTime && steps < MaxStepsPerFrame)
+ {
+ _accumulator -= FixedDeltaTime;
+ steps++;
+ }
+
+ return steps;
+ }
}
diff --git a/src/Engine.Kernel/Scheduling/Stage.cs b/src/Engine.Kernel/Scheduling/Stage.cs
index bbe54b2..b1ffe20 100644
--- a/src/Engine.Kernel/Scheduling/Stage.cs
+++ b/src/Engine.Kernel/Scheduling/Stage.cs
@@ -3,16 +3,22 @@ namespace Engine.Kernel.Scheduling;
///
/// Fixed, kernel-defined, not plugin-extensible — see "Frame stages" in
/// docs/kernel-contract.md §8. is the second addition
-/// to the original {Update, Render} set (after FixedUpdate was scoped for
-/// M4), added for M3's editor: engine.render splits its old single
-/// Clear+Draw+SwapBuffers system into a Render-stage draw and a
-/// Present-stage swap specifically so engine.editor's ImGui overlay — which
-/// has to run after the scene is drawn but before the buffers swap — has
-/// somewhere to register that isn't a race against load order. Not a stage
-/// added speculatively: SwapBuffers already needed to move somewhere real.
+/// to the original {Update, Render} set, added for M3's editor: engine.
+/// render splits its old single Clear+Draw+SwapBuffers system into a
+/// Render-stage draw and a Present-stage swap specifically so engine.
+/// editor's ImGui overlay — which has to run after the scene is drawn but
+/// before the buffers swap — has somewhere to register that isn't a race
+/// against load order. is the third, arriving
+/// with M4 exactly as ITime's own doc comment said it would: engine.physics
+/// steps Box3D here, at ITime.FixedDeltaTime's fixed rate, run zero-or-more
+/// times per frame by the accumulator Engine.Host drives — see
+/// Time.ConsumeFixedSteps. Runs before Update each frame it fires, so
+/// gameplay code reading a Rigidbody's position in Update sees this frame's
+/// physics result, not last frame's.
///
public enum Stage
{
+ FixedUpdate,
Update,
Render,
Present,
diff --git a/tests/Engine.Kernel.Tests/TimeTests.cs b/tests/Engine.Kernel.Tests/TimeTests.cs
index 5f696da..b0300cf 100644
--- a/tests/Engine.Kernel.Tests/TimeTests.cs
+++ b/tests/Engine.Kernel.Tests/TimeTests.cs
@@ -38,4 +38,37 @@ public class TimeTests
Assert.Equal(0.75, time.ElapsedTime, 3);
Assert.Equal(2, time.FrameCount);
}
+
+ [Fact]
+ public void ConsumeFixedSteps_BelowOneStep_ReturnsZero()
+ {
+ var time = new Time();
+
+ // FixedDeltaTime is 1/50 = 0.02s; a 60fps-ish frame is shorter.
+ time.Tick(0.01f);
+
+ Assert.Equal(0, time.ConsumeFixedSteps());
+ }
+
+ [Fact]
+ public void ConsumeFixedSteps_ConsumesWholeStepsAndKeepsRemainder()
+ {
+ var time = new Time();
+
+ time.Tick(0.05f); // 2.5 steps' worth
+ Assert.Equal(2, time.ConsumeFixedSteps());
+
+ time.Tick(0.03f); // remainder 0.01 + 0.03 = 0.04 -> 2 more steps
+ Assert.Equal(2, time.ConsumeFixedSteps());
+ }
+
+ [Fact]
+ public void ConsumeFixedSteps_CapsAtFiveEvenAfterAHugeStall()
+ {
+ var time = new Time();
+
+ time.Tick(10f); // a debugger pause, not a real frame
+
+ Assert.Equal(5, time.ConsumeFixedSteps());
+ }
}