How it started

How it's going

Yassine

Benabbas

DevRel engineer @ Worldline

Teacher ( yostane.github.io/lectures )

Lille Android User Group

Video game collector

@yostane

blog.worldline.tech

@TechAtWorldline

@techatworldline.bsky.social

@worldlinetech

Web Assembly (WASM)

  • Portable binary instruction format
  • Initially targeted for web browsers (to speed up JS)
00 61 73 6d 01 00 00 00  01 05 01 60 00 01 7f 03  |.asm.......`....|
  02 01 00 07 16 01 12 67  65 74 55 6e 69 76 65 72  |.......getUniver|
  73 61 6c 4e 75 6d 62 65  72 00 00 0a 06 01 04 00  |salNumber.......|
  41 2a 0b 00 0a 04 6e 61  6d 65 02 03 01 00 00     |A*....name.....|

Browser

WASM

runtime

JS engine

Based on: https://wasmlabs.dev/articles/docker-without-containers/

WASM on the web

Source code

...

Compiler / WASM toolchain

wasm-pack

...

WASM binary

Glue code

Need to do something related to WASM

.NET and WASM

  • .NET: C# OSS cross-platform framework
  • Compiles to WASM
    • Blazor WASM: Client framework (like VueJS)
    • wasm-tools: Vanilla approach
@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++; }
  }

Blazor wasm

public partial class MyClass
  {
      [JSExport]
      internal static string Greeting()
      {
          var text = $"Hello, {GetHRef()}";
          Console.WriteLine(text);
          return text;
      }
  
      [JSImport("window.location.href", "main.js")]
      internal static partial string GetHRef();
  }
setModuleImports('main.js', {
      window: {
          location: {
              href: () => globalThis.window.location.href
          }
      }
  });
  
  const text = exports.MyClass.Greeting();
  console.log(text);

.NET 7+

wasm-tools

Let's port a .NET game !

Game porting

  • Make a game run in platforms other than its original ones
  • By rewriting / adapting the source code for the new platform(s)
  • Not porting: virtual machine or emulator

MVG's video is a great source of inspiration

1993 for DOS

🌟Doom is portable by design🌟

+

Engine

Resources

sf2

wad

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

Ported to a LOT of platforms

25 official licensed ports https://www.thegamer.com/doom-how-many-platforms-ports-consoles

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

ManagedDoom

  • .NET Port of LinuxDoom (official source code)
  • ManagedDoom V1 Uses SFML (graphics + audio + input)
    • V2 uses silk.net (released 27 Dec 2022)
  • My work is a fork of ManagedDoom V1

id-Software/DOOM

sinshu/managed-doom

yostane/MangedDoom-Blazor

Porting plan

  • Clone sininshu/managed-doom
  • Change the build target to WASM
  • Re-implement SFML code:
    1. With mocks until the project compiles
    2. Then replace mocks with correct implementations
  • Implemenet minimal JS (gameloop, audio and video rendering)

dall-e

Game loop in C#

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

Game loop in JS

function gameLoop(){
    if (canAdvanceFrame()){
      const input = getPlayerInput();
      const { frame, audio } 
            = UpdateGameState(input, WAD);
      render(frame);
      play(audio);
    }
    requestAnimationFrame(gameLoop);
  }
  requestAnimationFrame(gameLoop);
using SFML.Audio; // Not available
  
  namespace ManagedDoom.Audio
  {
      public sealed class SfmlSound : ISound, IDisposable
      {
          private SoundBuffer[] buffers;
      }
  }

Example:

SFML.Audio.SoundBuffer

is not available

namespace SFML.Audio
  {
      public class SoundBuffer
      {
          public SoundBuffer(short[] samples, int v, uint sampleRate)
          {
            // TODO: implement
          }
      }
  }

So, let's implement it

first with a mock

namespace SFML.Audio
  {
      public class SoundBuffer
      {
          public short[] samples;
          private int v;
          public uint sampleRate;
  
          public SoundBuffer(short[] samples, int v, uint sampleRate)
          {
              this.samples = samples;
              this.v = v;
              this.sampleRate = sampleRate;
          }
  
          public Time Duration { get; internal set; }
      }
  }

After that,  with a complete implementation

ManagedDoom V1 architecture

For loop (game loop)

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

Frame buffer

Audio buffer

SFML Audio

SFML Video

 1 iteration of the Doom Engine

Updates the game state

.NET WASM

~70% OK

.NET WASM

wad

DOOM.wad

sf2

SoundFont.sf2

Blazor Doom architecture

pressed keys (keyup)

release keys (keydown)

['Z', 'Q']

['space']

wad

DOOM.wad

requestAnimationFrame

Run .Net

Audio buffer

Frame buffer

Canvas

Audio Context

Run JS

Run JS

 1 iteration of the Doom Engine

Updates the game state

sf2

SoundFont.sf2

<html>
    <head>
      <!-- Sets .Net interop and starts the game loop -->
      <script type="module" src="./main.js"></script>
     </head>
    <body>
      <canvas id="canvas" width="320" height="200" 
              style="image-rendering: pixelated" />
    </body>
  </html>

Entry point

Running the gameloop

main.js

import { dotnet } from "./dotnet.js";
  const { getAssemblyExports, getConfig } = await dotnet.create();
  const exports = await getAssemblyExports(getConfig().mainAssemblyName);
  await dotnet.run();
  
  function gameLoop(timestamp) {
      if (timestamp - lastFrameTimestamp >= 1000 / 30) {
          lastFrameTimestamp = timestamp;
          exports.BlazorDoom.MainJS.UpdateGameState(keys);
      }
      requestAnimationFrame(gameLoop);
  }

Running the gameloop

main.js

public partial class MainJS // this name is required
  {
    public static void Main()
    {
      app = new ManagedDoom.DoomApplication();
    }
    
    [JSExport] // Can be imported from JS
    public static void UpdateGameState(int[] keys)
    { // computes the next frame and sounds
       managedDoom.UpdateGameState(keys); 
    }
  }
import { dotnet } from "./dotnet.js";
  const { getAssemblyExports, getConfig } = await dotnet.create();
  const exports = await getAssemblyExports(getConfig().mainAssemblyName);
  await dotnet.run();
  
  function gameLoop(timestamp) {
      if (timestamp - lastFrameTimestamp >= 1000 / 30) {
          lastFrameTimestamp = timestamp;
          exports.BlazorDoom.MainJS.UpdateGameState(keys);
      }
      requestAnimationFrame(gameLoop);
  }

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

🔊

Audio playback

void PlayCurrentFrameSound(SoundBuffer soundBuffer)
  {
    int[] samples = Array.ConvertAll(soundBuffer.samples, Convert.ToInt32);
    BlazorDoom.Renderer.playSoundOnJS(samples, (int)soundBuffer.sampleRate);
  }
namespace BlazorDoom
  {
      [SupportedOSPlatform("browser")]
      public partial class Renderer
      {
          [JSImport("playSound", "blazorDoom/renderer.js")]
          internal static partial string playSoundOnJS(
            int[] samples, 
              int sampleRate
          );
      }
  }

Audio playback

export function playSound(samples, sampleRate) {
      audioContext = new AudioContext({
          sampleRate: sampleRate,
      });
      const length = samples.length;
      const audioBuffer = audioContext.createBuffer(
          1,
          length,
          sampleRate
      );
  
      var channelData = audioBuffer.getChannelData(0);
      for (let i = 0; i < length; i++) {
          // noralize the sample to be between -1 and 1
          channelData[i] = samples[i] / 0xffff;
      }
  
      var source = audioContext.createBufferSource();
      source.buffer = audioBuffer;
      source.connect(audioContext.destination);
      source.start();
  }
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

 12 bytes

Color palette

Canvas Frame

12 * 4 bytes (r, g, b, a)

  • Doom uses color indexing

  • Image built from top to bottom and from left to right

Frame rendering: from C# to JS

namespace BlazorDoom
  {
      [SupportedOSPlatform("browser")]
      public partial class Renderer
      {
          [JSImport("drawOnCanvas", "blazorDoom/renderer.js")]
          internal static partial string renderOnJS(byte[] screenData,
                                                     int[] colors);
      }
  }
export function drawOnCanvas(screenData, colors) {
    // 
  }
public class DoomRenderer
  {
    // Called by updateGameState
    private void Display(uint[] colors)
    {
      BlazorDoom.Renderer.renderOnJS(screen.Data, (int[])((object)colors));
    }
  }

Rendering a Frame buffer

export function drawOnCanvas(screenData, colors) {
      const context = getCanvas().context;
      const imageData = context.createImageData(320, 200);
      let y = 0;
      let x = 0;
      for (let i = 0; i < screenData.length; i += 1) {
          const dataIndex = (y * width + x) * 4;
          setSinglePixel(imageData, dataIndex, colors, screenData[i]);
          if (y >= height - 1) {
              y = 0;
              x += 1;
          } else {
              y += 1;
          }
      }
      context.putImageData(imageData, 0, 0);
  }
  
  function setSinglePixel(imageData, dataIndex, colors, colorIndex) {
      // Get color from the color palette
      const color = colors[colorIndex];
      // Extract RGB and spread it in the canvas
      imageData.data[dataIndex] = color & 0xff; // R 
      imageData.data[dataIndex + 1] = (color >> 8) & 0xff; // G
      imageData.data[dataIndex + 2] = (color >> 16) & 0xff; // B
      imageData.data[dataIndex + 3] = 255; // Alpha
  }
  

Shoot / validate

Open doors

Move

WAD selection

Key takeaways

  • WASM possibilities are limitless
  • Porting a game is way to learn programming while having fun

Feedback