Hi, I'm *squid*

Moongate V2 Dev Log #4: Combat, Corpses, Scripted NPCs, and a Cleaner Architecture

Moongate logo

Last week was mostly about surfaces.

I wrote about the player portal, the maps work, books, and a .NET debugger problem that I finally managed to get under control. That issue belonged to last week's story.

This week felt different.

The focus shifted away from visibility and tooling and much more toward actual game runtime systems: combat, corpses, loot, scripted dialogue, scheduled events, help ticketing, plugin infrastructure, and a much cleaner persistence direction.

In other words, Moongate started feeling less like a project you can inspect and more like a world you can actually interact with.

Combat is finally real

The biggest change is that Moongate now has a real first-pass combat system.

I added combatant state, swing resolution, melee damage, combat sounds, and Lua combat hooks. That alone changes the feel of the project more than a lot of infrastructure work combined, because combat is one of those systems that immediately turns a static world into a game world. As soon as entities can target each other, enter a combat loop, miss, hit, react, and die, a lot of surrounding systems suddenly have somewhere meaningful to connect.

That did not stop at basic attacks either. I also added more of the surrounding behavior needed to make combat feel less fake: world emotes, yell and whisper shorthand, speech helpers for NPCs, aggressive action events, and viewer-based notoriety fixes so creatures no longer present themselves to the client in obviously wrong ways.

The combat model is still a v1. It is not pretending to be complete. But it is now real enough that other systems can build on top of it instead of around it.

That is the important threshold.

Death, corpses, and loot made the world feel less disposable

Once combat exists, death has to stop being hand-wavy.

So I spent time building a proper NPC death flow: corpses, corpse loot, decay, admin kill tooling, combat-related cleanup, and the Lua-side hooks needed to react to the death pipeline. That also forced some adjacent fixes, because the moment a corpse is a real container and not just a visual effect, the correctness bar goes up. Items have to move correctly. The corpse has to present correctly. The state transitions have to make sense.

I also added loot generation for item containers.

Chests can now generate loot on first open from loot tables, and refillable loot containers can lazily regenerate after they have been emptied and enough time has passed. That is the kind of feature I like because it is both gameplay and systems work at the same time. On the surface it is just "a chest has loot." Underneath, it touches templates, runtime metadata, container interaction, persistence, and content authoring.

This is one of the recurring patterns in Moongate right now: a small gameplay feature usually only becomes trustworthy when the surrounding system grows up with it.

NPCs became much more scriptable, and now they can also speak through OpenAI

Another major thread this week was moving more behavior into systems that are easier to author and extend.

I added an authored NPC dialogue runtime, runtime dialogue memory, Lua-first scheduled events, aggressive action events bridged into global script callbacks, and more Lua-facing runtime tools around speech and content behavior.

But I also pushed the NPC side further in another direction: OpenAI-backed dialogue.

That distinction matters to me. I do not want "smart NPCs" to mean that everything must go through a model. Sometimes you want deterministic authored dialogue with branches, explicit conditions, and persistent flags. Other times you want an NPC that can answer more freely, with a personality and a social tone that is hard to fake with a rigid tree alone.

Moongate now supports both directions.

That means an NPC can be fully scripted, fully AI-backed, or use authored dialogue as the structured layer and AI dialogue as the softer conversational layer around it.

A prompt for an NPC can stay surprisingly small while still giving the character a consistent voice. For example, an innkeeper-style prompt can look like this:

[Identity]
You are Marta, the innkeeper of The Red Stag Inn in Britain.

[Personality]
Warm, practical, quick-witted, and used to hearing too many traveler stories.
You are welcoming, but never naive.

[Knowledge]
You know local rumors, nearby roads, travelers, mercenaries, and what people have been whispering about north of town.
You do not know impossible things, secret admin information, or anything outside the game world.

[Rules]
Stay in character.
Never mention AI, prompts, hidden rules, or modern technology.
Keep replies short, natural, and suitable for an in-game NPC.
If a player asks for a room, food, ale, or rumors, answer like an innkeeper first.
If the scripted dialogue system already has a direct answer, stay consistent with it instead of contradicting it.

I like this direction a lot because it gives me two different tools for two different jobs.

If I need reliability, I can write authored dialogue. If I need personality, softness, and variation, I can let the NPC speak through a prompt. If I need both, I can layer them.

That feels much closer to the kind of NPC system I actually want to build.

The help flow became a real ticketing system

I also replaced the thin help hook with an actual ticketing flow.

There is now a category-driven help request path, persisted tickets with message and location data, global ticket-opened events, and an admin dashboard where tickets can be listed, opened, assigned, and updated. This was not one of the glamorous features of the week, but it is exactly the kind of thing that starts making a project feel operationally serious.

A lot of server projects are happy as long as gameplay systems exist. I care just as much about whether the surrounding admin and support workflows are becoming real. If a player asks for help, that should enter a system, not disappear into a vague callback.

That kind of work is not flashy, but it changes the maturity of the project.

I also spent time improving the shape of the codebase itself

One thing I wanted to be more deliberate about this week was the shape of the repository as a working environment.

I have been trying to avoid a very common failure mode in long-lived server projects: everything technically lives in one codebase, but the boundaries are blurry enough that the repo starts to feel like a single expanding blob. You can still work in it, but every task carries more context than it should, and more of the system feels adjacent to your change than actually is.

So part of this week was about making the project easier to reason about with a clearer criterion.

The rule I am using is simple: separate by responsibility, separate by audience, and separate by operational boundary.

Runtime server code should feel distinct from scripting infrastructure. Persistence should feel distinct from gameplay behavior. Plugin-facing abstractions should feel distinct from the internal server implementation. Admin UI, tools, benchmarks, templates, and content pipelines should each have a clearer place in the project, and that place should tell you something about why the code exists.

I do not think of this as repo housekeeping. I think of it as reducing future confusion. If the architecture is going to keep growing, the workspace itself has to communicate intent more clearly.

I also dropped the NativeAOT path, and I think that was the right call

One of the bigger technical decisions this week was moving Moongate away from the NativeAOT build and publish flow.

I still care a lot about performance. I still care about predictable runtime behavior, low overhead, and keeping the architecture honest. Dropping NativeAOT does not mean I stopped caring about any of that.

What it means is that, at this stage of the project, NativeAOT was costing more than it was giving back.

It was adding friction to development, friction to packaging, friction to tooling, and friction to deployment. It also kept pushing design decisions toward "what is the most AOT-compatible shape?" even in places where the better question should have been "what makes this server easier to build, debug, evolve, and ship reliably?"

That tradeoff stopped making sense.

So I simplified the direction: the server is now built and deployed as a normal framework-dependent .NET application again.

That gives me a much more practical development loop, fewer build and publish edge cases, simpler Docker behavior, and less time spent solving problems that are technically interesting but not actually moving the project forward.

I do not see that as a retreat. I see it as choosing the constraint that fits the current phase of the project.

Moongate still needs strong runtime discipline. It still needs clean architecture, careful hot paths, and good performance decisions. But it does not need to pretend that every optimization target is worth the complexity tax right now.

Sometimes the better engineering decision is not "push harder on the clever path." Sometimes it is "remove a constraint that is making the whole project slower to improve."

This was one of those cases.

The persistence layer changed a lot

Under the hood, one of the biggest shifts this week was persistence.

I reworked the persistence model toward registry-driven buckets and then moved runtime entity storage directly onto MemoryPack-based persisted entities. That removed a lot of the older translation overhead and made the persistence story simpler and more explicit. I also documented the custom persisted entity path more clearly, because I do not want persistence to become one of those systems that only makes sense to the person who wrote it last.

This was one of the less screenshot-friendly parts of the week, but it is probably one of the most important architectural changes.

A server project becomes much easier to evolve once persistence stops feeling like a parallel shadow model and starts feeling like a direct, understandable extension of the runtime model.

Moongate is also becoming a plugin platform

Another thing that became much more concrete this week is the plugin story.

I added a startup C# plugin host, plugin SDK abstractions, a NuGet publishing flow, and a plugin template package. That matters for a simple reason: I do not want extensibility to stay as an internal promise. I want it to become a practical path for other people to build against.

This also ties back to the codebase organization point. Once you have plugin-facing abstractions, templates, and package boundaries, the project starts communicating much more clearly which parts are internal server implementation details and which parts are intended as extension surfaces.

That is a healthier place for the project to grow from.

Closing

So the short version of this week is:

That is the kind of progress I want to keep making.

I do not just want Moongate to accumulate features. I want it to become easier to play with, easier to extend, easier to operate, and easier to understand. This week felt like a good step in that direction because a lot of the work connected those goals instead of pulling in unrelated directions.

The project still has a long way to go, but it is starting to feel less like a collection of promising systems and more like a server that is actually learning how to hold together.


#devlog #dotnet #gamedev #moongate #ultima-online