ArTchie Studios
ArTchie-Bedder-Suite / Docs / BedderScript

The Moddable Root pattern — offer modding without making your whole game moddable

Most studios do not want every scene in the game opened up to modders. They want one sanctioned room where mods are made, and a way to play what people make — without exposing the boss fight, the save system, or the storefront.

That is this pattern. Two roles, one spine:

Role Who runs it What it is
Authoring Root the modder A bare, deliberately small scene with the BedderScript faces (F1–F4) mounted. Everything the modder can touch is in this scene. That boundary is the mod surface.
Player / Loader your end user The same room with the editor faces stripped out. It picks a saved mod (or an imported .abspack) and replays it.

Read §3 before you design anything. The single constraint in it decides your scene layout.


1. How BedderScript modding actually works

Nothing here is a scene snapshot. A mod is a booklet — an ordered list of small ops that replay onto your shipped scene.

Documents/<productName>/
  Scripts/                       <- .bedder files the player can edit  (BsScriptStore)
  Assets/                        <- imported models/images/audio + catalog.json  (BsAssetCatalog)
  SceneMods/
    <SceneName>.json             <- the WORKING booklet for that scene
    <SceneName>/Saves/<name>/
      booklet.json               <- a named save: a snapshot of the working booklet
      card.json                  <- name / description / author / created / thumbnail / opCount

(On iOS / tvOS / Android there is no Documents; the root is Application.persistentDataPathRuntime/Components/BsScriptStore.cs:34.)

The moving parts, all real types in this package:

Piece Type Where
One op BsSceneOp (op / id / kind / name / value / parent) Runtime/Components/BsSceneDelta.cs:21
The booklet BsSceneDelta.ops Runtime/Components/BsSceneDelta.cs:38
The op vocabulary create · import_asset · create_from_asset · set_texture · destroy · rename · add_component · remove_component · set_property · assign_script · remove_script · reparent · create_terrain · set_terrain_heights · set_terrain_alphamaps · set_terrain_details BsSceneDelta.cs:14-18
The replay robot BsSceneApply.Run(BsSceneDelta) returns BsApplyReport Runtime/Components/BsSceneApply.cs:40
Booklet storage BsSceneModStore (Append · Save · Load · SaveAs · ListSaves · ActivateSave · ClearWorking) Runtime/Components/BsSceneModLoader.cs:15
Boot replay BsSceneModLoader (MonoBehaviour) Runtime/Components/BsSceneModLoader.cs:190
Object identity BsId.guid, BsId.Ensure(go), BsId.Find(guid) Runtime/Components/BsId.cs:10

Ops address objects only by BsId guid. An object with no BsId cannot be targeted by a mod — that is your first and bluntest exposure control (§5).

There is one code path: the in-session LOAD button and the boot-time replay both call BsSceneApply.Run. Never fork it. A missing target skips politely and lands in BsApplyReport.Notes — a mod cannot brick your scene (BsSceneApply.cs:1-3, :48-57).


2. What a modder actually does in the root

With the faces mounted, a modder in a shipped build:

Key Face What it gives them
F1 CODE (BedderEder) Write / edit .bedder scripts. APPLY hot-swaps into the live BedderScriptRunner.
F2 Blox Blocks over the same script. One script, two faces.
F3 Scene Hierarchy, inspector, fly camera, gizmos, Project window, Import, Save as… / Open menu.
F4 Bug report Screenshot + repro to Documents/<product>/Bugs/.

Every click in F3 stages an op. Nothing is written until LOAD, which appends the staged ops to the working booklet (BsScenePanel.cs:5533 calls BsSceneModStore.Append). Save as… then snapshots that booklet under a name (BsSceneModStore.SaveAs, BsSceneModLoader.cs:112).


3. The constraint that decides your scene layout

Booklets are keyed by the name of the ACTIVE scene. Not by a GUID, not by a reference you set — by SceneManager.GetActiveScene().name, in four places:

  • BsSceneModLoader.OpenSaveRuntime/Components/BsSceneModLoader.cs:205
  • BsSceneModLoader.ResetScene:215
  • BsSceneModLoader.Start (the replay) — :226
  • the F3 panel's SceneName property — Runtime/IdeLib/Scene/BsScenePanel.cs:395

There is no scene-name override seam. So a mod authored in a scene called ModRoot replays only where the active scene is also called ModRoot.

That rules out the naive reading of "two scenes" — an Authoring.unity and a separate Player.unity will never see each other's mods. Two shapes actually work:

Shape A — one scene asset, two rigs (start here)

ModRoot.unity is both roles. A tiny boot component decides which loaders are alive:

  • authoringBsIdeLoader + BsBloxLoader + BsScenePanelLoader + BsBugReportLoader + BsSceneModLoader
  • playBsSceneModLoader only, plus your own save picker

The booklet key matches by construction. This is the shape to ship first.

Shape B — a shell scene that loads the root additively

Your game's menu lives in its own scene; the moddable room is loaded on top:

var op = SceneManager.LoadSceneAsync("ModRoot", LoadSceneMode.Additive);
op.completed += _ => SceneManager.SetActiveScene(SceneManager.GetSceneByName("ModRoot"));

SetActiveScene is not optional — without it the active scene is still your shell and the booklet key is wrong.

Sharp edge. BsSceneModLoader.OpenSave and ResetScene call SceneManager.LoadScene(scene) in Single mode (BsSceneModLoader.cs:208, :218). In Shape B that destroys your shell scene. Either put the save picker inside ModRoot, or make your shell a DontDestroyOnLoad bootstrap object instead of a scene. Also: ModRoot must be in Build Settings, because both calls load it by name.


4. Scene A — the Authoring Root

What goes in it

Only what you are willing to let a modder see, move, delete, re-parent, re-script and re-texture. A good root is boring: a floor, a light, a spawn point, the handful of prefabs that define your game's vocabulary, and the player controller. Everything a modder needs to make something that feels like your game — and nothing else.

The rig

One GameObject, conventionally BS_IDE, carrying:

Component Namespace Purpose
BsIdeLoader Showcase F1 CODE face (kb.f1Key, BsIdeLoader.cs:120)
BsBloxLoader Showcase F2 blocks (BsBloxLoader.cs:92)
BsScenePanelLoader Showcase F3 scene panel (BsScenePanelLoader.cs:98)
BsBugReportLoader Showcase F4 bug card (BsBugReportLoader.cs:38)
BsSceneModLoader BedderScript.Unity boot replay (package type)

Plus an EventSystem with an InputSystemUIInputModule — BedderScript is new Input System throughout; every face key is read from UnityEngine.InputSystem.Keyboard.current.

Be clear about where these come from. The four Bs*Loader MonoBehaviours are showcase reference code shipped in the ABS project payload under Assets/ArTchie Studios/Showcase/, not types inside com.bedder.script. The package ships the facesEderCodeView, BsScenePanel, BsPackageImportCard, BsBugReport. The loaders are roughly 120 lines each that mount a face on a canvas and bind a key. Copy them into your own namespace and adapt them; that is what they are for (MANUAL.md:340).

Sticker every object

BsSceneApply addresses objects by BsId.guid. Objects without one are invisible to the booklet. Run once per scene and commit the scene:

Tools / BedderScript / Add BsIds To Open SceneBsIdBaker.cs:12

Tools / BedderScript / Set Up Bedder Editor in Scene does both jobs at once: creates the UI_EventSystem, creates BS_IDE with all five components, and stickers every object (BsEditorSceneSetup.cs:19-53).

Seed the starter scripts

Player builds have no Assets/ tree. BsShipScriptsOnBuild (an IPreprocessBuildWithReport, callbackOrder 40) mirrors your editor scripts folder into StreamingAssets/BedderScripts at build time; on first run BsScriptStore.EnsureSeeded() copies them into Documents/<product>/Scripts so the modder starts with worked examples they can edit (BsShipScriptsOnBuild.cs:9-16, BsScriptStore.cs:98).


5. Choosing the mod surface — the four dials

The root scene's contents are the coarse control. These four are the fine ones.

Dial 1 — BsId coverage

No BsId, no ops. If you additively load a system scene alongside the root (your audio manager, your analytics), leave those objects unstickered and the booklet cannot name them. This is the cheapest, most reliable fence you have.

Dial 2 — the script host surface

What user scripts can do is exactly what your host exposes. ExtensibleEngineHost.RegisterApi is the seam (Runtime/Embedding/ExtensibleEngineHost.cs:23):

var host = new ExtensibleEngineHost();
host.RegisterApi("spawn_enemy", args => { /* your verb, your rules */ return Value.Null; });

Runtime/Body/BsBodyHost.cs:24-60 is a worked example — a whole platformer body API registered as roughly twenty named verbs.

Honest limit. BedderScriptRunner constructs new UnityEngineHost() directly (Runtime/Components/BedderScriptRunner.cs:47), and so does BedderScriptBehaviour (BedderScriptBehaviour.cs:23). Neither exposes a host factory. To run scripts on a scoped host today you drive BedderScriptDirector, which takes makeHost and makeLimits delegates (Runtime/Embedding/BedderScriptDirector.cs:41-46), or you subclass the runner. If you go the Director route, note that assign_script ops create BedderScriptRunner components (BsSceneApply.cs:479-480) — a scoped-host game should decide whether to allow that op at all.

Related: the default Unity host gives scripts whole-scene reach (find plus destroy / set_position on anything nameable). That is machine-safe but game-integrity-trusting. If mods must not grief the scene, scope a host that resolves names against an allow-list (SECURITY.md:56-59).

Dial 3 — sandbox limits

SandboxLimits.ForIos is the default everywhere (50 000 instructions per frame, 2 s wall clock, 1 000 spawned objects). SandboxLimits.ForDesktop raises them roughly ten-fold and the wall clock to 30 s (Runtime/Core/Runtime/SandboxLimits.cs:26-38). Every field is settable. A hostile script's worst case is that it trips a limit and its invocation stops with an error your host sees (SECURITY.md:45).

Dial 4 — the module resolver

ScriptEngine.ModuleResolver maps import names to source (Runtime/Core/ScriptEngine.cs:49). import names are attacker-chosen strings. If you map them to file paths, canonicalize — a ../../secrets that works is your bug, not the sandbox's (SECURITY.md:50-53).

Where to point the user folders

All three roots are overridable, so you can put mod data beside your own saves:

Override Type Default
BsScriptStore.UserScriptsRootOverride Func<string> <UserDataRoot>/<product>/Scripts (BsScriptStore.cs:49)
BsAssetCatalog.RootOverride string <UserDataRoot>/<product>/Assets (BsAssetCatalog.cs:33)
BsSceneModStore.RootOverride string <UserDataRoot>/<product>/SceneMods (BsSceneModLoader.cs:18)
BsSceneApply.ScriptSourceResolver Func<string,string> reads <scriptsRoot>/<stem>.bedder (BsSceneApply.cs:38)

Set them before first access. BsScenePanelLoader.cs:34-47 shows the resolver swap done in Awake, deliberately early enough that boot-time replay already sees it.


6. Trust tiers — there are TWO, and they are not the same

This is the part people get wrong. Do not conflate them.

Tier 1 — the script sandbox (narrow, audited)

A .bedder script cannot reach the CLR, the filesystem, the network, or arbitrary Unity API. No reflection on script input. No eval, no IL, no codegen. No IO capability exists to misuse. Walking an exposed object into CLR metadata is impossible by construction, not by configuration (SECURITY.md:17-29). The guarantee is the sum of what your host exposes — see Dial 2.

Tier 2 — the booklet and the asset catalog (deliberately WIDER)

The booklet and the catalog are the player modding their own machine — the same trust tier as editing a save file. Within that tier they get powers scripts deliberately do not (SECURITY.md:72-84):

  • add_component / set_property resolve any loaded component type by name and set members by reflection (BsSceneApply.ResolveComponentType, BsSceneApply.cs:675-677). The in-game inspector needs full reach. That is the local tier working as designed.
  • Asset files pass an extension allow-list (image / audio / model only — never code), a 50 MB cap, and are sha-deduped copies (BsAssetCatalog.cs:112-170).
  • Malformed model / texture bytes fail the import; they do not execute.

The line you must not cross yet

A booklet downloaded from another player is not save-file tier — it is hostile-input tier. SECURITY.md:86-91 states the three things that must exist before any booklet or asset sharing feature ships: (1) an op allow-list mode for add_component / set_property, (2) rejecting catalog fileName entries containing path separators at load, (3) a per-file booklet op cap. None of those exist today. Until they do, the only supported booklet source is the local player's own machine.

Practical reading for a studio: content packs (.abspack) are the sanctioned sharing container. Raw booklets are not. A pack carries assets and scripts through the audited import allow-list. A booklet carries a reflection-powered op list through nothing.


7. Scene B — the Player / Loader

Strip the four editor loaders. Keep BsSceneModLoader. Add your own front end.

Replay a mod

// Resume the working booklet automatically on Start:
sceneModLoader.autoLoad = true;             // BsSceneModLoader.cs:195 — OFF by default

autoLoad off means the scene starts pristine and the player chooses. Start() calls BsAssetImporters.Preload(delta.ops, …) first so async imports finish before the robot runs, then BsSceneApply.Run(delta) (BsSceneModLoader.cs:221-234). Do not call Run yourself without the preload — asset ops will skip with a note.

A save picker, in about ten lines

using BedderScript.Unity;

foreach (BsSceneSaveCard card in BsSceneModStore.ListSaves(sceneName))
{
    // card.name · card.description · card.author · card.created · card.opCount · card.folder
    AddButton(card.name, () => BsSceneModLoader.OpenSave(card.name));
}
AddButton("Reset", BsSceneModLoader.ResetScene);

OpenSave makes that save the working booklet, reloads the scene pristine, and replays exactly once — even when autoLoad is off (BsSceneModLoader.cs:203-210, :197-199). ResetScene drops the working booklet and reloads the shipped scene (:213-219). BsSceneSaveCard already carries description / author / thumbnail fields so a richer picker is a UI change, not a format change (BsSceneModLoader.cs:177-186).

Import a .abspack without shipping the whole F3 panel

BsPackageImportCard is a standalone modal you can mount on any canvas — it does not require BsScenePanel (Runtime/IdeLib/Scene/BsPackageImportCard.cs:18):

using BedderScript.Ide.Scene;

var card = new BsPackageImportCard
{
    ScriptsRoot = () => BsScriptStore.UserScriptsDirectory,   // where .bedder files land
    Status      = msg => ShowToast(msg),
    Imported    = () => RefreshMyLibraryUi(),
};
card.Open(myCanvas.transform, absolutePathToPack);

The card shows the same ritual as Unity's own import dialog: a folder tree, a tick per item, All / None, and a finish line naming what was skipped and why (BsPackageImportCard.cs:1-6, :185-210). On Import:

  • models / images / audio go to BsAssetCatalog.ImportFile into Imports/, sorted and bundled (:883)
  • .bedder files go straight into the scripts root, no conversion (:986-998)
  • .cs files are converted by BsCsToBedder, on by default, unticked by the user if they prefer (:1012). The converter is a translator assistant that marks what it could not map with TODO(bs), not a compiler. The finish line reports the flag count.

BsUnityPackage.ReadManifest pulls the pack's identity card — name / author / description / created from abspack/manifest.json (Runtime/Importers/BsUnityPackage.cs:45, :120). Plain .unitypackage files have no manifest and return null; both extensions import identically.


8. Distribution and the rights confirmation

Today the pack is authored from the Unity Editor:

Tools / BedderScript / Export ABS Pack… (BsAbsPackExporter.cs:40)

Pick assets or folders in the Project window, + Add selection, tick, Export. Prefabs bake to .bsmesh (mesh + material colors + albedo textures, which ride along beside the mesh); models, textures, audio and .bedder files go in as bytes. Meshes with Read/Write disabled get their importer flipped for the bake and restored afterward — the user's import settings stay theirs (BsAbsPackExporter.cs:189-244). The container is written by BsAbsPackWriter.Write(outPath, entries, manifest) — gzip + tar, the same layout as a .unitypackage plus abspack/manifest.json (Runtime/Importers/BsAbsPackWriter.cs:25).

The rights gate is not optional, and you should not remove it

The Export button sits inside an EditorGUI.DisabledScope that requires a pack name, at least one ticked item, and a ticked rights confirmation (BsAbsPackExporter.cs:125-128). Above it sits a warning that says, verbatim:

You are responsible for the contents of this pack. Only include assets you have the RIGHT to redistribute — your own work, or content whose license explicitly allows it. Third-party Asset Store purchases, extracted game files, and most downloaded content may NOT be redistributed. Shipping someone else's assets without permission violates their license and is often illegal.

If you build your own exporter — in-game or otherwise — carry this gate forward. The person exporting is the one who must hold redistribution rights, and a modding feature that quietly makes it easy to redistribute a paid Asset Store pack is a liability you are handing your players.


9. The recipe

Authoring Root

  1. File / New Scene, name it ModRoot. Keep it deliberately small: floor, light, spawn point, your vocabulary prefabs, the player controller. Nothing else.
  2. Add the player's BedderScriptRunner and point it at a starter .bedder.
  3. Run Tools / BedderScript / Set Up Bedder Editor in Scene. This creates UI_EventSystem (EventSystem + InputSystemUIInputModule), creates BS_IDE with BsIdeLoader, BsBloxLoader, BsScenePanelLoader, BsBugReportLoader, BsSceneModLoader, and stickers every object with a BsId.
  4. Leave BsSceneModLoader.autoLoad off for authoring — a modder wants the pristine room and opens their save deliberately.
  5. Put worked example .bedder files in your editor scripts folder so they ship into the build and seed into Documents/<product>/Scripts on first run.
  6. Add ModRoot to Build Settings. Save the scene — the BsId guids only persist if you do.

Player / Loader

  1. Duplicate the rig decision, not the scene. Add a small boot component to ModRoot that disables the four editor loaders when the game launches in play mode:
using BedderScript.Unity;
using UnityEngine;

public sealed class ModRootBoot : MonoBehaviour
{
    public bool authoringMode;          // set by your launcher / menu / build define
    public Behaviour[] editorFaces;     // BsIdeLoader, BsBloxLoader, BsScenePanelLoader, BsBugReportLoader
    public BsSceneModLoader modLoader;

    private void Awake()
    {
        foreach (var face in editorFaces) { if (face != null) { face.enabled = authoringMode; } }
        if (modLoader != null) { modLoader.autoLoad = false; }   // the picker decides
    }
}

Disable in Awake, before the loaders' Start builds their canvases.

  1. Build your player-side front end from §7: BsSceneModStore.ListSaves to buttons to BsSceneModLoader.OpenSave, plus BsSceneModLoader.ResetScene, plus a BsPackageImportCard if you accept .abspack content.
  2. If you want a separate shell scene, use Shape B in §3 — additive load plus SceneManager.SetActiveScene — and read the Single-mode sharp edge before you commit to it.

Distribution

  1. Author packs with Tools / BedderScript / Export ABS Pack…. Keep the rights gate.
  2. Tell modders where their work lives: Documents/<product>/SceneMods/ModRoot/Saves/<name>/.

10. Where this pattern is incomplete — read before you promise modding

These are real gaps, verified by reading the shipping code. They do not stop the pattern; they bound what you can honestly advertise.

A named save does not carry its scripts or its assets

BsSceneModStore.SaveAs writes exactly two files: booklet.json and card.json (BsSceneModLoader.cs:112-129). Nothing else. And the booklet references content indirectly:

  • Scripts by stem. assign_script stores only op.name (the file stem); the source text is resolved at replay time by ScriptSourceResolver from the local scripts folder (BsSceneApply.cs:472-478). Script text is never in the booklet.
  • Assets by catalog key. import_asset stores op.id = the catalog key and looks it up with BsAssetCatalog.Find (BsSceneApply.cs:141-147). Asset bytes are never in the booklet.

So a save is portable only within one machine's Documents tree. Copy Saves/<name>/booklet.json to a second machine and every assign_script and import_asset op skips with a note.

Catalog keys are per-machine random GUIDs

Worse than a missing-file problem: BsAssetCatalog.ImportFile mints key = Guid.NewGuid().ToString("N") for every import (BsAssetCatalog.cs:260). The key is not derived from content. Re-importing the identical file on a second machine produces a different key, so a transplanted booklet cannot be repaired by re-importing the same assets. There is a sha256 on every entry (BsAssetCatalog.cs:23) and a sha-based dedupe path within one machine (:221), but nothing resolves a booklet op by sha. A content-hash fallback in BsSceneApply.OpImportAsset is the obvious fix, and it is not written.

Same-stem script collision on import

When a .abspack carries player.bedder and the recipient already has one, the import lands it as player_2.bedder (BsPackageImportCard.cs:990-994). A booklet op saying assign_script "player" then binds the recipient's script, not the pack author's. Ship scripts with distinctive stems.

There is no in-game exporter

BsAbsPackExporter is an EditorWindow behind #if UNITY_EDITOR (BsAbsPackExporter.cs:10-23). Nothing in Runtime/ calls BsAbsPackWriter — a search across the package finds the writer referenced only from that editor tool. So today:

  • A developer can author and ship an .abspack.
  • A player in a shipped build can import one (BsPackageImportCard) but cannot create one. The round trip does not close in the player.

The container writer deliberately lives in the runtime assembly, so this is a UI job rather than an architecture job — but the UI does not exist. If you build it, carry the §8 rights gate.

Booklet sharing is not hardened

Restated because it matters: §6's three prerequisites are not implemented. Do not build a "download this mod" button on top of raw booklets.

What that adds up to

You want to offer Supported today
Modders build in a sanctioned room, in a shipped build Yes
Their work persists across restarts on their machine Yes (named saves)
You ship them starter content and vocabulary Yes (.abspack + seeded scripts)
They hand a finished mod to another player No — no in-game export, no portable asset keys, no hardened booklet import

Design your announcement around that table.


11. API quick reference

Call Signature File
Replay a booklet BsApplyReport BsSceneApply.Run(BsSceneDelta) Runtime/Components/BsSceneApply.cs:40
Script source seam Func<string,string> BsSceneApply.ScriptSourceResolver BsSceneApply.cs:38
Preload async assets void BsAssetImporters.Preload(IEnumerable<BsSceneOp>, Action) Runtime/Components/BsAssetImporters.cs:94
Working booklet path string BsSceneModStore.PathFor(string sceneName) Runtime/Components/BsSceneModLoader.cs:26
Append staged ops void BsSceneModStore.Append(string, IEnumerable<BsSceneOp>) :34
Snapshot under a name BsSceneSaveCard BsSceneModStore.SaveAs(scene, name, description, author) :112
List saves List<BsSceneSaveCard> BsSceneModStore.ListSaves(string sceneName) :131
Open a save bool BsSceneModLoader.OpenSave(string saveName) :203
Back to pristine void BsSceneModLoader.ResetScene() :213
Auto-replay flag bool BsSceneModLoader.autoLoad :195
Stamp an id BsId BsId.Ensure(GameObject) Runtime/Components/BsId.cs:37
Find by id BsId BsId.Find(string guid) BsId.cs:16
Scan a pack List<BsUnityPackageItem> BsUnityPackage.Scan(path, out why) Runtime/Importers/BsUnityPackage.cs:53
Read pack identity BsAbsPackManifest BsUnityPackage.ReadManifest(path) :120
Write a pack void BsAbsPackWriter.Write(outPath, List<Entry>, BsAbsPackManifest) Runtime/Importers/BsAbsPackWriter.cs:25
Mount the import modal void BsPackageImportCard.Open(Transform parent, string packagePath) Runtime/IdeLib/Scene/BsPackageImportCard.cs:185
Register a host verb void ExtensibleEngineHost.RegisterApi(string, Func<Value[],Value>) Runtime/Embedding/ExtensibleEngineHost.cs:23
Scoped host + limits new BedderScriptDirector(makeHost, makeLimits) Runtime/Embedding/BedderScriptDirector.cs:41
Sandbox presets SandboxLimits.ForIos · SandboxLimits.ForDesktop Runtime/Core/Runtime/SandboxLimits.cs:26

See also

  • SECURITY.md — the full threat model, the enforced limits table, the embedder's duties, and the local-tier scope note.
  • Documentation/Guide-Bedder.md — the five-minute tour of the four faces.
  • MANUAL.md §6 (the scene-op spine) and §7 (the in-game editor, face by face).

Source in the box: Assets/ArTchie Studios/ArTchie-Bedder-Suite/com.bedder.script/Documentation/Moddable-Root-Pattern.md