How it started

How it's going

DevFest Pisa 2025

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/

youtube.com/watch?v=0mCsluv5FXA

DOOM with typescript types

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

🔊

Already provided by the engine

Audio playback: passing the audio buffer to JS 

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 with AudioContext 

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();
}

Music playback

  • Doom, and many retro games, uses SF2 (sound font)
  • List of possible sounds
  • ​Effects can be applied (pitch, volume, etc.)
  • A music is a combination of sounds + effects
  • Similar to text fonts:
    • Reusable glyphs
    • Can be displayed with effects (bold, italic, etc.)

Note 0

Note 1

... Note n

Music streaming

sf2

Time

Time

Music streaming

Time

Time

AudioContext 😢

  • Glitchs when chaining small audio chunks

  • Does not handle streaming natively

Solution 🎶🥳

  • Group chunks into a big buffer

  • Schedule buffer playback chaining

  • Drawback: music start with delay (to fill the first buffer) 

sf2

Music streaming

function playMusic(samples) {
  	// Play the audio buffer when it's filled enough
    if (this.#currentMusicBufferIndex >= this.#musicBuffer.length) {
      const currentTime = this.#audioContext.currentTime;
      const duration = this.#currentMusicBufferIndex / this.musicSampleRate;
      // Schedule the playback to start after the end of the previous buffer
      source.start(this.expectedBufferEndTime, 0, duration);
      this.expectedBufferEndTime = currentTime + duration;
    }
	// Filling the buffer with the current chunk
    for (let i = 0; i < samples.length; i++) {
      this.#currentChannelData[this.#currentMusicBufferIndex + i] 
        = samples[i] / 32767;
    }
    this.#currentMusicBufferIndex += samples.length;
}
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

Web Canvas

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

What will be the final image?

Left to right then top to bottom

source: raycasting by MeTH

Doom uses raycasting

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 (or color palette)

  • Raycasting builds the image column by column (stripes)

    • Already computed by the engine, we just need to distribute the pixels

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 infinite
  • Porting a game is way to learn programming while having fun

🎮 + 💻 = 🥳

 +💡=  ∞

[GDG DevFest Pisa 2025] How I ported Doom to the browser with WebAssembly

By yostane

[GDG DevFest Pisa 2025] How I ported Doom to the browser with WebAssembly

WASM is a powerful platform-agnostic technology. Do you know that you can take advantage of it to port games to the browser as long as the source code can be compiled to WASM? In that regard, I ported DOOM to the Browser thanks to .Net support of WASM. I this talk, I will share with you how I managed to develop the port from a pure C# + SAML codebase to a mix of C# + JS. I'll show the process that I followed so that you can reproduce it for any similar game. I'll also share the issues that I encountered along the way and how I solved them. The concepts that I'll present can be applied to any language that targets WASM. So, come and live this porting adventure with me👍.

  • 65