diff --git a/src/OECS/EntityAllocator.cs b/src/OECS/EntityAllocator.cs
index df4cb0d..87be247 100644
--- a/src/OECS/EntityAllocator.cs
+++ b/src/OECS/EntityAllocator.cs
@@ -63,6 +63,16 @@ internal class EntityAllocator
_freeIds.Push(id);
}
+ ///
+ /// Registers the singleton entity so that returns true for it.
+ /// Called once by when the first singleton accessor is used.
+ ///
+ public void RegisterSingleton(Entity singleton)
+ {
+ EnsureCapacity(singleton.Id);
+ _versions[singleton.Id] = (byte)singleton.Version;
+ }
+
///
/// Returns true if the entity's version matches the current version for its ID.
///
diff --git a/src/OECS/QueryExecutor.cs b/src/OECS/QueryExecutor.cs
index b6188e3..40f235e 100644
--- a/src/OECS/QueryExecutor.cs
+++ b/src/OECS/QueryExecutor.cs
@@ -6,9 +6,12 @@ namespace OECS;
/// Provides query execution logic for .
/// Iterates one sparse set as the driver and probes the remaining sets
/// for membership. The "without" filter is checked after all "with" probes.
+///
+/// The singleton entity (ID 1) is automatically excluded from all queries.
///
internal static class QueryExecutor
{
+ private const uint SingletonId = 1;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool PassesWithoutFilter(ComponentStore store, Entity entity, IReadOnlySet withoutTypes)
{
@@ -39,6 +42,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
continue;
action(entity, ref dense[i]);
@@ -81,6 +85,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes))
continue;
@@ -103,6 +108,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!otherSet.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, withoutTypes))
continue;
@@ -130,6 +136,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!PassesWithoutFilter(store, entity, query.Without))
@@ -160,6 +167,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
@@ -193,6 +201,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
@@ -229,6 +238,7 @@ internal static class QueryExecutor
for (int i = 0; i < count; i++)
{
var entity = denseEntities[i];
+ if (entity.Id == SingletonId) continue;
if (!set2.Contains(entity)) continue;
if (!set3.Contains(entity)) continue;
if (!set4.Contains(entity)) continue;
diff --git a/src/OECS/World.cs b/src/OECS/World.cs
index f7cb418..ba0d2de 100644
--- a/src/OECS/World.cs
+++ b/src/OECS/World.cs
@@ -11,11 +11,17 @@ namespace OECS;
///
public class World : IDisposable
{
+ ///
+ /// The reserved entity ID for the singleton entity.
+ ///
+ public static readonly Entity SingletonEntity = new(1, 1);
+
private readonly EntityAllocator _allocator;
private readonly ComponentStore _components;
private readonly CommandQueue _commands;
private readonly RelationshipIndex _relationships;
private readonly ChangeBuffer _changes;
+ private bool _singletonCreated;
#if DEBUG
// Tracks entities whose components were accessed via GetComponent
@@ -235,6 +241,59 @@ public class World : IDisposable
return _changes.ObserveQuery(query);
}
+ // ── Singletons ───────────────────────────────────────────────────
+
+ ///
+ /// Sets (adds or replaces) a singleton component of type .
+ /// Singletons are stored on a reserved entity that is never destroyed
+ /// and excluded from normal queries.
+ ///
+ public void SetSingleton(T component) where T : struct
+ {
+ EnsureSingleton();
+ AddComponent(SingletonEntity, component);
+ }
+
+ ///
+ /// Returns a reference to the singleton component of type .
+ /// Throws if the singleton has not been set.
+ ///
+ public ref T GetSingleton() where T : struct
+ {
+ EnsureSingleton();
+ return ref GetComponent(SingletonEntity);
+ }
+
+ ///
+ /// Returns true if a singleton component of type exists.
+ ///
+ public bool HasSingleton() where T : struct
+ {
+ return HasComponent(SingletonEntity);
+ }
+
+ ///
+ /// Removes the singleton component of type .
+ /// No-op if the singleton does not have the component.
+ ///
+ public void RemoveSingleton() where T : struct
+ {
+ RemoveComponent(SingletonEntity);
+ }
+
+ private void EnsureSingleton()
+ {
+ if (_singletonCreated)
+ return;
+
+ _singletonCreated = true;
+
+ // Manually register the singleton entity in the allocator so
+ // IsAlive returns true for it. We bypass Allocate() because
+ // the singleton has a fixed ID and version.
+ _allocator.RegisterSingleton(SingletonEntity);
+ }
+
// ── Relationships ────────────────────────────────────────────────
///
diff --git a/tests/OECS.Tests/SingletonTests.cs b/tests/OECS.Tests/SingletonTests.cs
new file mode 100644
index 0000000..11fc1c9
--- /dev/null
+++ b/tests/OECS.Tests/SingletonTests.cs
@@ -0,0 +1,167 @@
+using FluentAssertions;
+using Xunit;
+
+namespace OECS.Tests;
+
+public class SingletonTests
+{
+ private struct GameConfig { public float Gravity; public int MaxPlayers; }
+ private struct TimeState { public float Elapsed; }
+
+ [Fact]
+ public void SetSingleton_GetSingleton_RoundTrips()
+ {
+ var world = new World();
+
+ world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 });
+
+ ref var config = ref world.GetSingleton();
+ config.Gravity.Should().Be(9.8f);
+ config.MaxPlayers.Should().Be(4);
+ }
+
+ [Fact]
+ public void SetSingleton_ReplacesExisting()
+ {
+ var world = new World();
+
+ world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 });
+ world.SetSingleton(new GameConfig { Gravity = 1.6f, MaxPlayers = 2 });
+
+ ref var config = ref world.GetSingleton();
+ config.Gravity.Should().Be(1.6f);
+ config.MaxPlayers.Should().Be(2);
+ }
+
+ [Fact]
+ public void HasSingleton_ReturnsCorrectly()
+ {
+ var world = new World();
+
+ world.HasSingleton().Should().BeFalse();
+ world.SetSingleton(new GameConfig());
+ world.HasSingleton().Should().BeTrue();
+ }
+
+ [Fact]
+ public void RemoveSingleton_RemovesIt()
+ {
+ var world = new World();
+
+ world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 });
+ world.HasSingleton().Should().BeTrue();
+
+ world.RemoveSingleton();
+ world.HasSingleton().Should().BeFalse();
+ }
+
+ [Fact]
+ public void RemoveSingleton_NoOp_WhenNotPresent()
+ {
+ var world = new World();
+
+ // Should not throw.
+ world.RemoveSingleton();
+ }
+
+ [Fact]
+ public void SingletonEntity_DoesNotAppearInNormalQueries()
+ {
+ var world = new World();
+
+ // Set a singleton with the same component type that a regular entity has.
+ world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 });
+
+ var regular = world.CreateEntity();
+ world.AddComponent(regular, new GameConfig { Gravity = 1.6f, MaxPlayers = 2 });
+
+ var query = world.Query().With().Build();
+ var results = new List();
+
+ world.ForEach(query, (Entity e, ref GameConfig cfg) =>
+ {
+ results.Add(e);
+ });
+
+ // Only the regular entity should appear, not the singleton.
+ results.Should().BeEquivalentTo([regular]);
+ }
+
+ [Fact]
+ public void SingletonEntity_IsAlive()
+ {
+ var world = new World();
+
+ world.SetSingleton(new GameConfig());
+
+ world.IsAlive(World.SingletonEntity).Should().BeTrue();
+ }
+
+ [Fact]
+ public void Singleton_SurvivesTickExecution()
+ {
+ var world = new World();
+ var group = new SystemGroup(world);
+
+ world.SetSingleton(new TimeState { Elapsed = 0f });
+
+ // System that reads and updates the singleton.
+ group.Add(new TimeSystem(world));
+
+ group.RunTimed(0.5f);
+ group.RunTimed(0.5f);
+
+ ref var time = ref world.GetSingleton();
+ time.Elapsed.Should().Be(1.0f);
+ }
+
+ [Fact]
+ public void MultipleSingletonTypes_AreIndependent()
+ {
+ var world = new World();
+
+ world.SetSingleton(new GameConfig { Gravity = 9.8f, MaxPlayers = 4 });
+ world.SetSingleton(new TimeState { Elapsed = 42f });
+
+ ref var config = ref world.GetSingleton();
+ ref var time = ref world.GetSingleton();
+
+ config.Gravity.Should().Be(9.8f);
+ time.Elapsed.Should().Be(42f);
+ }
+
+ [Fact]
+ public void Singleton_Mutation_ViaRef_IsVisible()
+ {
+ var world = new World();
+
+ world.SetSingleton(new GameConfig { Gravity = 0, MaxPlayers = 0 });
+
+ ref var config = ref world.GetSingleton();
+ config.Gravity = 9.8f;
+
+ ref var config2 = ref world.GetSingleton();
+ config2.Gravity.Should().Be(9.8f);
+ }
+
+ private class TimeSystem : ITickedSystem
+ {
+ private readonly World _world;
+
+ public QueryDescriptor Query { get; }
+
+ public TimeSystem(World world)
+ {
+ _world = world;
+ Query = world.Query().With().Build();
+ }
+
+ public void Run(World world) => Run(world, Tick.Logical());
+
+ public void Run(World world, Tick tick)
+ {
+ ref var time = ref _world.GetSingleton();
+ time.Elapsed += tick.DeltaTime;
+ }
+ }
+}