Skip to content

Case Study

Hearthheld: A Multiplayer Skyrim RP Server, Built on Someone Else's Engine

Role: Solo Developer (fork, tooling, and community design)Duration: August 2026 - presentStatus: Private pilot running
TypeScriptNode.jsElectron 32C++PythonDiscord OAuthSkyMPPapyrusWireGuardHetzner

Scale

25+

Alpha testers signed up

835

Commits in 13 days

~65,000

Lines across server, launcher, bot, and tooling

71

Gamemode modules

95

In-game chat commands

47

Server test suites

28

Plugin and data build tools

The Problem

Persistent multiplayer Skyrim roleplay exists, but the large open servers trade intimacy for scale: hundreds of players, harder moderation, and a culture that defaults to whoever shows up. Hearthheld takes the opposite bet, a small invite-only server capped at 100, designed so the people usually pushed to the margins of gaming spaces do not have to brace themselves before logging in. That part is community design. The rest is engineering inside constraints somebody else set:

  • ·The engine is a decade old. Skyrim Special Edition has no multiplayer, no server, and no API that was ever meant for this.
  • ·The multiplayer layer is somebody else's open-source project. SkyMP is a C++ client hook plus a TypeScript server SDK, under a licence that decides how the fork has to be published.
  • ·Its published documentation is behind its own source. The config reference omits five real settings and still links a domain that has been dead for years.
  • ·The client is a Windows game that Steam silently updates. SkyMP compares every plugin byte for byte, so one background update takes a player offline.
  • ·There is no staging environment. The world is a running server that people are roleplaying on while it changes underneath them.

So almost none of this is greenfield. The work is reading an unfamiliar codebase well enough to patch it, finding the seams in an engine that was never asked to do this, and building the tooling that makes a fragile, version-locked client stack survive contact with ordinary players' machines.

What Was Built

Four moving parts, plus the community layer that decided what the other three had to do.

Working inside SkyMP

  • ·Forked skyrim-multiplayer/skymp and pinned it, so upstream cannot move mid-pilot; the server subproject is AGPLv3, so running a modified server for other people obliges publishing the fork - public code, private community
  • ·Patches across the fork's C++ and TypeScript: aiming a cast at what the caster is actually looking at, sending spell interrupts for actors carrying no animation variables, stopping movement when chat takes the keyboard, and matching the met-players list against the id the server wrote it with
  • ·Found the whitelist this project was about to build already implemented upstream: discordAuth is real, works, and appears nowhere in the published config reference. Reading settings.ts took twenty minutes and removed a week of work
  • ·Read MasterClient and found the server registers itself publicly whenever authentication is on, with no opt-out, which turned the private network layer from a nice-to-have into a load-bearing requirement

The game server

  • ·Gamemode of 71 modules and roughly 33,000 lines running on Hetzner Cloud under Linux - the published docs claim Windows only, while CI has been building and testing the server on Ubuntu and Arch on every push
  • ·95 in-game chat commands: roleplay (/me, /whisper, /yell, /ooc), building and placement, ownership and locks, hunting, mining, professions, character restyling, and a separate game-master toolkit
  • ·A world with no NPCs - shops, the inn, and council seats are roles people play. That removed most of the Creation Kit cost and made GM work a first-class job staffed separately from moderation
  • ·47 test suites plus static checks that run before any deploy, including one that catches commands missing from the in-game help
  • ·A 53-entry in-game guide, so the charter and safety tools are readable where people are actually playing rather than only in Discord

The launcher

  • ·Electron app that installs and version-pins the entire client stack: Skyrim SE at runtime 1.6.1170, the matching SKSE64 build, the SkyMP client, and the shared modlist
  • ·Downgrades a Steam-updated install back to the pinned build by driving Steam's own depot console, verifying what came down is the target version, and backing up everything before it overwrites anything
  • ·Verifies every required plugin byte for byte against the server's copy, because a wrong version fails identically to a missing one and both should be caught before the game starts
  • ·Reads Vortex's own profiles read-only instead of moving files behind it - an earlier parking-folder approach had already lost thirty plugins by letting two systems believe they owned the same path
  • ·Installs and configures the NetBird overlay, and writes a port-scoped Windows Firewall rule limited to the private subnet
  • ·Arachnophobia mode: four independent switches that swap spider meshes locally, so the actor keeps its collision, AI, damage, and sync while rendering as nothing on one player's machine

Identity and access

  • ·A self-hosted master API folded into the Discord bot, replacing the dependency on the upstream gateway: Discord OAuth, poll-based login status, play sessions, an identity endpoint the game server calls, and load-order manifests
  • ·Discord guild membership is the whitelist - approving someone in Discord grants server access, removing them revokes it, and a configured ban role kicks a player mid-session
  • ·Membership is checked in the browser during OAuth rather than at spawn, so a non-member is told clearly instead of being refused at the door with nothing explaining why
  • ·Deletes a third-party account signup from onboarding, and makes the public server-listing problem disappear along with it
  • ·The same bot carries invite tokens, vouching, the growth throttle, and moderation tooling

The content pipeline

  • ·28 Python build tools, roughly 7,500 lines, that write .esp plugin records directly rather than clicking through the Creation Kit - reproducible, diffable, and rebuildable from source data
  • ·Generated data for map markers, mining veins, interiors, weather, races, perks, spells, recipes, emotes, and difficulty bands, consumed by the gamemode as JSON
  • ·Map markers had to be authored into Tamriel's own persistent cell at real coordinates: a marker's icon is a byte in its TNAM subrecord that no runtime call can write
  • ·Sites start parked and move to their real coordinates only on discovery, because the compass ignores the world map's hidden bit and shows any marker within range

Screenshots

Writing and Worldbuilding

The setting is original writing rather than a reskin of Bethesda's. A lore bible puts the server fifty years past the game, in a purpose-built free city chartered under a post-war confederacy: independent because no hold wants responsibility for it, run by a rotating council, watched warmly by Riften and warily by Windhelm. It is written to serve the design rather than decorate it. Play opens at the town's founding, so a half-empty settlement with a dozen people in it is the fiction working rather than a server waiting to fill up, and nothing about the old heroes is settled fact, so nobody arrives standing in an NPC's shadow.

Technical Architecture

Base platformPinned fork of SkyMP - C++ client hooks and a TypeScript server SDK; skymp5-server is AGPLv3, so the fork is public
Game serverNode gamemode, 71 modules, JSON-driven world data, 47 test suites gating deploys
IdentitySelf-hosted master API on Node http: Discord OAuth, play sessions, guild-membership whitelist, load-order manifests
LauncherElectron 32 and TypeScript, NSIS installer plus a portable build, self-updating from the master API
NetworkingNetBird overlay (WireGuard) over outbound connections only; the UDP game port is never exposed to the public internet
Content pipelinePython tooling writing Bethesda .esp records; generated JSON consumed directly by the gamemode
Client targetSkyrim Special Edition pinned to runtime 1.6.1170, SKSE64, one Vortex profile per server
HostingHetzner Cloud CPX12 on Linux, Docker, snapshots, panel-managed firewall

What I Learned

Most of what this project taught me was about working inside constraints I did not set, and most of it is in the changelog because it went wrong first.

Trust the source over the docs

SkyMP's published config reference lists twelve settings and no access control at all. The source reads five more, including the Discord-backed whitelist this project was about to build from scratch. The docs were not wrong so much as behind, and the same site still points people at a domain that died years ago. Twenty minutes in settings.ts deleted a week of work, and reset how I read documentation for anything I did not write.

A dependency you cannot pin is a dependency you do not have

Version parity in SkyMP is absolute: every plugin is compared byte for byte, and Steam updates Skyrim whenever it likes. So the launcher had to be able to put a player's game back to 1.6.1170 on demand, and the fork had to be pinned rather than tracked. That generalises well past games, since anything auto-updating on your users' machines is part of your compatibility surface whether you planned for it or not.

Missing data is not evidence of failure

A night went on three separate faults that were one. The character-customisation mod's light toggle did nothing, its colours did nothing, and its paint did not travel. The launcher had installed the plugins and the archive but left the engine DLL behind, because a mod whose files sit at the archive root had its folders skipped. The menu still opened, since the menu lives in the archive, and the load-order check still passed, since the plugins were there. Everything that DLL backed was absent and nothing said so. The diagnosis then took three wrong turns, all the same mistake in both directions: a healthy plugin called dead on a log line that means nothing of the sort, then an install called healthy while the one file that mattered was missing, and a reader asking a question whose empty answer is indistinguishable from no answer. A check that cannot tell "no" from "I did not hear you" will lie to you twice.

The engine will report success and do nothing

The first map markers went into a new interior cell, on the reasonable grounds that overriding a vanilla worldspace is a bigger thing to get wrong. Four separate calls reported success and the map stayed empty, because moveTo will not lift a reference out of an unloaded interior. Authoring them into Tamriel's persistent cell fixed it. In an old engine with no error surface, a call returning true is not evidence that anything happened; only measuring in game is.

The bug that only exists while somebody is playing

The log threw an uncaught exception every three seconds through an entire evening of play. The minimap's interior sweep reached for a name it was never handed, and that sweep only runs when somebody is actually on the server, so nothing in testing had ever gone near it. Anything gated on real presence, real sessions or real load is invisible to a test suite that has none of those, and the only defence I found was reading the live logs like they were output rather than exhaust.

A per-user view must not contradict the server's model

Taking spiders off one player's screen by deleting the references works for scenery and is dangerous for a live enemy: the server owns the actor and keeps sending updates, so the client ends up fighting its own view code over something that can still hit you. Replacing the mesh instead leaves collision, AI, damage and sync untouched, and renders nothing on one machine. Accessibility features earn their keep by being invisible to the authoritative model, not by editing it.

Licensing is an architectural constraint, not paperwork

skymp5-server is AGPLv3, so running a modified server for other people obliges publishing the source. Working that out before writing code settled the shape of the project upfront: the fork is public, the community is private, and that turned out to be fine, because open code exposes no roster, no Discord and no address. Discovering it afterwards would have been a much more expensive conversation.

Security

  • ·Discord guild membership is the access list: approving grants server access, removing revokes it, and a ban role kicks mid-session
  • ·Membership verified during OAuth in the browser, so a refusal is explained rather than silent
  • ·Self-hosted master API removes the third-party account dependency, and the public listing that came with it
  • ·The NetBird overlay is required rather than optional, so a leaked or listed address is not a reachable one
  • ·Windows Firewall rule is port-based and scoped to the private subnet rather than any/any, never tied to a binary path
  • ·Online mode only; SkyMP's offline mode has no authentication at all and cannot be used here
  • ·IP logging left off by default - the upstream event-log channel posts member IP addresses into Discord on every login
  • ·The public fork carries code only: the roster, the Discord, and the server address are not in it

Links