diff --git a/.gitignore b/.gitignore
index 394abaf..7699cab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,10 @@ imgui.ini
## Engine.Physics/native/) is what the engine actually ships.
native/*/build/
native/*/.fetchcontent-cache/
+
+## scripts/run-physics-demo.sh copies the game-specific plugin's built
+## output directly into its own folder (that's where its own pluginPaths
+## entry in project.json points) — regenerated, not source. plugin.json,
+## the .csproj, and the .cs files there stay tracked.
+samples/*/GamePlugins/*/*.dll
+samples/*/GamePlugins/*/*.pdb
diff --git a/LinguaEngine.sln b/LinguaEngine.sln
index 914442e..1250894 100644
--- a/LinguaEngine.sln
+++ b/LinguaEngine.sln
@@ -73,6 +73,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio", "plugins\eng
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Tests", "tests\Engine.Audio.Tests\Engine.Audio.Tests.csproj", "{E610BD6A-A732-4863-8D19-555B75134E13}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PhysicsDemo", "PhysicsDemo", "{51874E45-60A1-E43B-395E-A3BE4050752F}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GamePlugins", "GamePlugins", "{9760813E-A577-3C56-DE20-0218A108BABD}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "physics-demo-game", "physics-demo-game", "{83FF5113-4091-0AE3-A9AD-50B7BB917258}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame", "samples\PhysicsDemo\GamePlugins\physics-demo-game\PhysicsDemoGame.csproj", "{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame.Tests", "tests\PhysicsDemoGame.Tests\PhysicsDemoGame.Tests.csproj", "{0201686F-0E2A-400F-8166-CBD11B65D724}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -371,6 +383,30 @@ Global
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.Build.0 = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.ActiveCfg = Release|Any CPU
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.Build.0 = Release|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.Build.0 = Debug|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.Build.0 = Debug|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.ActiveCfg = Release|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.Build.0 = Release|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.ActiveCfg = Release|Any CPU
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.Build.0 = Release|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.Build.0 = Debug|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.Build.0 = Debug|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.ActiveCfg = Release|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.Build.0 = Release|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.ActiveCfg = Release|Any CPU
+ {0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -408,5 +444,10 @@ Global
{2C2E545D-6937-476D-AC62-F96CEA6403F9} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
{E610BD6A-A732-4863-8D19-555B75134E13} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
+ {51874E45-60A1-E43B-395E-A3BE4050752F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {9760813E-A577-3C56-DE20-0218A108BABD} = {51874E45-60A1-E43B-395E-A3BE4050752F}
+ {83FF5113-4091-0AE3-A9AD-50B7BB917258} = {9760813E-A577-3C56-DE20-0218A108BABD}
+ {1AFDA567-D86A-43C8-91B9-34CC13F48FEF} = {83FF5113-4091-0AE3-A9AD-50B7BB917258}
+ {0201686F-0E2A-400F-8166-CBD11B65D724} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
diff --git a/plugins/engine.render/Engine.Render.Contracts/CubeRenderer.cs b/plugins/engine.render/Engine.Render.Contracts/CubeRenderer.cs
new file mode 100644
index 0000000..a5ad304
--- /dev/null
+++ b/plugins/engine.render/Engine.Render.Contracts/CubeRenderer.cs
@@ -0,0 +1,12 @@
+using Engine.Kernel.World;
+
+namespace Engine.Render.Contracts;
+
+///
+/// A unit cube (1x1x1 before Transform.LocalScale), same no-size-fields
+/// reasoning as QuadRenderer — Transform.LocalScale already means "how
+/// big." Added for M4's physics demo: a BoxCollider falling and settling
+/// only reads as a real physics object on screen if it actually looks like
+/// a box, not a flat card.
+///
+public sealed class CubeRenderer : Component;
diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs
index 4ae757f..769c58f 100644
--- a/plugins/engine.render/Engine.Render/RenderPlugin.cs
+++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs
@@ -12,11 +12,14 @@ namespace Engine.Render;
///
/// M3's render pipeline: a real perspective camera, drawing every
-/// GameObject with a QuadRenderer at its own WorldMatrix — upgraded from
-/// M2's single hardcoded NDC-space quad specifically because gizmos need
-/// real 3D geometry and a real camera to mean anything (a handle dragged
-/// in screen space has to map onto an actual 3D axis). See M3 in
-/// docs/kernel-contract.md §8.
+/// GameObject with a QuadRenderer or CubeRenderer at its own WorldMatrix —
+/// upgraded from M2's single hardcoded NDC-space quad specifically because
+/// gizmos need real 3D geometry and a real camera to mean anything (a
+/// handle dragged in screen space has to map onto an actual 3D axis). See
+/// M3 in docs/kernel-contract.md §8. CubeRenderer itself is M4's addition,
+/// for the same reason M3's gizmo needed a real camera: a BoxCollider
+/// falling and settling only reads as a physics object if it looks like
+/// one, not a flat card.
///
/// GameObject.WorldMatrix and this plugin's own matrices are both
/// System.Numerics.Matrix4x4, which is row-vector (v' = v * M, and
@@ -95,11 +98,64 @@ public sealed class RenderPlugin : IPlugin
0.5f, 0.5f, 0f, 1f, 1f,
];
+ // Unit cube (1x1x1 before LocalScale), centered at its own origin — see
+ // CubeRenderer's own doc comment for why this exists (M4's physics
+ // demo needs falling BoxColliders to actually look like boxes). No
+ // face culling is enabled anywhere in this plugin, so winding order
+ // doesn't matter here the way it would with CullFace on.
+ private static readonly float[] CubeVertices =
+ [
+ // position uv
+ -0.5f, -0.5f, 0.5f, 0f, 0f, // front (+Z)
+ 0.5f, -0.5f, 0.5f, 1f, 0f,
+ 0.5f, 0.5f, 0.5f, 1f, 1f,
+ 0.5f, 0.5f, 0.5f, 1f, 1f,
+ -0.5f, 0.5f, 0.5f, 0f, 1f,
+ -0.5f, -0.5f, 0.5f, 0f, 0f,
+
+ 0.5f, -0.5f, -0.5f, 0f, 0f, // back (-Z)
+ -0.5f, -0.5f, -0.5f, 1f, 0f,
+ -0.5f, 0.5f, -0.5f, 1f, 1f,
+ -0.5f, 0.5f, -0.5f, 1f, 1f,
+ 0.5f, 0.5f, -0.5f, 0f, 1f,
+ 0.5f, -0.5f, -0.5f, 0f, 0f,
+
+ -0.5f, -0.5f, -0.5f, 0f, 0f, // left (-X)
+ -0.5f, -0.5f, 0.5f, 1f, 0f,
+ -0.5f, 0.5f, 0.5f, 1f, 1f,
+ -0.5f, 0.5f, 0.5f, 1f, 1f,
+ -0.5f, 0.5f, -0.5f, 0f, 1f,
+ -0.5f, -0.5f, -0.5f, 0f, 0f,
+
+ 0.5f, -0.5f, 0.5f, 0f, 0f, // right (+X)
+ 0.5f, -0.5f, -0.5f, 1f, 0f,
+ 0.5f, 0.5f, -0.5f, 1f, 1f,
+ 0.5f, 0.5f, -0.5f, 1f, 1f,
+ 0.5f, 0.5f, 0.5f, 0f, 1f,
+ 0.5f, -0.5f, 0.5f, 0f, 0f,
+
+ -0.5f, 0.5f, 0.5f, 0f, 0f, // top (+Y)
+ 0.5f, 0.5f, 0.5f, 1f, 0f,
+ 0.5f, 0.5f, -0.5f, 1f, 1f,
+ 0.5f, 0.5f, -0.5f, 1f, 1f,
+ -0.5f, 0.5f, -0.5f, 0f, 1f,
+ -0.5f, 0.5f, 0.5f, 0f, 0f,
+
+ -0.5f, -0.5f, -0.5f, 0f, 0f, // bottom (-Y)
+ 0.5f, -0.5f, -0.5f, 1f, 0f,
+ 0.5f, -0.5f, 0.5f, 1f, 1f,
+ 0.5f, -0.5f, 0.5f, 1f, 1f,
+ -0.5f, -0.5f, 0.5f, 0f, 1f,
+ -0.5f, -0.5f, -0.5f, 0f, 0f,
+ ];
+
private GL? _gl;
private IEngineWindow? _window;
private CameraService? _camera;
private uint _vao;
private uint _vbo;
+ private uint _cubeVao;
+ private uint _cubeVbo;
private uint _program;
private uint _texture;
private int _modelLocation;
@@ -143,6 +199,20 @@ public sealed class RenderPlugin : IPlugin
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
_gl.EnableVertexAttribArray(1);
+ _gl.BindVertexArray(0);
+
+ _cubeVao = _gl.GenVertexArray();
+ _gl.BindVertexArray(_cubeVao);
+
+ _cubeVbo = _gl.GenBuffer();
+ _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _cubeVbo);
+ _gl.BufferData(BufferTargetARB.ArrayBuffer, CubeVertices, BufferUsageARB.StaticDraw);
+
+ _gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
+ _gl.EnableVertexAttribArray(0);
+ _gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
+ _gl.EnableVertexAttribArray(1);
+
_gl.BindVertexArray(0);
_gl.Enable(EnableCap.DepthTest);
@@ -159,7 +229,7 @@ public sealed class RenderPlugin : IPlugin
ctx.Events.Subscribe(_onTextureReloaded);
ctx.Services.Provide(new GlScreenCapture(_gl, _window));
- ctx.Schedule.Add(Stage.Render, Draw).Reads();
+ ctx.Schedule.Add(Stage.Render, Draw).Reads().Reads();
ctx.Schedule.Add(Stage.Present, Present);
ctx.Log.Info("GL context created, 3D quad pipeline ready");
}
@@ -176,6 +246,8 @@ public sealed class RenderPlugin : IPlugin
_gl.DeleteTexture(_texture);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
+ _gl.DeleteVertexArray(_cubeVao);
+ _gl.DeleteBuffer(_cubeVbo);
_gl.DeleteProgram(_program);
_gl.Dispose();
}
@@ -237,6 +309,13 @@ public sealed class RenderPlugin : IPlugin
SetMatrix(_modelLocation, go.WorldMatrix);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
}
+
+ _gl.BindVertexArray(_cubeVao);
+ foreach (var go in world.Query())
+ {
+ SetMatrix(_modelLocation, go.WorldMatrix);
+ _gl.DrawArrays(PrimitiveType.Triangles, 0, 36);
+ }
}
// Split from Draw() into its own Stage.Present system so engine.editor
diff --git a/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj
new file mode 100644
index 0000000..fef7ffc
--- /dev/null
+++ b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj
@@ -0,0 +1,16 @@
+
+
+
+ PhysicsDemoGame
+ PhysicsDemoGame
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGamePlugin.cs b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGamePlugin.cs
new file mode 100644
index 0000000..a8a139c
--- /dev/null
+++ b/samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGamePlugin.cs
@@ -0,0 +1,119 @@
+using System.Numerics;
+using Engine.Audio.Contracts;
+using Engine.Input.Contracts;
+using Engine.Kernel.Plugins;
+using Engine.Kernel.Scheduling;
+using Engine.Kernel.World;
+using Engine.Physics.Contracts;
+using Engine.Render.Contracts;
+using Silk.NET.Input;
+
+namespace PhysicsDemoGame;
+
+///
+/// M4's "one small game, end to end": pressing Space drops a new box from
+/// above the ground; engine.physics simulates it falling and settling;
+/// engine.render draws it as a real cube (CubeRenderer, added specifically
+/// for this); this plugin plays a bounce sound (engine.audio) the moment
+/// each box's fall actually stops. Physics, audio, input, and rendering
+/// all cooperate here through nothing but the kernel's own vocabulary —
+/// this plugin never references engine.physics/audio/render's
+/// implementation assemblies, only their Contracts.
+///
+/// "Landed" isn't a Box3D contact event — the native shim doesn't expose
+/// one (see native/physics-native/lingua_physics.c's own doc comment on
+/// why nothing but scalars crosses that boundary; a contact callback would
+/// need to carry structured per-contact data across it, exactly what's
+/// kept out). Honest scope for a demo this size: watch each spawned box's
+/// own Y velocity via IPhysicsService every frame, and call it landed the
+/// first time a real fall (velocity past FallingThreshold at some point)
+/// settles back near zero.
+///
+public sealed class PhysicsDemoGamePlugin : IPlugin
+{
+ private const float SpawnHeight = 6f;
+ private const float FallingThreshold = -1f;
+ private const float RestThreshold = 0.3f;
+
+ private readonly Random _random = new();
+ private readonly List _spawned = [];
+ private readonly HashSet _hasFallen = [];
+ private readonly HashSet _landed = [];
+
+ private IEngineInput? _input;
+ private IPhysicsService? _physics;
+ private IAudioService? _audio;
+ private GameObject? _bounceSound;
+ private bool _spacePressedLastFrame;
+
+ public void Configure(IPluginContext ctx)
+ {
+ _input = ctx.Services.Require();
+ _physics = ctx.Services.Require();
+ _audio = ctx.Services.Require();
+
+ ctx.Schedule.Add(Stage.Update, Tick)
+ .Writes()
+ .Writes()
+ .Writes();
+
+ ctx.Log.Info("physics demo ready — press Space to drop a box");
+ }
+
+ public void Shutdown(IPluginContext ctx)
+ {
+ ctx.Schedule.RemoveAllFrom("physics-demo-game");
+ _spawned.Clear();
+ _hasFallen.Clear();
+ _landed.Clear();
+ _bounceSound = null;
+ _input = null;
+ _physics = null;
+ _audio = null;
+ }
+
+ private void Tick(IWorld world)
+ {
+ _bounceSound ??= world.Roots.FirstOrDefault(go => go.Name == "Bounce");
+
+ var spacePressed = _input!.IsKeyDown(Key.Space);
+ if (spacePressed && !_spacePressedLastFrame)
+ Spawn(world);
+ _spacePressedLastFrame = spacePressed;
+
+ foreach (var box in _spawned)
+ {
+ if (_landed.Contains(box))
+ continue;
+
+ var velocityY = _physics!.GetLinearVelocity(box).Y;
+
+ if (velocityY < FallingThreshold)
+ {
+ _hasFallen.Add(box);
+ continue;
+ }
+
+ if (_hasFallen.Contains(box) && MathF.Abs(velocityY) < RestThreshold)
+ {
+ _landed.Add(box);
+ if (_bounceSound is not null)
+ _audio!.Play(_bounceSound);
+ }
+ }
+ }
+
+ private void Spawn(IWorld world)
+ {
+ var go = world.CreateGameObject($"Box{_spawned.Count}");
+ go.Transform = Transform.Identity;
+ go.Transform.LocalPosition = new Vector3(
+ (float)(_random.NextDouble() * 4 - 2), SpawnHeight, (float)(_random.NextDouble() * 4 - 2));
+
+ go.AddComponent().Type = BodyType.Dynamic;
+ go.AddComponent();
+ go.AddComponent();
+
+ _spawned.Add(go);
+ }
+}
diff --git a/samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json b/samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json
new file mode 100644
index 0000000..04cae77
--- /dev/null
+++ b/samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json
@@ -0,0 +1,12 @@
+{
+ "id": "physics-demo-game",
+ "version": "0.1.0",
+ "assembly": "PhysicsDemoGame.dll",
+ "dependsOn": {
+ "engine.input": "^0.1",
+ "engine.physics": "^0.1",
+ "engine.audio": "^0.1",
+ "engine.render": "^0.1"
+ },
+ "reloadable": true
+}
diff --git a/samples/PhysicsDemo/assets/bounce.wav b/samples/PhysicsDemo/assets/bounce.wav
new file mode 100644
index 0000000..3df2120
Binary files /dev/null and b/samples/PhysicsDemo/assets/bounce.wav differ
diff --git a/samples/PhysicsDemo/assets/music.wav b/samples/PhysicsDemo/assets/music.wav
new file mode 100644
index 0000000..59b4cd0
Binary files /dev/null and b/samples/PhysicsDemo/assets/music.wav differ
diff --git a/samples/PhysicsDemo/assets/texture.png b/samples/PhysicsDemo/assets/texture.png
new file mode 100644
index 0000000..524e41a
Binary files /dev/null and b/samples/PhysicsDemo/assets/texture.png differ
diff --git a/samples/PhysicsDemo/project.json b/samples/PhysicsDemo/project.json
new file mode 100644
index 0000000..c5794ff
--- /dev/null
+++ b/samples/PhysicsDemo/project.json
@@ -0,0 +1,13 @@
+{
+ "engineVersion": "^0.1",
+ "plugins": [
+ { "id": "engine.windowing" },
+ { "id": "engine.assets" },
+ { "id": "engine.render" },
+ { "id": "engine.input" },
+ { "id": "engine.physics" },
+ { "id": "engine.audio" },
+ { "id": "physics-demo-game" }
+ ],
+ "pluginPaths": ["GamePlugins"]
+}
diff --git a/samples/PhysicsDemo/scene.json b/samples/PhysicsDemo/scene.json
new file mode 100644
index 0000000..239c76c
--- /dev/null
+++ b/samples/PhysicsDemo/scene.json
@@ -0,0 +1,42 @@
+[
+ {
+ "name": "Ground",
+ "transform": {
+ "position": [0, 0, 0],
+ "rotation": [0, 0, 0, 1],
+ "scale": [8, 1, 8]
+ },
+ "components": [
+ { "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 0, "Friction": 0.6, "Restitution": 0.3 } },
+ { "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": { "HalfExtents": { "X": 4, "Y": 0.5, "Z": 4 } } },
+ { "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} }
+ ],
+ "children": []
+ },
+ {
+ "name": "DemoBox",
+ "transform": { "position": [0, 3, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
+ "components": [
+ { "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 2, "Friction": 0.6, "Restitution": 0.3 } },
+ { "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": {} },
+ { "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} }
+ ],
+ "children": []
+ },
+ {
+ "name": "Music",
+ "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
+ "components": [
+ { "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/music.wav", "Volume": 0.35, "Loop": true, "PlayOnAwake": true } }
+ ],
+ "children": []
+ },
+ {
+ "name": "Bounce",
+ "transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
+ "components": [
+ { "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/bounce.wav", "Volume": 0.8, "Loop": false, "PlayOnAwake": false } }
+ ],
+ "children": []
+ }
+]
diff --git a/scripts/run-physics-demo.sh b/scripts/run-physics-demo.sh
new file mode 100755
index 0000000..4f95a79
--- /dev/null
+++ b/scripts/run-physics-demo.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Stages the shared engine plugins, builds+places the sample's own
+# game-specific plugin (physics-demo-game, listed in this project's own
+# pluginPaths rather than the shared engine catalog — it's this game's
+# logic, not a reusable engine plugin), then runs samples/PhysicsDemo.
+# Extra args are forwarded to `engine run`.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+CONFIG="${CONFIGURATION:-Debug}"
+STAGE_DIR="$REPO_ROOT/.stage/plugins"
+GAME_DIR="$REPO_ROOT/samples/PhysicsDemo/GamePlugins/physics-demo-game"
+
+"$REPO_ROOT/scripts/stage-plugins.sh" "$STAGE_DIR"
+
+cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.dll "$GAME_DIR/" 2>/dev/null || true
+cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.pdb "$GAME_DIR/" 2>/dev/null || true
+
+cd "$REPO_ROOT/samples/PhysicsDemo"
+exec dotnet "$REPO_ROOT/src/Engine.Host/bin/$CONFIG/net9.0/Engine.Host.dll" run --windowed \
+ --plugins "$STAGE_DIR" --project project.json --scene scene.json "$@"
diff --git a/tests/PhysicsDemoGame.Tests/PhysicsDemoGame.Tests.csproj b/tests/PhysicsDemoGame.Tests/PhysicsDemoGame.Tests.csproj
new file mode 100644
index 0000000..642567c
--- /dev/null
+++ b/tests/PhysicsDemoGame.Tests/PhysicsDemoGame.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/PhysicsDemoGame.Tests/PhysicsDemoGamePluginTests.cs b/tests/PhysicsDemoGame.Tests/PhysicsDemoGamePluginTests.cs
new file mode 100644
index 0000000..e6cb94b
--- /dev/null
+++ b/tests/PhysicsDemoGame.Tests/PhysicsDemoGamePluginTests.cs
@@ -0,0 +1,176 @@
+using System.Numerics;
+using Engine.Audio.Contracts;
+using Engine.Input.Contracts;
+using Engine.Kernel.Diagnostics;
+using Engine.Kernel.Events;
+using Engine.Kernel.Plugins;
+using Engine.Kernel.Scheduling;
+using Engine.Kernel.Services;
+using Engine.Kernel.World;
+using Engine.Physics.Contracts;
+using PhysicsDemoGame;
+using Silk.NET.Input;
+
+namespace PhysicsDemoGame.Tests;
+
+// No real window, no real GLFW keyboard, no real Box3D/miniaudio needed to
+// test this plugin's OWN logic (spawn-on-press, landed-detection) — only
+// whether it calls its three service dependencies correctly. Real OS-level
+// key injection isn't available in this sandbox (no xdotool/ydotool under
+// Wayland) — a fake IEngineInput the test drives directly is the honest
+// substitute, not a compromise: it exercises PhysicsDemoGamePlugin's real
+// Configure()/Tick() through the real Schedule (so SystemAccessScope's
+// Writes() declarations are genuinely
+// checked), with IPhysicsService/IAudioService swapped for controllable
+// fakes since engine.physics/engine.audio's own correctness is already
+// covered by their own test projects.
+
+internal sealed class FakeInput : IEngineInput
+{
+ public bool SpaceDown;
+ public bool IsKeyDown(Key key) => key == Key.Space && SpaceDown;
+ public IInputContext Native => throw new NotSupportedException();
+}
+
+internal sealed class FakePhysics : IPhysicsService
+{
+ public readonly Dictionary Velocity = [];
+
+ public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) { }
+ public Vector3 GetLinearVelocity(GameObject go) => Velocity.GetValueOrDefault(go, Vector3.Zero);
+ public void SetLinearVelocity(GameObject go, Vector3 velocity) => Velocity[go] = velocity;
+}
+
+internal sealed class FakeAudio : IAudioService
+{
+ public readonly List Played = [];
+
+ public void Play(GameObject go) => Played.Add(go);
+ public void Stop(GameObject go) { }
+ public bool IsPlaying(GameObject go) => false;
+}
+
+internal sealed class NullLogger : ILogger
+{
+ public void Info(string message) { }
+ public void Warn(string message) { }
+ public void Error(string message) { }
+}
+
+internal sealed class TestPluginContext(
+ IWorld world, IServiceRegistry services, ISchedule schedule, IEventBus events, ITime time) : IPluginContext
+{
+ public IWorld World { get; } = world;
+ public IServiceRegistry Services { get; } = services;
+ public ISchedule Schedule { get; } = schedule;
+ public IEventBus Events { get; } = events;
+ public ILogger Log { get; } = new NullLogger();
+ public ITime Time { get; } = time;
+}
+
+public class PhysicsDemoGamePluginTests
+{
+ private static (GameWorld World, Schedule Schedule, FakeInput Input, FakePhysics Physics, FakeAudio Audio) Setup()
+ {
+ var world = new GameWorld();
+ var schedule = new Schedule();
+ var services = new ServiceRegistry();
+ var input = new FakeInput();
+ var physics = new FakePhysics();
+ var audio = new FakeAudio();
+
+ services.Provide(input);
+ services.Provide(physics);
+ services.Provide(audio);
+
+ var ctx = new TestPluginContext(world, services, schedule, new EventBus(), new Time());
+ new global::PhysicsDemoGame.PhysicsDemoGamePlugin().Configure(ctx);
+
+ return (world, schedule, input, physics, audio);
+ }
+
+ [Fact]
+ public void PressingSpace_SpawnsExactlyOneBox_EvenIfHeldAcrossFrames()
+ {
+ var (world, schedule, input, _, _) = Setup();
+
+ input.SpaceDown = true;
+ schedule.RunStage(Stage.Update, world); // press
+ schedule.RunStage(Stage.Update, world); // still held — no second spawn
+
+ Assert.Single(world.Roots);
+ }
+
+ [Fact]
+ public void ReleasingAndPressingAgain_SpawnsASecondBox()
+ {
+ var (world, schedule, input, _, _) = Setup();
+
+ input.SpaceDown = true;
+ schedule.RunStage(Stage.Update, world);
+ input.SpaceDown = false;
+ schedule.RunStage(Stage.Update, world);
+ input.SpaceDown = true;
+ schedule.RunStage(Stage.Update, world);
+
+ Assert.Equal(2, world.Roots.Count);
+ }
+
+ [Fact]
+ public void SpawnedBox_HasRigidbodyBoxColliderAndCubeRenderer()
+ {
+ var (world, schedule, input, _, _) = Setup();
+
+ input.SpaceDown = true;
+ schedule.RunStage(Stage.Update, world);
+
+ var box = Assert.Single(world.Roots);
+ Assert.NotNull(box.GetComponent());
+ Assert.NotNull(box.GetComponent());
+ Assert.Equal(BodyType.Dynamic, box.GetComponent()!.Type);
+ }
+
+ [Fact]
+ public void BoxThatNeverActuallyFalls_DoesNotTriggerTheBounceSound()
+ {
+ var (world, schedule, input, physics, audio) = Setup();
+ var bounce = world.CreateGameObject("Bounce");
+
+ input.SpaceDown = true;
+ schedule.RunStage(Stage.Update, world);
+ var box = world.Roots.First(go => go != bounce);
+
+ // Spawned at rest (velocity never set below the falling threshold)
+ // — must not be mistaken for "landed."
+ for (var i = 0; i < 5; i++)
+ schedule.RunStage(Stage.Update, world);
+
+ Assert.Empty(audio.Played);
+ }
+
+ [Fact]
+ public void BoxThatFallsThenSettles_TriggersTheBounceSoundExactlyOnce()
+ {
+ var (world, schedule, input, physics, audio) = Setup();
+ var bounce = world.CreateGameObject("Bounce");
+
+ input.SpaceDown = true;
+ schedule.RunStage(Stage.Update, world);
+ input.SpaceDown = false;
+ var box = world.Roots.First(go => go != bounce);
+
+ physics.Velocity[box] = new Vector3(0, -5, 0); // falling fast
+ schedule.RunStage(Stage.Update, world);
+ Assert.Empty(audio.Played);
+
+ physics.Velocity[box] = new Vector3(0, 0, 0); // settled
+ schedule.RunStage(Stage.Update, world);
+ Assert.Single(audio.Played);
+ Assert.Same(bounce, audio.Played[0]);
+
+ // Still resting on later frames — must not re-trigger.
+ schedule.RunStage(Stage.Update, world);
+ schedule.RunStage(Stage.Update, world);
+ Assert.Single(audio.Played);
+ }
+}