ashtonmeuser / godot-wasm-doom

Porting Doom to Godot in 34 Lines of GDScript

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Porting Doom to Godot in 34 Lines of GDScript

Using a WebAssembly Doom port and the Godot Wasm addon, the 1993 classic Doom can be run and rendered in the Godot game engine.

This article documents the porting process. The resulting source code can be found here.

Background

The title of this article is somewhat misleading. The entirety of Doom cannot be implemented in a few dozen lines of GDScript. Instead, we'll be running and rendering a precompiled WebAssembly port of Doom within Godot.

WebAssembly, or Wasm, is an incredibly powerful and interesting technology. Originally developed for the web, it is now finding use outside of the browser. The greatest advantages of Wasm, in my opinion, are the following:

  1. Incredible speed (approaching native implementation).
  2. Safe and sandboxed runtime environment.
  3. Compilation target for many languages (Rust, Go, C, etc.).

The Godot Wasm FAQ provides some more insight as to why one would want to use WebAssembly within Godot.

To take advantage of Wasm's benefits, I created the Godot Wasm addon for the Godot game engine. This addon enables compiling and initializing Wasm modules, accessing exported functions and globals from Godot, providing imports implemented in GDScript, and accessing Wasm module memory. The latest release as of this writing, Godot Wasm v0.3.4, includes type inference for Wasm import and export functions. This makes the addon far more compatible with existing Wasm modules.

Throughout the creation of the Godot Wasm addon, one question kept nagging at me: Can it run Doom?

Acknowledgement

This project owes a huge thanks to the incredible work of Cornelius Diekmann. Their WebAssembly from Scratch project provided great insight into the porting process, the underlying Doom Wasm module, and clear guidance on running the module. Diekmann's Wasm Doom example will be referenced throughout this write-up.

This article is written in a similar fashion to Diekmann's with the hope that somebody finds it similarly educational.

Getting Started

The first thing we'll need is the Doom WebAssembly module. By inspecting the sources of Diekmann's Wasm Doom example, we can find and download the doom.wasm module (available here).

Next, we'll need to install the Godot game engine. Version 4.2.1 was used for this project.

Now we'll need to install the Godot Wasm addon. This is available via the Godot Asset Library. Further instructions regarding getting started with the Godot Wasm addon can be found here.

With the addon installed, let's create a simple Godot project. Open Godot, create a new project, add a single MarginContainer node, and anchor it as a Full Rect to occupy the entire view. When first running the project, you'll need to confirm that this scene is to be used as the main scene.

Create main container

Copy the downloaded doom.wasm file to the root directory of your Godot project.

Let's dive into the code. Attach a script to your MarginContainer and save it as Main.gd. Create an @onready variable that will hold our Godot Wasm Wasm instance (see Wasm class documentation).

@onready var wasm = Wasm.new()

In our _ready() function, we'll need to load and compile the Wasm module binary (see Usage guide).

var file = FileAccess.get_file_as_bytes("res://doom.wasm")
wasm.compile(file)

Run the project and ensure there are no errors thrown.

Inspecting the doom.wasm module allows us to gain a better understanding of the exposed API. In the terminal, a similar inspection can be performed with the Wasmer CLI via wasmer inspect doom.wasm.

var info = wasm.inspect()
print(info)

This should print the following (formatted for clarity).

{
   "import_functions": {
      "js.js_console_log": [[2, 2], []],
      "js.js_draw_screen": [[2], []],
      "js.js_milliseconds_since_start": [[], [2]],
      "js.js_stderr": [[2, 2], []],
      "js.js_stdout": [[2, 2], []]
   },
   "export_globals": {
      "__data_end": [2, false],
      "__heap_base": [2, false]
   },
   "export_functions": {
      "I_FinishUpdate": [[], []],
      "I_GetTime": [[], [2]],
      "I_InitGraphics": [[], []],
      "I_ReadScreen": [[2], []],
      "I_SetPalette": [[2], []],
      "I_ShutdownGraphics": [[], []],
      "I_StartFrame": [[], []],
      "I_StartTic": [[], []],
      "I_UpdateNoBlit": [[], []],
      "___errno_location": [[], [2]],
      "__fpclassifyl": [[2, 2], [2]],
      "__lock": [[2], []],
      "__lockfile": [[2], [2]],
      "__signbitl": [[2, 2], [2]],
      "__stdio_close": [[], []],
      "__stdio_seek": [[], []],
      "__syscall3": [[2, 2, 2, 2], [2]],
      "__toread": [[2], [2]],
      "__uflow": [[2], [2]],
      "__unlock": [[2], []],
      "__unlockfile": [[2], []],
      "access": [[2, 2], [2]],
      "add_browser_event": [[2, 2], []],
      "close": [[2], [2]],
      "copysignl": [[2, 2, 2, 2, 2], []],
      "doom_loop_step": [[], []],
      "exit": [[2], []],
      "fabsl": [[2, 2, 2], []],
      "fmodl": [[2, 2, 2, 2, 2], []],
      "fopen": [[2, 2], [2]],
      "free": [[2], []],
      "frexpl": [[2, 2, 2, 2], []],
      "fstat": [[2, 2], [2]],
      "getenv": [[2], [2]],
      "lseek": [[2, 2, 2], [2]],
      "main": [[2, 2], [2]],
      "malloc": [[2], [2]],
      "mbrtowc": [[2, 2, 2, 2], [2]],
      "mbsinit": [[2], [2]],
      "open": [[2, 2, 2], [2]],
      "read": [[2, 2, 2], [2]],
      "realloc": [[2, 2], [2]],
      "scalbn": [[3, 2], [3]],
      "scalbnl": [[2, 2, 2, 2], []],
      "strerror": [[2], [2]],
      "usleep": [[2], [2]],
      "wctomb": [[2, 2], [2]],
      "write": [[2, 2, 2], [2]]
   },
   "memory": {
      "min": 6684672,
      "max": 4294901760
   }
}

That's a lot of information! Fret not; we'll be ignoring most of this. For now, take note of the import_functions and memory properties.

Let's forge ahead and try to instantiate the Wasm module.

wasm.instantiate({})

You should see the following error:

Main.gd:10 @ _ready(): Godot Wasm: Missing import function js.js_console_log
<C++ Source> src/godot-wasm.cpp:330 @ instantiate()
<Stack Trace> Main.gd:10 @ _ready()

Instantiation of our Wasm module is failing because we're not providing the expected imports.

Satisfying Imports

Referring back to the inspection of the module, we can see five import functions. If you inspect the module via the Wasmer CLI tool, you'll also note that the module requires a memory import.

By viewing the main.js source of Diekmann's example, we can confirm that the following import functions are provided:

  1. js.js_console_log
  2. js.js_stdout
  3. js.js_stderr
  4. js.js_milliseconds_since_start
  5. js.js_draw_screen

The object returned by wasm.inspect() represents each import function signature as an array of two arrays. The first array represents the parameter types, and the second represents the return values. Empty arrays represent no function parameters and a void return type, respectively. The types use Godot's Variant.Type enumeration. As an example, the js.js_console_log import function takes two integers as arguments and returns no value.

First, we'll satisfy function imports with stubbed-out functions. In your Main.gd file, add the following functions:

func console_log(offset, length):
	print("console_log: %s" % [offset, length])

func stdout(offset, length):
	print("stdout: %s %s" % [offset, length])

func stderr(offset, length):
	print("stderr: %s %s" % [offset, length])

func milliseconds_since_start():
	print("milliseconds_since_start")
	return 0

func draw_screen(offset):
	print("draw_screen: %s" % offset)

These functions don't implement the logic they're expected to yet. However, we'll at least be able to tell when the Wasm module is calling an imported function.

Let's provide these functions to the module as imports during instantiation. Each import function is represented by an array containing a Godot Object and the name of the method to call. We're using self as the target Object because each of the targeted methods is defined in the same file. Once again, in _ready(), add the following:

var imports = {
	"functions": {
		"js.js_console_log": [self, "console_log"],
		"js.js_draw_screen": [self, "draw_screen"],
		"js.js_milliseconds_since_start": [self, "milliseconds_since_start"],
		"js.js_stdout": [self, "stdout"],
		"js.js_stderr": [self, "stderr"]
	}
}
wasm.instantiate(imports)

We should now see a new error.

Main.gd:17 @ _ready(): Godot Wasm: Missing import memory
<C++ Source> src/godot-wasm.cpp:348 @ instantiate()
<Stack Trace> Main.gd:17 @ _ready()

We'll now need to satisfy the memory import requirement. WebAssembly modules can either define their own internal (often exported) memory, which is created automatically by the runtime on instantiation, or import an external memory resource. In the case of our doom.wasm module, the latter applies.

Create another @onready variable at the top level of your main script.

@onready var memory = WasmMemory.new()

The external memory resource must meet some minimum size requirements. Referring back to our inspection, we can see that our memory must be a minimum of 6684672 bytes in size. Wasm module memory is not typically dealt with in bytes but rather pages, equivalent to 65536 bytes each. With this in mind, our memory resource must be a minimum of 102 (6684672 / 65536) pages. See the Memory Operations guide and WasmMemory class documentation for more information.

Let's expand our memory resource. We'll follow Diekmann's example and allocate 108 pages of memory. In _ready() and before instantiating our module, add the following:

memory.grow(108)

You can confirm the size of the memory via memory.inspect().

Finally, let's modify our import object to include the memory and instantiate the module.

var imports = {
	"functions": {
		"js.js_console_log": [self, "console_log"],
		"js.js_draw_screen": [self, "draw_screen"],
		"js.js_milliseconds_since_start": [self, "milliseconds_since_start"],
		"js.js_stdout": [self, "stdout"],
		"js.js_stderr": [self, "stderr"]
	},
	"memory": memory
}
wasm.instantiate(imports)

The script should run without throwing any errors.

As an aside, the compilation and instantiation steps can be completed in one call with the load() method, which takes both the binary and import object as arguments.

wasm.load(file, imports)

Initialize Doom, Take 1

Let's attempt to get Doom running. Referring to the Wasm module inspection from earlier, take note of the exported function main(). Sure enough, Diekmann's implementation calls this function first to initialize Doom. It takes two integers as arguments and returns an integer. The argument values go unused, and we'll ignore the return value.

To call a Wasm export function, use the function() method. An array containing our arguments must be provided.

wasm.function("main", [0, 0])

We should see some output as well as an error thrown.

console_log: 7077952 59
console_log: 7078072 55

Main.gd:21 @ _ready(): Godot Wasm: Failed calling function main
<C++ Source> src/godot-wasm.cpp:458 @ function()
<Stack Trace> Main.gd:21 @ _ready()

Our main() function failed; let's dive a little deeper.

Implement Logging Imports

When calling our main() export function, the Wasm module called the console_log() import function twice before the invocation failed. We'll implement some basic logging to aid in debugging.

Some background regarding Wasm memory is important at this stage of the journey. WebAssembly memory is simply a contiguous buffer or array of bytes. As we saw earlier, this array can be expanded or grown. Memory is very important and frequently used with Wasm because of the limited API that can be exposed via import/export functions, also known as the Foreign Function Interface, or FFI. Only the following four fundamental data types can be directly exposed via Wasm import/export functions:

  • 32-bit integer
  • 64-bit integer
  • 32-bit floating point
  • 64-bit floating point

This begs the question: how do we transfer a string between Godot (the host) and the Wasm module (the guest)?

The answer is to take advantage of the module's memory. The Wasm module can write a string to memory in an agreed-upon format, and the host, i.e., Godot, can later read it. The host can be instructed where in memory to begin reading and how many bytes to read with simple integer values passed via import/export functions.

Godot Wasm's WasmMemory class (see WasmMemory class documentation) inherits from Godot's own StreamPeer class and closely mirrors Godot's StreamPeerBuffer class. This allows us to easily read raw bytes in a variety of contexts.

We now have the context required to implement our logging functions. We've already seen that the js.js_console_log import function accepts two integers as arguments. These integers represent the data offset, i.e., starting point and the data length, respectively. We'll use these to read the data to be printed. Referring to Diekmann's example, we expect strings to be stored as UTF-8. Reimplement the stdout and console_log GDScript functions as follows:

func stdout(offset, length):
	memory.seek(offset)
	var message = memory.get_utf8_string(length)
	print(message)

func console_log(offset, length):
	stdout(offset, length) # Reuse stdout implementation

The seek() method moves the cursor to a memory offset, while get_utf8_string() (inherited from StreamPeer) reads a UTF-8 string from raw bytes.

Running the project again reveals the expected data printed to the console before main() fails.

Hello, World, from JS Console! Answer=42 (101010 in binary)
Hello, world from rust! 🦀🦀🦀 (println! working)

Referencing Diekmann's example, we see the first two lines match. We can surmise that the error is happening before the following expected next line.

Starting D_DoomMain

Fruitlessly Debugging

We're expecting to see logs from the initialization of Doom. Firstly, we expect to see "Starting D_DoomMain". Let's investigate the cause of the main() function invocation error.

At this point, we have to get into the gritty details of the Godot Wasm addon. We'll abandon the prepackaged addon from the Asset Library and compile the addon ourselves in order to debug.

Clone the repo and follow Godot Wasm's Development guide.

Let's modify the addon source. In src/godot-wasm.cpp, modify the function() method (permalink here) to the following:

wasm_trap_t* trap = wasm_func_call(func, &f_args, &f_results);
if (trap) {
  wasm_message_t message;
  wasm_trap_message(trap, &message);
  PRINT_ERROR(message.data);
  wasm_trap_delete(trap);
}

Note

The macros used in Godot Wasm are currently undergoing changes. Instead of PRINT_ERROR you may need to use GODOT_WASM_PRINT_ERROR or directly call UtilityFunctions::print(String(message.data)) depending on the version of Godot Wasm used.

The above conditional reads the message from a returned wasm_trap_t pointer. Hopefully, this message provides some clarity.

Rebuild the Godot Wasm addon following the wiki Development guide.

scons target=template_release platform=linux

Populate the addon in the Godot Wasm Doom project (found in GODOT_DOOM_DIR/addons/godot-wasm) with the binaries generated by the Godot Wasm build step (found in GODOT_WASM_DIR/addons/godot-wasm). It may be convenient to create a symbolic link from the Doom project to the addon project to automatically get updated binaries. This can be accomplished on UNIX platforms by deleting the existing addons directory in the Doom project and running ln -s PATH/TO/GODOT_WASM_DIR/addons PATH/TO/GODOT_DOOM_DIR/addons.

Running the project again results in a new error being logged.

Main.gd:21 @ _ready(): Godot Wasm: out of bounds memory access
<C++ Source> src/godot-wasm.cpp:463 @ function()
<Stack Trace> Main.gd:21 @ _ready()

The Wasm module or underlying runtime seems to be accessing memory outside of the allocated bounds.

Let's try allocating more memory. Using memory.grow(), allocate an assortment of memory sizes up to the maximum of 65536 pages. All values fail, with the final, largest value producing the following error:

Main.gd:21 @ _ready(): Godot Wasm: unreachable
<C++ Source> src/godot-wasm.cpp:463 @ function()
<Stack Trace> Main.gd:21 @ _ready()

No luck. Revert the memory size to 108 pages. Unfortunately, the error messages do not provide many clues to go on. However, we've got another trick up our sleeve.

Changing the WebAssembly Runtime

Godot Wasm supports both the Wasmer and Wasmtime runtimes. By default, Wasmer is used. As of writing this (2024-03-06), the prepackaged Godot Wasm addon, e.g., via Godot Asset Library, uses the default Wasmer runtime.

Let's go ahead and compile Godot Wasm again, this time using the Wasmtime runtime. Refer to the Changing Runtime documentation. The following is compiling for Linux; replace platform as required, e.g., windows, macos.

scons target=template_release platform=linux wasm_runtime=wasmtime

Unless using a symbolic link between projects, package the addon binaries once again.

Note

Compiling with the Wasmtime runtime on Windows is failing static linking as of Godot Wasm v0.3.4. In addition to the built Godot Wasm binaries, you'll need to copy wasmtime.dll to GODOT_DOOM_DIR/addons/godot-wasm/bin/windows and to update godot-wasm.gdextension to include the Wasmtime DLL as a dependency. Refer to the addons directory of the project source. This issue is now captured in godot-wasm#65.

Running the Godot Wasm Doom project now produces a plethora of STDOUT output and no errors! Success!

It seems as though there may be a deficiency with the Wasmer runtime (to be explored further).

Initialize Doom, Take 2

Now that our custom-built Godot Wasm addon is using Wasmtime as the underlying WebAssembly runtime, we're ready to return to the Godot Wasm Doom project. Running the project once again produces the following (truncated) logs:

Hello, World, from JS Console! Answer=42 (101010 in binary)
Hello, world from rust! 🦀🦀🦀 (println! working)
Starting D_DoomMain
Triggering a printf
Doom's screen is 320x200
mallocing 12 bytes at 7078176
...
mallocing 140 bytes at 7078320
startskill 2 deathmatch: 0 startmap: 1 startepisode: 1
player 1 of 1 (1 nodes)
S_Init: Setting up sound.
stderr: 1047440 29
HU_Init: Setting up heads up display.
ST_Init: Init status bar.
I_InitGraphics (TODO)

Using Diekmann's example as our guide, this is exactly the output we're expecting. Doom is successfully initializing!

Implementing Additional Imports

In the above logs, we can see one call to the unimplemented stderr() import function.

stderr: 1047440 29

Let's go ahead and implement a simple error logging function similar to what we did for console_log() and stdout() to properly satisfy the js.js_stderr import function. We'll push error messages printed from Doom to Godot's warning-level logging. This will draw attention to Doom errors while reserving Godot's error-level logging for critical errors encountered while running the Wasm module.

func stderr(offset, length):
	memory.seek(offset)
	var message = memory.get_utf8_string(length)
	push_warning(message)

Running the project again should produce a single warning.

Main.gd:42 @ stderr(): S_Init: default sfx volume 8
<C++ Source> core/variant/variant_utility.cpp:1111 @ push_warning()
<Stack Trace> Main.gd:42 @ stderr()
Main.gd:21 @ _ready()

Doom threw an error relating to sound effects. Sound effects were not implemented in this port of Doom, so we'll go ahead and ignore this supposed error.

Next, let's implement the js.js_milliseconds_since_start import function. In the Main.gd GDScript file, modify the milliseconds_since_start() method to the following:

func milliseconds_since_start():
	return Time.get_ticks_msec()

This uses the Time singleton to return the number of milliseconds since the program was started.

As an aside, we can shorten our script by passing the Time singleton object and the get_ticks_msec() method name directly as the import. We can now delete our custom milliseconds_since_start() method.

var imports = {
	"functions": {
		...
		"js.js_milliseconds_since_start": [Time, "get_ticks_msec"],
		...
	}
}

Calling the Game Loop

Referring once again to Diekmann's example, we can see that there are only three export functions used.

  1. main
  2. doom_loop_step
  3. add_browser_event

Let's proceed to calling the main Doom game loop. At the end of the _ready() function in your Main.gd script, call the doom_loop_step() export function.

wasm.function("doom_loop_step", [])

Running the project, we should see exactly one new log line appear.

BASETIME initialized to 472

If we still had a print() call in our milliseconds_since_start(), we'd see that the Doom main loop function invoked the js.js_milliseconds_since_start import function. This crossed the FFI barrier from the Wasm module (guest) to Godot (host) and ingested the returned value. The printed value (in this case, 472) is the number of milliseconds that Godot's Time singleton has recorded since the program was started.

The game loop should not just be called once. Rather, it should be called repeatedly, ticking the game along with each invocation. Let's use Godot's _process() method to call Doom's game loop. Remove the call to doom_loop_step in _ready() and add the following to your script:

func _process(_delta):
	wasm.function("doom_loop_step", [])

Running the project again, we should see repeated calls to the js.js_draw_screen import function.

ST_Init: Init status bar.
I_InitGraphics (TODO)
BASETIME initialized to 1262
draw_screen: 5278204
draw_screen: 5278204
draw_screen: 5278204
draw_screen: 5278204
...

We're ready to implement our final (and most complex) import function, js.js_draw_screen!

Rendering Doom

Per Diekmann's write-up, there is a custom rendering shim applied on top of classic Doom implemented in Rust. This simplifies our drawing/rendering implementation substantially, as it is outputting standard RGBA8888 (also called RGBA8). This format consists of four color channels, each with a bit depth of one byte or eight bits. Godot supports this format via the FORMAT_RGBA8 flag.

We know from Doom's logs that the screen dimensions are defined as 320x200.

We're finally getting to some graphical work in Godot. Click the 2D view and ensure the previously added MarginContainer is set to anchor mode Full Rect. Add a TextureRect to the MarginContainer. Select the TextureRect, and on the right side of the screen, set the Texture property with a new ImageTexture.

Add TextureRect

Back to the code. We'll need to instantiate an Image that holds the graphical data created by Doom. The image's data will be flashed to the ImageTexture created above. At the top of Main.gd, with the @onready variables, add the following:

var image = Image.new()

We'll need to create our Image and set it as the image value of the ImageTexture. Add the following anywhere in the _ready() function:

image = Image.create(320, 200, false, Image.FORMAT_RGBA8)
$TextureRect.texture.set_image(image)

We're now ready to draw the screen. Taking a look at js.js_draw_screen, we see that a single integer parameter is provided. This value points to the offset in memory at which graphical data begins. No length parameter is provided, as we can calculate the required space manually based on screen size, color channels, and channel bit depth (in bytes). We should anticipate reading SCREEN_SIZE × N_CHANNELS × BIT_DEPTH bytes, or 320 × 200 × 4 × 1 = 256,000 bytes.

As with retrieving strings from Wasm memory, we'll need to seek() to the correct offset in memory and read the data using one of the methods provided by the StreamPeer interface. This time, instead of reading a UTF-8 string, we'll read the raw bytes from memory as an array. The get_data() method is perfect for this. Take note that the return value of get_data is an array with two values: an error code and the raw data array itself. Replace our placeholder draw_screen() method with the following:

func draw_screen(offset):
	memory.seek(offset)
	var data = memory.get_data(320 * 200 * 4)
	image.set_data(320, 200, false, Image.FORMAT_RGBA8, data[1])
	$TextureRect.texture.update(image)

Running the project, we'll get our first glimpse of Doom!

First glimpse of Doom

Something doesn't look quite right. In debugging this graphical issue with reference to Diekmann's example, it's clear that we should be using screen dimensions of 640x400. I'm not entirely sure where the discrepancy between the reported and actual resolutions comes from. Modify the Image creation and draw_screen() methods to reflect the new 640x400 resolution. Run the project.

Fixed Doom rendering

Success! Doom should be correctly displayed in all its pixelated glory, slowly cycling through three title/demo screens.

Keyboard Input

We've implemented all import functions. The final export function, add_browser_event, remains. This function is used to forward keyboard input to the Wasm module.

As indicated by wasm.inspect(), the add_browser_event() export function receives two integer arguments and returns void. The arguments are as follows:

  1. A boolean (represented as an integer) that denotes whether a key was released or pressed. This boolean is misleading in that a value of false or 0 represents a key pressed, while true or 1 represents a key released.
  2. An integer key code that denotes the key that was pressed or released.

Let's grab some input. First, let's create a simple placeholder function used to explore input events in Godot.

func _input(event):
	if event is InputEventKey and !event.is_echo():
		var pressed = event.is_pressed()
		var keycode = event.keycode
		print("Keycode %s pressed? %s" % [keycode, pressed])

The _input() method is called on frames during which input was detected. We're first making sure that the event is a keyboard event that was not echoed. Next, we're checking to see if the key was pressed or released. Running the program and pressing the Enter key will output the following:

Keycode 4194309 pressed? true
Keycode 4194309 pressed? false

The key code value above is a bespoke Godot value based on the Key enumeration. Printing Key.KEY_ENTER will produce the same value. This value does not match that expected by Doom, which uses DOS key codes, e.g., 13 for Enter. We'll need to map expected keys from Godot's values to Doom's. Informed by Diekmann's example, let's include a simple, incomplete Dictionary to map key codes at the top of Main.gd. Each Dictionary key represents a Godot key code, while their values represent the corresponding Doom/DOS key codes.

var keys = { KEY_ENTER: 13, KEY_BACKSPACE: 127, KEY_SPACE: 32, KEY_LEFT: 0xac, KEY_RIGHT: 0xae, KEY_UP: 0xad, KEY_DOWN: 0xaf, KEY_CTRL: 0x80+0x1d, KEY_ALT: 0x80+0x38, KEY_ESCAPE: 27, KEY_TAB: 9, KEY_SHIFT: 16 }

This captures many of the required keys but notably misses numeric, alphabetic, and function keys.

Let's modify our _input() method to display the mapped keys. We will map all unknown keys to key code 0, which Doom ultimately ignores. We'll also invert the value of is_pressed() and cast it to an integer to match Doom's API.

func _input(event):
	if event is InputEventKey and !event.is_echo():
		var pressed = int(!event.is_pressed())
		var keycode = keys.get(event.keycode, 0)
		print("Keycode %s pressed? %s" % [keycode, pressed])

Running the program and pressing the Enter key will now produce the following:

Keycode 13 pressed? 0
Keycode 13 pressed? 1

Finally, let's forward that mapped value to Doom via the add_browser_event export function.

func _input(event):
	if event is InputEventKey and !event.is_echo():
		var pressed = int(!event.is_pressed())
		var keycode = keys.get(event.keycode, 0)
		wasm.function("add_browser_event", [pressed, keycode])

When running the program, you should now be able to interact with Doom! Note that we're using the original Doom keybinds, e.g., CTRL: shoot, Space: use/open, Enter: select.

Interacting with Doom

Lastly, let's implement the remaining keybinds. The following ranges of Godot key codes will be mapped to Doom's expected values.

  • [65, 90]: Alphabetic ASCII key codes that must be mapped to their lowercase selves, i.e., add 32.
  • [48, 57]: Numeric keys whose values can be passed straight through to Doom, i.e., Godot and DOS key codes values match.
  • [4194332, 4194343]: Function keys F1 through F12. These should be mapped to values 187 through 198.

We'll use some array mapping magic to map each range member to a Dictionary with a single key equal to the Godot key code and a corresponding value equal to the Doom/DOS key code. This format matches that defined by the previously declared keys variable. For each Dictionary, we can then use the merge() method to include them in our mapping. Add the following anywhere in _ready().

var alphabetic = range(KEY_A, KEY_Z + 1).map(func(x): return { x: x + 32 })
var numeric = range(KEY_0, KEY_9 + 1).map(func(x): return { x: x })
var function = range(KEY_F1, KEY_F12 + 1).map(func(x): return { x: 187 + x - KEY_F1 })
for k in alphabetic + numeric + function:
	keys.merge(k)

With that, we should receive alphabetic, numeric, and function key events. We can test this out by running the program, starting a new game of Doom, and pressing the 1 key. Our character should switch to bare hands. Pressing 2 returns to the default weapon.

Wrapping Up

Done and done. I hope this exploration was as entertaining and educational for you as it was for me. Additionally, I hope this convinces you of the incredible power of WebAssembly! Because of the low performance requirements of Wasm and the fact that we've rendered the game to a simple ImageTexture, this is highly adaptable. For example, imagine walking up to a virtual monitor running a fully-functional version of Doom inside a 3D game!

Some additional steps are required to export this project. See the Exporting guide for more information.

The final resultant source code for this project is available here. Several of the functions defined above have been altered for brevity, although their logic remains the same.

Final Remarks

As a personal plug, please go star the Godot Wasm project on GitHub. Feel free to open an issue or PR!

Call for aid: I'm by no means an expert with Windows and am struggling with statically linking the Wasmtime library as described by godot-wasm#65. I'd love a hand with this one!

About

Porting Doom to Godot in 34 Lines of GDScript

License:MIT License


Languages

Language:GDScript 100.0%