Final review pass: fix regressions, close a real spec gap

- Restore scheduler parallelism (dropped by accident in the ECS ->
  GameObject/Component rewrite; nothing about the object model
  actually prevents parallel execution of systems with disjoint
  declared access), plus the structural-change queuing that makes
  that safe.
- Rename leftover ECS-era `Entity` references to `GameObject`.
- Add per-project plugin configuration (project.json): the doc had no
  mechanism backing the "configurable per project" requirement, even
  though the README already claimed it.
- State the indie/small-team scope explicitly — it's the unstated
  premise behind several tradeoffs already in the doc (GC, GameObject
  over ECS, no custom RHI).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
Emil
2026-09-02 00:19:19 +03:00
co-authored by Claude Sonnet 5
parent 03ca9c62b7
commit ae6c4aba33
+45 -6
View File
@@ -8,9 +8,17 @@ plugins alike, will be written by an LLM agent rather than a human.
- **Stack** — .NET 9, C# 13
- **Platforms** — Linux, Windows
- **Kernel** — BCL only, no dependencies
- **Object model** — `GameObject` / `Component`
- **Primary author** — an agent
- **Goal** — Play-in-editor with no domain reload
Built for a small team's own use, at indie scale — not AAA. That scope
licenses several calls made below: accepting GC pauses instead of chasing a
zero-allocation hot path, picking `GameObject`/`Component` over a faster
struct-of-arrays ECS, buying rendering and windowing off the shelf instead of
writing them. None of those are free choices at a bigger scale; at this one,
dev velocity outweighs the performance left on the table.
---
## 1. Principle: the kernel is a shared language, not "the engine minus plugins"
@@ -41,10 +49,10 @@ nothing.
| # | Piece | Role |
|---|---|---|
| 01 | **World** | `GameObject` hierarchy (parent/children, name, tags) plus typed `Component` instances, with a type index so `Query<T>()` costs O(matches), not O(all). Plain classes, zero `unsafe` — see §7. |
| 02 | **Scheduler** | Frame stages, topological system ordering, and debug-mode enforcement of declared component access — see §7. |
| 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. |
| 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. |
| 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. |
| 05 | **Event Bus** | Decoupled notifications: entity created, asset reloaded, plugin unloaded. |
| 05 | **Event Bus** | Decoupled notifications: `GameObject` created, asset reloaded, plugin unloaded. |
| 06 | **Time & Log** | Frame clock, fixed-step accumulator, logging interface. Kept minimal. |
`GameObject.Transform` is the one field embedded directly rather than
@@ -67,6 +75,36 @@ architecture's real test: if the editor can't be assembled as plugins, the
extensibility claim is decorative. A game build is the same kernel minus the
editor plugins.
### Per-project configuration
A plugin's manifest declares what it needs; a **project's** manifest
declares which plugins it loads, at which versions, and where to find its
own. This is the piece that actually makes modularity a per-project
property rather than a claim about the engine in the abstract — a new
project doesn't fork the engine to swap an implementation, it points its
manifest at a different plugin satisfying the same contracts, or adds
project-local plugins that never leave its own tree.
```json
// MyGame/project.json
{
"engineVersion": "^0.3",
"plugins": [
{ "id": "engine.windowing" },
{ "id": "engine.render", "version": "^0.3" },
{ "id": "engine.physics", "version": "^0.2" },
{ "id": "mygame.enemies" }
],
"pluginPaths": ["./plugins"]
}
```
`engine.render` here could just as well point at a project-local fork with
the same `contracts` and a bumped `id` — the Plugin Host resolves a
project's manifest through the exact same dependency graph it already
builds for plugin-to-plugin `dependsOn`, so nothing new has to be built to
support it.
### Two channels, two costs
Plugins talk to the kernel — and to each other — through two paths with
@@ -79,7 +117,7 @@ deliberately different prices:
| **Events** (bus) | Facts with no fixed consumer at design time: asset reloaded, plugin unloaded, entity destroyed. | Allocation + fan-out to subscribers | tens / frame |
> **The line that must never be crossed.** Never write
> `IPhysicsService.GetPosition(entity)`. A single call is cheap, but that
> `IPhysicsService.GetPosition(GameObject go)`. A single call is cheap, but that
> shape of API invites calling it in a loop over entities — and now the
> plugin boundary sits in the hot path. Position is on `GameObject.Transform`,
> not a service method. Services hand out *capabilities*; `World` hands out
@@ -161,8 +199,9 @@ public sealed class RenderPlugin : IPlugin
ctx.Services.Provide<IRenderer>(new VulkanRenderer(window));
// data plane: the system queries GameObjects by component type.
// Reads/Writes are declared explicitly, and in debug builds
// the scheduler enforces that the system only touches what
// Reads/Writes are declared explicitly — the scheduler uses
// them to run systems with disjoint access in parallel, and
// in debug builds enforces that a system only touches what
// it declared — see §7.
ctx.Schedule.Add(Stage.Render, SubmitDrawCalls)
.After("engine.transform:propagate")
@@ -296,7 +335,7 @@ exists to enable.
| **ALC leaks** — the main killer | Unload silently fails from one forgotten reference. Symptom: memory growth after N reloads; cause takes days to find. | 200-cycle test in CI. Diagnose pinning references in the host itself, not via an external profiler. |
| **Scope** | The kernel is 35k lines and a couple of months. The renderer, asset pipeline, and editor are years, and they decide whether the engine ships. | Don't write your own RHI. Silk.NET or Veldrid underneath; originality goes into the architecture on top. |
| **GC pressure from Components** | Every component is a heap object; churn from creating/destroying GameObjects at runtime (bullets, particles, pickups) means allocation and collection, against a 16.6 ms frame budget. | Pool GameObjects and components for anything spawned/destroyed at high frequency. Server GC. `Query<T>()` iterators must not allocate. |
| **Creeping abstraction** | The temptation to hide `World` behind a "cleaner" interface. Kills performance invisibly and irreversibly. | The rule in §2 is law. Review rejects any service method that takes an `Entity`. |
| **Creeping abstraction** | The temptation to hide `World` behind a "cleaner" interface. Kills performance invisibly and irreversibly. | The rule in §2 is law. Review rejects any service method that takes a `GameObject`. |
| **Plausible-but-wrong code** — agent-specific | The agent produces code that compiles, passes a smoke test, and breaks on someone else's GPU — sync, barriers, resource lifetime. | Minimize new subsystems; Silk.NET/Veldrid is risk management, not time-saving. A conformance harness gates every plugin merge. |
| **Contract drift** | A contract change requires updating every dependent plugin, and a stale implementation keeps compiling while silently diverging from spec. | Versions in the manifest, plus running *every* plugin's harness on every build, not just the changed one. |