Odin && WebAssembly

Posted on Mar 10, 2025

Introduction

This post walks through building a WebAssembly (WASM) app using the Odin language and Raylib. You’ll learn how to use a custom allocator and logger suitable for Emscripten, and compile your Odin code to run in the browser.

What You’ll Build

You’ll compile a minimal Odin + Raylib app into WASM and render it in a browser, using Odin’s js_wasm32 backend.

Preview of pawn flick credits to ElMonetina

Prerequisites

Before starting, make sure you have the following:

  • Odin: Latest nightly build (odin version)
  • Emscripten: Installed and in your PATH (emcc --version)
  • Raylib WASM static libs: From Raylib’s GitHub Releases
  • Basic knowledge of Odin syntax and modules

Step 1: Odin WebAssembly Template

Start with this helpful GitHub template:

odin-wasm-template

This gives you:

  • A working WASM allocator
  • A browser-safe logger
  • Example project layout

Step 2: WASM Allocator

This allocator wraps Emscripten’s malloc, calloc, free, and realloc for Odin’s runtime.

/*
This allocator uses the malloc, calloc, free and realloc procs that emscripten
exposes in order to allocate memory. Just like Odin's default heap allocator
this uses proper alignment, so that maps and simd works.
*/

package main_web

import "core:mem"
import "core:c"
import "base:intrinsics"

// This will create bindings to emscripten's implementation of libc
// memory allocation features.
@(default_calling_convention = "c")
foreign {
	calloc  :: proc(num, size: c.size_t) -> rawptr ---
	free    :: proc(ptr: rawptr) ---
	malloc  :: proc(size: c.size_t) -> rawptr ---
	realloc :: proc(ptr: rawptr, size: c.size_t) -> rawptr ---
}

emscripten_allocator :: proc "contextless" () -> mem.Allocator {
	return mem.Allocator{emscripten_allocator_proc, nil}
}

emscripten_allocator_proc :: proc(
	allocator_data: rawptr,
	mode: mem.Allocator_Mode,
	size, alignment: int,
	old_memory: rawptr,
	old_size: int,
	location := #caller_location
) -> (data: []byte, err: mem.Allocator_Error)  {
	// These aligned alloc procs are almost indentical those in
	// `_heap_allocator_proc` in `core:os`. Without the proper alignment you
	// cannot use maps and simd features.

	aligned_alloc :: proc(size, alignment: int, zero_memory: bool, old_ptr: rawptr = nil) -> ([]byte, mem.Allocator_Error) {
		a := max(alignment, align_of(rawptr))
		space := size + a - 1

		allocated_mem: rawptr
		if old_ptr != nil {
			original_old_ptr := mem.ptr_offset((^rawptr)(old_ptr), -1)^
			allocated_mem = realloc(original_old_ptr, c.size_t(space+size_of(rawptr)))
		} else if zero_memory {
			// calloc automatically zeros memory, but it takes a number + size
			// instead of just size.
			allocated_mem = calloc(c.size_t(space+size_of(rawptr)), 1)
		} else {
			allocated_mem = malloc(c.size_t(space+size_of(rawptr)))
		}
		aligned_mem := rawptr(mem.ptr_offset((^u8)(allocated_mem), size_of(rawptr)))

		ptr := uintptr(aligned_mem)
		aligned_ptr := (ptr - 1 + uintptr(a)) & -uintptr(a)
		diff := int(aligned_ptr - ptr)
		if (size + diff) > space || allocated_mem == nil {
			return nil, .Out_Of_Memory
		}

		aligned_mem = rawptr(aligned_ptr)
		mem.ptr_offset((^rawptr)(aligned_mem), -1)^ = allocated_mem

		return mem.byte_slice(aligned_mem, size), nil
	}

	aligned_free :: proc(p: rawptr) {
		if p != nil {
			free(mem.ptr_offset((^rawptr)(p), -1)^)
		}
	}

	aligned_resize :: proc(p: rawptr, old_size: int, new_size: int, new_alignment: int) -> ([]byte, mem.Allocator_Error) {
		if p == nil {
			return nil, nil
		}
		return aligned_alloc(new_size, new_alignment, true, p)
	}

	switch mode {
	case .Alloc:
		return aligned_alloc(size, alignment, true)

	case .Alloc_Non_Zeroed:
		return aligned_alloc(size, alignment, false)

	case .Free:
		aligned_free(old_memory)
		return nil, nil

	case .Resize:
		if old_memory == nil {
			return aligned_alloc(size, alignment, true)
		}

		bytes := aligned_resize(old_memory, old_size, size, alignment) or_return

		// realloc doesn't zero the new bytes, so we do it manually.
		if size > old_size {
			new_region := raw_data(bytes[old_size:])
			intrinsics.mem_zero(new_region, size - old_size)
		}

		return bytes, nil

	case .Resize_Non_Zeroed:
		if old_memory == nil {
			return aligned_alloc(size, alignment, false)
		}

		return aligned_resize(old_memory, old_size, size, alignment)

	case .Query_Features:
		set := (^mem.Allocator_Mode_Set)(old_memory)
		if set != nil {
			set^ = {.Alloc, .Free, .Resize, .Query_Features}
		}
		return nil, nil

	case .Free_All, .Query_Info:
		return nil, .Mode_Not_Implemented
	}
	return nil, .Mode_Not_Implemented
}

Step 3: Console Logger for Browser

This logger uses Emscripten’s puts to cleanly log to the browser console. It avoids issues with Odin’s default logger.

/*
This logger is largely a copy of the console logger in `core:log`, but it uses
emscripten's `puts` proc to write into he console of the web browser.

This is more or less identical to the logger in Aronicu's repository:
https://github.com/Aronicu/Raylib-WASM/tree/main
*/

package main_web

import "core:c"
import "core:fmt"
import "core:log"
import "core:strings"

Emscripten_Logger_Opts :: log.Options{.Level, .Short_File_Path, .Line}

create_emscripten_logger :: proc (lowest := log.Level.Debug, opt := Emscripten_Logger_Opts) -> log.Logger {
	return log.Logger{data = nil, procedure = logger_proc, lowest_level = lowest, options = opt}
}

// This create's a binding to `puts` which will be linked in as part of the
// emscripten runtime.
@(default_calling_convention = "c")
foreign {
	puts :: proc(buffer: cstring) -> c.int ---
}

@(private="file")
logger_proc :: proc(
	logger_data: rawptr,
	level: log.Level,
	text: string,
	options: log.Options,
	location := #caller_location
) {
	b := strings.builder_make(context.temp_allocator)
	strings.write_string(&b, Level_Headers[level])
	do_location_header(options, &b, location)
	fmt.sbprint(&b, text)

	if bc, bc_err := strings.to_cstring(&b); bc_err == nil {
		puts(bc)
	}
}

@(private="file")
Level_Headers := [?]string {
	0 ..< 10 = "[DEBUG] --- ",
	10 ..< 20 = "[INFO ] --- ",
	20 ..< 30 = "[WARN ] --- ",
	30 ..< 40 = "[ERROR] --- ",
	40 ..< 50 = "[FATAL] --- ",
}

@(private="file")
do_location_header :: proc(opts: log.Options, buf: ^strings.Builder, location := #caller_location) {
	if log.Location_Header_Opts & opts == nil {
		return
	}
	fmt.sbprint(buf, "[")
	file := location.file_path
	if .Short_File_Path in opts {
		last := 0
		for r, i in location.file_path {
			if r == '/' {
				last = i + 1
			}
		}
		file = location.file_path[last:]
	}

	if log.Location_File_Opts & opts != nil {
		fmt.sbprint(buf, file)
	}
	if .Line in opts {
		if log.Location_File_Opts & opts != nil {
			fmt.sbprint(buf, ":")
		}
		fmt.sbprint(buf, location.line)
	}

	if .Procedure in opts {
		if (log.Location_File_Opts | {.Line}) & opts != nil {
			fmt.sbprint(buf, ":")
		}
		fmt.sbprintf(buf, "%s()", location.procedure)
	}

	fmt.sbprint(buf, "] ")
}

Output example from this page:

INFO: Initializing raylib 5.5
INFO: Platform backend: WEB (HTML5)
INFO: Supported raylib modules:
INFO:     > rcore:..... loaded (mandatory)
INFO:     > rlgl:...... loaded (mandatory)
INFO:     > rshapes:... loaded (optional)
INFO:     > rtextures:. loaded (optional)
INFO:     > rtext:..... loaded (optional)
INFO:     > rmodels:... loaded (optional)
INFO:     > raudio:.... loaded (optional)
INFO: DISPLAY: Device initialized successfully
INFO:     > Display size: 720 x 480
INFO:     > Screen size:  720 x 480
INFO:     > Render size:  720 x 480
INFO:     > Viewport offsets: 0, 0
INFO: GL: Supported extensions count: 67
INFO: GL: OpenGL device information:
INFO:     > Vendor:   WebKit
INFO:     > Renderer: WebKit WebGL
INFO:     > Version:  OpenGL ES 2.0 (WebGL 1.0 (OpenGL ES 2.0 Chromium))
INFO:     > GLSL:     OpenGL ES GLSL ES 1.00 (WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium))
[...]

Step 4: Entry Point for the Web

This entrypoint sets up the allocator, logger, and exported main_* procs called from index.html.

@(export)
main_start :: proc "c" () {
    context = runtime.default_context()
    context.allocator = emscripten_allocator()
    runtime.init_global_temporary_allocator(1*mem.Megabyte)
    context.logger = create_emscripten_logger()
    web_context = context
    game.init()
}

@(export)
main_update :: proc "c" () -> bool {
    context = web_context
    game.update()
    return game.should_run()
}

@(export)
main_end :: proc "c" () {
    context = web_context
    game.shutdown()
}

@(export)
web_window_size_changed :: proc "c" (w: c.int, h: c.int) {
    context = web_context
    game.parent_window_size_changed(int(w), int(h))
}

Step 5: Compile the Project

export ODIN_PATH=<YOUR_ODIN_PATH>
export WEB_BUILD_PATH=<YOUR_WEB_BUILD_PATH>

mkdir -p $WEB_BUILD_PATH

odin build src/platform/web -target:js_wasm32 -build-mode:obj \
    -define:RAYLIB_WASM_LIB=env.o -define:RAYGUI_WASM_LIB=env.o \
    -vet -strict-style -out:$WEB_BUILD_PATH/game

cp $ODIN_PATH/core/sys/wasm/js/odin.js $WEB_BUILD_PATH/odin.js

emcc -o $WEB_BUILD_PATH/index.html $WEB_BUILD_PATH/game.wasm.o \
    $ODIN_PATH/vendor/raylib/wasm/libraylib.a $ODIN_PATH/vendor/raylib/wasm/libraygui.a \
    -sUSE_GLFW=3 -sWASM_BIGINT -sWARN_ON_UNDEFINED_SYMBOLS=0 \
    -sASSERTIONS --shell-file src/platform/web/index_template.html

Next Steps

Now that your Odin code runs in the browser:

  • 🖱 Add keyboard/mouse input handling
  • 🖼 Load images, draw textures or shapes
  • 🔊 Add audio playback with Raylib
  • 📦 Bundle for deployment on GitHub Pages
  • 🔗 Interface with JS using Odin’s foreign import from JS APIs

Conclusion

With Odin, Raylib, and Emscripten, you can build fast, native-feeling web apps and games in a low-level language. This setup gives you a powerful starting point for real-time visualizations, interactive demos, or browser-based system experiments.