How I ported DOOM to the browser with .Net's Blazor WASM

Speaker

DevRel @ Worldline

Teacher

Yassine

Benabbas

yostane

YCoding

Game porting

  • Make a game run in platforms other than its original ones
  • Achieved by adapting the code for the new platform
  • Not porting: virtual machine or emulator

MVG's video is a great source of inspiration

.net and the browser

  • .net: OSS cross-platform framework
  • C# language
  • In 2020 .Net 5 introduced Blazor WASM ​​​
    • Component based
    • We can run .net locally on the browser
@page "/counter"

<h1>Counter</h1>
<p>Current count: @currentCount</p>
<button @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;
    private void IncrementCount() { currentCount++; }
}

A Razor component

Let's port a .net game !

  • Released in 1993 for DOS
  • One the most successful First Person Shooters
  • Has two parts:
    • engine: Game logic
    • WAD file: all assets and maps

🌟 Doom is portable by design 🌟

http://mrglitchsreviews.blogspot.com/2012/09/doom-console-ports.html

Ported to a LOT of platforms

https://www.link-cable.com/top-10-weird-doom-ports/

Microsoft and Doom

  • The game was used to demosntrate Windows 95
  • Microsoft acquisition of Zenimax in 2020

Source: https://www.yahoo.com/news/1995-bill-gates-gave-crazy-180900044.html

ManagedDoom

  • .Net Port of LinuxDoom
  • ManagedDoom V1 Uses SFML (graphics + audio + input)
    • V2 (in beta as of Jan 3) uses silk.net
  • My work is a based on ManagedDoom V1

id-Software/DOOM

sinshu/managed-doom

yostane/MangedDoom-Blazor

Porting strategy

  • Compile the app: replace SFML code with "TODO: implement"
  • Implement TODOs little by little, priority to frame rendering
  • Optimize and clean later strategy
  • Read Doom-Wiki and SFML documentation only when necessary
    • Used to understand frame format

2 weeks

as a side-project

creator.nightcafe.studio

Game loop pseudo-code

while (waitForNextFrame()){
  const input = getPlayerInput();
  const { frame, audio } 
  	      = UpdateGameState(input, WAD);
  render(frame);
  play(audio);
}

ManagedDoom V1 architecture

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

Frame

Audio

 UpdateGameState

Blazor

~70% OK

Browser

SFML Audio

SFML Video

DOOM.wad

wad

while loop

Blazor Doom architecture

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

wad

DOOM.wad

requestAnimationFrame

Audio buffer

Frame buffer

Canvas

Audio Context

 UpdateGameState

Blazor Doom architecture

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

wad

DOOM.wad

requestAnimationFrame

Audio buffer

Frame buffer

Canvas

Audio Context

 UpdateGameState

BlazorDoom component

Blazor Doom architecture

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

wad

DOOM.wad

requestAnimationFrame

DotNet.invokeMethod

Audio buffer

Frame buffer

Canvas

Audio Context

IJSRuntime.Invoke

IJSRuntime.InvokeUnmarshalled

 UpdateGameState

BlazorDoom component

IJSRuntime.InvokeVoid

Technical details

With some code !

https://doom.fandom.com/wiki/Cacodemon/Doom

<canvas id="canvas" image-rendering: pixelated;" ...>
</canvas>
@code {
  // Entry point of the game
  private async Task StartGame()
  {
    // steup the game object
    app = new ManagedDoom.DoomApplication(...);
    // JS method that calls "requestAnimationFrame"
    jsProcessRuntime.InvokeVoid("gameLoop");
  }
}

Entry point and frame pacing

window.gameLoop = function (timestamp) {
  // Check the pacing
  if (timestamp - lastFrameTimestamp >= frameTime) {
    lastFrameTimestamp = timestamp;
    // Updates the game state (advances a frame)
    DotNet.invokeMethod('BlazorDoom', 'UpdateGame', downKeys, upKeys);
  }
  // This replaces the for loop in a traditional game
  // Request the browser to notify us when we do the next iteration
  window.requestAnimationFrame(window.gameLoop);
}

Entry point and frame pacing

window.gameLoop = function (timestamp) {
  // Check the pacing
  if (timestamp - lastFrameTimestamp >= frameTime) {
    lastFrameTimestamp = timestamp;
    // Updates the game state (advances a frame)
    DotNet.invokeMethod('BlazorDoom', 'UpdateGame', downKeys, upKeys);
  }
  // This replaces the for loop in a traditional game
  // Request the browser to notify us when we do the next iteration
  window.requestAnimationFrame(window.gameLoop);
}
// The code that I showed earlier
[JSInvokable("GameLoop")]
public static void UpdateGame(uint[] downKeys, uint[] upKeys)
{
  app.Run(downKeys, upKeys);
}

Audio and video rendering

Audio playback

0.5 1 0.75 0 -0.75 -1 -0.5 0

https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Audio_concepts

AudioContext

Sample

1 / Sampling frequency

+ Sampling frequency

🔊

playByteArray(samples, sampleRate) {
  const audioBuffer = this.context.createBuffer(
    1, length, this.context.sampleRate
  );
  var channelData = audioBuffer.getChannelData(0);
  // JS receives a weird "samples" array
  for (let i = 0; i < length; i += 2) {
    // Scale the value to between -1 and 1
    channelData[i] = samples[i] / 0xffff;
  }
  // Play the audio
  var source = this.context.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(this.context.destination);
  source.start();
}

Audio playback

// Somewhere in the Doom Engine's audio module
DoomApplication.WebAssemblyJSRuntime.Invoke<object>(
	"playSound", new object[] { samples, sampleRate, 0, Position }
);
0 1 2 3 1 1 2 3
0 1 2 3

From 1D frame to a 2D frame

2 2 0 1

Frame data

Color palette

Frame

  • Image built from top to bottom and from left to right
  • Doom uses color indexing

C# Byte Array to JS, considerations

0 1 2 3 1 1 2 3
0 1 2 3
2 2 0 1

Frame data

Color palette

0123

1123

2201

0123

  • 4 bytes in C# -> 1 number in JS
  • n elements in C# -> (n / 4) elements in JS
  • Bit shifting required in JS !

IJSRuntime.InvokeUnmarshalled

window.renderWithColorsAndScreenDataUnmarshalled = (screenData, colors) => {
  // JS receives an array with 4 bytes per item
  for (var i = 0; i < (width * height) / 4; i += 1) {
    // Gets the array sent from C#
    const screenDataItem = BINDING.mono_array_get(screenData, i);
    for (var mask = 0; mask <= 24; mask += 8) {
      let dataIndex = y * (width * 4) + x;
      setSinglePixel(imageData, dataIndex, colors, 
                     (screenDataItem >> mask) & 0xff);
      // Build the image from top to bottom, left to right
      if (y >= height - 1) { y = 0; x += 4; } else { y += 1; }
    }
  }
  context.putImageData(imageData, 0, 0);
};
function setSinglePixel(imageData, dataIndex, colors, colorIndex) {
  const color = BINDING.mono_array_get(colors, colorIndex);
  imageData.data[dataIndex] = color & 0xff;
  imageData.data[dataIndex + 1] = (color >> 8) & 0xff;
  imageData.data[dataIndex + 2] = (color >> 16) & 0xff;
  imageData.data[dataIndex + 3] = 255;
}

Frame rendering

// Somwhere in the Doom Engine's graphics module
var args = new object[] { screen.Data, colors, 320, 200 };
// Send the frame buffer to JS
DoomApplication.WebAssemblyJSRuntime.InvokeUnmarshalled<byte[], uint[], int>
	("renderWithColorsAndScreenDataUnmarshalled", screen.Data, colors);

Tips and lessons learned

  • Avoid Array.Copy on Big arrays (in .Net 5)
  • Extensive logging from Blazor sloooows the app
  • Calling Blazor from JS is very fast
    • But has problems with certain data types
    • Undocumented APIs removed in .Net 7 in favor of JS Interop
  • window.requestAnimationFrame allows to pace the frames

  • Browsers require interaction with the page to play audio

JS Interop in .net >= 7

  • Less intricate way to run .Net from JS (no components)
  • More adapted to this case than Blazor
export function setLocalStorage(todosJson) {
  window.localStorage.setItem('dotnet-wasm-todomvc', todosJson);
}
static partial class Interop
{
    [JSImport("setLocalStorage", "todoMVC/store.js")]
    internal static partial void _setLocalStorage(string json);
}
public partial class MainJS
{
    [JSExport]
    public static void OnHashchange(string url)
    {
        controller?.SetView(url);
    }
}
const exports = await getAssemblyExports(
  getConfig().mainAssemblyName);
exports.TodoMVC.MainJS.OnHashchange(document.location.hash);

Call JS from .Net

Call .Net from JS

Next steps

  • Short term:
    • Migrate to JS Interop
    • Update to ManagedDoom V2
  • Middle term:
    • Game music
    • Test WADs othen than DOOM1
  • Long term / wish:
    • PR this port to ManagedDoom

Conclusion

  • WASM makes existing code compatible with Browsers
  • Porting games is fun

Thanks !

Any questions ?

Source code

Slides

Links

  • https://mspoweruser.com/this-doom-digital-camera-source-port-is-amazing-and-bizarre/
  • https://www.link-cable.com/top-10-weird-doom-ports/
  • pixabay.com