
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
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:
- With mocks until the project compiles
- 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