Files
lingua-engine/README.md
T
EmilandClaude Sonnet 5 cd8f221ddd Fix real bugs from independent review (docs/review-handoff.md)
A second AI session read the whole codebase in parallel (read-only, no
code changes) and left a handoff doc. Addressed the correctness findings:

- PhysicsWorld.Sync's early return compared _bodies.Count to live.Count,
  not their contents — same size, different membership (destroy one
  tracked GameObject, gain one untracked-because-no-collider one; or any
  Restore where the scene has both a Rigidbody+collider object and a
  Rigidbody-without-one) skipped cleanup entirely, leaking the native
  Box3D body forever. Fixed by checking the actual stale set. Two new
  regression tests reproduce the review's own two scenarios via a new
  Lingua_GetBodyCount native export, asserting on the native table's own
  count rather than PhysicsWorld's C#-side bookkeeping.
- TryCreateBody stored handle -1 (native shim refused: invalid world, or
  its 8192-slot body table full) as if it were real — every later
  GetBodyTransform on it silently teleported the GameObject to the
  origin with a degenerate rotation, no error anywhere. Now checked and
  warned once, same as the missing-collider case.
- PluginHost.Load didn't roll back anything when Configure threw partway
  through: Schedule.Add/Events.Subscribe registrations it already made
  stayed forever, and its ALC was never unloaded — neither loaded (no
  _loaded entry) nor cleanly unloadable. Fixed with try/catch: best-effort
  Shutdown (the only thing that knows which services this plugin
  provided), RemoveAllFrom on both Schedule and EventBus, best-effort ALC
  unload, rethrow. New fixture plugin (sandbox.failing-configure, mirrors
  sandbox.echo's own real-load pattern) registers a system against a
  shared Ping component then throws, so FailedConfigureRollbackTests can
  assert the dangling system actually stops firing — an earlier version
  tried to prove this via AssemblyLoadContext.All instead, which passed
  even against the deliberately-reverted buggy code (the ALC turned out
  to get collected either way once its only references went out of
  scope); watching the dangling system is what actually distinguishes
  rolled-back from not, confirmed by deliberately reverting the fix and
  watching this specific test fail before restoring it.
- Engine.Host's "r <id>" left a plugin unloaded on a failed reload with
  no honest indication of that, and retrying threw "not loaded" instead
  of ever reaching Load again. Added PluginHost.IsLoaded so the handler
  only calls Unload when there's something to unload, and the failure
  message now says the plugin is unloaded, not just "failed."
- AssetService.ReloadWithRetry's `when (attempt < 4)` guard meant the
  5th and final IOException fell out of the loop and propagated from a
  discarded fire-and-forget Task — no log, no event, nothing. Now logged.
- EditorState.Selected kept pointing at a GameObject Restore had already
  destroyed after ExitPlay, so Inspector/gizmo would silently keep
  editing something no longer in the world. EditorPlugin.DrawUi now
  compares IsPlaying against its own previous frame (not just reacting to
  the Stop button) so this is caught whether Play was exited via the
  button or the "stop" stdin command — the same stdin-vs-real-control gap
  already hit once earlier this session — and re-resolves the selection
  by name.
- README.md and kernel-contract.md's Event Bus/Time rows had fallen a
  milestone behind (still said "no asset system yet" and "no fixed-step
  accumulator yet" after both shipped).

Full suite: 98 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
2026-09-02 20:57:08 +03:00

8.0 KiB
Raw Blame History

Lingua Engine

A modular, plugin-first game engine built around one idea: the kernel is a shared language, not a shared implementation. Everything the engine can do — rendering, physics, audio, even the editor itself — is a plugin that speaks that language. The kernel only defines the vocabulary plugins use to understand each other.

Built for Linux and Windows, in C#/.NET, with two goals that shape every design decision:

  • Fast iteration. No Unity-style domain reload. Plugins hot-reload their compiled code without resetting game state, because state never lives in plugin code to begin with — see docs/kernel-contract.md.
  • A small, frozen kernel. Everything else — including the parts most engines treat as core — is a plugin, versioned and replaceable per project.

How this gets built

Most of the code here — kernel and plugins alike — is written by an LLM coding agent rather than by hand. That's not incidental: it's a design input. It's why the object model is GameObject/Component instead of a hand-rolled ECS, why registration is verbose and explicit instead of convention-based, and why the engine has a headless, scriptable introspection surface no classic editor bothers with — see docs/kernel-contract.md.

Status

M0 done. The kernel — World (GameObject/Component, type-indexed queries), Schedule (stage execution, conflict batching, debug-mode access enforcement), PluginHost (two-ALC load/unload, verified leak-free over 200 cycles), and a headless CLI (engine run --headless ... --dump) — all exist and are tested. The full agent loop from docs/kernel-contract.md#7 runs end to end.

M1 done. engine.windowing, engine.render (a real shader-drawn triangle, not just a clear color), and engine.input all exist over Silk.NET. The milestone's actual claim — edit a plugin's code, rebuild just it, reload it while a real window stays open, see the change with no app restart — is proven against a live GL context: two PNGs of the same running window, before and after a live reload, orange triangle then green, same process the whole time. IScreenCapture (engine.render) reads the frame back from the GPU and writes it to a file with a hand-rolled PNG encoder — no SixLabors.ImageSharp (its license isn't MIT/Apache) and no desktop screenshot tool, so this is checkable without a screen at all, exactly the introspection story docs/kernel-contract.md#7 argues for.

The kernel is closed. All four questions the original design left open — Time/Log's home, whether the Event Bus is real infrastructure or event-components, whether frame stages are fixed or plugin-extensible, and the data-oriented-fast-path question — are resolved, each with working code behind it, not just an answer written into the doc. Time and the Event Bus (Publish/Subscribe, leak-safe the same way Schedule already is) both shipped; sandbox.echo subscribes to PluginLoaded for real, so the 200-cycle leak test now proves EventBus doesn't leak too, not just Schedule. See the resolutions in docs/kernel-contract.md — one of the four (the fast path) is deliberately still open, but with a concrete trigger condition instead of a deadline, not left vague.

M2 done. World actually saves and loads now (SceneFormat, replacing the old introspection-only WorldDumper — there was never a real reason for "what an agent reads to check a frame" and "what a scene file is" to be different shapes). Verified beyond round-trip unit tests: two separate CLI runs against the same scene file, second one picking up right where the first left off, component state and all.

engine.assets hot-reloads textures from disk — the actual "done when" for M2. engine.render's triangle became a textured quad; swap the PNG file on disk while the app is running and the picture changes with no restart, no manual reload command, just a FileSystemWatcher noticing and IEventBus carrying TextureReloaded from engine.assets to engine.render. Verified the same honest way as M1 — real screenshots, before and after, same running process — plus two things caught and fixed along the way rather than papered over: a PNG decoder was needed (no SixLabors.ImageSharp, same licensing reason as the encoder — it's a second, independent implementation of the format, tested against all five PNG filter types, not just the one this codebase's own writer produces), and a real hang, not a hypothetical one: SwapBuffers blocking forever once VSync had nothing to wait on — reproduced by locking the screen, fixed by turning VSync off, since nothing here needs frame pacing yet.

M3 done. The editor is engine.editor, a plugin like any other — no special-cased editor layer in the kernel. An ImGui overlay (Silk.NET.OpenGL. Extensions.ImGui) draws over the live scene; getting it to actually appear in the same frame (not delayed by one) needed splitting engine.render's old Draw-then-SwapBuffers system in two, so a new Stage.Present could run the swap after every Stage.Render system — this plugin's draw and engine.editor's ImGui pass both — had drawn into the same back buffer. See the "Frame stages" resolution in docs/kernel-contract.md for why adding a stage was still the right call under a "fixed, kernel-defined" rule.

Hierarchy and Inspector both work off reflection, not per-component-type code: HierarchyPanel walks IWorld.Roots directly, InspectorPanel enumerates a selected GameObject's Transform and every attached Component's public fields via FieldInfo, live-editable for int/float/bool/string/Vector3. A brand new component type in any plugin gets an Inspector for free the moment it's attached.

Play/Stop is IWorld.Snapshot()/Restore() — a scene-format dump taken on Enter, restored on Exit — plus Engine.Host skipping Stage.Update outside Play. Nothing here is a domain reload; see docs/kernel-contract.md §5. M3's actual "done when," entering Play in under 100ms, is proven twice: a kernel-level timed test and a real editor run logging 13ms.

The gizmo is a real 3-axis translate handle, not a flat 2D overlay — chosen deliberately over a scoped 2D version specifically so a screen-space drag means something: it projects the selected GameObject's world position through the actual camera's View/Projection and reads the drag back the same way. The underlying math (GizmoMath) has no GL or ImGui dependency and is unit-tested on its own — including the case a screenshot can't easily catch, dragging an object parented under a non-uniformly-scaled parent.

M4 in progress. engine.physics wraps Box3D and engine.audio wraps miniaudio, both through a deliberately narrow C shim — neither library's own config structs (large, with function pointers and, for Box3D, at least one type that "cannot be directly copied") cross the P/Invoke boundary, only plain scalars and handles do. samples/PhysicsDemo ties physics, audio, input, and rendering together in one running project with engine.editor deliberately left out of its project.json — the shippable configuration, not the dev one. .github/workflows/build.yml builds and tests both native shims and the full solution on real ubuntu-latest and windows-latest runners, since nothing on a single Linux dev machine can otherwise verify the Windows half of M4's own "done when." Still open: the build pipeline hasn't actually run on GitHub yet (pending a workflow OAuth scope grant), and M4's "one small game" target is the physics demo so far, not yet a scored 20-minute one.

See the build order (M0M4) in docs/kernel-contract.md for the rest.

Design and implementation are argued over in the same place: the doc is still the thing to disagree with before code changes to match.

License

MIT