C# in the Browser.


Intro

When I first started writing code, all the projects I worked on used a single-language stack. There were still many languages to choose from, but the choice was almost always driven by the platform or the business environment more than the preferences of any developer. BASIC on the TI, C on DOS, C++ on Windows, Lisp on VMS/Ultrix, and so on.

It wasn’t until my first startup where I had an introduction to true polyglot programming. The complexities began when we added database components to a three-tier C++ application written for Windows. Initially we targeted Oracle’s PL/SQL for the database, but some of our small-business clients wanted to run SQL Server 6.5 (to keep costs down). That meant maintaining a second copy of the database components in T-SQL. Demand then arose for the client-tier to run on the iMac G3 (the newest hotness at the time). The impracticalities of making a Win32-based program run on MacOS led to a parallel-port of the client stack to Java. As the dotcom boom moved into full swing, everything had a web page and that meant HTML was added to the daily mix.

Our tiny startup got its first tastes of the costs of polyglot programming. Trying to staff expertise across a spectrum of technologies required more hiring, more time and more expense. Trying to deliver a stable product across multiple target platforms with inconsistent capabilities, inconsistent behavior, non-overlapping bugs and varying failure modes was technically challenging. Learning multiple programming languages took time, and that decreased the time available to “go deep” on any one language. To hone your expertise. To perfect your craft.

And, the language divisions in the code gave rise to politics and power dynamics. C++ programmers didn’t want to implement their features in the Java client (and vice versa). Doing so would have required tremendous learning on their part, not just the language itself, but also the tools, the ecosystem, and the test matrix. As new products were launched, staffing availability on one team or the other (rather than architectural constraints) led to language choices which in turn emerged as a powerful disincentive for non-native language programmers to join the new projects.

Over the years, the percentage of projects requiring polyglot development has grown to encompass almost everything we do. While at Google I earned the “badge” (an internal just-for-fun set of automated achievements driven by the company-wide source control system) that indicated I had checked in code across various commits spanning over 20 different programming languages. Some people had badges for 30 or more!

The blog you are reading right now (a fairly simple web page) requires: Markdown for the text, HTML for the surrounding web pages, CSS for styling, Javascript for browser-side scripting, Mermaid for charts, C# for example code, Liquid for dynamic page generation, Jekyll for publishing, and GitHub YAML for workflows:

Markdown Logo HTML Logo CSS Logo JS Logo JS Logo CS Logo Liquid Logo Jekyll Logo Github Logo

Whew! That is A LOT to keep in your head all at once. As such, not just on the web, but in all my projects, I’m always looking for ways to simplify the architectural stack. To do more with fewer languages and technologies. I find this empowers more of the team to contribute across the project without learning ramps. It simplifies the tools we work with. It allows us to deepen our understanding of the code. In short: it allows us to perfect our craft.

The WASM Promise

WebAssembly Logo When WASM burst on to the scene in 2015 it claimed it would become the lingua franca of programming (in the browser and even beyond). It would deliver a platform that any programming language could target. It would enable developers to leverage their skills in the programming language of their choice to bridge the frequently insurmountable gap between native and browser-based applications.

Unfortunately, WASM has been very slow to deliver on those lofty goals. Though early progress with low-level programming languages like C, C++, and Rust garnered a lot of enthusiasm, higher-level languages like C#, Java, and Golang have proved to be more challenging. Limited support in WASM for Garbage Collection, JIT, Multithreading, 64-bit address spaces, and Networking have made porting existing modern runtimes difficult. Additionally, the reliance of high-level programming languages on large Base Class Libraries tends to lead to large compiled binary sizes, long download delays, and slow start-up times in the browser.

However, the industry as a whole, across many programming languages, has evolved in the past ten years to address some of these issues. A renewed interest in GC development, AOT-compilation, and trimming has made targeting WASM more viable than ever for many high-level languages. The WASM spec has expanded to include SIMD support, 64-bit memories, improved GC, and Exception Handling. Though not officially part of the spec, even limited support for multithreading is also available in many browsers, with other workarounds possible when it isn’t.

WASM’s promise to extend devlopers’ existing skills into new domains is slowly becoming a reality.

Write Once, Run Anywhere

Java Icon When Sun first released Java in 1996 they did so with the slogan “Write Once, Run Anywhere”. The explicit promise was that Java would solve the cross-platform software problem. Being universally available on all platforms (even in the browser), there would never be a platform-driven need to use any other programming language. Over the years, Java has (at least to a certain extent) successfully delivered on that promise (though its browser roots have largely fallen by the wayside). It remains today one of the most widely deployable programming languages in the world.

C# Icon When Microsoft first announced C# in 2000 it implied that .NET would take a similar path. ECMA and ISO standardization shortly followed, appearing to pay dividends on that implication. Nevertheless, Microsoft invested very little effort into delivering a cross-platform implementation at that time. By 2004, third-party company Ximian (later Xamarin) released Mono, its own effort to implement a cross-platform version of the .NET runtime based on the ECMA specs. Though Mono saw wide adoption, it continued to experience feature lag and incompatibilities in relation to Microsoft’s Windows-based reference implementation. This hampered the overall perception that .NET was a unified cross-platform ecosystem in the way that Java was.

In 2014 Microsoft announced work had begun on .NET Core, a stripped-down and newly redesigned implementation of .NET that would be open-source and would adopt a cross-platform goal from the start. When it launched in 2016 it brought direct C# support to Windows, Linux and MacOS. In the intervening years, Mono had also secured a strong foothold for C# on mobile platforms (e.g. Android, iOS) and in the browser (WASM). Microsoft acquired Xamarin and in 2019 announced the unification of the two runtimes into a single unified platform across Windows, Linux, MacOS, Android, iOS, and WASM, calling it .NET 5. From .NET 5 to .NET 10, the runtime has continued to integrate and normalize contributions from both codebases while investing heavily in a unified project and build system, nuget packaging, tooling, AOT-compilation, trimming, and hot-reload.

I’m happy to see that .NET 10 is (finally) a world-class cross-platform programming environment.

The Project

Recently I decided to experiment with C# in WASM to explore what the modern framework was capable of.

Separately, as a humble, hobby, side-project (mostly to learn more about how rendering and layout work), I have been tinkering with a pure C#, cross-platform, library-based Game Engine. The idea was to avoid the “hosted” model of big engines like Unity, Godot, or Unreal which own the entire process and wrap around your code. In that “hosted” model the engine dictates the process structure, the engine lifetime, and much of the build infrastructure. A library-based game engine is instead just a nuget package, like any other nuget package, that can be embedded in an existing project just by adding a package reference:

1
2
3
  <ItemGroup>
    <PackageReference Include="MarymoorStudios.Games.Engine" />
  </ItemGroup>

Being pure C# meant you wouldn’t need any other language skills, special knowledge, or additional editor tools to use it. Your favorite IDE, the C# compiler, and a decent C# debugger were all you needed to make it work. (You can skip the debugger if you never write any bugs!) Anyway, the details of the Game Engine would fill an entire blog post on its own! One that will have to be separate from this post.

At any rate, it occurred to me that porting this Game Engine to WASM would make an interesting experiment. It is small enough to be a manageable scope, but large enough to present some real challenges. It has complex concurrency, resource lifetime, memory management, GPU-based rendering, and keyboard/mouse IO. It also happens to depend on several of the packages in the MSC (Marymoor Studios Core libraries), which would give me an opportunity to see how hard it would be to port them to WASM as well.

Binding the Canvas

My first thought was to leverage an existing framework to do the heavy lifting of binding my Game Engine code to an HTML <canvas> element and then let the Game Engine do all the rendering within the viewport defined by the <canvas>. There are a bunch of powerful frameworks out there that are already working to solve this problem. Part of my goal was to keep things small, simple, and with as few technologies as possible (including minimal HTML, CSS, and Javascript) so leveraging an existing library seemed like a great way forward.

I looked at Avalonia, Blazor, MAUI, and Uno. All of them seemed capable of achieving the goal, but each had their own pros and cons.

Avalonia and Uno are both XAML-based. Some people love XAML and… some people don’t. Over the years, I have done several projects with XAML, but have never felt at home with it. For me, I’ve mostly found it be a programming language in its own right, distinct from C#, that often has multiple ways to do the same thing, each of which has its own subtleties and bugs, making discovery hard and composition inconsistent. XAML has always had powerful design tooling, which in some contexts really speeds up prototyping. I’m the kind of person that always seem to end up back in the code anyway, so these tools have only had limited appeal for me.

In contrast, Blazor and MAUI lean heavily into ASP.NET’s Razor syntax (though MAUI also supports XAML). Razor is one of those hybrid languages… a little bit of HTML, a little bit of CSS, a little bit of C#, a little bit of XML, sprinkle in some template expansion and VOILA! You have an unreadable mess of specialized punctuation that is powerful enough to do almost anything! 😜 Only in Razor can you simultaneously define scopes with < />, { }, ( ), @, <!-- -->, @* *@, /* */ and " "!

1
2
3
4
5
6
7
8
9
10
11
12
13
<DemoPanel>
    <!-- HTML comment -->
    <style>
        /* CSS comment (not C#!) */
    </style>
    @* Razor comment *@
    <ListBlock Items="Items">
        @foreach (var item in Items)
        {
            <button name="@(item.Value)" @onclick="() => { /* C# comment */ }"/>
        }
    </ListBlock>
</DemoPanel>

But, all joking about markup syntaxes aside, there were three main reasons none of these frameworks really worked for my use case:

  • Another Language:
    You still have to learn another language, whether it be XAML or Razor or HTML/CSS, to use these frameworks well. They all help you leverage your C# expertise in a new domain, but they don’t even attempt to be pure C#-only solutions. They intend to give you access to the power of both C# and existing web technologies together at the same time (for better or worse).

  • Big Frameworks:
    They are all relatively large frameworks, most of whose functionality I wasn’t going to use. All this power might have been a positive in a different context (perhaps in your context), but for this project it was a negative. All of these frameworks strive to hide their complexity with dotnet SDKs, workloads, project templates, and sample code, but as soon as you turn, in any way, from the well-trodden path you must dive into the details to figure out how things really work.

  • They Own The Process:
    None of these technologies are really libraries. They are app frameworks. And that means they are very opinionated about how the process is structured, how lifetime is managed, when and where things are instantiated, how scheduling is implemented, and what concurrency looks like. There is nothing wrong with being opinionated. Especially if either (a) you don’t care about those choices and are happy to leave them up to someone else to focus on your own value-add, or (b) you are starting a new project from scratch and can adopt those imposed opinions as your own from the start. In my case, well… in my hobby projects at least… I have my own opinions. 🙂

Microsoft.NET.Sdk.WebAssembly

Fortunately for me, after digging around a bit in the implementation of Blazor and the Blazor SDK, I came across Microsoft.NET.Sdk.WebAssembly. I have been really impressed with the tradeoffs it represents. Microsoft.NET.Sdk.WebAssembly is a smaller (almost minimal) repackaging of the core technologies from the compiler, the dotnet SDK, and the dotnet WASM runtime which, in turn, power Blazor and MAUI, but without any of the other parts of those frameworks.

It gives you a bare-bones bootloader (dotnet.js) which creates a WASM instance and loads the dotnet WASM runtime into it. It returns a javascript function that executes the entry assembly’s initialization and its Main function (like any regular dotnet console app). The single page main.js for my Game Engine Demo app is as simple as:

1
2
3
4
5
6
7
import { dotnet } from './_framework/dotnet.js'

const { runMain } = await dotnet
  .withApplicationArguments("msgCanvas")
  .create();

await runMain();

And the corresponding HTML for the page is only this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!DOCTYPE html>
<html>
<head>
  <title>MarymoorStudios.Games.Engine Demo</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script type="module" src="main.js"></script>

  <style>
    html, body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      font-family: system-ui, sans-serif;
    }

    #msgCanvas {
      display: block;
      width: 100svw;
      height: 100svh;
    }
  </style>
</head>
<body>
  <canvas id="msgCanvas" tabindex="0"></canvas>
</body>
</html>

This HTML simply creates a single <canvas> element and then pins its size to span the whole visible viewport of the page.

The body of my C# Main function creates some root capabilities (e.g. logging) then executes a block of code that looks almost identical to its desktop counterpart:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    // Get the canvas using the name passed on the command line.
    string elementId = args[0];
    JSObject? canvas = GetElementById(elementId);
    Contract.Invariant(canvas is null);

    using ResourceLoader loader = new(typeof(DemoScenes).Assembly);
    using FontManager fontManager = new();
    DemoScenes.LoadFonts(loader, fontManager);
    using WasmWindowManager manager = await WasmWindowManager.Create(loggerFactory, loader, fontManager);
    using WasmGameWindow window = await manager.Create(canvas, useGL: true);
    using WasmNavigationManager navigation = await WasmNavigationManager.Create(manager);

    // Extract the scene from the query parameters.
    string location = navigation.Location;
    DemoScenes.Scenes scene = DemoScenes.Scenes.Frame;
    if (NavigationManager.TryGetQueryValue(location, "scene", out ReadOnlySpan<char> value))
    {
    if (Enum.TryParse(value, true, out DemoScenes.Scenes s))
    {
        scene = s;
    }
    }

    Node node = DemoScenes.GetScene(scene, navigation);
    window.SceneTree.Root.AddChild(node);
    await window.Terminated;

This is just a standard async function that runs the embedded Game Engine in an async activity. The activity creates a logical window whose viewport is bound to the <canvas> element, loads a scene from the resource loader, assigns that scene as the root of the window’s SceneTree, and then waits for the user to exit the scene.

Microsoft.NET.Sdk.WebAssembly operates largely the same as any dotnet SDK-based console csproj, but with some notable additions. <Content> can be published as static web assets directly into the resulting web page (this includes both index.html and main.js above). Dependencies, including both <ProjectReference> and <PackageReference> are allowed, but should target either net10.0 or net10.0-browser as their <TargetFramework>. Developer builds also support single-page local hosting with attached IDE-based debugging of both WASM-hosted C# and in-page Javascript via WasmAppHost (a Kestrel-based single-page HTTP dev server that is included in the SDK nuget). (Note: To make debugging work you may also need to install the wasm-tools workload via dotnet.exe.)

When publishing a Microsoft.NET.Sdk.WebAssembly project it will automatically AOT compile to WASM all referenced assemblies, (optionally) perform trimming, (optionally) compute client-side asset fingerprinting, and then binplace a copy-to-deploy-ready directory structure for web server deployment.

The Demo

Check out the (hopefully) working output of the Game Engine Demo app from our web site HERE.

Remember: the HTML has only a single <canvas> element. Everything you see on-page is pure C#! The entire app is running client-side within the browser with no server components. All of the parts are served up as static web assets.

I’ve only tested it so far on Edge and Chrome, and on Windows 10, Windows 11, and a few Android devices. (Note: touch swipes on mobile are NOT yet implemented, so you have to scroll the scrollbars with taps.) Write to me on LinkedIn (or email) with your experiences on other platforms. I’d love to hear how it went.

Conclusion

In this is post, we took a look at the current state of the art for C# on WASM in the browser. We met Microsoft.NET.Sdk.WebAssembly and demonstrated through a small demo app, how easy it is to use and how much it is capable of. I’m always looking for new ways to leverage my existing skills in new domains, and .NET 10’s WASM functionality opens up a lot possibilities. I hope WASM helps you code on (the web)!

Previous

Read the previous post in this series.

Feedback

Write us with feedback.

See Also