Skip to content

Bundle Project Layout

A bundle project is a solution with one project per compiled blob, a library for code shared between them, and a small set of convention folders. The layout below is the reference convention used by BlackICE's example bundles, nothing enforces it beyond the manifest rules, but it keeps project names aligned with blob names.

my-bundle/
├── sv/                      # server component project -> sv.wasm
│   ├── sv.csproj            # references replay.blackice.server
│   └── App.cs               # server-side entry point
├── cl/                      # client component project -> cl.wasm
│   ├── cl.csproj            # references replay.blackice.client
│   └── App.cs               # client-side entry point
├── sh/                      # shared code, compiled into both blobs
│   ├── sh.csproj            # references replay.blackice.shared
│   └── *.cs
├── data/
│   └── config.toml          # the bundle manifest
├── ui/                      # optional: web UI content for [ReUi]
├── vfs/                     # optional: files mounted into the server sandbox
├── dist/                    # staging: assembled bundle (see below)
└── Directory.Build.targets  # optional: stages dist/ after every build

Components and the entry point

The sv and cl projects are class libraries that target wasi-wasm through the package props and produce the two blobs. Each contains its own top-level App class:

sv/App.cs
public static class App
{
    public static void Start()
    {
        // Server side: register gamemodes, spawn logic, server commands.
        Console.WriteLine("Hello world");
    }
}
cl/App.cs
public static class App
{
    public static void Start()
    {
        // Client side: register input handlers, HUD logic, client commands.
        Console.WriteLine("Hello world");
    }
}

The script host (BlackICE or REServer) calls App.Start() when the bundle is activated in that realm:

  • On the server when the bundle starts
  • On each client once it has entered a certain loading stage during the connecting phase

Info

Certain operations can only be done during execution of the App.Start() phase. This includes registering console commands, event handlers and hooks on the client side

Generally bundles divide logic like this:

  • Gamemode rules, spawning, and anything authoritative go into sv
  • Per-player input, presentation, and UI state goes into in cl
  • Message types that cross the client/server boundary, plus helpers both sides need, live in sh

Shared code

sh is an class library referenced by both sv and cl:

sh/sh.csproj
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <OutputType>Library</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
    </PropertyGroup>

    <ItemGroup>
        <PackageReference Include="replay.blackice.shared" Version="1.0.*" />
    </ItemGroup>
</Project>

Because of its dependency on replay.blackice.shared, it has access to types like Vector3, Vector4, Quaternion, re.ClientId, re.EntityId and more

When a server or client component is compiled, the shared code it references is linked into that component's blob. Anything in sh that a side does not use is trimmed away.

Blob naming

The blob file name is the project's assembly name. Project sv produces sv.wasm, project cl produces cl.wasm. The example manifest refers to the blobs by those exact file names:

[Scripting]
client_blob = "cl.wasm"
server_blob = "sv.wasm"

Keep project names short and matching, or set <AssemblyName> explicitly and use that name in the manifest.

Assembling the bundle

A build produces one blob per component under

<project>/bin/<Configuration>/net10.0/wasi-wasm/native/<project>.wasm

BlackICE example bundles use a Directory.Build.targets file to stage all outputs and data files in a dist/ directory. Potential data files include the manifest config.toml, UI files and VFS content for the server side.

An example Directory.Build.targets file:

Directory.Build.targets
<Project>
    <PropertyGroup>
        <DistDir>$(MSBuildThisFileDirectory)dist\</DistDir>
    </PropertyGroup>

    <Target Name="CopyWasiArtifacts" AfterTargets="Build">
        <MakeDir Directories="$(DistDir)" />

        <Copy SourceFiles="$(MSBuildThisFileDirectory)data\config.toml"
              DestinationFolder="$(DistDir)"
              SkipUnchangedFiles="true"
              Condition="Exists('$(MSBuildThisFileDirectory)data\config.toml')" />

        <Copy SourceFiles="$(MSBuildThisFileDirectory)sv\bin\$(Configuration)\net10.0\wasi-wasm\native\sv.wasm"
              DestinationFolder="$(DistDir)"
              Condition="Exists('$(MSBuildThisFileDirectory)sv\bin\$(Configuration)\net10.0\wasi-wasm\native\sv.wasm')" />

        <Copy SourceFiles="$(MSBuildThisFileDirectory)cl\bin\$(Configuration)\net10.0\wasi-wasm\native\cl.wasm"
              DestinationFolder="$(DistDir)"
              Condition="Exists('$(MSBuildThisFileDirectory)cl\bin\$(Configuration)\net10.0\wasi-wasm\native\cl.wasm')" />
    </Target>
</Project>

MSBuild accepts backslashes as path separators on all platforms.

The staged dist/ folder is the actual bundle. A server owner deploys its contents into the server's bundle directory, where the folder name becomes the bundle name. See Bundle Config for the manifest itself and Bundle Distribution for how a bundle is installed and served.

Tip

A clean setup can be achieved by sym-linking the dist/ folder into the server installations data/ugc/<bundle_name> folder. This provides a clean separation between your source code and the produced artifacts

Example: ln -s /opt/my-mono-repo/bundle1/dist /opt/server/data/ugc/bundle1