Isekai Mugen Soul Online Isekai Mugen Soul Online

Isekai Mugen Soul Online Devlog #5: Subjugation, Persistent Unique NPCs & Working Shadows

The game's namesake mechanic lands: mobs subjugated into persistent, procedurally generated Unique NPCs with appearance DNA, exclusive trait sets and weekly wages. Plus a day/night cycle whose sun finally moves, and the shadow bug that turned out to be three bugs.

Isekai Mugen Soul Online Devlog #5: Subjugation, Persistent Unique NPCs & Working Shadows

Welcome to Devlog #5 of Isekai Mugen Soul - Online (็•ฐไธ–็•Œ็„ก้™ใ‚ฝใ‚ฆใƒซใ‚บ)!

This release is the one where the game starts being this game. Up to now it was a good-looking 3D world with combat and farming bolted on. What it did not have was the mechanic the whole project is named after: subjugation โ€” the act of defeating a monster and claiming it as a person rather than a corpse.

That is now in, end to end, and it survives a server restart.


โœจ 1. Subjugation: a mob becomes an individual

The loop is short to describe and was surprisingly deep to build. Weakening a creature below 30% HP makes it eligible. A subjugation attempt rolls the spec's formula:

              CHA_player ร— 2 + SkillMastery
   P = โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ร— (1 โˆ’ HPcur / HPmax)
        CHA_player + WIS_target ร— 3

Succeed, and the creature leaves the spawn pool permanently and is registered as a Unique NPC with a name, a face, a personality, and a wage.

Three things had to be true at once for this to be honest rather than cosmetic.

The conversion is atomic

The mob is removed from the spawner and the NPC record is written in the same synchronous block. Node runs one tick of the simulation at a time with no await between the two, so no other message handler can ever observe a world that has neither the mob nor the NPC. A crash after the write leaves the NPC on disk and the mob gone โ€” which is the safe direction, because a duplicate mob is invisible while a lost companion is a player's investment evaporating.

Identity comes from the character, not the session

netId is a per-session wire detail, reassigned every time the process starts. An NPC keyed on one would be renamed by every restart, orphaning its own save record each time. The stable key is seeded from the bonded character plus a generation counter, which has a pleasant side effect: the same character subjugating its Nth mob always produces the same individual, so a save can be replayed and a generation bug reproduced.

Here is an actual NPC from the live server, written to disk by the game and read back after a restart:

Grutaren  ยท  origin: puglin  ยท  level 2
traits: [Adaptable, Cautious]   ยท  wage 53 penya/week  ยท  loyalty 12
appearance DNA: 15 bytes  ยท  position (โˆ’2.1, โˆ’10.8)

Same name. Same traits. Same face. Same spot on the ground.


๐Ÿงฌ 2. Procedural appearance DNA

Every Unique NPC gets a 15-channel genome. Each channel is a normalised 0..1 gene rather than a pixel value, for three reasons:

  1. Blending two parents is then a lerp, which is what makes lineage nearly free.
  2. Mutation is a bounded jitter with no need to know the valid range twice.
  3. The renderer decides what "build 0.7" looks like, so one record can be drawn as a body, a portrait, or an inventory icon without being re-authored.

The discrete channels โ€” eye, ear, horn and marking variants โ€” are indices into authored tables, so an offspring has to inherit one parent's variant outright rather than interpolating into a shape nobody ever modelled.

Above that sits the exclusivity rule from the source design: a character can never hold both halves of a counter-trait pair. Determined and Indecisive cannot coexist. The generator does not merely intend this โ€” it asserts it, re-rolling on an illegal set, and the persistence layer re-validates every record it loads and drops the ones that fail. One bad NPC cannot leak into a world where four other systems trust its traits without checking them.

The face travels over the wire as 15 bytes, not as float32x14. Variant indices round-trip exactly, and the continuous channels lose under half a percent โ€” invisible on a body being scaled and tinted.


โ˜€๏ธ 3. The day/night sun that never actually moved

This one is my favourite bug of the project so far, because the symptom was three removes from the cause.

The symptom: a huge hard-edged black wedge raking across the ground, with a perfectly straight edge. It read as a rendering fault.

The cause: three separate defects stacked.

  1. The sun never rose. The sky palette changed, the light's intensity changed, and the sun disc orbited the sky โ€” but the light's direction stayed pinned to the vector it was constructed with. Only its position orbited, and for a DirectionalLight the position matters only for shadow-map placement. Shadows pointed the same way at noon and at dusk.

  2. A near-horizontal sun makes absurd shadows. The elevation had a floor of 0.12 โ€” about 7ยฐ โ€” so the world stayed playable at night. But a directional light does not attenuate with angle, so a 7ยฐ sun is as bright as a noon one while casting a shadow 115 metres long for a 14-metre tree. The wedge was a single treeline raking the whole clearing dark. The floor is now about 19ยฐ, which caps a shadow at roughly three times its caster's height.

  3. The shadow frustum was pinned to spawn. autoUpdateExtends is off and the frustum derives from the light's position, so the box of valid shadow data was a 70ร—70 m square around the world origin. Walk away and you walk out of it, and the map's own boundary becomes a hard edge across the ground. It now follows the viewer at a 46 m half-extent โ€” past the 42 m fog, so nothing you can see is ever outside it โ€” and the anchor is quantised to whole shadow-map texels so static shadow edges do not crawl.

There was also a fix I had to revert, which is worth recording. Shrinking the ortho box as the sun dropped looked like an obvious optimisation for a raking sun โ€” more texels per metre where it matters. It has the geometry backwards. The ortho width must cover a ground radius in every horizontal direction regardless of sun angle; a raking sun elongates shadows along the frustum's depth axis, which is the far plane's job. The width stays fixed and the far plane gets the margin.


๐Ÿ’ฌ 4. Chat, and a UI that stopped eating keystrokes

Four channels โ€” Say, Yell, System and Whisper โ€” with per-channel ranges and cooldowns (26 m for a normal voice, 60 m to shout, whisper unbounded but slower). /s, /y and /w <name> work as prefixes.

The interesting part was the bug it fixed elsewhere. A player reported they "cannot login", but typing their password played a sound. That was the giveaway: Digit1, Digit2 and Digit3 were bound to skill castings, and the password field had no guard. The keystrokes were reaching the game through the login form.

So every input manager now checks whether a text field or the auth overlay owns focus before acting on a key. That is one rule in four places instead of four separate special cases, and it is why the composer can now capture typing without the game reacting to any of it.


๐Ÿงช 5. Verification that fails for the right reasons

Two harnesses now guard the deployed game rather than the source.

tools/verify-live.mjs โ€” 25 checks over a real WebSocket. It logs in, walks the character to a mob using real input frames, weakens it below the threshold with real damage, subjugates it, and then asserts the resulting NPC is in replicated state with its DNA, trait set, origin species and relationship tier โ€” and that the mob has left the spawn pool. It deliberately does not bypass the 30% gate, because a test that shortcuts a gate would also pass on a server that had dropped the gate entirely.

tools/verify-visual.mjs โ€” 13 checks in a real browser. It caught a harness bug worth mentioning: it was "failing to log in" because the username field is pre-filled from localStorage and a triple-click did not select it, so the test was typing garciamoonshineGarciaMoonshin. The game was fine; the test was lying. It now replaces field contents outright and waits for the gateway overlay to genuinely clear before screenshotting โ€” otherwise every visual check silently captures the login card.

tools/capture-world.mjs exists purely to produce an image a human can judge. It captures through the page's own canvas hook instead of Chrome's compositor, which is the path that times out under software rendering.


๐Ÿ”Œ 6. Sessions, reconnect and AFK

A player reported that switching browser tabs froze the game and forced a full reload. Chasing it turned up a genuinely nasty bug and a gap worth filling.

The freeze was a stray block in the wrong scope

visibilitychange correctly stopped the render loop when the tab was hidden. The resume branch reset the clock and then fell straight into a leftover copy of a debug experiment โ€” an invisible probe box with an infinite animation and an onAfterRenderObservable โ€” which had been pasted inside the event handler. The line that restarts the render loop had been displaced by it.

So the tab came back and nothing ever resumed. Worse, because that block lived in an event handler rather than at module scope, it leaked on every switch: a new probe box, a new animation and a new observer each time. A duplicated block is not merely dead code.

Resuming needed three more things than "start drawing again":

  • Stop the input interval. A backgrounded tab still fires setInterval (throttled), so the client kept telling the server the player was holding whatever keys were down when they left.
  • Release held keys. The matching keyup events happen in a tab nobody is listening to, so a character switched away from mid-run would run forever.
  • Drop the interpolation buffers. They describe a world from minutes ago; replaying them shows every other player retracing a journey they already finished.

Not having to log in again

Three layers, because they solve three different problems:

1. Reconnect grace (60 seconds). onDrop holds the seat open with allowReconnection. The character keeps its netId, position, inventory and companions, and onReconnect has almost nothing to rebuild โ€” so there is no half-restored state to get wrong. Colyseus calls onLeave only for consented leaves and removes the client from its roster before either hook runs, which is why the grace belongs in onDrop.

2. A persistent session token (12 hours). HMAC-signed, bound to one character, and rotated on every use โ€” so a copy lifted from browser storage is only good until the real player next loads the page. The signing secret lives in the save file, so restarting the container does not silently sign everyone out. A refresh now lands straight in the world; the gateway shows a "restoring your traveller" card instead of a login form flashing past, with a sign in as someone else escape for a shared machine.

3. Staged AFK. This is the part where the obvious design is wrong. The tempting policy โ€” no input for N minutes, disconnect โ€” would make "go and make a cup of tea" a way to become invulnerable, and would delete the character from every system that tracks who is present. An absent player is still in the world: standing where they left off, still attackable, crops still growing.

So the policy is staged, and generous at the front because mining, fishing and watching a crop grow all involve long stretches of no input:

Idle What happens
5 min Falls idle; stops being simulated as an active agent
10 min Flagged AFK to other players
40 min Told the seat is about to be released
45 min Disconnected โ€” the seat, never the session

The kick exists to free capacity. It never costs a login: the token stays valid, so returning is automatic. That distinction is the whole design.


๐Ÿ”ฎ What's Next on the Roadmap

  • Milestone 3 โ€” Autonomous NPC life. Utility-driven needs (satiety, energy, ambition, affection), the weekly payroll cycle with resignation when wages go unpaid, and permadeath with the 60-second stabilisation window.
  • NPC courtship, marriage and offspring. The lineage code โ€” appearance blending and non-contradictory trait inheritance โ€” is already written and tested. It needs a world to run in.
  • Milestone 4 โ€” Land deeds, 2 m snap-to-grid parcels, and offline simulation catch-up, so a base left alone for a week is not a week of lost crop cycles.
  • Milestone 7 โ€” Position quantisation, which is required before any 100-player load test, and KTX2 texture transcoding.

Progress: 166 shared tests, 38 server tests, 29 live protocol checks and 21 browser checks all green โ€” including a real page reload and a frame-counting tab-switch test. Conventions clean. The game is live and playable at isekai.garciamoonshine.com.