diff --git a/BUILDING.md b/BUILDING.md
index 94474e9..d81e98b 100644
--- a/BUILDING.md
+++ b/BUILDING.md
@@ -84,7 +84,9 @@ $env:OPENQ4_GAMELIBS_REPO = "C:\path\to\openQ4-game"  # PowerShell
 
 To rebuild the game libraries as part of the openQ4 build, set `OPENQ4_BUILD_GAMELIBS=1` before running compile.
 
-During configure, openQ4 stages the required game-library source inputs from openQ4-game into `.tmp/gamelibs_stage/` and writes a source manifest with file hashes and git state. Meson requires that manifest before compiling the game modules, so Linux and experimental macOS builds consume the companion repository directly without maintaining a local `src/game` mirror. The standalone openQ4-game build remains useful for Windows/MSVC developer outputs and compatibility checks, but the cross-platform engine build owns the staged module build.
+During configure, openQ4 stages the required game-library source inputs from openQ4-game into `<builddir>/gamelibs_stage/` and writes a source manifest with file hashes and git state. Each Meson build directory owns an isolated stage, so parallel native and cross builds cannot replace one another's source snapshot. Meson requires that manifest before compiling the game modules, so Linux and experimental macOS builds consume the companion repository directly without maintaining a local `src/game` mirror. The standalone openQ4-game build remains useful for Windows/MSVC developer outputs and compatibility checks, but the cross-platform engine build owns the staged module build.
+
+Emscripten builds produce a GPL engine main module plus exactly one dynamically loaded GameLib side module per build directory. Use `-Dbuild_games=true -Dbuild_game_sp=true -Dbuild_game_mp=false` for the campaign client and a separate build directory with `-Dbuild_games=true -Dbuild_game_sp=false -Dbuild_game_mp=true` for the multiplayer client. Selecting both GameLibs, or neither, is rejected at configure time.
 
 ---
 
@@ -291,7 +293,7 @@ Package the default SDL3 backend with OpenGL plus Wayland/EGL support, and keep
 - Wayland decoration preference: `OPENQ4_WAYLAND_PREFER_LIBDECOR=1` when a compositor behaves better with libdecor.
 - Wayland window-operation diagnostics: `OPENQ4_WAYLAND_SYNC_WINDOW_OPS=1`; use only for troubleshooting because some compositors can block during window animations.
 
-Build from the companion `openQ4-game` checkout through `OPENQ4_GAMELIBS_REPO` or the default sibling path. The staged `.tmp/gamelibs_stage/openq4_gamelibs_stage_manifest.json` records the exact source-input hashes and git state used by the engine build; keep that manifest in CI artifacts when investigating packaging or reproducibility problems, but do not package `.tmp/gamelibs_stage/` as a runtime payload.
+Build from the companion `openQ4-game` checkout through `OPENQ4_GAMELIBS_REPO` or the default sibling path. The staged `<builddir>/gamelibs_stage/openq4_gamelibs_stage_manifest.json` records the exact source-input hashes and git state used by that engine build; keep that manifest in CI artifacts when investigating packaging or reproducibility problems, but do not package `gamelibs_stage/` as a runtime payload.
 
 Release packaging publishes detached Linux debug symbols as `openq4-<version>-linux-<arch>-debugsymbols.tar.xz`. Keep that archive paired with the matching runtime package so crash reports from optimized Linux builds can be symbolized.
 
diff --git a/content/baseoq4/meson.build b/content/baseoq4/meson.build
index 4e949b7..1152451 100644
--- a/content/baseoq4/meson.build
+++ b/content/baseoq4/meson.build
@@ -2,7 +2,7 @@ if build_games and build_game_sp
   if host_system == 'windows'
     shared_module(
       game_sp_binary_name,
-      game_sources,
+      game_sp_sources,
       include_directories: game_include_dirs,
       link_with: game_idlib_library,
       cpp_args: game_common_cpp_args,
@@ -17,7 +17,7 @@ if build_games and build_game_sp
   elif host_system == 'darwin'
     shared_library(
       game_sp_binary_name,
-      game_sources,
+      game_sp_sources,
       include_directories: game_include_dirs,
       link_with: game_idlib_library,
       cpp_args: game_common_cpp_args,
@@ -29,10 +29,23 @@ if build_games and build_game_sp
       install: true,
       install_dir: install_game_dir,
     )
+  elif host_system == 'emscripten'
+    shared_module(
+      game_sp_binary_name,
+      game_sp_sources,
+      include_directories: game_include_dirs,
+      link_with: game_idlib_library,
+      cpp_args: game_common_cpp_args,
+      c_args: game_common_c_args,
+      cpp_pch: game_module_pch_headers,
+      link_args: game_module_link_args,
+      name_prefix: '', name_suffix: 'wasm',
+      install: false,
+    )
   else
     shared_module(
       game_sp_binary_name,
-      game_sources,
+      game_sp_sources,
       include_directories: game_include_dirs,
       link_with: game_idlib_library,
       cpp_args: game_common_cpp_args,
@@ -50,14 +63,14 @@ if build_games and build_game_mp
   if host_system == 'windows'
     shared_module(
       game_mp_binary_name,
-      game_sources,
+      game_mp_sources,
       include_directories: game_include_dirs,
       link_with: game_idlib_library,
       cpp_args: game_common_cpp_args + ['-DGAME_MPAPI'],
       c_args: game_common_c_args,
       cpp_pch: game_module_pch_headers,
       link_args: game_module_link_args,
-      vs_module_defs: game_module_defs_file,
+      vs_module_defs: mpgame_module_defs_file,
       name_prefix: '',
       install: true,
       install_dir: install_game_dir,
@@ -65,7 +78,7 @@ if build_games and build_game_mp
   elif host_system == 'darwin'
     shared_library(
       game_mp_binary_name,
-      game_sources,
+      game_mp_sources,
       include_directories: game_include_dirs,
       link_with: game_idlib_library,
       cpp_args: game_common_cpp_args + ['-DGAME_MPAPI'],
@@ -77,10 +90,23 @@ if build_games and build_game_mp
       install: true,
       install_dir: install_game_dir,
     )
+  elif host_system == 'emscripten'
+    shared_module(
+      game_mp_binary_name,
+      game_mp_sources,
+      include_directories: game_include_dirs,
+      link_with: game_idlib_library,
+      cpp_args: game_common_cpp_args + ['-DGAME_MPAPI'],
+      c_args: game_common_c_args,
+      cpp_pch: game_module_pch_headers,
+      link_args: game_module_link_args,
+      name_prefix: '', name_suffix: 'wasm',
+      install: false,
+    )
   else
     shared_module(
       game_mp_binary_name,
-      game_sources,
+      game_mp_sources,
       include_directories: game_include_dirs,
       link_with: game_idlib_library,
       cpp_args: game_common_cpp_args + ['-DGAME_MPAPI'],
diff --git a/meson.build b/meson.build
index a2c7b43..1d42698 100644
--- a/meson.build
+++ b/meson.build
@@ -8,7 +8,7 @@ project(
 
 host_system = host_machine.system()
 host_cpu_family = host_machine.cpu_family()
-if not ['windows', 'linux', 'darwin'].contains(host_system)
+if not ['windows', 'linux', 'darwin', 'emscripten'].contains(host_system)
   error('Unsupported host system for openQ4 Meson build: ' + host_system)
 endif
 
@@ -19,6 +19,8 @@ elif host_cpu_family == 'x86'
   binary_arch = 'x86'
 elif host_cpu_family == 'aarch64'
   binary_arch = 'arm64'
+elif host_cpu_family == 'wasm32'
+  binary_arch = 'wasm'
 endif
 game_sp_binary_name = 'game-sp_' + binary_arch
 game_mp_binary_name = 'game-mp_' + binary_arch
@@ -64,6 +66,8 @@ elif host_system == 'darwin'
       + 'Release packages use platform_backend=sdl3; do not treat the native Cocoa/OpenGL backend as supported release coverage.'
     )
   endif
+elif host_system == 'emscripten'
+  message('Configuring the OpenQ4 WebAssembly platform port.')
 elif host_system != 'windows'
   error('Unsupported host system for platform backend selection: ' + host_system)
 endif
@@ -177,9 +181,12 @@ game_libs_source_root = ''
 game_libs_repo_summary = 'not required'
 game_stage_root = ''
 game_stage_game_root = ''
-game_stage_game_include_rel = '.tmp/gamelibs_stage/src/game'
+game_stage_mpgame_root = ''
+game_stage_game_include_rel = 'gamelibs_stage/src/game'
+game_stage_mpgame_include_rel = 'gamelibs_stage/src/mpgame'
 bse_source_root = meson.project_source_root() / 'src' / 'bse'
 game_module_defs_file = ''
+mpgame_module_defs_file = ''
 
 if not build_engine and not build_games
   error('At least one build target must be enabled. Use -Dbuild_engine=true and/or -Dbuild_games=true.')
@@ -188,6 +195,10 @@ endif
 openq4_pak_files = []
 openq4_paks_generated_header = []
 if build_engine or build_games
+  # Directory manifests are intentionally refreshed for desktop developer
+  # builds. On Emscripten they feed the engine PCH, so marking them stale on
+  # every invocation would rebuild the browser client even when unchanged.
+  refresh_pak_manifests = host_system != 'emscripten'
   openq4_pak_script_files = files(
     'tools/build/openq4_pak.py',
   )
@@ -207,7 +218,7 @@ if build_engine or build_games
       '@OUTPUT@',
     ],
     depend_files: openq4_pak_script_files + openq4_pak_manifest_script_files,
-    build_always_stale: true,
+    build_always_stale: refresh_pak_manifests,
     build_by_default: true,
   )
   openq4_pak1_source_manifest = custom_target(
@@ -222,7 +233,7 @@ if build_engine or build_games
       '@OUTPUT@',
     ],
     depend_files: openq4_pak_script_files + openq4_pak_manifest_script_files,
-    build_always_stale: true,
+    build_always_stale: refresh_pak_manifests,
     build_by_default: true,
   )
 
@@ -317,13 +328,17 @@ if needs_game_headers
   if not fs_mod.is_dir(game_libs_source_root / 'game')
     error('openQ4-game source directory not found: ' + game_libs_source_root / 'game')
   endif
+  if not fs_mod.is_dir(game_libs_source_root / 'mpgame')
+    error('openQ4-game MP source directory not found: ' + game_libs_source_root / 'mpgame')
+  endif
 
   game_stage_result = run_command(
     py,
     meson.project_source_root() / 'tools' / 'build' / 'stage_gamelibs.py',
     meson.project_source_root(),
     game_libs_repo_root,
-    meson.project_source_root() / '.tmp' / 'gamelibs_stage',
+    meson.project_build_root() / 'gamelibs_stage',
+    meson.project_build_root(),
     check: true,
   )
   game_stage_root = game_stage_result.stdout().strip()
@@ -332,18 +347,26 @@ if needs_game_headers
   endif
 
   game_stage_game_root = game_stage_root / 'src' / 'game'
+  game_stage_mpgame_root = game_stage_root / 'src' / 'mpgame'
   if not fs_mod.is_dir(game_stage_game_root)
     error('Staged game source directory not found: ' + game_stage_game_root)
   endif
+  if not fs_mod.is_dir(game_stage_mpgame_root)
+    error('Staged MP game source directory not found: ' + game_stage_mpgame_root)
+  endif
   game_stage_manifest = game_stage_root / 'openq4_gamelibs_stage_manifest.json'
   if not fs_mod.is_file(game_stage_manifest)
     error('Staged openQ4-game source manifest not found: ' + game_stage_manifest)
   endif
 
   game_module_defs_file = game_stage_game_root / 'Game.def'
+  mpgame_module_defs_file = game_stage_mpgame_root / 'mpgame.def'
   if host_system == 'windows' and not fs_mod.is_file(game_module_defs_file)
     error('Staged Game.def not found: ' + game_module_defs_file)
   endif
+  if host_system == 'windows' and not fs_mod.is_file(mpgame_module_defs_file)
+    error('Staged mpgame.def not found: ' + mpgame_module_defs_file)
+  endif
 
   game_libs_repo_summary = game_libs_repo_root
 endif
@@ -398,30 +421,54 @@ if build_engine
   openq4_engine_sources += openq4_paks_generated_header
 endif
 
-game_sources = []
+game_sp_sources = []
+game_mp_sources = []
 bse_sources = []
 game_idlib_sources = []
 if build_games
-  game_source_discovery = run_command(
-    py,
-    meson.project_source_root() / 'tools' / 'build' / 'list_sources.py',
-    game_stage_root,
-    'src/game',
-    'src/game/Callbacks.cpp',
-    'src/game/gamesys/Callbacks.cpp',
-    check: true,
-  )
-  game_source_paths = game_source_discovery.stdout().strip().split('\n')
-  if game_source_paths.length() == 0
-    error('tools/build/list_sources.py returned no game source files.')
+  if build_game_sp
+    game_sp_source_discovery = run_command(
+      py,
+      meson.project_source_root() / 'tools' / 'build' / 'list_sources.py',
+      game_stage_root,
+      'src/game',
+      'src/game/Callbacks.cpp',
+      'src/game/gamesys/Callbacks.cpp',
+      check: true,
+    )
+    game_sp_source_paths = game_sp_source_discovery.stdout().strip().split('\n')
+    if game_sp_source_paths.length() == 0
+      error('tools/build/list_sources.py returned no SP game source files.')
+    endif
+
+    game_sp_absolute_paths = []
+    foreach rel_path : game_sp_source_paths
+      game_sp_absolute_paths += [game_stage_root / rel_path]
+    endforeach
+    game_sp_sources = files(game_sp_absolute_paths)
   endif
 
-  game_absolute_paths = []
-  foreach rel_path : game_source_paths
-    game_absolute_paths += [game_stage_root / rel_path]
-  endforeach
+  if build_game_mp
+    game_mp_source_discovery = run_command(
+      py,
+      meson.project_source_root() / 'tools' / 'build' / 'list_sources.py',
+      game_stage_root,
+      'src/mpgame',
+      'src/mpgame/Callbacks.cpp',
+      'src/mpgame/gamesys/Callbacks.cpp',
+      check: true,
+    )
+    game_mp_source_paths = game_mp_source_discovery.stdout().strip().split('\n')
+    if game_mp_source_paths.length() == 0
+      error('tools/build/list_sources.py returned no MP game source files.')
+    endif
 
-  game_sources = files(game_absolute_paths)
+    game_mp_absolute_paths = []
+    foreach rel_path : game_mp_source_paths
+      game_mp_absolute_paths += [game_stage_root / rel_path]
+    endforeach
+    game_mp_sources = files(game_mp_absolute_paths)
+  endif
 
   savegame_compat_header = custom_target(
     'openq4_savegame_compat_generated',
@@ -436,12 +483,17 @@ if build_games
       '--header-out',
       '@OUTPUT@',
     ],
-    depend_files: files(engine_source_paths) + game_sources + files(
+    depend_files: files(engine_source_paths) + game_sp_sources + game_mp_sources + files(
       'tools/build/generate_savegame_compat_header.py',
     ),
     install: false,
   )
-  game_sources += [savegame_compat_header]
+  if build_game_sp
+    game_sp_sources += [savegame_compat_header]
+  endif
+  if build_game_mp
+    game_mp_sources += [savegame_compat_header]
+  endif
   if build_engine
     # Session preflights the exact GameLibs source stamp before tearing down
     # the active map, so the engine target must see the same generated header.
@@ -517,7 +569,11 @@ sdl3_dep = disabler()
 macos_graphics_bridge_dep = disabler()
 deps = []
 if build_engine
-  if host_system == 'linux' and use_sdl3_backend
+  if host_system == 'emscripten'
+    # Browser builds use Emscripten's WebGL entry points directly.  GLEW is a
+    # desktop loader and cannot discover functions in a WebGL context.
+    glew_dep = declare_dependency()
+  elif host_system == 'linux' and use_sdl3_backend
     glew_sp = subproject('glew', default_options: glew_default_options)
     glew_dep = glew_sp.get_variable('glew_dep')
   else
@@ -536,7 +592,9 @@ if build_engine
     default_options: fallback_opt,
   )
 
-  if host_system == 'darwin'
+  if host_system == 'emscripten'
+    openal_dep = declare_dependency(link_args: ['-lopenal'])
+  elif host_system == 'darwin'
     if macos_openal_provider == 'apple_framework'
       openal_dep = dependency('appleframeworks', modules: ['OpenAL'], required: true)
     else
@@ -570,7 +628,13 @@ if build_engine
     endif
   endif
 
-  if use_sdl3_backend
+  if host_system == 'emscripten'
+    # SDL3 is supplied by the Emscripten ports tree rather than pkg-config.
+    sdl3_dep = declare_dependency(
+      compile_args: ['-sUSE_SDL=3'],
+      link_args: ['-sUSE_SDL=3', '-lwebsocket'],
+    )
+  elif use_sdl3_backend
     sdl3_dep = dependency(
       'sdl3',
       version: '>=3.4.4',
@@ -639,6 +703,8 @@ if build_engine
     if use_macos_metal_bridge
       deps += [macos_graphics_bridge_dep]
     endif
+  elif host_system == 'emscripten'
+    deps += [sdl3_dep]
   endif
 endif
 
@@ -650,6 +716,35 @@ engine_link_args = []
 linux_executable_pie = false
 linux_module_link_hardening_args = []
 
+if host_system == 'emscripten'
+  # OpenQ4 uses idException for recoverable frame drops. Emscripten otherwise
+  # compiles catch blocks out and aborts on the first post-load exception.
+  shared_cpp_args += ['-fexceptions']
+  engine_link_args += [
+    '-sMAIN_MODULE=1', '-sEXPORT_ALL=1',
+    '-sAUTOLOAD_DYLIBS=0',
+    '-sDISABLE_EXCEPTION_CATCHING=0',
+    '-sWASMFS',
+    '-lopfs.js',
+    '-sASYNCIFY',
+    '-sASYNCIFY_STACK_SIZE=8388608',
+    '-sALLOW_MEMORY_GROWTH=1',
+    '-sINITIAL_MEMORY=268435456',
+    '-sMAXIMUM_MEMORY=2147483648',
+    '-sSTACK_SIZE=8388608',
+    '-sFORCE_FILESYSTEM=1',
+    '-sEXIT_RUNTIME=0',
+    '-sENVIRONMENT=web',
+    '-sMODULARIZE=1',
+    '-sEXPORT_ES6=1',
+    '-sEXPORT_NAME=createOpenQ4',
+    '-sEXPORTED_RUNTIME_METHODS=ccall,FS_createPath,FS_createDataFile,addRunDependency,removeRunDependency,requestFullscreen',
+    '-sFULL_ES3=1',
+    '-sMIN_WEBGL_VERSION=2',
+    '-sMAX_WEBGL_VERSION=2',
+  ]
+endif
+
 if host_system == 'windows'
   shared_cpp_args += ['-D_WINDOWS', '-DWIN32']
   shared_c_args += ['-D_WINDOWS', '-DWIN32']
@@ -788,9 +883,11 @@ bse_client_include_dirs = [root_include_dir, src_include_dir, src_bse_include_di
 
 if needs_game_headers
   staged_game_include_dir = include_directories(game_stage_game_include_rel)
-  engine_include_dirs += [staged_game_include_dir]
-  game_idlib_include_dirs += [staged_game_include_dir]
-  game_include_dirs += [staged_game_include_dir]
+  staged_mpgame_include_dir = include_directories(game_stage_mpgame_include_rel)
+  staged_game_include_dirs = [staged_game_include_dir, staged_mpgame_include_dir]
+  engine_include_dirs += staged_game_include_dirs
+  game_idlib_include_dirs += staged_game_include_dirs
+  game_include_dirs += staged_game_include_dirs
   bse_client_include_dirs += [staged_game_include_dir]
 endif
 
@@ -802,6 +899,25 @@ bse_cpp_args = shared_cpp_args
 bse_c_args = shared_c_args
 bse_library = disabler()
 
+if host_system == 'emscripten'
+  engine_cpp_args += ['-sMAIN_MODULE=1', build_game_sp ? '-DOPENQ4_WEB_GAME_SP' : '-DOPENQ4_WEB_GAME_MP']
+  engine_c_args += ['-sMAIN_MODULE=1']
+  bse_cpp_args += ['-sMAIN_MODULE=1']
+  bse_c_args += ['-sMAIN_MODULE=1']
+endif
+
+if host_system == 'emscripten' and build_games
+  if (build_game_sp and build_game_mp) or (not build_game_sp and not build_game_mp)
+    error('Each Emscripten build directory must select exactly one GameLib: build_game_sp or build_game_mp.')
+  endif
+endif
+
+if build_engine and host_system == 'emscripten'
+  if not build_games
+    error('The Emscripten client requires build_games=true and exactly one OpenQ4 GameLib side module.')
+  endif
+endif
+
 if build_engine
   bse_library = static_library(
     'openq4_bse',
@@ -810,7 +926,7 @@ if build_engine
     cpp_args: bse_cpp_args,
     c_args: bse_c_args,
     cpp_pch: pch_headers,
-    pic: host_system == 'linux',
+    pic: host_system == 'linux' or host_system == 'emscripten',
     install: false,
   )
 endif
@@ -945,6 +1061,12 @@ elif host_system == 'darwin'
   endforeach
 elif host_system == 'linux'
   game_module_link_args += linux_module_link_hardening_args
+elif host_system == 'emscripten'
+  game_module_link_args += [
+    '-sSIDE_MODULE=1',
+    '-sDISABLE_EXCEPTION_CATCHING=0',
+    '-sASYNCIFY',
+  ]
 endif
 
 game_idlib_library = disabler()
@@ -956,7 +1078,7 @@ if build_engine or build_games
     cpp_args: game_common_cpp_args,
     c_args: game_common_c_args,
     cpp_pch: game_idlib_pch_headers,
-    pic: host_system == 'linux',
+    pic: host_system == 'linux' or host_system == 'emscripten',
     install: false,
   )
 endif
diff --git a/src/framework/Common.cpp b/src/framework/Common.cpp
index 37d66d5..8a348f6 100644
--- a/src/framework/Common.cpp
+++ b/src/framework/Common.cpp
@@ -5166,19 +5166,31 @@ static bool openQ4_IsValidGameModuleName( const char *moduleName ) {
 static const char *openQ4_SelectGameModuleBaseName( void ) {
+#if defined( __EMSCRIPTEN__ )
+#if defined( OPENQ4_WEB_GAME_SP ) && !defined( OPENQ4_WEB_GAME_MP )
+	return "game_sp";
+#elif defined( OPENQ4_WEB_GAME_MP ) && !defined( OPENQ4_WEB_GAME_SP )
+	return "game_mp";
+#else
+	#error "Emscripten builds must select exactly one Web GameLib variant"
+#endif
+#else
 	const char *nextModule = cvarSystem->GetCVarString( "com_nextGameModule" );
 	if ( openQ4_IsValidGameModuleName( nextModule ) ) {
 		return idStr::Icmp( nextModule, "game_mp" ) == 0 ? "game_mp" : "game_sp";
 	}
 
 	const char *gameType = cvarSystem->GetCVarString( "si_gameType" );
 	return openQ4_IsMultiplayerGameType( gameType ) ? "game_mp" : "game_sp";
+#endif
 }
 
 #if defined( _M_X64 ) || defined( __x86_64__ )
 	#define OPENQ4_MODULE_ARCH_TAG "x64"
 #elif defined( _M_IX86 ) || defined( __i386__ )
 	#define OPENQ4_MODULE_ARCH_TAG "x86"
 #elif defined( _M_ARM64 ) || defined( __aarch64__ )
 	#define OPENQ4_MODULE_ARCH_TAG "arm64"
+#elif defined( __EMSCRIPTEN__ )
+	#define OPENQ4_MODULE_ARCH_TAG "wasm"
 #else
 	#define OPENQ4_MODULE_ARCH_TAG "unknown"
 #endif
@@ -5251,7 +5263,6 @@ void idCommonLocal::LoadGameDLL( void ) {
 	gameImport_t	gameImport;
 	gameExport_t	gameExport;
 	GetGameAPI_t	GetGameAPI;
-
 	const char *gameModuleBaseName = openQ4_SelectGameModuleBaseName();
 	openQ4_BuildGameModuleBinaryName( gameModuleBaseName, preferredGameModuleBinary );
 	selectedModuleBinary = preferredGameModuleBinary;
@@ -5305,2 +5317,9 @@ void idCommonLocal::LoadGameDLL( void ) {
+	if ( gameExport.game == NULL || gameExport.gameEdit == NULL ) {
+		Sys_DLL_Unload( gameDLL );
+		gameDLL = NULL;
+		common->FatalError( "game DLL '%s' returned null interfaces", selectedModuleBinary );
+		return;
+	}
+
 	game								= gameExport.game;
 	gameEdit							= gameExport.gameEdit;
diff --git a/src/framework/FileSystem.cpp b/src/framework/FileSystem.cpp
index 492dce8..113e41e 100644
--- a/src/framework/FileSystem.cpp
+++ b/src/framework/FileSystem.cpp
@@ -5471,6 +5471,11 @@ void idFileSystemLocal::Init( void ) {
 	// line variable sets don't happen until after the filesystem
 	// has already been initialized
 	common->StartupVariable( "fs_basepath", false );
+#ifdef __EMSCRIPTEN__
+	// Browser builds keep the self-authored CC0 Q4 Web runtime in a shared OPFS
+	// root while each user-selected retail revision has its own fs_basepath.
+	common->StartupVariable( "fs_cdpath", false );
+#endif
 	common->StartupVariable( "fs_homepath", false );
 	common->StartupVariable( "fs_savepath", false );
 	common->StartupVariable( "fs_game", false );
@@ -5520,8 +5525,15 @@ void idFileSystemLocal::Init( void ) {
 	if ( fs_savepath.GetString()[0] == '\0' ) {
 		fs_savepath.SetString( fs_homepath.GetString() );
 	}
-	// fs_cdpath is locked to the process current directory.
+	// Native packages keep runtime files adjacent to the executable. Browser
+	// packages may point fs_cdpath at a versioned, shared OPFS runtime root.
+#ifdef __EMSCRIPTEN__
+	if ( fs_cdpath.GetString()[0] == '\0' ) {
+		fs_cdpath.SetString( Sys_DefaultCDPath() );
+	}
+#else
 	fs_cdpath.SetString( Sys_DefaultCDPath() );
+#endif
 
 	// try to start up normally
 	Startup( );
@@ -6589,8 +6601,15 @@ void idFileSystemLocal::FindDLL( const char *name, char _dllPath[ MAX_OSPATH ],
 
 	sys->DLL_GetFileName( name, dllName, MAX_OSPATH );
 
-	idStr exeDir = Sys_EXEPath();
+	idStr exeDir;
+#ifdef __EMSCRIPTEN__
+	// Browser GameLib side modules are staged explicitly at /baseoq4.  Do not
+	// depend on the unavailable /proc/<pid>/exe path or the process cwd.
+	exeDir = "/";
+#else
+	exeDir = Sys_EXEPath();
 	exeDir.StripFilename();
+#endif
 
 	// Only load openQ4 game modules staged next to the executable or in the
 	// package root the application bundle was extracted into (macOS stages the
diff --git a/src/framework/async/AsyncNetwork.cpp b/src/framework/async/AsyncNetwork.cpp
index cf48a12..e02701d 100644
--- a/src/framework/async/AsyncNetwork.cpp
+++ b/src/framework/async/AsyncNetwork.cpp
@@ -65,6 +65,7 @@ idCVar				idAsyncNetwork::master4( "net_master4", "", CVAR_SYSTEM | CVAR_ARCHIVE
 idCVar				idAsyncNetwork::LANServer( "net_LANServer", "0", CVAR_SYSTEM | CVAR_BOOL | CVAR_NOCHEAT, "config LAN games only - affects clients and servers" );
 idCVar				idAsyncNetwork::serverReloadEngine( "net_serverReloadEngine", "0", CVAR_SYSTEM | CVAR_INTEGER | CVAR_NOCHEAT, "perform a full reload on next map restart (including flushing referenced pak files) - decreased if > 0" );
 idCVar				idAsyncNetwork::serverAllowServerMod( "net_serverAllowServerMod", "0", CVAR_SYSTEM | CVAR_BOOL | CVAR_NOCHEAT, "allow server-side mods" );
+idCVar				idAsyncNetwork::serverAllowDataChecksumMismatch( "net_serverAllowDataChecksumMismatch", "0", CVAR_SYSTEM | CVAR_BOOL | CVAR_NOCHEAT, "allow non-pure clients whose declaration checksum differs from the server" );
 idCVar				idAsyncNetwork::idleServer( "si_idleServer", "0", CVAR_SYSTEM | CVAR_BOOL | CVAR_INIT | CVAR_SERVERINFO, "game clients are idle" );
 idCVar				idAsyncNetwork::clientDownload( "net_clientDownload", "1", CVAR_SYSTEM | CVAR_INTEGER | CVAR_ARCHIVE, "client pk4 downloads policy: 0 - never, 1 - ask, 2 - always (will still prompt for binary code)" );
 idCVar				idAsyncNetwork::serverDemo( "net_serverDemo", "1", CVAR_SYSTEM | CVAR_BOOL | CVAR_ARCHIVE, "automatically record complete multiplayer server demos" );
diff --git a/src/framework/async/AsyncNetwork.h b/src/framework/async/AsyncNetwork.h
index c488349..42a4c2d 100644
--- a/src/framework/async/AsyncNetwork.h
+++ b/src/framework/async/AsyncNetwork.h
@@ -195,6 +195,7 @@ public:
 	static idCVar			LANServer;						// LAN mode
 	static idCVar			serverReloadEngine;				// reload engine on map change instead of growing the referenced paks
 	static idCVar			serverAllowServerMod;			// let a pure server start with a different game code than what is referenced in game code
+	static idCVar			serverAllowDataChecksumMismatch;	// allow mismatched declaration data on explicitly configured non-pure servers
 	static idCVar			idleServer;						// serverinfo reply, indicates all clients are idle
 	static idCVar			clientDownload;					// preferred download policy
 	static idCVar			serverDemo;						// automatically record server demos
diff --git a/src/framework/async/AsyncServer.cpp b/src/framework/async/AsyncServer.cpp
index 2c0ad4b..7480776 100644
--- a/src/framework/async/AsyncServer.cpp
+++ b/src/framework/async/AsyncServer.cpp
@@ -1101,6 +1101,27 @@ int idAsyncServer::GetNumClients( void ) const {
 	return ret;
 }
 
+int idAsyncServer::GetNumActiveClients( void ) const {
+	int count = 0;
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
+		if ( clients[i].clientState >= SCS_PUREWAIT ) {
+			count++;
+		}
+	}
+	return count;
+}
+
+int idAsyncServer::GetNumBotClients( void ) const {
+	int count = 0;
+	for ( int i = 0; i < MAX_ASYNC_CLIENTS; i++ ) {
+		if ( clients[i].clientState >= SCS_PUREWAIT &&
+			clients[i].channel.GetRemoteAddress().type == NA_BOT ) {
+			count++;
+		}
+	}
+	return count;
+}
+
 /*
 ==================
 idAsyncServer::GetNumIdleClients
@@ -2277,6 +2298,7 @@ void idAsyncServer::ProcessConnectMessage( const netadr_t from, const idBitMsg &
 	char		guid[ 12 ];
 	char		password[ 17 ];
 	int			i, ichallenge, islot, OS, numClients;
+	int			botClientNum = -1;
 
 	protocol = msg.ReadLong();
 	OS = msg.ReadShort();
@@ -2295,8 +2317,13 @@ void idAsyncServer::ProcessConnectMessage( const netadr_t from, const idBitMsg &
 
 	// check the client data - only for non pure servers
 	if ( !sessLocal.mapSpawnData.serverInfo.GetInt( "si_pure" ) && clientDataChecksum != serverDataChecksum ) {
-		PrintOOB( from, SERVER_PRINT_MISC, "#str_04842" );
-		return;
+		if ( !idAsyncNetwork::serverAllowDataChecksumMismatch.GetBool() ) {
+			PrintOOB( from, SERVER_PRINT_MISC, "#str_04842" );
+			return;
+		}
+		common->Warning(
+			"accepting non-pure client %s with mismatched data checksum (client 0x%08x, server 0x%08x)",
+			Sys_NetAdrToString( from ), clientDataChecksum, serverDataChecksum );
 	}
 
 	if ( ( ichallenge = ValidateChallenge( from, challenge, clientId ) ) == -1 ) {
@@ -2323,8 +2350,16 @@ void idAsyncServer::ProcessConnectMessage( const netadr_t from, const idBitMsg &
 		serverClient_t &client = clients[ i ];
 		if ( client.clientState >= SCS_PUREWAIT ) {
 			numClients++;
+			if ( client.channel.GetRemoteAddress().type == NA_BOT ) {
+				botClientNum = i;
+			}
 		}
 	}
+	const int maxPlayers = sessLocal.mapSpawnData.serverInfo.GetInt( "si_maxPlayers" );
+	const int numClientsForAdmission =
+		( !IsServerDemoPlaying() && botClientNum >= 0 && numClients >= maxPlayers )
+			? numClients - 1
+			: numClients;
 
 	// game may be passworded, client banned by IP or GUID
 	// if authState == CDK_PUREOK, the check was already performed once before entering pure checks
@@ -2332,8 +2367,8 @@ void idAsyncServer::ProcessConnectMessage( const netadr_t from, const idBitMsg &
 	msg.ReadString( password, sizeof( password ) );
 	char reason[MAX_STRING_CHARS];
 	allowReply_t reply = IsServerDemoPlaying()
-		? game->RepeaterAllowClient( clientId, numClients, Sys_NetAdrToString( from ), guid, true, password, password, reason )
-		: game->ServerAllowClient( clientId, numClients, Sys_NetAdrToString( from ), guid, password, password, reason );
+		? game->RepeaterAllowClient( clientId, numClientsForAdmission, Sys_NetAdrToString( from ), guid, true, password, password, reason )
+		: game->ServerAllowClient( clientId, numClientsForAdmission, Sys_NetAdrToString( from ), guid, password, password, reason );
 	if ( reply != ALLOW_YES ) {
 		common->DPrintf( "game denied connection for %s\n", Sys_NetAdrToString( from ) );
 
@@ -2363,6 +2398,14 @@ void idAsyncServer::ProcessConnectMessage( const netadr_t from, const idBitMsg &
 		return;
 	}
 
+	if ( !IsServerDemoPlaying() && botClientNum >= 0 && numClients >= maxPlayers ) {
+		common->Printf( "bot_minplayers: freeing bot client %d for human %s\n",
+			botClientNum, Sys_NetAdrToString( from ) );
+		DropClient( botClientNum, "was removed to make room for a player" );
+		ClearClient( botClientNum );
+		numClients--;
+	}
+
 	ping = serverTime - challenges[ ichallenge ].pingTime;
 	common->Printf( "challenge from %s connecting with %d ping\n", Sys_NetAdrToString( from ), ping );
 	challenges[ ichallenge ].connected = true;
@@ -3569,4 +3612,21 @@ int idAsyncServer::AllocOpenClientSlotForAI(const char* botName, int maxPlayersO
 
 	return botClientId;
 }
+
+/*
+===============
+idAsyncServer::RemoveClientSlotForAI
+===============
+*/
+int idAsyncServer::RemoveClientSlotForAI(const char* reason) {
+	for (int i = MAX_ASYNC_CLIENTS - 1; i >= 0; i--) {
+		if (clients[i].clientState >= SCS_PUREWAIT &&
+			clients[i].channel.GetRemoteAddress().type == NA_BOT) {
+			DropClient(i, reason && reason[0] ? reason : "was removed by bot_minplayers");
+			ClearClient(i);
+			return i;
+		}
+	}
+	return -1;
+}
 // jmarshall end
diff --git a/src/framework/async/AsyncServer.h b/src/framework/async/AsyncServer.h
index fff6fff..0e7ef6b 100644
--- a/src/framework/async/AsyncServer.h
+++ b/src/framework/async/AsyncServer.h
@@ -155,6 +155,8 @@ public:
 	float				GetClientIncomingCompression( int clientNum ) const;
 	float				GetClientIncomingPacketLoss( int clientNum ) const;
 	int					GetNumClients( void ) const;
+	int					GetNumActiveClients( void ) const;
+	int					GetNumBotClients( void ) const;
 	int					GetNumIdleClients( void ) const;
 	int					GetLocalClientNum( void ) const { return localClientNum; }
 
@@ -191,6 +193,7 @@ public:
 
 // jmarshall
 	int					AllocOpenClientSlotForAI(const char* botName, int maxPlayersOnServer);
+	int					RemoveClientSlotForAI(const char* reason);
 	int 				ServerSetBotUserCommand(int clientNum, int frameNum, const usercmd_t& cmd);
 // jmarshall end
 
diff --git a/src/framework/async/NetworkSystem.cpp b/src/framework/async/NetworkSystem.cpp
index 425d1fa..c13f00f 100644
--- a/src/framework/async/NetworkSystem.cpp
+++ b/src/framework/async/NetworkSystem.cpp
@@ -288,6 +288,23 @@ int idNetworkSystem::AllocateClientSlotForBot(const char* botName, int maxPlayer
 	return idAsyncNetwork::server.AllocOpenClientSlotForAI(botName, maxPlayersOnServer);
 }
 
+/*
+==================
+idNetworkSystem::RemoveClientSlotForBot
+==================
+*/
+int idNetworkSystem::RemoveClientSlotForBot(const char* reason) {
+	return idAsyncNetwork::server.RemoveClientSlotForAI(reason);
+}
+
+int idNetworkSystem::ServerGetNumActiveClients(void) {
+	return idAsyncNetwork::server.GetNumActiveClients();
+}
+
+int idNetworkSystem::ServerGetNumBotClients(void) {
+	return idAsyncNetwork::server.GetNumBotClients();
+}
+
 /*
 ==================
 idNetworkSystem::ServerSetBotUserCommand
diff --git a/src/framework/async/NetworkSystem.h b/src/framework/async/NetworkSystem.h
index fc77af2..91e4bb3 100644
--- a/src/framework/async/NetworkSystem.h
+++ b/src/framework/async/NetworkSystem.h
@@ -92,6 +92,9 @@ public:
 
 // jmarshall
 	virtual int				AllocateClientSlotForBot(const char *botName, int maxPlayersOnServer);
+	virtual int				RemoveClientSlotForBot(const char *reason);
+	virtual int				ServerGetNumActiveClients(void);
+	virtual int				ServerGetNumBotClients(void);
 	virtual int				ServerSetBotUserCommand(int clientNum, int frameNum, const usercmd_t& cmd);
 	virtual int				ServerSetBotUserName(int clientNum, const char* playerName);
 // jmarshall end
diff --git a/src/idlib/precompiled.h b/src/idlib/precompiled.h
index 5fc218d..dde8816 100644
--- a/src/idlib/precompiled.h
+++ b/src/idlib/precompiled.h
@@ -133,6 +133,39 @@ public:
 
 #endif // _WINDOWS
 
+#ifdef __EMSCRIPTEN__
+
+#include <stddef.h>
+#include <limits.h>
+#include <float.h>
+
+	#undef WIN32
+	#undef _XBOX
+	#undef _CONSOLE
+	#define _OPENGL
+	#define _LITTLE_ENDIAN
+	#define _CASE_SENSITIVE_FILESYSTEM
+	#ifndef Q4SDK
+		#define Q4SDK_MD5R
+	#endif
+	#define NEWLINE "\n"
+	#define _GLVAS_SUPPPORT
+	#define RESTRICT
+	#define TIME_THIS_SCOPE(x)
+
+	#ifndef ID_ALIGNMENTCHECKER_DEFINED
+	#define ID_ALIGNMENTCHECKER_DEFINED
+class AlignmentChecker
+{
+public:
+	static void UpdateCount(void const * const ptr) {}
+	static void ClearCount() {}
+	static void Print() {}
+};
+	#endif
+
+#endif
+
 #ifdef __linux__
 
 // for offsetof
@@ -404,8 +437,12 @@ const float MAX_BOUND_SIZE = 65536.0f;
 //-----------------------------------------------------
 
 #ifdef GAME_DLL
+#ifdef GAME_MPAPI
+#include "../mpgame/Game_local.h"
+#else
 #include "../game/Game_local.h"
 #endif
+#endif
 
 #ifndef Q4SDK
 
@@ -448,4 +485,3 @@ const float MAX_BOUND_SIZE = 65536.0f;
 #endif	/* __cplusplus */
 
 #endif /* !__PRECOMPILED_H__ */
-
diff --git a/src/renderer/ImageManager.cpp b/src/renderer/ImageManager.cpp
index 677c2e6..3219827 100644
--- a/src/renderer/ImageManager.cpp
+++ b/src/renderer/ImageManager.cpp
@@ -849,12 +849,14 @@ void idImageManager::BindNull() {
 	tmu = &backEnd.glState.tmu[backEnd.glState.currenttmu];
 
 	RB_LogComment("BindNull()\n");
+	#if !defined(__EMSCRIPTEN__)
 	if (tmu->textureType == TT_CUBIC) {
 		glDisable(GL_TEXTURE_CUBE_MAP_EXT);
 	}
 	else if (tmu->textureType == TT_2D) {
 		glDisable(GL_TEXTURE_2D);
 	}
+	#endif
 	tmu->textureType = TT_DISABLED;
 
 }
diff --git a/src/renderer/Image_load.cpp b/src/renderer/Image_load.cpp
index 3807adf..fffeadc 100644
--- a/src/renderer/Image_load.cpp
+++ b/src/renderer/Image_load.cpp
@@ -771,6 +771,7 @@ void idImage::Bind() {
 
 	// enable or disable apropriate texture modes
 	if (tmu->textureType != opts.textureType && (backEnd.glState.currenttmu < glConfig.maxTextureUnits)) {
+		#if !defined(__EMSCRIPTEN__)
 		if (tmu->textureType == TT_CUBIC) {
 			glDisable(GL_TEXTURE_CUBE_MAP_EXT);
 		}
@@ -784,6 +785,7 @@ void idImage::Bind() {
 		else if (opts.textureType == TT_2D) {
 			glEnable(GL_TEXTURE_2D);
 		}
+		#endif
 		tmu->textureType = opts.textureType;
 	}
 
@@ -1511,4 +1513,3 @@ void idImage::SetSamplerState( textureFilter_t tf, textureRepeat_t tr ) {
 	R_BindTextureForDirectAccess( ( opts.textureType == TT_CUBIC ) ? GL_TEXTURE_CUBE_MAP_EXT : GL_TEXTURE_2D, texnum );
 	SetTexParameters();
 }
-
diff --git a/src/renderer/RenderSystem.cpp b/src/renderer/RenderSystem.cpp
index ece419b..ff05449 100644
--- a/src/renderer/RenderSystem.cpp
+++ b/src/renderer/RenderSystem.cpp
@@ -48,6 +48,8 @@ static const char *R_GetBackEndRendererName( backEndName_t renderer ) {
 	switch ( renderer ) {
 		case BE_ARB2:
 			return "ARB2";
+		case BE_WEBGL2:
+			return "WebGL2";
 		default:
 			return "BAD";
 	}
@@ -81,6 +83,12 @@ static backEndName_t R_RequestBackEndRenderer( const char *rendererName ) {
 		return BE_BAD;
 	}
 
+	#if defined(__EMSCRIPTEN__)
+	if ( idStr::Icmp( rendererName, "webgl2" ) == 0 || idStr::Icmp( rendererName, "arb2" ) == 0 ) {
+		return BE_WEBGL2;
+	}
+	#endif
+
 	if ( idStr::Icmp( rendererName, "arb2" ) == 0 ) {
 		return glConfig.allowARB2Path ? BE_ARB2 : BE_BAD;
 	}
@@ -94,6 +102,9 @@ R_PickBestBackEndRenderer
 ===============================
 */
 static backEndName_t R_PickBestBackEndRenderer() {
+	#if defined(__EMSCRIPTEN__)
+	return BE_WEBGL2;
+	#endif
 	if ( glConfig.allowARB2Path ) {
 		return BE_ARB2;
 	}
@@ -1030,7 +1041,11 @@ void idRenderSystemLocal::SetBackEndRenderer() {
 		// idRenderSystemLocal::Init() still calls this before OpenGL capability
 		// probing has run. Preserve the legacy optimistic ARB2 placeholder there
 		// and defer the real request/fallback resolution until R_InitOpenGL().
+		#if defined(__EMSCRIPTEN__)
+		backEndRenderer = BE_WEBGL2;
+		#else
 		backEndRenderer = BE_ARB2;
+		#endif
 		backEndRendererHasVertexPrograms = true;
 		backEndRendererMaxLight = 999.0f;
 		return;
@@ -1050,6 +1065,11 @@ void idRenderSystemLocal::SetBackEndRenderer() {
 	backEndRendererMaxLight = 1.0;
 
 	switch( backEndRenderer ) {
+	case BE_WEBGL2:
+		common->Printf( "using WebGL2 renderSystem\n" );
+		backEndRendererHasVertexPrograms = true;
+		backEndRendererMaxLight = 999;
+		break;
 	case BE_ARB2:
 		common->Printf( "using ARB2 renderSystem\n" );
 		backEndRendererHasVertexPrograms = true;
diff --git a/src/renderer/RenderSystem_init.cpp b/src/renderer/RenderSystem_init.cpp
index 5faadbb..00ab26a 100644
--- a/src/renderer/RenderSystem_init.cpp
+++ b/src/renderer/RenderSystem_init.cpp
@@ -30,6 +30,10 @@ If you have questions concerning this license or the applicable additional terms
 
 
 #include "tr_local.h"
+#if defined(__EMSCRIPTEN__)
+#include "webgl2/WebGL2Backend.h"
+#include <emscripten/html5_webgl.h>
+#endif
 #include "DXT/DXTCodec.h"
 #include "RendererBootstrap.h"
 #include "GLDebugScope.h"
@@ -927,6 +931,9 @@ R_CheckPortableExtensions
 ==================
 */
 static void R_CheckPortableExtensions( void ) {
+	#if defined(__EMSCRIPTEN__)
+	common->FatalError( "R_CheckPortableExtensions is a desktop-only GLEW probe" );
+	#else
 	R_RecordRendererStartupPhase( RENDERER_STARTUP_PHASE_R_CHECK_PORTABLE_EXTENSIONS );
 	glConfig.glVersion = atof( glConfig.version_string );
 	R_ClearMissingRequiredOpenGLFeatures();
@@ -1208,6 +1215,7 @@ static void R_CheckPortableExtensions( void ) {
 			r_glTier.GetString(),
 			RendererTier_Name( glConfig.rendererTier ) );
 	}
+	#endif
 
 }
 
@@ -1549,6 +1557,108 @@ static void R_NormalizeDisplayCvars( void ) {
 	}
 }
 
+#if defined(__EMSCRIPTEN__)
+/*
+========================
+R_CheckWebGL2Capabilities
+
+WebGL owns function resolution, so the browser path records the GLES3 core
+contract directly instead of running the desktop GLEW/ARB capability probe.
+========================
+*/
+static void R_CheckWebGL2Capabilities( void ) {
+	R_RecordRendererStartupPhase( RENDERER_STARTUP_PHASE_R_CHECK_PORTABLE_EXTENSIONS );
+	memset( &glConfig.backendCaps, 0, sizeof( glConfig.backendCaps ) );
+	memset( &glConfig.renderFeatures, 0, sizeof( glConfig.renderFeatures ) );
+
+	const EMSCRIPTEN_WEBGL_CONTEXT_HANDLE context = emscripten_webgl_get_current_context();
+	const bool s3tc = context > 0 && emscripten_webgl_enable_extension( context, "WEBGL_compressed_texture_s3tc" ) == EM_TRUE;
+	const bool anisotropic = context > 0 && emscripten_webgl_enable_extension( context, "EXT_texture_filter_anisotropic" ) == EM_TRUE;
+	const bool colorBufferFloat = context > 0 && emscripten_webgl_enable_extension( context, "EXT_color_buffer_float" ) == EM_TRUE;
+
+	glConfig.glVersion = 3.0f;
+	glGetIntegerv( GL_MAX_TEXTURE_IMAGE_UNITS, &glConfig.maxTextureImageUnits );
+	glConfig.maxTextureImageUnits = Min( glConfig.maxTextureImageUnits, MAX_MULTITEXTURE_UNITS );
+	glConfig.maxTextureUnits = glConfig.maxTextureImageUnits;
+	glConfig.maxTextureCoords = glConfig.maxTextureImageUnits;
+	glGetIntegerv( GL_MAX_DRAW_BUFFERS, &glConfig.maxDrawBuffers );
+	glGetIntegerv( GL_MAX_COLOR_ATTACHMENTS, &glConfig.maxColorAttachments );
+
+	glConfig.multitextureAvailable = glConfig.maxTextureImageUnits >= 5;
+	glConfig.anisotropicAvailable = anisotropic;
+	glConfig.maxTextureAnisotropy = 1.0f;
+	if ( anisotropic ) {
+		glGetFloatv( GL_MAX_TEXTURE_MAX_ANISOTROPY_EXT, &glConfig.maxTextureAnisotropy );
+	}
+	glConfig.textureLODBiasAvailable = false;
+	glConfig.textureEnvAddAvailable = false;
+	glConfig.textureEnvCombineAvailable = false;
+	glConfig.blendSquareAvailable = true;
+	glConfig.registerCombinersAvailable = false;
+	glConfig.cubeMapAvailable = true;
+	glConfig.envDot3Available = false;
+	glConfig.texture3DAvailable = true;
+	glConfig.sharedTexturePaletteAvailable = false;
+	glConfig.textureCompressionAvailable = s3tc;
+	glConfig.bptcTextureCompressionAvailable = false;
+	glConfig.drawRangeElementsAvailable = true;
+	glConfig.blendMinMaxAvailable = true;
+	glConfig.floatBufferAvailable = colorBufferFloat;
+	glConfig.textureSRGBAvailable = true;
+	glConfig.framebufferSRGBAvailable = false;
+	glConfig.ARBVertexBufferObjectAvailable = true;
+	glConfig.pixelBufferObjectAvailable = true;
+	// WebGL 2 rejects client-memory vertex arrays.  Keep the legacy cache on
+	// GLES buffer objects even if a desktop-oriented config disabled VBOs.
+	r_useVertexBuffers.SetBool( true );
+	r_useIndexBuffers.SetBool( true );
+	glConfig.ARBVertexProgramAvailable = false;
+	glConfig.ARBFragmentProgramAvailable = false;
+	glConfig.twoSidedStencilAvailable = true;
+	glConfig.atiTwoSidedStencilAvailable = false;
+	glConfig.textureNonPowerOfTwoAvailable = true;
+	glConfig.depthBoundsTestAvailable = false;
+	glConfig.GLSLProgramAvailable = true;
+	glConfig.GLSL130Available = true;
+	glConfig.nvProgramsAvailable = false;
+	glConfig.atiFragmentShaderAvailable = false;
+	glConfig.allowNV20Path = false;
+	glConfig.allowNV10Path = false;
+	glConfig.allowR200Path = false;
+	glConfig.allowARB2Path = false;
+	glConfig.rendererTier = RENDERER_TIER_LEGACY_GL2_COMPAT;
+
+	tr.stencilIncr = GL_INCR_WRAP;
+	tr.stencilDecr = GL_DECR_WRAP;
+	glConfig.backendCaps.contextCreated = true;
+	glConfig.backendCaps.glMajor = 3;
+	glConfig.backendCaps.glMinor = 0;
+	glConfig.backendCaps.glVersion = 3.0f;
+	glConfig.backendCaps.profile = RENDERER_CONTEXT_PROFILE_CORE;
+	glConfig.backendCaps.hasGLSL = true;
+	glConfig.backendCaps.hasVBO = true;
+	glConfig.backendCaps.hasPBO = true;
+	glConfig.backendCaps.hasFBO = true;
+	glConfig.backendCaps.hasMRT = glConfig.maxDrawBuffers > 1;
+	glConfig.backendCaps.hasSRGBTextures = true;
+	glConfig.backendCaps.hasUBO = true;
+	glConfig.backendCaps.hasVAO = true;
+	glConfig.backendCaps.hasInstancing = true;
+	glConfig.backendCaps.hasTextureArrays = true;
+	glConfig.backendCaps.hasSync = true;
+	glConfig.backendCaps.hasMapBufferRange = true;
+	glConfig.backendCaps.maxTextureSize = glConfig.maxTextureSize;
+	glConfig.backendCaps.maxTextureUnits = glConfig.maxTextureUnits;
+	glConfig.backendCaps.maxTextureCoords = glConfig.maxTextureCoords;
+	glConfig.backendCaps.maxTextureImageUnits = glConfig.maxTextureImageUnits;
+	glConfig.backendCaps.maxDrawBuffers = glConfig.maxDrawBuffers;
+	glConfig.backendCaps.maxColorAttachments = glConfig.maxColorAttachments;
+
+	common->Printf( "WebGL2 capabilities: %d texture units, %d draw buffers, S3TC %s\n",
+		glConfig.maxTextureImageUnits, glConfig.maxDrawBuffers, s3tc ? "enabled" : "unavailable" );
+}
+#endif
+
 
 /*
 ==================
@@ -1706,6 +1816,12 @@ void R_InitOpenGL( void ) {
 	glConfig.isInitialized = true;
 
 	// recheck all the extensions (FIXME: this might be dangerous)
+	#if defined(__EMSCRIPTEN__)
+	R_CheckWebGL2Capabilities();
+	if ( !R_WebGL2_Init() ) {
+		common->FatalError( "WebGL2 renderer failed its GLSL ES 3.00 startup smoke test" );
+	}
+	#else
 	R_CheckPortableExtensions();
 	R_GLDebugOutput_Init();
 
@@ -1728,6 +1844,7 @@ void R_InitOpenGL( void ) {
 	R_GLDebugOutput_FlushMessages();
 
 	R_RendererUpload_Init( glConfig.backendCaps );
+	#endif
 
 	// allocate the vertex array range or vertex objects
 	R_RecordRendererStartupPhase( RENDERER_STARTUP_PHASE_VERTEX_CACHE_INIT );
@@ -3714,6 +3831,9 @@ static void R_PerformFullVidRestart( bool forceWindow ) {
 	globalImages->PurgeAllImages();
 	R_ShutdownFrameData();
 	R_RendererMetrics_ShutdownGpuTimers();
+	#if defined(__EMSCRIPTEN__)
+	R_WebGL2_Shutdown();
+	#else
 	R_MaterialResourceTable_Shutdown();
 	R_RenderGraphResources_Shutdown();
 	R_ModernGLExecutor_Shutdown();
@@ -3721,6 +3841,7 @@ static void R_PerformFullVidRestart( bool forceWindow ) {
 	RendererBootstrap_Shutdown();
 	R_PurgeFramebufferCopyFBOs();
 	R_GLDebugOutput_Shutdown();
+	#endif
 	GLimp_Shutdown();
 	glConfig.isInitialized = false;
 
@@ -4249,6 +4370,9 @@ void idRenderSystemLocal::ShutdownOpenGL( void ) {
 	// free the context and close the window
 	R_ShutdownFrameData();
 	R_RendererMetrics_ShutdownGpuTimers();
+	#if defined(__EMSCRIPTEN__)
+	R_WebGL2_Shutdown();
+	#else
 	R_MaterialResourceTable_Shutdown();
 	R_RenderGraphResources_Shutdown();
 	R_ModernGLExecutor_Shutdown();
@@ -4256,6 +4380,7 @@ void idRenderSystemLocal::ShutdownOpenGL( void ) {
 	RendererBootstrap_Shutdown();
 	R_PurgeFramebufferCopyFBOs();
 	R_GLDebugOutput_Shutdown();
+	#endif
 	GLimp_Shutdown();
 	glConfig.isInitialized = false;
 	R_ClearActiveRenderTextures();
diff --git a/src/renderer/VertexCache.cpp b/src/renderer/VertexCache.cpp
index a91b83b..91f7e17 100644
--- a/src/renderer/VertexCache.cpp
+++ b/src/renderer/VertexCache.cpp
@@ -338,6 +338,7 @@ void idVertexCache::Init() {
 	}
 
 	EndFrame();
+	initialized = true;
 }
 
 /*
@@ -360,6 +361,11 @@ idVertexCache::Shutdown
 ===========
 */
 void idVertexCache::Shutdown() {
+	if ( !initialized ) {
+		return;
+	}
+	initialized = false;
+
 	PurgeAll();
 
 	while( deferredFreeList.next != &deferredFreeList ) {
diff --git a/src/renderer/VertexCache.h b/src/renderer/VertexCache.h
index 4eda2ad..b627bf5 100644
--- a/src/renderer/VertexCache.h
+++ b/src/renderer/VertexCache.h
@@ -135,6 +135,7 @@ private:
 	int				listNum;				// currentFrame % NUM_VERTEX_FRAMES, determines which tempBuffers to use
 
 	bool			virtualMemory;			// not fast stuff
+	bool			initialized;				// dedicated servers never initialize the renderer cache
 
 	bool			allocatingTempBuffer;	// force GL_STREAM_DRAW_ARB
 
diff --git a/src/renderer/qgl.h b/src/renderer/qgl.h
index 24b4716..2a3643e 100644
--- a/src/renderer/qgl.h
+++ b/src/renderer/qgl.h
@@ -7,7 +7,89 @@
 #ifndef __QGL_H__
 #define __QGL_H__
 
+#if defined(__EMSCRIPTEN__)
+// Emscripten's GL headers expose the WebGL 2 / OpenGL ES 3 entry points
+// directly.  GLEW is a desktop function loader and cannot initialise against
+// a browser-owned context.
+#ifndef GL_GLEXT_PROTOTYPES
+#define GL_GLEXT_PROTOTYPES 1
+#endif
+#include <GL/gl.h>
+#include <GL/glext.h>
+#include <GLES3/gl3.h>
+#include <GLES3/gl2ext.h>
+
+// The shared image/vertex-cache code predates core buffer objects.  Keep its
+// names source compatible while routing every used operation to the GLES3
+// core function.  The WebGL renderer itself only uses core names.
+#define glActiveTextureARB glActiveTexture
+#define glClientActiveTextureARB glClientActiveTexture
+#define glBindBufferARB glBindBuffer
+#define glBufferDataARB glBufferData
+#define glBufferSubDataARB glBufferSubData
+#define glCompressedTexImage2DARB glCompressedTexImage2D
+#define glCompressedTexSubImage2DARB glCompressedTexSubImage2D
+#define glDeleteBuffersARB glDeleteBuffers
+#define glDisableVertexAttribArrayARB glDisableVertexAttribArray
+#define glEnableVertexAttribArrayARB glEnableVertexAttribArray
+#define glGenBuffersARB glGenBuffers
+#define glVertexAttribPointerARB glVertexAttribPointer
+#ifndef GL_ARRAY_BUFFER_ARB
+#define GL_ARRAY_BUFFER_ARB GL_ARRAY_BUFFER
+#endif
+#ifndef GL_ELEMENT_ARRAY_BUFFER_ARB
+#define GL_ELEMENT_ARRAY_BUFFER_ARB GL_ELEMENT_ARRAY_BUFFER
+#endif
+#ifndef GL_STATIC_DRAW_ARB
+#define GL_STATIC_DRAW_ARB GL_STATIC_DRAW
+#endif
+#ifndef GL_STREAM_DRAW_ARB
+#define GL_STREAM_DRAW_ARB GL_STREAM_DRAW
+#endif
+#ifndef GL_DYNAMIC_DRAW_ARB
+#define GL_DYNAMIC_DRAW_ARB GL_DYNAMIC_DRAW
+#endif
+#ifndef GL_TEXTURE0_ARB
+#define GL_TEXTURE0_ARB GL_TEXTURE0
+#endif
+#ifndef GL_TEXTURE_CUBE_MAP_EXT
+#define GL_TEXTURE_CUBE_MAP_EXT GL_TEXTURE_CUBE_MAP
+#endif
+#ifndef GL_MAX_TEXTURE_IMAGE_UNITS_ARB
+#define GL_MAX_TEXTURE_IMAGE_UNITS_ARB GL_MAX_TEXTURE_IMAGE_UNITS
+#endif
+#ifndef GL_MAX_TEXTURE_COORDS_ARB
+#define GL_MAX_TEXTURE_COORDS_ARB GL_MAX_TEXTURE_IMAGE_UNITS
+#endif
+
+// A small amount of capability-gated desktop code is still compiled into the
+// browser executable even though the WebGL2 backend never enters it.  Expose
+// the GLES3 core feature floor without pulling in GLEW or its function-pointer
+// globals.
+#define GLEW_OK GL_NO_ERROR
+#define GLEW_ARB_draw_buffers 1
+#define GLEW_ARB_framebuffer_object 1
+#define GLEW_ARB_framebuffer_sRGB 0
+#define GLEW_ARB_multisample 1
+#define GLEW_ARB_pixel_buffer_object 1
+#define GLEW_ARB_seamless_cube_map 0
+#define GLEW_ARB_texture_cube_map_array 0
+#define GLEW_ARB_texture_multisample 0
+#define GLEW_ARB_timer_query 0
+#define GLEW_EXT_direct_state_access 0
+#define GLEW_EXT_framebuffer_blit 1
+#define GLEW_EXT_framebuffer_object 1
+#define GLEW_EXT_framebuffer_sRGB 0
+#define GLEW_EXT_pixel_buffer_object 1
+#define GLEW_EXT_texture_sRGB 1
+#define GLEW_VERSION_1_3 1
+#define GLEW_VERSION_2_1 1
+#define GLEW_VERSION_3_0 1
+#define GLEW_VERSION_3_2 0
+#define GLEW_VERSION_3_3 0
+#else
 #include "../external/glew/glew.h"
+#endif
 
 #if defined(_WIN32) || defined(_WINDOWS)
 // RAVEN BEGIN
diff --git a/src/renderer/tr_backend.cpp b/src/renderer/tr_backend.cpp
index e00a7e2..fb288d1 100644
--- a/src/renderer/tr_backend.cpp
+++ b/src/renderer/tr_backend.cpp
@@ -72,6 +72,9 @@ void RB_SetDefaultGLState( void ) {
 
 	RB_LogComment( "--- R_SetDefaultGLState ---\n" );
 
+	#if defined(__EMSCRIPTEN__)
+	glClearDepthf( 1.0f );
+	#else
 	glClearDepth( 1.0f );
 	glColor4f (1,1,1,1);
 
@@ -79,6 +82,7 @@ void RB_SetDefaultGLState( void ) {
 	glEnableClientState( GL_VERTEX_ARRAY );
 	glEnableClientState( GL_TEXTURE_COORD_ARRAY );
 	glDisableClientState( GL_COLOR_ARRAY );
+	#endif
 
 	//
 	// make sure our GL state vector is set correctly
@@ -94,16 +98,22 @@ void RB_SetDefaultGLState( void ) {
 	glEnable( GL_SCISSOR_TEST );
 	glEnable( GL_CULL_FACE );
 	glDisable( GL_SAMPLE_ALPHA_TO_COVERAGE );
+	#if !defined(__EMSCRIPTEN__)
 	glDisable( GL_LIGHTING );
 	glDisable( GL_LINE_STIPPLE );
+	#endif
 	glDisable( GL_STENCIL_TEST );
 
+	#if !defined(__EMSCRIPTEN__)
 	glPolygonMode (GL_FRONT_AND_BACK, GL_FILL);
+	#endif
 	glDepthMask( GL_TRUE );
 	glDepthFunc( GL_ALWAYS );
  
 	glCullFace( GL_FRONT_AND_BACK );
+	#if !defined(__EMSCRIPTEN__)
 	glShadeModel( GL_SMOOTH );
+	#endif
 
 	if ( r_useScissor.GetBool() ) {
 		glScissor( 0, 0, glConfig.vidWidth, glConfig.vidHeight );
@@ -112,6 +122,7 @@ void RB_SetDefaultGLState( void ) {
 	for ( i = maxStateUnits - 1 ; i >= 0 ; i-- ) {
 		GL_SelectTexture( i );
 
+		#if !defined(__EMSCRIPTEN__)
 		// object linear texgen is our default
 		glTexGenf( GL_S, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR );
 		glTexGenf( GL_T, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR );
@@ -126,7 +137,13 @@ void RB_SetDefaultGLState( void ) {
 		if ( glConfig.cubeMapAvailable ) {
 			glDisable( GL_TEXTURE_CUBE_MAP_EXT );
 		}
+		#else
+		backEnd.glState.tmu[i].textureType = TT_DISABLED;
+		backEnd.glState.tmu[i].current2DMap = -1;
+		backEnd.glState.tmu[i].currentCubeMap = -1;
+		#endif
 	}
+	GL_SelectTexture( 0 );
 }
 
 
@@ -175,7 +192,9 @@ void GL_SelectTexture( int unit ) {
 	}
 
 	glActiveTextureARB( GL_TEXTURE0_ARB + unit );
+	#if !defined(__EMSCRIPTEN__)
 	glClientActiveTextureARB( GL_TEXTURE0_ARB + unit );
+	#endif
 	RB_LogComment( "glActiveTextureARB( %i );\nglClientActiveTextureARB( %i );\n", unit, unit );
 
 	backEnd.glState.currenttmu = unit;
@@ -404,17 +423,20 @@ void GL_State( int stateBits ) {
 	// fill/line mode
 	//
 	if ( diff & GLS_POLYMODE_LINE ) {
+		#if !defined(__EMSCRIPTEN__)
 		if ( stateBits & GLS_POLYMODE_LINE ) {
 			glPolygonMode( GL_FRONT_AND_BACK, GL_LINE );
 		} else {
 			glPolygonMode( GL_FRONT_AND_BACK, GL_FILL );
 		}
+		#endif
 	}
 
 	//
 	// alpha test
 	//
 	if ( diff & GLS_ATEST_BITS ) {
+		#if !defined(__EMSCRIPTEN__)
 		switch ( stateBits & GLS_ATEST_BITS ) {
 		case 0:
 			glDisable( GL_ALPHA_TEST );
@@ -435,6 +457,7 @@ void GL_State( int stateBits ) {
 			assert( 0 );
 			break;
 		}
+		#endif
 	}
 
 	backEnd.glState.glStateBits = stateBits;
diff --git a/src/renderer/tr_local.h b/src/renderer/tr_local.h
index 076a2d8..9408cbf 100644
--- a/src/renderer/tr_local.h
+++ b/src/renderer/tr_local.h
@@ -858,6 +858,7 @@ const int MAX_GUI_SURFACES	= 1024;		// default size of the drawSurfs list for gu
 
 typedef enum {
 	BE_ARB2,
+	BE_WEBGL2,
 	BE_BAD
 } backEndName_t;
 
diff --git a/src/renderer/tr_render.cpp b/src/renderer/tr_render.cpp
index 67d44e0..e0e45c1 100644
--- a/src/renderer/tr_render.cpp
+++ b/src/renderer/tr_render.cpp
@@ -30,6 +30,9 @@ If you have questions concerning this license or the applicable additional terms
 
 
 #include "tr_local.h"
+#if defined(__EMSCRIPTEN__)
+#include "webgl2/WebGL2Backend.h"
+#endif
 
 /*
 
@@ -735,10 +738,12 @@ to actually render the visible surfaces for this view
 =================
 */
 void RB_BeginDrawingView (void) {
+	#if !defined(__EMSCRIPTEN__)
 	// set the modelview matrix for the viewer
 	glMatrixMode(GL_PROJECTION);
 	glLoadMatrixf( backEnd.viewDef->projectionMatrix );
 	glMatrixMode(GL_MODELVIEW);
+	#endif
 
 	// set the window clipping
 	glViewport( tr.viewportOffset[0] + backEnd.viewDef->viewport.x1, 
@@ -1072,7 +1077,11 @@ void RB_DrawView( const void *data ) {
 	RB_ShowOverdraw();
 
 	// render the scene, jumping to the hardware specific interaction renderers
+	#if defined(__EMSCRIPTEN__)
+	RB_WebGL2_DrawView();
+	#else
 	RB_STD_DrawView();
+	#endif
 
 	// restore the context for 2D drawing if we were stubbing it out
 	if ( r_skipRenderContext.GetBool() && backEnd.viewDef->viewEntitys ) {
diff --git a/src/renderer/webgl2/WebGL2Backend.cpp b/src/renderer/webgl2/WebGL2Backend.cpp
new file mode 100644
index 0000000..1191c17
--- /dev/null
+++ b/src/renderer/webgl2/WebGL2Backend.cpp
@@ -0,0 +1,1373 @@
+/*
+===========================================================================
+
+OpenQ4 WebGL 2 renderer backend.
+
+The pass ordering, material decomposition and stencil-volume algorithm are
+adapted from D3Wasm's GPLv3 draw_gles2 backend.  The implementation here uses
+WebGL 2 / GLES 3 core entry points and GLSL ES 3.00; no desktop GL loader or
+fixed-function vertex pipeline is involved.
+
+===========================================================================
+*/
+
+#include "../tr_local.h"
+#include "WebGL2Backend.h"
+
+#if defined(__EMSCRIPTEN__)
+
+#include <stdint.h>
+
+namespace {
+
+enum webAttrib_t {
+	WEB_ATTR_POSITION = 0,
+	WEB_ATTR_COLOR = 1,
+	WEB_ATTR_TEXCOORD = 2,
+	WEB_ATTR_NORMAL = 3,
+	WEB_ATTR_TANGENT = 4,
+	WEB_ATTR_BITANGENT = 5
+};
+
+struct webProgram_t {
+	GLuint program;
+	GLint mvp;
+	GLint modelView;
+	GLint textureMatrix;
+	GLint lightProjection;
+	GLint fogMatrix;
+	GLint localLightOrigin;
+	GLint localViewOrigin;
+	GLint bumpMatrixS;
+	GLint bumpMatrixT;
+	GLint diffuseMatrixS;
+	GLint diffuseMatrixT;
+	GLint specularMatrixS;
+	GLint specularMatrixT;
+	GLint colorModulate;
+	GLint colorAdd;
+	GLint glColor;
+	GLint diffuseColor;
+	GLint specularColor;
+	GLint alphaTest;
+	GLint clipPlane;
+	GLint hasClipPlane;
+	GLint cubeMode;
+	GLint hasCubeCoords;
+	GLint samplers[5];
+};
+
+static webProgram_t webZFillProgram;
+static webProgram_t webInteractionProgram;
+static webProgram_t webAmbientProgram;
+static webProgram_t webCubeProgram;
+static webProgram_t webShadowProgram;
+static webProgram_t webFogProgram;
+static webProgram_t webBlendProgram;
+static webProgram_t *webCurrentProgram = NULL;
+static GLuint webFallbackIndexBuffer = 0;
+static idPlane webFogPlanes[4];
+
+static const char *webZFillVertexShader = R"glsl(#version 300 es
+precision highp float;
+layout(location=0) in vec4 attr_Vertex;
+layout(location=2) in vec2 attr_TexCoord;
+uniform mat4 u_modelViewProjectionMatrix;
+uniform mat4 u_textureMatrix;
+uniform vec4 u_clipPlane;
+uniform bool u_hasClipPlane;
+out vec2 var_TexCoord;
+out float var_ClipDistance;
+void main() {
+	var_TexCoord = (u_textureMatrix * vec4(attr_TexCoord, 0.0, 1.0)).xy;
+	var_ClipDistance = u_hasClipPlane ? dot(u_clipPlane, attr_Vertex) : 1.0;
+	gl_Position = u_modelViewProjectionMatrix * attr_Vertex;
+}
+)glsl";
+
+static const char *webZFillFragmentShader = R"glsl(#version 300 es
+precision mediump float;
+in vec2 var_TexCoord;
+in float var_ClipDistance;
+uniform sampler2D u_fragmentMap0;
+uniform float u_alphaTest;
+uniform vec4 u_glColor;
+layout(location=0) out vec4 fragColor;
+void main() {
+	if (var_ClipDistance < 0.0 || texture(u_fragmentMap0, var_TexCoord).a < u_alphaTest) {
+		discard;
+	}
+	fragColor = u_glColor;
+}
+)glsl";
+
+static const char *webInteractionVertexShader = R"glsl(#version 300 es
+precision highp float;
+layout(location=0) in vec4 attr_Vertex;
+layout(location=1) in vec4 attr_Color;
+layout(location=2) in vec2 attr_TexCoord;
+layout(location=3) in vec3 attr_Normal;
+layout(location=4) in vec3 attr_Tangent;
+layout(location=5) in vec3 attr_Bitangent;
+uniform mat4 u_modelViewProjectionMatrix;
+uniform mat4 u_lightProjection;
+uniform float u_colorModulate;
+uniform float u_colorAdd;
+uniform vec4 u_lightOrigin;
+uniform vec4 u_viewOrigin;
+uniform vec4 u_bumpMatrixS;
+uniform vec4 u_bumpMatrixT;
+uniform vec4 u_diffuseMatrixS;
+uniform vec4 u_diffuseMatrixT;
+uniform vec4 u_specularMatrixS;
+uniform vec4 u_specularMatrixT;
+out vec2 var_TexDiffuse;
+out vec2 var_TexNormal;
+out vec2 var_TexSpecular;
+out vec4 var_TexLight;
+out lowp vec4 var_Color;
+out vec3 var_L;
+out vec3 var_H;
+void main() {
+	vec4 tc = vec4(attr_TexCoord, 0.0, 1.0);
+	mat3 tangentFrame = mat3(attr_Tangent, attr_Bitangent, attr_Normal);
+	var_TexNormal = vec2(dot(u_bumpMatrixS, tc), dot(u_bumpMatrixT, tc));
+	var_TexDiffuse = vec2(dot(u_diffuseMatrixS, tc), dot(u_diffuseMatrixT, tc));
+	var_TexSpecular = vec2(dot(u_specularMatrixS, tc), dot(u_specularMatrixT, tc));
+	var_TexLight = vec4(dot(u_lightProjection[0], attr_Vertex),
+		dot(u_lightProjection[1], attr_Vertex),
+		dot(u_lightProjection[2], attr_Vertex),
+		dot(u_lightProjection[3], attr_Vertex));
+	vec3 lightVector = u_lightOrigin.xyz - attr_Vertex.xyz;
+	vec3 viewVector = u_viewOrigin.xyz - attr_Vertex.xyz;
+	var_L = lightVector * tangentFrame;
+	var_H = (normalize(lightVector) + normalize(viewVector)) * tangentFrame;
+	var_Color = attr_Color * u_colorModulate + vec4(u_colorAdd);
+	gl_Position = u_modelViewProjectionMatrix * attr_Vertex;
+}
+)glsl";
+
+static const char *webInteractionFragmentShader = R"glsl(#version 300 es
+precision mediump float;
+in vec2 var_TexDiffuse;
+in vec2 var_TexNormal;
+in vec2 var_TexSpecular;
+in vec4 var_TexLight;
+in lowp vec4 var_Color;
+in vec3 var_L;
+in vec3 var_H;
+uniform vec4 u_diffuseColor;
+uniform vec4 u_specularColor;
+uniform sampler2D u_fragmentMap0;
+uniform sampler2D u_fragmentMap1;
+uniform sampler2D u_fragmentMap2;
+uniform sampler2D u_fragmentMap3;
+uniform sampler2D u_fragmentMap4;
+layout(location=0) out vec4 fragColor;
+void main() {
+	vec3 normal = normalize(texture(u_fragmentMap0, var_TexNormal).agb * 2.0 - 1.0);
+	float nDotL = max(dot(normal, normalize(var_L)), 0.0);
+	float nDotH = max(dot(normal, normalize(var_H)), 0.0);
+	vec3 projection = textureProj(u_fragmentMap2, var_TexLight.xyw).rgb;
+	vec3 falloff = texture(u_fragmentMap1, vec2(var_TexLight.z, 0.5)).rgb;
+	vec3 diffuse = texture(u_fragmentMap3, var_TexDiffuse).rgb * u_diffuseColor.rgb;
+	vec3 specular = texture(u_fragmentMap4, var_TexSpecular).rgb * u_specularColor.rgb * 2.0;
+	vec3 color = (diffuse + pow(nDotH, 12.0) * specular) * nDotL * projection * falloff;
+	fragColor = vec4(color, 1.0) * var_Color;
+}
+)glsl";
+
+static const char *webAmbientVertexShader = R"glsl(#version 300 es
+precision highp float;
+layout(location=0) in vec4 attr_Vertex;
+layout(location=1) in vec4 attr_Color;
+layout(location=2) in vec2 attr_TexCoord;
+uniform mat4 u_modelViewProjectionMatrix;
+uniform mat4 u_textureMatrix;
+uniform float u_colorModulate;
+uniform float u_colorAdd;
+out vec2 var_TexCoord;
+out lowp vec4 var_Color;
+void main() {
+	var_TexCoord = (u_textureMatrix * vec4(attr_TexCoord, 0.0, 1.0)).xy;
+	var_Color = attr_Color * u_colorModulate + vec4(u_colorAdd);
+	gl_Position = u_modelViewProjectionMatrix * attr_Vertex;
+}
+)glsl";
+
+static const char *webAmbientFragmentShader = R"glsl(#version 300 es
+precision mediump float;
+in vec2 var_TexCoord;
+in lowp vec4 var_Color;
+uniform sampler2D u_fragmentMap0;
+uniform vec4 u_glColor;
+layout(location=0) out vec4 fragColor;
+void main() {
+	fragColor = texture(u_fragmentMap0, var_TexCoord) * u_glColor * var_Color;
+}
+)glsl";
+
+static const char *webCubeVertexShader = R"glsl(#version 300 es
+precision highp float;
+layout(location=0) in vec4 attr_Vertex;
+layout(location=1) in vec4 attr_Color;
+layout(location=2) in vec3 attr_CubeCoord;
+layout(location=3) in vec3 attr_Normal;
+uniform mat4 u_modelViewProjectionMatrix;
+uniform mat4 u_modelViewMatrix;
+uniform mat4 u_textureMatrix;
+uniform vec4 u_viewOrigin;
+uniform float u_colorModulate;
+uniform float u_colorAdd;
+uniform int u_cubeMode;
+uniform bool u_hasCubeCoords;
+out vec3 var_TexCoord;
+out lowp vec4 var_Color;
+void main() {
+	vec3 direction;
+	if (u_cubeMode == 1) {
+		vec3 skyCoord = u_hasCubeCoords ? attr_CubeCoord : attr_Vertex.xyz - u_viewOrigin.xyz;
+		direction = (u_textureMatrix * vec4(skyCoord, 0.0)).xyz;
+	} else if (u_cubeMode == 2) {
+		vec3 eye = normalize((u_modelViewMatrix * attr_Vertex).xyz);
+		vec3 normal = normalize(mat3(u_modelViewMatrix) * attr_Normal);
+		direction = (u_textureMatrix * vec4(reflect(eye, normal), 0.0)).xyz;
+	} else {
+		direction = (u_textureMatrix * vec4(attr_Normal, 0.0)).xyz;
+	}
+	var_TexCoord = direction;
+	var_Color = attr_Color * u_colorModulate + vec4(u_colorAdd);
+	gl_Position = u_modelViewProjectionMatrix * attr_Vertex;
+}
+)glsl";
+
+static const char *webCubeFragmentShader = R"glsl(#version 300 es
+precision mediump float;
+in vec3 var_TexCoord;
+in lowp vec4 var_Color;
+uniform samplerCube u_fragmentMap0;
+uniform vec4 u_glColor;
+layout(location=0) out vec4 fragColor;
+void main() {
+	fragColor = texture(u_fragmentMap0, var_TexCoord) * u_glColor * var_Color;
+}
+)glsl";
+
+static const char *webShadowVertexShader = R"glsl(#version 300 es
+precision highp float;
+layout(location=0) in vec4 attr_Vertex;
+uniform mat4 u_modelViewProjectionMatrix;
+uniform vec4 u_lightOrigin;
+void main() {
+	gl_Position = u_modelViewProjectionMatrix *
+		(attr_Vertex.w * u_lightOrigin + attr_Vertex - u_lightOrigin);
+}
+)glsl";
+
+static const char *webShadowFragmentShader = R"glsl(#version 300 es
+precision lowp float;
+layout(location=0) out vec4 fragColor;
+void main() { fragColor = vec4(0.0); }
+)glsl";
+
+static const char *webFogVertexShader = R"glsl(#version 300 es
+precision highp float;
+layout(location=0) in vec4 attr_Vertex;
+uniform mat4 u_modelViewProjectionMatrix;
+uniform mat4 u_fogMatrix;
+uniform bool u_hasClipPlane;
+out vec2 var_TexFog;
+out vec2 var_TexFogEnter;
+void main() {
+	gl_Position = u_modelViewProjectionMatrix * attr_Vertex;
+	if (u_hasClipPlane) {
+		float divisor = dot(u_fogMatrix[2], attr_Vertex);
+		var_TexFog = vec2(dot(u_fogMatrix[0], attr_Vertex), dot(u_fogMatrix[1], attr_Vertex)) / divisor;
+		var_TexFogEnter = vec2(dot(u_fogMatrix[3], attr_Vertex), 0.5);
+	} else {
+		var_TexFog = vec2(dot(u_fogMatrix[0], attr_Vertex), dot(u_fogMatrix[1], attr_Vertex));
+		var_TexFogEnter = vec2(dot(u_fogMatrix[2], attr_Vertex), dot(u_fogMatrix[3], attr_Vertex));
+	}
+}
+)glsl";
+
+static const char *webFogFragmentShader = R"glsl(#version 300 es
+precision mediump float;
+in vec2 var_TexFog;
+in vec2 var_TexFogEnter;
+uniform sampler2D u_fragmentMap0;
+uniform sampler2D u_fragmentMap1;
+uniform vec4 u_glColor;
+layout(location=0) out vec4 fragColor;
+void main() {
+	fragColor = texture(u_fragmentMap0, var_TexFog) *
+		texture(u_fragmentMap1, var_TexFogEnter) * vec4(u_glColor.rgb, 1.0);
+}
+)glsl";
+
+static GLint WebUniform( GLuint program, const char *name ) {
+	return glGetUniformLocation( program, name );
+}
+
+static void WebDeleteProgram( webProgram_t &program ) {
+	if ( program.program != 0 ) {
+		glDeleteProgram( program.program );
+	}
+	memset( &program, 0, sizeof( program ) );
+}
+
+static bool WebCompileShader( GLenum type, const char *name, const char *source, GLuint &shader ) {
+	shader = glCreateShader( type );
+	glShaderSource( shader, 1, &source, NULL );
+	glCompileShader( shader );
+	GLint compiled = GL_FALSE;
+	glGetShaderiv( shader, GL_COMPILE_STATUS, &compiled );
+	if ( compiled == GL_TRUE ) {
+		return true;
+	}
+	GLint length = 0;
+	glGetShaderiv( shader, GL_INFO_LOG_LENGTH, &length );
+	idStr log;
+	if ( length > 1 ) {
+		char *buffer = new char[length];
+		glGetShaderInfoLog( shader, length, NULL, buffer );
+		log = buffer;
+		delete[] buffer;
+	}
+	common->Warning( "WebGL2 %s shader compile failed: %s", name, log.c_str() );
+	glDeleteShader( shader );
+	shader = 0;
+	return false;
+}
+
+static bool WebCreateProgram( webProgram_t &program, const char *name, const char *vertexSource, const char *fragmentSource ) {
+	GLuint vertexShader = 0;
+	GLuint fragmentShader = 0;
+	if ( !WebCompileShader( GL_VERTEX_SHADER, name, vertexSource, vertexShader ) ||
+		!WebCompileShader( GL_FRAGMENT_SHADER, name, fragmentSource, fragmentShader ) ) {
+		if ( vertexShader != 0 ) {
+			glDeleteShader( vertexShader );
+		}
+		return false;
+	}
+
+	memset( &program, 0, sizeof( program ) );
+	program.program = glCreateProgram();
+	glAttachShader( program.program, vertexShader );
+	glAttachShader( program.program, fragmentShader );
+	glLinkProgram( program.program );
+	glDeleteShader( vertexShader );
+	glDeleteShader( fragmentShader );
+
+	GLint linked = GL_FALSE;
+	glGetProgramiv( program.program, GL_LINK_STATUS, &linked );
+	if ( linked != GL_TRUE ) {
+		GLint length = 0;
+		glGetProgramiv( program.program, GL_INFO_LOG_LENGTH, &length );
+		idStr log;
+		if ( length > 1 ) {
+			char *buffer = new char[length];
+			glGetProgramInfoLog( program.program, length, NULL, buffer );
+			log = buffer;
+			delete[] buffer;
+		}
+		common->Warning( "WebGL2 %s program link failed: %s", name, log.c_str() );
+		WebDeleteProgram( program );
+		return false;
+	}
+
+	program.mvp = WebUniform( program.program, "u_modelViewProjectionMatrix" );
+	program.modelView = WebUniform( program.program, "u_modelViewMatrix" );
+	program.textureMatrix = WebUniform( program.program, "u_textureMatrix" );
+	program.lightProjection = WebUniform( program.program, "u_lightProjection" );
+	program.fogMatrix = WebUniform( program.program, "u_fogMatrix" );
+	program.localLightOrigin = WebUniform( program.program, "u_lightOrigin" );
+	program.localViewOrigin = WebUniform( program.program, "u_viewOrigin" );
+	program.bumpMatrixS = WebUniform( program.program, "u_bumpMatrixS" );
+	program.bumpMatrixT = WebUniform( program.program, "u_bumpMatrixT" );
+	program.diffuseMatrixS = WebUniform( program.program, "u_diffuseMatrixS" );
+	program.diffuseMatrixT = WebUniform( program.program, "u_diffuseMatrixT" );
+	program.specularMatrixS = WebUniform( program.program, "u_specularMatrixS" );
+	program.specularMatrixT = WebUniform( program.program, "u_specularMatrixT" );
+	program.colorModulate = WebUniform( program.program, "u_colorModulate" );
+	program.colorAdd = WebUniform( program.program, "u_colorAdd" );
+	program.glColor = WebUniform( program.program, "u_glColor" );
+	program.diffuseColor = WebUniform( program.program, "u_diffuseColor" );
+	program.specularColor = WebUniform( program.program, "u_specularColor" );
+	program.alphaTest = WebUniform( program.program, "u_alphaTest" );
+	program.clipPlane = WebUniform( program.program, "u_clipPlane" );
+	program.hasClipPlane = WebUniform( program.program, "u_hasClipPlane" );
+	program.cubeMode = WebUniform( program.program, "u_cubeMode" );
+	program.hasCubeCoords = WebUniform( program.program, "u_hasCubeCoords" );
+	for ( int i = 0; i < 5; ++i ) {
+		char samplerName[32];
+		idStr::snPrintf( samplerName, sizeof( samplerName ), "u_fragmentMap%d", i );
+		program.samplers[i] = WebUniform( program.program, samplerName );
+	}
+	return true;
+}
+
+static void WebUseProgram( webProgram_t &program ) {
+	if ( webCurrentProgram == &program ) {
+		return;
+	}
+	glUseProgram( program.program );
+	webCurrentProgram = &program;
+}
+
+static void WebUniform1i( GLint location, int value ) {
+	if ( location >= 0 ) {
+		glUniform1i( location, value );
+	}
+}
+
+static void WebUniform1f( GLint location, float value ) {
+	if ( location >= 0 ) {
+		glUniform1f( location, value );
+	}
+}
+
+static void WebUniform4fv( GLint location, const float *value ) {
+	if ( location >= 0 ) {
+		glUniform4fv( location, 1, value );
+	}
+}
+
+static void WebUniformMatrix4fv( GLint location, const float *value ) {
+	if ( location >= 0 ) {
+		glUniformMatrix4fv( location, 1, GL_FALSE, value );
+	}
+}
+
+static void WebSetDefaultUniforms( webProgram_t &program ) {
+	static const float identity[16] = {
+		1, 0, 0, 0,
+		0, 1, 0, 0,
+		0, 0, 1, 0,
+		0, 0, 0, 1
+	};
+	static const float white[4] = { 1, 1, 1, 1 };
+	WebUseProgram( program );
+	WebUniformMatrix4fv( program.textureMatrix, identity );
+	WebUniform4fv( program.glColor, white );
+	WebUniform1f( program.colorModulate, 0.0f );
+	WebUniform1f( program.colorAdd, 1.0f );
+	WebUniform1f( program.alphaTest, 0.0f );
+	WebUniform1i( program.hasClipPlane, 0 );
+	WebUniform1i( program.hasCubeCoords, 0 );
+	for ( int i = 0; i < 5; ++i ) {
+		WebUniform1i( program.samplers[i], i );
+	}
+}
+
+static void WebComputeMVP( const drawSurf_t *surf, float mvp[16] ) {
+	float projection[16];
+	memcpy( projection, backEnd.viewDef->projectionMatrix, sizeof( projection ) );
+	if ( surf->space->weaponDepthHack ) {
+		projection[14] = backEnd.viewDef->projectionMatrix[14] * 0.25f;
+	}
+	if ( surf->space->modelDepthHack != 0.0f ) {
+		projection[14] = backEnd.viewDef->projectionMatrix[14] - surf->space->modelDepthHack;
+	}
+	myGlMultMatrix( surf->space->modelViewMatrix, projection, mvp );
+}
+
+static bool WebBeginDepthHack( const drawSurf_t *surf ) {
+	if ( surf->space->weaponDepthHack && surf->space->modelDepthHack == 0.0f ) {
+		glDepthRangef( 0.0f, 0.5f );
+		return true;
+	}
+	return false;
+}
+
+static void WebEndDepthHack( bool restore ) {
+	if ( restore ) {
+		glDepthRangef( 0.0f, 1.0f );
+	}
+}
+
+static void WebSetScissor( const idScreenRect &rect ) {
+	if ( !r_useScissor.GetBool() || backEnd.currentScissor.Equals( rect ) ) {
+		return;
+	}
+	backEnd.currentScissor = rect;
+	if ( backEnd.currentScissor.x2 < backEnd.currentScissor.x1 || backEnd.currentScissor.y2 < backEnd.currentScissor.y1 ) {
+		backEnd.currentScissor = backEnd.viewDef->scissor;
+	}
+	glScissor(
+		tr.viewportOffset[0] + backEnd.viewDef->viewport.x1 + backEnd.currentScissor.x1,
+		tr.viewportOffset[1] + backEnd.viewDef->viewport.y1 + backEnd.currentScissor.y1,
+		backEnd.currentScissor.x2 + 1 - backEnd.currentScissor.x1,
+		backEnd.currentScissor.y2 + 1 - backEnd.currentScissor.y1 );
+}
+
+static const void *WebBufferOffset( const void *base, uintptr_t offset ) {
+	return reinterpret_cast<const void *>( reinterpret_cast<uintptr_t>( base ) + offset );
+}
+
+static bool WebPrepareAmbientGeometry( const drawSurf_t *surf, bool needsLighting ) {
+	if ( surf == NULL || surf->geo == NULL || surf->geo->numIndexes <= 0 ) {
+		return false;
+	}
+#if defined(_MD5R_SUPPORT) || defined(Q4SDK_MD5R)
+	if ( surf->geo->ambientCache == NULL && surf->geo->primBatchMesh != NULL ) {
+		if ( !R_CreatePackedSurfaceFrameCaches( const_cast<srfTriangles_t *>( surf->geo ), needsLighting, true ) ) {
+			return false;
+		}
+	}
+#else
+	(void)needsLighting;
+#endif
+	return surf->geo->ambientCache != NULL;
+}
+
+static void WebSetDrawVertAttributes( const srfTriangles_t *tri, unsigned mask ) {
+	const void *base = vertexCache.Position( tri->ambientCache );
+	struct attributeDesc_t {
+		GLuint index;
+		GLint size;
+		GLenum type;
+		GLboolean normalized;
+		uintptr_t offset;
+	};
+	static const attributeDesc_t attributes[] = {
+		{ WEB_ATTR_POSITION, 3, GL_FLOAT, GL_FALSE, DRAWVERT_XYZ_OFFSET },
+		{ WEB_ATTR_COLOR, 4, GL_UNSIGNED_BYTE, GL_TRUE, DRAWVERT_COLOR_OFFSET },
+		{ WEB_ATTR_TEXCOORD, 2, GL_FLOAT, GL_FALSE, DRAWVERT_ST_OFFSET },
+		{ WEB_ATTR_NORMAL, 3, GL_FLOAT, GL_FALSE, DRAWVERT_NORMAL_OFFSET },
+		{ WEB_ATTR_TANGENT, 3, GL_FLOAT, GL_FALSE, DRAWVERT_TANGENT0_OFFSET },
+		{ WEB_ATTR_BITANGENT, 3, GL_FLOAT, GL_FALSE, DRAWVERT_TANGENT1_OFFSET }
+	};
+	for ( unsigned i = 0; i < sizeof( attributes ) / sizeof( attributes[0] ); ++i ) {
+		const unsigned bit = 1u << attributes[i].index;
+		if ( ( mask & bit ) != 0 ) {
+			glEnableVertexAttribArray( attributes[i].index );
+			glVertexAttribPointer( attributes[i].index, attributes[i].size, attributes[i].type,
+				attributes[i].normalized, sizeof( idDrawVert ), WebBufferOffset( base, attributes[i].offset ) );
+		} else {
+			glDisableVertexAttribArray( attributes[i].index );
+		}
+	}
+}
+
+static bool WebSetDynamicTexCoordAttribute( const drawSurf_t *surf, int components ) {
+	if ( surf->dynamicTexCoords == NULL ) {
+		return false;
+	}
+	const void *base = vertexCache.Position( surf->dynamicTexCoords );
+	glEnableVertexAttribArray( WEB_ATTR_TEXCOORD );
+	glVertexAttribPointer( WEB_ATTR_TEXCOORD, components, GL_FLOAT, GL_FALSE,
+		components * sizeof( float ), base );
+	return true;
+}
+
+static bool WebSetPackedSkyTexgen( const drawSurf_t *surf, webProgram_t &program ) {
+	if ( surf->texGenTransformAndViewOrg == NULL ) {
+		return false;
+	}
+	const float *packed = surf->texGenTransformAndViewOrg;
+	const float textureMatrix[16] = {
+		packed[0], packed[4], packed[8], 0,
+		packed[1], packed[5], packed[9], 0,
+		packed[2], packed[6], packed[10], 0,
+		packed[3], packed[7], packed[11], 1
+	};
+	WebUniformMatrix4fv( program.textureMatrix, textureMatrix );
+	WebUniform4fv( program.localViewOrigin, packed + 12 );
+	return true;
+}
+
+static void WebDrawElements( const srfTriangles_t *tri, int count = -1 ) {
+	if ( tri == NULL ) {
+		return;
+	}
+	const int drawCount = count >= 0 ? count : tri->numIndexes;
+	if ( drawCount <= 0 ) {
+		return;
+	}
+	backEnd.pc.c_drawElements++;
+	backEnd.pc.c_drawIndexes += drawCount;
+	backEnd.pc.c_drawVertexes += tri->numVerts;
+	const int finalCount = r_singleTriangle.GetBool() ? Min( drawCount, 3 ) : drawCount;
+	if ( tri->indexCache != NULL ) {
+		const void *offset = vertexCache.Position( tri->indexCache );
+		glDrawElements( GL_TRIANGLES, finalCount, GL_INDEX_TYPE, offset );
+		backEnd.pc.c_vboIndexes += drawCount;
+		return;
+	}
+	if ( tri->indexes == NULL ) {
+		return;
+	}
+	idVertexCache::BindIndexBuffer( webFallbackIndexBuffer );
+	glBufferData( GL_ELEMENT_ARRAY_BUFFER, drawCount * sizeof( glIndex_t ), tri->indexes, GL_STREAM_DRAW );
+	glDrawElements( GL_TRIANGLES, finalCount, GL_INDEX_TYPE, NULL );
+}
+
+static void WebBindImage( int unit, idImage *image ) {
+	if ( image == NULL ) {
+		return;
+	}
+	GL_SelectTexture( unit );
+	image->Bind();
+}
+
+static void WebSetVertexColorMode( webProgram_t &program, stageVertexColor_t mode ) {
+	switch ( mode ) {
+	case SVC_MODULATE:
+		WebUniform1f( program.colorModulate, 1.0f );
+		WebUniform1f( program.colorAdd, 0.0f );
+		break;
+	case SVC_INVERSE_MODULATE:
+		WebUniform1f( program.colorModulate, -1.0f );
+		WebUniform1f( program.colorAdd, 1.0f );
+		break;
+	default:
+		WebUniform1f( program.colorModulate, 0.0f );
+		WebUniform1f( program.colorAdd, 1.0f );
+		break;
+	}
+}
+
+static void WebSetPlane( idPlane &plane, float x, float y, float z, float w ) {
+	plane[0] = x;
+	plane[1] = y;
+	plane[2] = z;
+	plane[3] = w;
+}
+
+} // namespace
+
+bool R_WebGL2_Init( void ) {
+	common->Printf( "----- WebGL2 renderer init -----\n" );
+	const bool ok =
+		WebCreateProgram( webZFillProgram, "zfill", webZFillVertexShader, webZFillFragmentShader ) &&
+		WebCreateProgram( webInteractionProgram, "interaction", webInteractionVertexShader, webInteractionFragmentShader ) &&
+		WebCreateProgram( webAmbientProgram, "ambient", webAmbientVertexShader, webAmbientFragmentShader ) &&
+		WebCreateProgram( webCubeProgram, "cube", webCubeVertexShader, webCubeFragmentShader ) &&
+		WebCreateProgram( webShadowProgram, "shadow", webShadowVertexShader, webShadowFragmentShader ) &&
+		WebCreateProgram( webFogProgram, "fog", webFogVertexShader, webFogFragmentShader ) &&
+		WebCreateProgram( webBlendProgram, "blend", webFogVertexShader, webFogFragmentShader );
+	if ( !ok ) {
+		R_WebGL2_Shutdown();
+		return false;
+	}
+	WebSetDefaultUniforms( webZFillProgram );
+	WebSetDefaultUniforms( webInteractionProgram );
+	WebSetDefaultUniforms( webAmbientProgram );
+	WebSetDefaultUniforms( webCubeProgram );
+	WebSetDefaultUniforms( webShadowProgram );
+	WebSetDefaultUniforms( webFogProgram );
+	WebSetDefaultUniforms( webBlendProgram );
+	WebUseProgram( webBlendProgram );
+	WebUniform1i( webBlendProgram.hasClipPlane, 1 );
+	WebUseProgram( webFogProgram );
+	WebUniform1i( webFogProgram.hasClipPlane, 0 );
+	glGenBuffers( 1, &webFallbackIndexBuffer );
+	glUseProgram( 0 );
+	webCurrentProgram = NULL;
+	common->Printf( "WebGL2: GLSL ES 3.00 shader smoke compiled 7/7 programs\n" );
+	return true;
+}
+
+void R_WebGL2_Shutdown( void ) {
+	WebDeleteProgram( webZFillProgram );
+	WebDeleteProgram( webInteractionProgram );
+	WebDeleteProgram( webAmbientProgram );
+	WebDeleteProgram( webCubeProgram );
+	WebDeleteProgram( webShadowProgram );
+	WebDeleteProgram( webFogProgram );
+	WebDeleteProgram( webBlendProgram );
+	if ( webFallbackIndexBuffer != 0 ) {
+		glDeleteBuffers( 1, &webFallbackIndexBuffer );
+		webFallbackIndexBuffer = 0;
+	}
+	webCurrentProgram = NULL;
+	idVertexCache::InvalidateBufferBindings();
+}
+
+namespace {
+
+static const float webIdentityMatrix[16] = {
+	1, 0, 0, 0,
+	0, 1, 0, 0,
+	0, 0, 1, 0,
+	0, 0, 0, 1
+};
+
+static GLint WebStencilReference( void ) {
+	const int stencilBits = idMath::ClampInt( 1, 30, glConfig.stencilBits > 0 ? glConfig.stencilBits : 8 );
+	return 1 << ( stencilBits - 1 );
+}
+
+static void WebFillDepthSurface( const drawSurf_t *surf ) {
+	if ( surf == NULL || surf->material == NULL || !surf->material->IsDrawn() ||
+		surf->material->Coverage() == MC_TRANSLUCENT || !WebPrepareAmbientGeometry( surf, false ) ) {
+		return;
+	}
+
+	const idMaterial *material = surf->material;
+	const srfTriangles_t *tri = surf->geo;
+	const float *regs = surf->shaderRegisters;
+	bool hasEnabledStage = false;
+	for ( int i = 0; i < material->GetNumStages(); ++i ) {
+		const shaderStage_t *stage = material->GetStage( i );
+		if ( regs[stage->conditionRegister] != 0.0f ) {
+			hasEnabledStage = true;
+			break;
+		}
+	}
+	if ( !hasEnabledStage ) {
+		return;
+	}
+
+	WebSetScissor( surf->scissorRect );
+	float mvp[16];
+	WebComputeMVP( surf, mvp );
+	WebUniformMatrix4fv( webZFillProgram.mvp, mvp );
+	WebUniformMatrix4fv( webZFillProgram.textureMatrix, webIdentityMatrix );
+	WebUniform1f( webZFillProgram.alphaTest, 0.0f );
+	if ( backEnd.viewDef->numClipPlanes > 0 ) {
+		idPlane localClipPlane;
+		R_GlobalPlaneToLocal( surf->space->modelMatrix, backEnd.viewDef->clipPlanes[0], localClipPlane );
+		WebUniform4fv( webZFillProgram.clipPlane, localClipPlane.ToFloatPtr() );
+		WebUniform1i( webZFillProgram.hasClipPlane, 1 );
+	} else {
+		WebUniform1i( webZFillProgram.hasClipPlane, 0 );
+	}
+	WebSetDrawVertAttributes( tri, ( 1u << WEB_ATTR_POSITION ) | ( 1u << WEB_ATTR_TEXCOORD ) );
+	const bool restoreDepthRange = WebBeginDepthHack( surf );
+
+	const bool polygonOffset = material->TestMaterialFlag( MF_POLYGONOFFSET );
+	if ( polygonOffset ) {
+		glEnable( GL_POLYGON_OFFSET_FILL );
+		glPolygonOffset( r_offsetFactor.GetFloat(), r_offsetUnits.GetFloat() * material->GetPolygonOffset() );
+	}
+
+	const bool subview = material->GetSort() == SS_SUBVIEW;
+	static const float black[4] = { 0, 0, 0, 1 };
+	float subviewColor[4] = { 1, 1, 1, 1 };
+	if ( subview ) {
+		const float modulation = backEnd.overBright > 0.0f ? 1.0f / backEnd.overBright : 1.0f;
+		subviewColor[0] = subviewColor[1] = subviewColor[2] = modulation;
+		GL_State( GLS_SRCBLEND_DST_COLOR | GLS_DSTBLEND_ZERO | GLS_DEPTHFUNC_LESS );
+		WebUniform4fv( webZFillProgram.glColor, subviewColor );
+	} else {
+		WebUniform4fv( webZFillProgram.glColor, black );
+	}
+
+	bool drawSolid = material->Coverage() == MC_OPAQUE;
+	if ( material->Coverage() == MC_PERFORATED ) {
+		bool attemptedAlphaStage = false;
+		for ( int i = 0; i < material->GetNumStages(); ++i ) {
+			const shaderStage_t *stage = material->GetStage( i );
+			if ( !stage->hasAlphaTest || regs[stage->conditionRegister] == 0.0f ) {
+				continue;
+			}
+			attemptedAlphaStage = true;
+			if ( regs[stage->color.registers[3]] <= 0.0f ) {
+				continue;
+			}
+			WebUniform1f( webZFillProgram.alphaTest, regs[stage->alphaTestRegister] );
+			if ( stage->texture.hasMatrix ) {
+				float textureMatrix[16];
+				RB_GetShaderTextureMatrix( regs, &stage->texture, textureMatrix );
+				WebUniformMatrix4fv( webZFillProgram.textureMatrix, textureMatrix );
+			} else {
+				WebUniformMatrix4fv( webZFillProgram.textureMatrix, webIdentityMatrix );
+			}
+			GL_SelectTexture( 0 );
+			RB_BindVariableStageImage( &stage->texture, regs );
+			WebDrawElements( tri );
+		}
+		if ( !attemptedAlphaStage ) {
+			drawSolid = true;
+		}
+	}
+
+	if ( drawSolid ) {
+		WebUniform1f( webZFillProgram.alphaTest, 0.0f );
+		WebUniformMatrix4fv( webZFillProgram.textureMatrix, webIdentityMatrix );
+		WebBindImage( 0, globalImages->whiteImage );
+		WebDrawElements( tri );
+	}
+
+	if ( polygonOffset ) {
+		glDisable( GL_POLYGON_OFFSET_FILL );
+	}
+	if ( subview ) {
+		GL_State( GLS_DEPTHFUNC_LESS );
+	}
+	WebEndDepthHack( restoreDepthRange );
+}
+
+static void WebFillDepthBuffer( drawSurf_t **drawSurfs, int numDrawSurfs ) {
+	if ( !backEnd.viewDef->viewEntitys ) {
+		return;
+	}
+	WebUseProgram( webZFillProgram );
+	static const float black[4] = { 0, 0, 0, 1 };
+	WebUniform4fv( webZFillProgram.glColor, black );
+	GL_State( GLS_DEPTHFUNC_LESS );
+	glEnable( GL_STENCIL_TEST );
+	glStencilFunc( GL_ALWAYS, WebStencilReference(), 0xff );
+	for ( int i = 0; i < numDrawSurfs; ++i ) {
+		WebFillDepthSurface( drawSurfs[i] );
+	}
+	backEnd.currentSpace = NULL;
+}
+
+static void WebSubmitInteraction( drawInteraction_t &interaction );
+
+static void WebCreateSingleDrawInteractions( const drawSurf_t *surf, const viewLight_t *viewLight ) {
+	if ( r_skipInteractions.GetBool() || !WebPrepareAmbientGeometry( surf, true ) ) {
+		return;
+	}
+	const idMaterial *surfaceShader = surf->material;
+	const idMaterial *lightShader = viewLight->lightShader;
+	const float *surfaceRegs = surf->shaderRegisters;
+	const float *lightRegs = viewLight->shaderRegisters;
+	drawInteraction_t interaction;
+	memset( &interaction, 0, sizeof( interaction ) );
+	interaction.surf = surf;
+	interaction.lightFalloffImage = viewLight->falloffImage;
+	R_GlobalPointToLocal( surf->space->modelMatrix, viewLight->globalLightOrigin, interaction.localLightOrigin.ToVec3() );
+	R_GlobalPointToLocal( surf->space->modelMatrix, backEnd.viewDef->renderView.vieworg, interaction.localViewOrigin.ToVec3() );
+	interaction.localLightOrigin[3] = 0.0f;
+	interaction.localViewOrigin[3] = 1.0f;
+	interaction.ambientLight = lightShader->IsAmbientLight();
+
+	idPlane localLightProject[4];
+	for ( int i = 0; i < 4; ++i ) {
+		R_GlobalPlaneToLocal( surf->space->modelMatrix, viewLight->lightProject[i], localLightProject[i] );
+	}
+
+	for ( int lightStageIndex = 0; lightStageIndex < lightShader->GetNumStages(); ++lightStageIndex ) {
+		const shaderStage_t *lightStage = lightShader->GetStage( lightStageIndex );
+		if ( lightRegs[lightStage->conditionRegister] == 0.0f ) {
+			continue;
+		}
+		interaction.lightImage = lightStage->texture.image;
+		interaction.lightProjection[0] = localLightProject[0].ToVec4();
+		interaction.lightProjection[1] = localLightProject[1].ToVec4();
+		interaction.lightProjection[2] = localLightProject[3].ToVec4();
+		interaction.lightProjection[3] = localLightProject[2].ToVec4();
+		if ( lightStage->texture.hasMatrix ) {
+			float lightTextureMatrix[16];
+			RB_GetShaderTextureMatrix( lightRegs, &lightStage->texture, lightTextureMatrix );
+			RB_BakeTextureMatrixIntoTexgen( reinterpret_cast<idPlane *>( interaction.lightProjection ), lightTextureMatrix );
+		}
+
+		interaction.bumpImage = NULL;
+		interaction.diffuseImage = NULL;
+		interaction.specularImage = NULL;
+		interaction.diffuseColor.Zero();
+		interaction.specularColor.Zero();
+		const float lightColor[4] = {
+			backEnd.lightScale * lightRegs[lightStage->color.registers[0]],
+			backEnd.lightScale * lightRegs[lightStage->color.registers[1]],
+			backEnd.lightScale * lightRegs[lightStage->color.registers[2]],
+			lightRegs[lightStage->color.registers[3]]
+		};
+
+		for ( int surfaceStageIndex = 0; surfaceStageIndex < surfaceShader->GetNumStages(); ++surfaceStageIndex ) {
+			const shaderStage_t *surfaceStage = surfaceShader->GetStage( surfaceStageIndex );
+			if ( surfaceStage->lighting == SL_AMBIENT || surfaceRegs[surfaceStage->conditionRegister] == 0.0f ) {
+				continue;
+			}
+			switch ( surfaceStage->lighting ) {
+			case SL_BUMP:
+				WebSubmitInteraction( interaction );
+				interaction.diffuseImage = NULL;
+				interaction.specularImage = NULL;
+				R_SetDrawInteraction( surfaceStage, surfaceRegs, &interaction.bumpImage, interaction.bumpMatrix, NULL );
+				break;
+			case SL_DIFFUSE:
+				if ( interaction.diffuseImage != NULL ) {
+					WebSubmitInteraction( interaction );
+				}
+				R_SetDrawInteraction( surfaceStage, surfaceRegs, &interaction.diffuseImage,
+					interaction.diffuseMatrix, interaction.diffuseColor.ToFloatPtr() );
+				for ( int i = 0; i < 4; ++i ) {
+					interaction.diffuseColor[i] *= lightColor[i];
+				}
+				interaction.vertexColor = surfaceStage->vertexColor;
+				break;
+			case SL_SPECULAR:
+				if ( interaction.specularImage != NULL ) {
+					WebSubmitInteraction( interaction );
+				}
+				R_SetDrawInteraction( surfaceStage, surfaceRegs, &interaction.specularImage,
+					interaction.specularMatrix, interaction.specularColor.ToFloatPtr() );
+				for ( int i = 0; i < 4; ++i ) {
+					interaction.specularColor[i] *= lightColor[i];
+				}
+				interaction.vertexColor = surfaceStage->vertexColor;
+				break;
+			default:
+				break;
+			}
+		}
+		WebSubmitInteraction( interaction );
+	}
+}
+
+static void WebSubmitInteraction( drawInteraction_t &interaction ) {
+	if ( interaction.bumpImage == NULL ) {
+		return;
+	}
+	if ( interaction.diffuseImage == NULL || r_skipDiffuse.GetBool() ) {
+		interaction.diffuseImage = globalImages->blackImage;
+	}
+	if ( interaction.specularImage == NULL || r_skipSpecular.GetBool() || interaction.ambientLight ) {
+		interaction.specularImage = globalImages->blackImage;
+	}
+	if ( r_skipBump.GetBool() ) {
+		interaction.bumpImage = globalImages->flatNormalMap;
+	}
+	const bool hasDiffuse = interaction.diffuseImage != globalImages->blackImage &&
+		( interaction.diffuseColor[0] > 0 || interaction.diffuseColor[1] > 0 || interaction.diffuseColor[2] > 0 );
+	const bool hasSpecular = interaction.specularImage != globalImages->blackImage &&
+		( interaction.specularColor[0] > 0 || interaction.specularColor[1] > 0 || interaction.specularColor[2] > 0 );
+	if ( !hasDiffuse && !hasSpecular ) {
+		return;
+	}
+
+	WebUniform4fv( webInteractionProgram.localLightOrigin, interaction.localLightOrigin.ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.localViewOrigin, interaction.localViewOrigin.ToFloatPtr() );
+	WebUniformMatrix4fv( webInteractionProgram.lightProjection, interaction.lightProjection[0].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.bumpMatrixS, interaction.bumpMatrix[0].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.bumpMatrixT, interaction.bumpMatrix[1].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.diffuseMatrixS, interaction.diffuseMatrix[0].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.diffuseMatrixT, interaction.diffuseMatrix[1].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.specularMatrixS, interaction.specularMatrix[0].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.specularMatrixT, interaction.specularMatrix[1].ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.diffuseColor, interaction.diffuseColor.ToFloatPtr() );
+	WebUniform4fv( webInteractionProgram.specularColor, interaction.specularColor.ToFloatPtr() );
+	WebSetVertexColorMode( webInteractionProgram, interaction.vertexColor );
+	WebBindImage( 0, interaction.bumpImage );
+	WebBindImage( 1, interaction.lightFalloffImage );
+	WebBindImage( 2, interaction.lightImage );
+	WebBindImage( 3, interaction.diffuseImage );
+	WebBindImage( 4, interaction.specularImage );
+	GL_SelectTexture( 0 );
+	WebDrawElements( interaction.surf->geo );
+}
+
+static void WebDrawInteractionChain( const drawSurf_t *surf, const viewLight_t *viewLight, int depthFunc ) {
+	if ( surf == NULL ) {
+		return;
+	}
+	WebUseProgram( webInteractionProgram );
+	GL_State( GLS_SRCBLEND_ONE | GLS_DSTBLEND_ONE | GLS_DEPTHMASK | depthFunc );
+	for ( ; surf != NULL; surf = surf->nextOnLight ) {
+		if ( !WebPrepareAmbientGeometry( surf, true ) ) {
+			continue;
+		}
+		WebSetScissor( surf->scissorRect );
+		float mvp[16];
+		WebComputeMVP( surf, mvp );
+		WebUniformMatrix4fv( webInteractionProgram.mvp, mvp );
+		WebSetDrawVertAttributes( surf->geo,
+			( 1u << WEB_ATTR_POSITION ) | ( 1u << WEB_ATTR_COLOR ) | ( 1u << WEB_ATTR_TEXCOORD ) |
+			( 1u << WEB_ATTR_NORMAL ) | ( 1u << WEB_ATTR_TANGENT ) | ( 1u << WEB_ATTR_BITANGENT ) );
+		const bool restoreDepthRange = WebBeginDepthHack( surf );
+		WebCreateSingleDrawInteractions( surf, viewLight );
+		WebEndDepthHack( restoreDepthRange );
+	}
+	backEnd.currentSpace = NULL;
+}
+
+static void WebDrawShadowSurface( const drawSurf_t *surf, const viewLight_t *viewLight ) {
+	const srfTriangles_t *tri = surf->geo;
+	if ( tri == NULL || tri->shadowCache == NULL ) {
+		return;
+	}
+	idVec4 localLight;
+	R_GlobalPointToLocal( surf->space->modelMatrix, viewLight->globalLightOrigin, localLight.ToVec3() );
+	localLight[3] = 0.0f;
+	WebUniform4fv( webShadowProgram.localLightOrigin, localLight.ToFloatPtr() );
+	const void *base = vertexCache.Position( tri->shadowCache );
+	glEnableVertexAttribArray( WEB_ATTR_POSITION );
+	glVertexAttribPointer( WEB_ATTR_POSITION, 4, GL_FLOAT, GL_FALSE, sizeof( shadowCache_t ), base );
+	for ( int i = WEB_ATTR_COLOR; i <= WEB_ATTR_BITANGENT; ++i ) {
+		glDisableVertexAttribArray( i );
+	}
+
+	int numIndexes = tri->numIndexes;
+	bool external = false;
+	if ( r_useExternalShadows.GetInteger() == 2 ) {
+		numIndexes = tri->numShadowIndexesNoCaps;
+		external = true;
+	} else if ( r_useExternalShadows.GetInteger() != 0 ) {
+		if ( ( surf->dsFlags & DSF_VIEW_INSIDE_SHADOW ) == 0 ) {
+			numIndexes = tri->numShadowIndexesNoCaps;
+			external = true;
+		} else if ( !viewLight->viewInsideLight && ( tri->shadowCapPlaneBits & SHADOW_CAP_INFINITE ) == 0 ) {
+			numIndexes = ( viewLight->viewSeesShadowPlaneBits & tri->shadowCapPlaneBits ) != 0
+				? tri->numShadowIndexesNoFrontCaps : tri->numShadowIndexesNoCaps;
+			external = true;
+		}
+	}
+	const GLenum frontFace = backEnd.viewDef->isMirror ? GL_BACK : GL_FRONT;
+	const GLenum backFace = backEnd.viewDef->isMirror ? GL_FRONT : GL_BACK;
+	if ( external ) {
+		glStencilOpSeparate( backFace, GL_KEEP, GL_KEEP, tr.stencilIncr );
+		glStencilOpSeparate( frontFace, GL_KEEP, GL_KEEP, tr.stencilDecr );
+	} else {
+		glStencilOpSeparate( backFace, GL_KEEP, tr.stencilDecr, GL_KEEP );
+		glStencilOpSeparate( frontFace, GL_KEEP, tr.stencilIncr, GL_KEEP );
+	}
+	WebDrawElements( tri, numIndexes );
+}
+
+static void WebDrawShadowChain( const drawSurf_t *surf, const viewLight_t *viewLight ) {
+	if ( surf == NULL || !r_shadows.GetBool() ) {
+		return;
+	}
+	WebUseProgram( webShadowProgram );
+	GL_State( GLS_DEPTHMASK | GLS_COLORMASK | GLS_ALPHAMASK | GLS_DEPTHFUNC_LESS );
+	if ( r_shadowPolygonFactor.GetFloat() != 0.0f || r_shadowPolygonOffset.GetFloat() != 0.0f ) {
+		glEnable( GL_POLYGON_OFFSET_FILL );
+		glPolygonOffset( r_shadowPolygonFactor.GetFloat(), -r_shadowPolygonOffset.GetFloat() );
+	}
+	glStencilFunc( GL_ALWAYS, WebStencilReference(), 0xff );
+	GL_Cull( CT_TWO_SIDED );
+	for ( ; surf != NULL; surf = surf->nextOnLight ) {
+		WebSetScissor( surf->scissorRect );
+		float mvp[16];
+		WebComputeMVP( surf, mvp );
+		WebUniformMatrix4fv( webShadowProgram.mvp, mvp );
+		const bool restoreDepthRange = WebBeginDepthHack( surf );
+		WebDrawShadowSurface( surf, viewLight );
+		WebEndDepthHack( restoreDepthRange );
+	}
+	GL_Cull( CT_FRONT_SIDED );
+	glDisable( GL_POLYGON_OFFSET_FILL );
+	glStencilFunc( GL_GEQUAL, WebStencilReference(), 0xff );
+	glStencilOp( GL_KEEP, GL_KEEP, GL_KEEP );
+}
+
+static void WebDrawInteractions( void ) {
+	for ( const viewLight_t *light = backEnd.viewDef->viewLights; light != NULL; light = light->next ) {
+		if ( light->lightShader->IsFogLight() || light->lightShader->IsBlendLight() ||
+			( light->localInteractions == NULL && light->globalInteractions == NULL && light->translucentInteractions == NULL ) ) {
+			continue;
+		}
+		backEnd.vLight = const_cast<viewLight_t *>( light );
+		if ( light->globalShadows != NULL || light->localShadows != NULL ) {
+			WebSetScissor( light->scissorRect );
+			glClear( GL_STENCIL_BUFFER_BIT );
+		} else {
+			glStencilFunc( GL_ALWAYS, WebStencilReference(), 0xff );
+		}
+		WebDrawShadowChain( light->globalShadows, light );
+		WebDrawInteractionChain( light->localInteractions, light, GLS_DEPTHFUNC_EQUAL );
+		WebDrawShadowChain( light->localShadows, light );
+		WebDrawInteractionChain( light->globalInteractions, light, GLS_DEPTHFUNC_EQUAL );
+		if ( !r_skipTranslucent.GetBool() ) {
+			glStencilFunc( GL_ALWAYS, WebStencilReference(), 0xff );
+			WebDrawInteractionChain( light->translucentInteractions, light, GLS_DEPTHFUNC_LESS );
+		}
+	}
+	glStencilFunc( GL_ALWAYS, WebStencilReference(), 0xff );
+}
+
+static int WebCubeModeForTexgen( texgen_t texgen ) {
+	if ( texgen == TG_SKYBOX_CUBE || texgen == TG_WOBBLESKY_CUBE ) {
+		return 1;
+	}
+	if ( texgen == TG_REFLECT_CUBE ) {
+		return 2;
+	}
+	return 0;
+}
+
+static void WebDrawAmbientSurface( const drawSurf_t *surf, const float mvp[16] ) {
+	const idMaterial *material = surf->material;
+	if ( material == NULL || !material->HasAmbient() || !WebPrepareAmbientGeometry( surf, false ) ) {
+		return;
+	}
+	const float *regs = surf->shaderRegisters;
+	WebSetScissor( surf->scissorRect );
+	GL_Cull( material->GetCullType() );
+	const bool materialOffset = material->TestMaterialFlag( MF_POLYGONOFFSET );
+	if ( materialOffset ) {
+		glEnable( GL_POLYGON_OFFSET_FILL );
+		glPolygonOffset( r_offsetFactor.GetFloat(), r_offsetUnits.GetFloat() * material->GetPolygonOffset() );
+	}
+
+	for ( int i = 0; i < material->GetNumStages(); ++i ) {
+		const shaderStage_t *stage = material->GetStage( i );
+		if ( stage->lighting != SL_AMBIENT || regs[stage->conditionRegister] == 0.0f ) {
+			continue;
+		}
+		const int blendBits = stage->drawStateBits & ( GLS_SRCBLEND_BITS | GLS_DSTBLEND_BITS );
+		if ( blendBits == ( GLS_SRCBLEND_ZERO | GLS_DSTBLEND_ONE ) ) {
+			continue;
+		}
+		float color[4];
+		for ( int channel = 0; channel < 4; ++channel ) {
+			color[channel] = regs[stage->color.registers[channel]];
+		}
+		if ( blendBits == ( GLS_SRCBLEND_ONE | GLS_DSTBLEND_ONE ) &&
+			color[0] <= 0.0f && color[1] <= 0.0f && color[2] <= 0.0f ) {
+			continue;
+		}
+		if ( blendBits == ( GLS_SRCBLEND_SRC_ALPHA | GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA ) && color[3] <= 0.0f ) {
+			continue;
+		}
+
+		const bool cube = stage->texture.texgen == TG_DIFFUSE_CUBE || stage->texture.texgen == TG_REFLECT_CUBE ||
+			stage->texture.texgen == TG_SKYBOX_CUBE || stage->texture.texgen == TG_WOBBLESKY_CUBE;
+		webProgram_t &program = cube ? webCubeProgram : webAmbientProgram;
+		WebUseProgram( program );
+		WebUniformMatrix4fv( program.mvp, mvp );
+		WebUniform4fv( program.glColor, color );
+		WebSetVertexColorMode( program, stage->vertexColor );
+		WebUniformMatrix4fv( program.textureMatrix, webIdentityMatrix );
+		if ( stage->texture.hasMatrix ) {
+			float textureMatrix[16];
+			RB_GetShaderTextureMatrix( regs, &stage->texture, textureMatrix );
+			WebUniformMatrix4fv( program.textureMatrix, textureMatrix );
+		}
+
+		if ( cube ) {
+			const int cubeMode = WebCubeModeForTexgen( stage->texture.texgen );
+			WebUniform1i( program.cubeMode, cubeMode );
+			WebUniform1i( program.hasCubeCoords, 0 );
+			WebUniformMatrix4fv( program.modelView, surf->space->modelViewMatrix );
+			idVec4 localViewOrigin;
+			R_GlobalPointToLocal( surf->space->modelMatrix, backEnd.viewDef->renderView.vieworg, localViewOrigin.ToVec3() );
+			localViewOrigin[3] = 1.0f;
+			WebUniform4fv( program.localViewOrigin, localViewOrigin.ToFloatPtr() );
+			if ( cubeMode == 1 ) {
+				WebSetDrawVertAttributes( surf->geo, ( 1u << WEB_ATTR_POSITION ) | ( 1u << WEB_ATTR_COLOR ) );
+				if ( WebSetDynamicTexCoordAttribute( surf, 3 ) ) {
+					WebUniform1i( program.hasCubeCoords, 1 );
+				} else {
+					WebSetPackedSkyTexgen( surf, program );
+				}
+			} else {
+				WebSetDrawVertAttributes( surf->geo,
+					( 1u << WEB_ATTR_POSITION ) | ( 1u << WEB_ATTR_COLOR ) | ( 1u << WEB_ATTR_NORMAL ) );
+				if ( cubeMode == 2 ) {
+					float reflectionMatrix[16];
+					R_TransposeGLMatrix( backEnd.viewDef->worldSpace.modelViewMatrix, reflectionMatrix );
+					WebUniformMatrix4fv( program.textureMatrix, reflectionMatrix );
+				}
+			}
+		} else {
+			WebSetDrawVertAttributes( surf->geo,
+				( 1u << WEB_ATTR_POSITION ) | ( 1u << WEB_ATTR_COLOR ) | ( 1u << WEB_ATTR_TEXCOORD ) );
+			if ( stage->texture.texgen == TG_POT_CORRECTION ) {
+				WebSetDynamicTexCoordAttribute( surf, 2 );
+			}
+		}
+
+		GL_SelectTexture( 0 );
+		if ( stage->newStage != NULL && stage->newStage->numShaderTextures > 0 ) {
+			idImage *fallbackImage = RB_ResolveGLSLShaderTextureImage( stage->newStage, 0, NULL );
+			WebBindImage( 0, fallbackImage != NULL ? fallbackImage : globalImages->whiteImage );
+		} else {
+			RB_BindVariableStageImage( &stage->texture, regs );
+		}
+		GL_State( stage->drawStateBits );
+		if ( stage->privatePolygonOffset != 0.0f ) {
+			glEnable( GL_POLYGON_OFFSET_FILL );
+			glPolygonOffset( r_offsetFactor.GetFloat(), r_offsetUnits.GetFloat() * stage->privatePolygonOffset );
+		}
+		WebDrawElements( surf->geo );
+		if ( stage->privatePolygonOffset != 0.0f && !materialOffset ) {
+			glDisable( GL_POLYGON_OFFSET_FILL );
+		}
+	}
+	if ( materialOffset ) {
+		glDisable( GL_POLYGON_OFFSET_FILL );
+	}
+}
+
+static int WebDrawAmbientPasses( drawSurf_t **drawSurfs, int numDrawSurfs, bool allowPostProcess ) {
+	if ( backEnd.viewDef->viewEntitys && r_skipAmbient.GetBool() ) {
+		return numDrawSurfs;
+	}
+	int i = 0;
+	for ( ; i < numDrawSurfs; ++i ) {
+		const drawSurf_t *surf = drawSurfs[i];
+		if ( surf == NULL || surf->space == NULL || surf->material == NULL ) {
+			continue;
+		}
+		if ( surf->material->GetSort() >= SS_POST_PROCESS && !allowPostProcess ) {
+			break;
+		}
+		if ( surf->material->SuppressInSubview() ) {
+			continue;
+		}
+		float mvp[16];
+		WebComputeMVP( surf, mvp );
+		const bool restoreDepthRange = WebBeginDepthHack( surf );
+		WebDrawAmbientSurface( surf, mvp );
+		WebEndDepthHack( restoreDepthRange );
+	}
+	backEnd.currentSpace = NULL;
+	GL_Cull( CT_FRONT_SIDED );
+	return i;
+}
+
+typedef void ( *webChainDraw_t )( const drawSurf_t *, const viewLight_t * );
+
+static void WebRenderLightChain( const drawSurf_t *surf, const viewLight_t *light, webProgram_t &program, webChainDraw_t draw ) {
+	for ( ; surf != NULL; surf = surf->nextOnLight ) {
+		if ( surf->geo == NULL ) {
+			continue;
+		}
+		WebSetScissor( surf->scissorRect );
+		float mvp[16];
+		WebComputeMVP( surf, mvp );
+		WebUniformMatrix4fv( program.mvp, mvp );
+		const bool restoreDepthRange = WebBeginDepthHack( surf );
+		draw( surf, light );
+		WebEndDepthHack( restoreDepthRange );
+	}
+	backEnd.currentSpace = NULL;
+}
+
+static void WebDrawFogSurface( const drawSurf_t *surf, const viewLight_t * ) {
+	if ( !WebPrepareAmbientGeometry( surf, false ) ) {
+		return;
+	}
+	idPlane localPlanes[4];
+	R_GlobalPlaneToLocal( surf->space->modelMatrix, webFogPlanes[0], localPlanes[0] );
+	localPlanes[0][3] += 0.5f;
+	WebSetPlane( localPlanes[1], 0, 0, 0, 0.5f );
+	R_GlobalPlaneToLocal( surf->space->modelMatrix, webFogPlanes[2], localPlanes[2] );
+	localPlanes[2][3] += FOG_ENTER;
+	R_GlobalPlaneToLocal( surf->space->modelMatrix, webFogPlanes[3], localPlanes[3] );
+	WebUniformMatrix4fv( webFogProgram.fogMatrix, localPlanes[0].ToFloatPtr() );
+	WebSetDrawVertAttributes( surf->geo, 1u << WEB_ATTR_POSITION );
+	WebDrawElements( surf->geo );
+}
+
+static void WebDrawFogLight( const viewLight_t *light ) {
+	if ( light->frustumTris == NULL || light->frustumTris->ambientCache == NULL ) {
+		return;
+	}
+	const shaderStage_t *stage = light->lightShader->GetStage( 0 );
+	const float *regs = light->shaderRegisters;
+	float color[4];
+	for ( int i = 0; i < 4; ++i ) {
+		color[i] = regs[stage->color.registers[i]];
+	}
+	const float distanceScale = color[3] <= 1.0f ? -0.5f / DEFAULT_FOG_DISTANCE : -0.5f / color[3];
+	const float *worldModelView = backEnd.viewDef->worldSpace.modelViewMatrix;
+	WebSetPlane( webFogPlanes[0], distanceScale * worldModelView[2], distanceScale * worldModelView[6],
+		distanceScale * worldModelView[10], distanceScale * worldModelView[14] );
+	WebSetPlane( webFogPlanes[1], distanceScale * worldModelView[0], distanceScale * worldModelView[4],
+		distanceScale * worldModelView[8], distanceScale * worldModelView[12] );
+	WebSetPlane( webFogPlanes[2], 0.001f * light->fogPlane[0], 0.001f * light->fogPlane[1],
+		0.001f * light->fogPlane[2], 0.001f * light->fogPlane[3] );
+	const float viewDistance = backEnd.viewDef->renderView.vieworg * webFogPlanes[2].Normal() + webFogPlanes[2][3];
+	WebSetPlane( webFogPlanes[3], 0, 0, 0, FOG_ENTER + viewDistance );
+
+	WebUseProgram( webFogProgram );
+	WebUniform4fv( webFogProgram.glColor, color );
+	WebBindImage( 0, globalImages->fogImage );
+	WebBindImage( 1, globalImages->fogEnterImage );
+	GL_SelectTexture( 0 );
+	GL_State( GLS_DEPTHMASK | GLS_SRCBLEND_SRC_ALPHA | GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA | GLS_DEPTHFUNC_EQUAL );
+	WebRenderLightChain( light->globalInteractions, light, webFogProgram, WebDrawFogSurface );
+	WebRenderLightChain( light->localInteractions, light, webFogProgram, WebDrawFogSurface );
+
+	drawSurf_t frustumSurface;
+	memset( &frustumSurface, 0, sizeof( frustumSurface ) );
+	frustumSurface.space = &backEnd.viewDef->worldSpace;
+	frustumSurface.geo = light->frustumTris;
+	frustumSurface.scissorRect = backEnd.viewDef->scissor;
+	GL_State( GLS_DEPTHMASK | GLS_SRCBLEND_SRC_ALPHA | GLS_DSTBLEND_ONE_MINUS_SRC_ALPHA | GLS_DEPTHFUNC_LESS );
+	GL_Cull( CT_BACK_SIDED );
+	WebRenderLightChain( &frustumSurface, light, webFogProgram, WebDrawFogSurface );
+	GL_Cull( CT_FRONT_SIDED );
+}
+
+static void WebDrawBlendSurface( const drawSurf_t *surf, const viewLight_t *light ) {
+	if ( !WebPrepareAmbientGeometry( surf, false ) ) {
+		return;
+	}
+	idPlane localProject[4];
+	for ( int i = 0; i < 4; ++i ) {
+		R_GlobalPlaneToLocal( surf->space->modelMatrix, light->lightProject[i], localProject[i] );
+	}
+	WebUniformMatrix4fv( webBlendProgram.fogMatrix, localProject[0].ToFloatPtr() );
+	WebSetDrawVertAttributes( surf->geo, 1u << WEB_ATTR_POSITION );
+	WebDrawElements( surf->geo );
+}
+
+static void WebDrawBlendLight( const viewLight_t *light ) {
+	if ( light->globalInteractions == NULL && light->localInteractions == NULL ) {
+		return;
+	}
+	WebUseProgram( webBlendProgram );
+	WebBindImage( 1, light->falloffImage );
+	const float *regs = light->shaderRegisters;
+	for ( int i = 0; i < light->lightShader->GetNumStages(); ++i ) {
+		const shaderStage_t *stage = light->lightShader->GetStage( i );
+		if ( regs[stage->conditionRegister] == 0.0f ) {
+			continue;
+		}
+		float color[4];
+		for ( int channel = 0; channel < 4; ++channel ) {
+			color[channel] = regs[stage->color.registers[channel]];
+		}
+		WebUniform4fv( webBlendProgram.glColor, color );
+		WebBindImage( 0, stage->texture.image );
+		GL_State( GLS_DEPTHMASK | stage->drawStateBits | GLS_DEPTHFUNC_EQUAL );
+		WebRenderLightChain( light->globalInteractions, light, webBlendProgram, WebDrawBlendSurface );
+		WebRenderLightChain( light->localInteractions, light, webBlendProgram, WebDrawBlendSurface );
+	}
+	GL_SelectTexture( 0 );
+}
+
+static void WebDrawFogAndBlendLights( void ) {
+	if ( backEnd.viewDef->isXraySubview ) {
+		return;
+	}
+	glDisable( GL_STENCIL_TEST );
+	for ( const viewLight_t *light = backEnd.viewDef->viewLights; light != NULL; light = light->next ) {
+		if ( light->lightShader->IsFogLight() && !r_skipFogLights.GetBool() ) {
+			WebDrawFogLight( light );
+		} else if ( light->lightShader->IsBlendLight() && !r_skipBlendLights.GetBool() ) {
+			WebDrawBlendLight( light );
+		}
+	}
+	glEnable( GL_STENCIL_TEST );
+}
+
+} // namespace
+
+void RB_WebGL2_DrawView( void ) {
+	drawSurf_t **drawSurfs = const_cast<drawSurf_t **>( backEnd.viewDef->drawSurfs );
+	const int numDrawSurfs = backEnd.viewDef->numDrawSurfs;
+	RB_LogComment( "---------- RB_WebGL2_DrawView ----------\n" );
+	backEnd.depthFunc = GLS_DEPTHFUNC_EQUAL;
+	RB_BeginDrawingView();
+	RB_DetermineLightScale();
+	WebFillDepthBuffer( drawSurfs, numDrawSurfs );
+	WebDrawInteractions();
+	const int processed = WebDrawAmbientPasses( drawSurfs, numDrawSurfs, false );
+	WebDrawFogAndBlendLights();
+	if ( processed < numDrawSurfs && !r_skipPostProcess.GetBool() ) {
+		if ( backEnd.viewDef->viewEntitys && !backEnd.currentRenderCopied ) {
+			globalImages->currentRenderImage->CopyFramebuffer(
+				backEnd.viewDef->viewport.x1,
+				backEnd.viewDef->viewport.y1,
+				backEnd.viewDef->viewport.x2 - backEnd.viewDef->viewport.x1 + 1,
+				backEnd.viewDef->viewport.y2 - backEnd.viewDef->viewport.y1 + 1 );
+			backEnd.currentRenderCopied = true;
+		}
+		WebDrawAmbientPasses( drawSurfs + processed, numDrawSurfs - processed, true );
+	}
+	glUseProgram( 0 );
+	webCurrentProgram = NULL;
+	GL_SelectTexture( 0 );
+}
+
+#endif // __EMSCRIPTEN__
diff --git a/src/renderer/webgl2/WebGL2Backend.h b/src/renderer/webgl2/WebGL2Backend.h
new file mode 100644
index 0000000..bcc5c4a
--- /dev/null
+++ b/src/renderer/webgl2/WebGL2Backend.h
@@ -0,0 +1,10 @@
+#ifndef OPENQ4_RENDERER_WEBGL2_BACKEND_H
+#define OPENQ4_RENDERER_WEBGL2_BACKEND_H
+
+#if defined(__EMSCRIPTEN__)
+bool R_WebGL2_Init( void );
+void R_WebGL2_Shutdown( void );
+void RB_WebGL2_DrawView( void );
+#endif
+
+#endif
diff --git a/src/renderer/webgl2/WebGL2GLShims.cpp b/src/renderer/webgl2/WebGL2GLShims.cpp
new file mode 100644
index 0000000..a8281ff
--- /dev/null
+++ b/src/renderer/webgl2/WebGL2GLShims.cpp
@@ -0,0 +1,242 @@
+/*
+===========================================================================
+
+Link-time compatibility symbols for desktop renderer objects in the wasm
+client.  The active WebGL2 backend never uses fixed-function, ARB assembly,
+DSA or compute entry points.  Core-equivalent shader-object calls are mapped
+to GLES3; unsupported calls are inert so dormant desktop diagnostics can stay
+linked without Emscripten's legacy-GL emulation layer.
+
+===========================================================================
+*/
+
+#include "../tr_local.h"
+
+#if defined(__EMSCRIPTEN__)
+
+extern "C" {
+
+void APIENTRY glAttachObjectARB( GLhandleARB program, GLhandleARB shader ) {
+	glAttachShader( program, shader );
+}
+
+void APIENTRY glBindAttribLocationARB( GLhandleARB program, GLuint index, const GLcharARB *name ) {
+	glBindAttribLocation( program, index, reinterpret_cast<const GLchar *>( name ) );
+}
+
+void APIENTRY glCompileShaderARB( GLhandleARB shader ) {
+	glCompileShader( shader );
+}
+
+GLhandleARB APIENTRY glCreateProgramObjectARB( void ) {
+	return glCreateProgram();
+}
+
+GLhandleARB APIENTRY glCreateShaderObjectARB( GLenum type ) {
+	return glCreateShader( type );
+}
+
+void APIENTRY glDeleteObjectARB( GLhandleARB object ) {
+	if ( glIsShader( object ) ) {
+		glDeleteShader( object );
+	} else if ( glIsProgram( object ) ) {
+		glDeleteProgram( object );
+	}
+}
+
+void APIENTRY glDetachObjectARB( GLhandleARB program, GLhandleARB shader ) {
+	glDetachShader( program, shader );
+}
+
+void APIENTRY glGetInfoLogARB( GLhandleARB object, GLsizei maxLength, GLsizei *length, GLcharARB *log ) {
+	if ( glIsShader( object ) ) {
+		glGetShaderInfoLog( object, maxLength, length, reinterpret_cast<GLchar *>( log ) );
+	} else {
+		glGetProgramInfoLog( object, maxLength, length, reinterpret_cast<GLchar *>( log ) );
+	}
+}
+
+void APIENTRY glGetObjectParameterivARB( GLhandleARB object, GLenum pname, GLint *params ) {
+	if ( glIsShader( object ) ) {
+		glGetShaderiv( object, pname, params );
+	} else {
+		glGetProgramiv( object, pname, params );
+	}
+}
+
+GLint APIENTRY glGetUniformLocationARB( GLhandleARB program, const GLcharARB *name ) {
+	return glGetUniformLocation( program, reinterpret_cast<const GLchar *>( name ) );
+}
+
+void APIENTRY glLinkProgramARB( GLhandleARB program ) {
+	glLinkProgram( program );
+}
+
+void APIENTRY glShaderSourceARB( GLhandleARB shader, GLsizei count, const GLcharARB **strings, const GLint *lengths ) {
+	glShaderSource( shader, count, reinterpret_cast<const GLchar *const *>( strings ), lengths );
+}
+
+void APIENTRY glUniform1fARB( GLint location, GLfloat value ) {
+	glUniform1f( location, value );
+}
+
+void APIENTRY glUniform1iARB( GLint location, GLint value ) {
+	glUniform1i( location, value );
+}
+
+void APIENTRY glUniform2fvARB( GLint location, GLsizei count, const GLfloat *value ) {
+	glUniform2fv( location, count, value );
+}
+
+void APIENTRY glUseProgramObjectARB( GLhandleARB program ) {
+	glUseProgram( program );
+}
+
+void APIENTRY glDrawBuffer( GLenum buffer ) {
+	glDrawBuffers( 1, &buffer );
+}
+
+void APIENTRY glBindBuffersBase( GLenum target, GLuint first, GLsizei count, const GLuint *buffers ) {
+	for ( GLsizei i = 0; i < count; ++i ) {
+		glBindBufferBase( target, first + i, buffers != NULL ? buffers[i] : 0 );
+	}
+}
+
+void APIENTRY glBindSamplers( GLuint first, GLsizei count, const GLuint *samplers ) {
+	for ( GLsizei i = 0; i < count; ++i ) {
+		glBindSampler( first + i, samplers != NULL ? samplers[i] : 0 );
+	}
+}
+
+void APIENTRY glCreateBuffers( GLsizei count, GLuint *buffers ) {
+	glGenBuffers( count, buffers );
+}
+
+void APIENTRY glCreateFramebuffers( GLsizei count, GLuint *framebuffers ) {
+	glGenFramebuffers( count, framebuffers );
+}
+
+void APIENTRY glCreateSamplers( GLsizei count, GLuint *samplers ) {
+	glGenSamplers( count, samplers );
+}
+
+void APIENTRY glCreateTextures( GLenum, GLsizei count, GLuint *textures ) {
+	glGenTextures( count, textures );
+}
+
+void APIENTRY glGetQueryObjectiv( GLuint query, GLenum pname, GLint *params ) {
+	GLuint value = 0;
+	glGetQueryObjectuiv( query, pname, &value );
+	*params = static_cast<GLint>( value );
+}
+
+void APIENTRY glGetQueryObjectui64v( GLuint query, GLenum pname, GLuint64 *params ) {
+	GLuint value = 0;
+	glGetQueryObjectuiv( query, pname, &value );
+	*params = value;
+}
+
+GLenum APIENTRY glCheckNamedFramebufferStatus( GLuint, GLenum ) {
+	return GL_FRAMEBUFFER_COMPLETE;
+}
+
+GLuint APIENTRY glGetProgramResourceIndex( GLuint, GLenum, const GLchar * ) {
+	return GL_INVALID_INDEX;
+}
+
+void APIENTRY glUniform1fvARB( GLint location, GLsizei count, const GLfloat *value ) {
+	glUniform1fv( location, count, value );
+}
+
+void APIENTRY glUniform2fARB( GLint location, GLfloat x, GLfloat y ) {
+	glUniform2f( location, x, y );
+}
+
+void APIENTRY glUniform3fvARB( GLint location, GLsizei count, const GLfloat *value ) {
+	glUniform3fv( location, count, value );
+}
+
+void APIENTRY glUniform4fvARB( GLint location, GLsizei count, const GLfloat *value ) {
+	glUniform4fv( location, count, value );
+}
+
+void APIENTRY glUniformMatrix4fvARB( GLint location, GLsizei count, GLboolean transpose, const GLfloat *value ) {
+	glUniformMatrix4fv( location, count, transpose, value );
+}
+
+// Unsupported desktop-only entry points.  Their owning ARB2/ModernGL/debug
+// paths are never selected by BE_WEBGL2.
+void APIENTRY glAlphaFunc( GLenum, GLclampf ) {}
+void APIENTRY glArrayElement( GLint ) {}
+void APIENTRY glBegin( GLenum ) {}
+void APIENTRY glBindProgramARB( GLenum, GLuint ) {}
+void APIENTRY glBindTextureUnit( GLuint, GLuint ) {}
+void APIENTRY glBindTextures( GLuint, GLsizei, const GLuint * ) {}
+void APIENTRY glBindVertexBuffer( GLuint, GLuint, GLintptr, GLsizei ) {}
+void APIENTRY glBufferStorage( GLenum, GLsizeiptr, const void *, GLbitfield ) {}
+void APIENTRY glColor3f( GLfloat, GLfloat, GLfloat ) {}
+void APIENTRY glColor3fv( const GLfloat * ) {}
+void APIENTRY glColor4f( GLfloat, GLfloat, GLfloat, GLfloat ) {}
+void APIENTRY glColor4fv( const GLfloat * ) {}
+void APIENTRY glColor4ubv( const GLubyte * ) {}
+void APIENTRY glColorPointer( GLint, GLenum, GLsizei, const void * ) {}
+void APIENTRY glDebugMessageCallback( GLDEBUGPROC, const void * ) {}
+void APIENTRY glDebugMessageCallbackARB( GLDEBUGPROCARB, const void * ) {}
+void APIENTRY glDebugMessageControl( GLenum, GLenum, GLenum, GLsizei, const GLuint *, GLboolean ) {}
+void APIENTRY glDepthBoundsEXT( GLclampd, GLclampd ) {}
+void APIENTRY glDisableClientState( GLenum ) {}
+void APIENTRY glDispatchCompute( GLuint, GLuint, GLuint ) {}
+void APIENTRY glDrawPixels( GLsizei, GLsizei, GLenum, GLenum, const void * ) {}
+void APIENTRY glEnableClientState( GLenum ) {}
+void APIENTRY glEnd( void ) {}
+void APIENTRY glLoadIdentity( void ) {}
+void APIENTRY glLoadMatrixf( const GLfloat * ) {}
+void APIENTRY glMatrixMode( GLenum ) {}
+void *APIENTRY glMapBufferARB( GLenum, GLenum ) { return NULL; }
+void APIENTRY glMemoryBarrier( GLbitfield ) {}
+void APIENTRY glMultiTexCoord2fARB( GLenum, GLfloat, GLfloat ) {}
+void APIENTRY glMultiDrawElementsIndirect( GLenum, GLenum, const void *, GLsizei, GLsizei ) {}
+void APIENTRY glNamedBufferData( GLuint, GLsizeiptr, const void *, GLenum ) {}
+void APIENTRY glNamedBufferSubData( GLuint, GLintptr, GLsizeiptr, const void * ) {}
+void APIENTRY glNamedFramebufferDrawBuffer( GLuint, GLenum ) {}
+void APIENTRY glNamedFramebufferDrawBuffers( GLuint, GLsizei, const GLenum * ) {}
+void APIENTRY glNamedFramebufferReadBuffer( GLuint, GLenum ) {}
+void APIENTRY glNamedFramebufferTexture( GLuint, GLenum, GLuint, GLint ) {}
+void APIENTRY glNormalPointer( GLenum, GLsizei, const void * ) {}
+void APIENTRY glObjectLabel( GLenum, GLuint, GLsizei, const GLchar * ) {}
+void APIENTRY glOrtho( GLdouble, GLdouble, GLdouble, GLdouble, GLdouble, GLdouble ) {}
+void APIENTRY glPointSize( GLfloat ) {}
+void APIENTRY glPolygonMode( GLenum, GLenum ) {}
+void APIENTRY glPopAttrib( void ) {}
+void APIENTRY glPopDebugGroup( void ) {}
+void APIENTRY glPopMatrix( void ) {}
+void APIENTRY glProgramEnvParameter4fvARB( GLenum, GLuint, const GLfloat * ) {}
+void APIENTRY glProgramLocalParameter4fvARB( GLenum, GLuint, const GLfloat * ) {}
+void APIENTRY glProgramStringARB( GLenum, GLenum, GLsizei, const void * ) {}
+void APIENTRY glPushAttrib( GLbitfield ) {}
+void APIENTRY glPushDebugGroup( GLenum, GLuint, GLsizei, const GLchar * ) {}
+void APIENTRY glPushMatrix( void ) {}
+void APIENTRY glRasterPos2f( GLfloat, GLfloat ) {}
+void APIENTRY glTexCoord2f( GLfloat, GLfloat ) {}
+void APIENTRY glTexCoord2fv( const GLfloat * ) {}
+void APIENTRY glTexCoordPointer( GLint, GLenum, GLsizei, const void * ) {}
+void APIENTRY glTexEnvfv( GLenum, GLenum, const GLfloat * ) {}
+void APIENTRY glTexEnvi( GLenum, GLenum, GLint ) {}
+void APIENTRY glTexGenf( GLenum, GLenum, GLfloat ) {}
+void APIENTRY glTexGenfv( GLenum, GLenum, const GLfloat * ) {}
+void APIENTRY glTexImage2DMultisample( GLenum, GLsizei, GLenum, GLsizei, GLsizei, GLboolean ) {}
+void APIENTRY glTextureParameteri( GLuint, GLenum, GLint ) {}
+void APIENTRY glTextureStorage2D( GLuint, GLsizei, GLenum, GLsizei, GLsizei ) {}
+void APIENTRY glTextureStorage2DMultisample( GLuint, GLsizei, GLenum, GLsizei, GLsizei, GLboolean ) {}
+GLboolean APIENTRY glUnmapBufferARB( GLenum ) { return GL_FALSE; }
+void APIENTRY glVertex2f( GLfloat, GLfloat ) {}
+void APIENTRY glVertex3f( GLfloat, GLfloat, GLfloat ) {}
+void APIENTRY glVertex3fv( const GLfloat * ) {}
+void APIENTRY glVertexPointer( GLint, GLenum, GLsizei, const void * ) {}
+void APIENTRY glVertexAttribBinding( GLuint, GLuint ) {}
+void APIENTRY glVertexAttribFormat( GLuint, GLint, GLenum, GLboolean, GLuint ) {}
+void APIENTRY glVertexBindingDivisor( GLuint, GLuint ) {}
+
+} // extern "C"
+
+#endif
diff --git a/src/sound/OpenAL/AL_SoundHardware.cpp b/src/sound/OpenAL/AL_SoundHardware.cpp
index 2b0a42f..3acec9a 100644
--- a/src/sound/OpenAL/AL_SoundHardware.cpp
+++ b/src/sound/OpenAL/AL_SoundHardware.cpp
@@ -288,7 +288,7 @@ static bool openQ4_LoadHardwareEfxProcs() {
 }
 #endif
 
-#if defined( ALC_HRTF_SOFT ) && defined( ALC_HRTF_STATUS_SOFT ) && defined( ALC_HRTF_DISABLED_SOFT ) && defined( ALC_HRTF_ENABLED_SOFT )
+#if !defined( __EMSCRIPTEN__ ) && defined( ALC_HRTF_SOFT ) && defined( ALC_HRTF_STATUS_SOFT ) && defined( ALC_HRTF_DISABLED_SOFT ) && defined( ALC_HRTF_ENABLED_SOFT )
 	#define OPENQ4_OPENAL_HRTF_SUPPORTED 1
 #else
 	#define OPENQ4_OPENAL_HRTF_SUPPORTED 0
@@ -1970,4 +1970,3 @@ void idSoundHardware_OpenAL::Update()
 	*/
 }
 
-
diff --git a/src/sys/emscripten/wasm_net.cpp b/src/sys/emscripten/wasm_net.cpp
new file mode 100644
index 0000000..9361efa
--- /dev/null
+++ b/src/sys/emscripten/wasm_net.cpp
@@ -0,0 +1,705 @@
+/*
+===========================================================================
+
+Doom 3 GPL Source Code
+Copyright (C) 1999-2011 id Software LLC, a ZeniMax Media company.
+
+This file is part of the Doom 3 GPL Source Code. Doom 3 Source Code is free
+software: you can redistribute it and/or modify it under the terms of the GNU
+General Public License as published by the Free Software Foundation, either
+version 3 of the License, or (at your option) any later version.
+
+===========================================================================
+*/
+
+#include "../../idlib/precompiled.h"
+
+#include <arpa/inet.h>
+#include <ctype.h>
+#include <emscripten/emscripten.h>
+#include <emscripten/websocket.h>
+#include <stdint.h>
+#include <stdlib.h>
+
+idPort clientPort, serverPort;
+
+idCVar net_ip( "net_ip", "localhost", CVAR_SYSTEM, "local IP address" );
+idCVar net_port( "net_port", "", CVAR_SYSTEM | CVAR_INTEGER, "local IP port number" );
+
+namespace {
+
+const int WEB_MAX_DATAGRAM_BYTES = 65507;
+const int WEB_QUEUE_CAPACITY = 64;
+const int WEB_QUEUE_BYTE_LIMIT = 1024 * 1024;
+const size_t WEB_BACKPRESSURE_BYTES = 512 * 1024;
+const double WEB_RECONNECT_MIN_MSEC = 250.0;
+const double WEB_RECONNECT_MAX_MSEC = 10000.0;
+
+struct webDatagram_t {
+	netadr_t	from;
+	byte *		data;
+	int			size;
+};
+
+class idWebDatagramQueue {
+public:
+	idWebDatagramQueue() : head( 0 ), count( 0 ), bytes( 0 ) {
+		memset( entries, 0, sizeof( entries ) );
+	}
+
+	~idWebDatagramQueue() {
+		Clear();
+	}
+
+	bool Push( const netadr_t &from, const void *data, int size ) {
+		if ( size < 0 || size > WEB_MAX_DATAGRAM_BYTES || ( size > 0 && data == NULL ) ) {
+			return false;
+		}
+		if ( count == WEB_QUEUE_CAPACITY || bytes + size > WEB_QUEUE_BYTE_LIMIT ) {
+			return false;
+		}
+
+		const int tail = ( head + count ) % WEB_QUEUE_CAPACITY;
+		webDatagram_t &entry = entries[ tail ];
+		entry.from = from;
+		entry.size = size;
+		entry.data = size > 0 ? static_cast<byte *>( malloc( size ) ) : NULL;
+		if ( size > 0 && entry.data == NULL ) {
+			entry.size = 0;
+			return false;
+		}
+		if ( size > 0 ) {
+			memcpy( entry.data, data, size );
+		}
+		count++;
+		bytes += size;
+		return true;
+	}
+
+	const webDatagram_t *Front() const {
+		return count > 0 ? &entries[ head ] : NULL;
+	}
+
+	void Pop() {
+		if ( count == 0 ) {
+			return;
+		}
+		webDatagram_t &entry = entries[ head ];
+		bytes -= entry.size;
+		free( entry.data );
+		memset( &entry, 0, sizeof( entry ) );
+		head = ( head + 1 ) % WEB_QUEUE_CAPACITY;
+		count--;
+		if ( count == 0 ) {
+			head = 0;
+		}
+	}
+
+	void Clear() {
+		while ( count > 0 ) {
+			Pop();
+		}
+	}
+
+	int Count() const {
+		return count;
+	}
+
+private:
+	webDatagram_t	entries[ WEB_QUEUE_CAPACITY ];
+	int				head;
+	int				count;
+	int				bytes;
+};
+
+enum webSocketPhase_t {
+	WEB_SOCKET_IDLE,
+	WEB_SOCKET_CONNECTING,
+	WEB_SOCKET_OPEN,
+	WEB_SOCKET_RETRY
+};
+
+struct webPortState_t {
+	idPort *			owner;
+	EMSCRIPTEN_WEBSOCKET_T socket;
+	webSocketPhase_t	phase;
+	netadr_t			remote;
+	bool				hasRemote;
+	bool				closing;
+	int				retryAttempt;
+	double				retryAt;
+	unsigned int		droppedIncoming;
+	unsigned int		droppedOutgoing;
+	idWebDatagramQueue incoming;
+	idWebDatagramQueue outgoing;
+
+	webPortState_t() : owner( NULL ), socket( 0 ), phase( WEB_SOCKET_IDLE ),
+			hasRemote( false ), closing( false ), retryAttempt( 0 ), retryAt( 0.0 ),
+			droppedIncoming( 0 ), droppedOutgoing( 0 ) {
+		memset( &remote, 0, sizeof( remote ) );
+	}
+};
+
+static_assert( sizeof( void * ) == sizeof( int ), "The WebSocket idPort backend requires wasm32 pointers" );
+static webPortState_t *webTextChannelState = NULL;
+
+EM_JS( int, Web_BuildGatewayUrl, ( int port, char *buffer, int bufferSize ), {
+	var configured = Module['q4WebSocketUrl'];
+	var value = typeof configured === 'function' ? configured(port) : configured;
+	if (typeof value !== 'string' || !value.length) {
+		value = '/game/' + port;
+	}
+	value = value.split('{port}').join(String(port));
+	if (value.charAt(0) === '/') {
+		var locationObject = globalThis.location;
+		var secure = locationObject && locationObject.protocol === 'https:';
+		var host = locationObject && locationObject.host ? locationObject.host : '127.0.0.1';
+		value = (secure ? 'wss://' : 'ws://') + host + value;
+	}
+	var needed = lengthBytesUTF8(value) + 1;
+	if (needed > bufferSize) {
+		return 0;
+	}
+	stringToUTF8(value, buffer, bufferSize);
+	return 1;
+} );
+
+EM_JS( void, Web_InstallNetworkApi, (), {
+	var network = Module['q4Network'] || {};
+	var textHandler = typeof network['onText'] === 'function' ? network['onText'] : null;
+	var pendingText = Array.isArray(network['_pendingText']) ? network['_pendingText'] : [];
+	Object.defineProperty(network, 'onText', {
+		configurable: true,
+		get: function() { return textHandler; },
+		set: function(handler) {
+			textHandler = typeof handler === 'function' ? handler : null;
+			if (!textHandler) return;
+			var queued = pendingText.splice(0);
+			for (var i = 0; i < queued.length; ++i) textHandler(queued[i]);
+		}
+	});
+	network['_pendingText'] = pendingText;
+	network['_dispatchText'] = function(text) {
+		if (textHandler) {
+			textHandler(text);
+		} else {
+			if (pendingText.length >= 64) pendingText.shift();
+			pendingText.push(text);
+		}
+	};
+	network['sendText'] = function(payload) {
+		var text = typeof payload === 'string' ? payload : JSON.stringify(payload);
+		if (typeof text !== 'string') {
+			return false;
+		}
+		var pointer = stringToNewUTF8(text);
+		try {
+			return !!Module['_Q4_WebSocketSendText'](pointer);
+		} finally {
+			_free(pointer);
+		}
+	};
+	Module['q4Network'] = network;
+} );
+
+EM_JS( void, Web_DispatchText, ( const char *data, int size ), {
+	var network = Module['q4Network'];
+	if (network && typeof network['_dispatchText'] === 'function') {
+		network['_dispatchText'](UTF8ToString(data, size));
+	}
+} );
+
+static bool Web_EqualAddress( const netadr_t &left, const netadr_t &right ) {
+	if ( left.type != right.type || left.port != right.port || left.scopeId != right.scopeId ) {
+		return false;
+	}
+	switch ( left.type ) {
+		case NA_LOOPBACK:
+		case NA_BROADCAST:
+		case NA_BOT:
+			return true;
+		case NA_IP:
+			return memcmp( left.ip, right.ip, sizeof( left.ip ) ) == 0;
+		case NA_IP6:
+			return memcmp( left.ip6, right.ip6, sizeof( left.ip6 ) ) == 0;
+		default:
+			return false;
+	}
+}
+
+static double Web_Now() {
+	return emscripten_get_now();
+}
+
+static void Web_DeleteSocket( webPortState_t *state, bool sendClose ) {
+	if ( state == NULL || state->socket <= 0 ) {
+		return;
+	}
+	const EMSCRIPTEN_WEBSOCKET_T socket = state->socket;
+	state->socket = 0;
+	if ( sendClose ) {
+		emscripten_websocket_close( socket, 1000, "OpenQ4 transport reset" );
+	}
+	emscripten_websocket_delete( socket );
+}
+
+static void Web_ScheduleReconnect( webPortState_t *state ) {
+	if ( state == NULL || state->closing || !state->hasRemote ) {
+		return;
+	}
+	double delay = WEB_RECONNECT_MIN_MSEC;
+	for ( int i = 0; i < state->retryAttempt && delay < WEB_RECONNECT_MAX_MSEC; i++ ) {
+		delay *= 2.0;
+	}
+	if ( delay > WEB_RECONNECT_MAX_MSEC ) {
+		delay = WEB_RECONNECT_MAX_MSEC;
+	}
+	if ( state->retryAttempt < 16 ) {
+		state->retryAttempt++;
+	}
+	state->retryAt = Web_Now() + delay;
+	state->phase = WEB_SOCKET_RETRY;
+}
+
+static void Web_FlushOutgoing( webPortState_t *state );
+
+static bool Web_OnOpen( int, const EmscriptenWebSocketOpenEvent *event, void *userData ) {
+	webPortState_t *state = static_cast<webPortState_t *>( userData );
+	if ( state == NULL || state->closing || state->socket != event->socket ) {
+		return true;
+	}
+	state->phase = WEB_SOCKET_OPEN;
+	state->retryAttempt = 0;
+	state->retryAt = 0.0;
+	common->Printf( "WebSocket UDP gateway connected: %s\n", Sys_NetAdrToString( state->remote ) );
+	Web_FlushOutgoing( state );
+	return true;
+}
+
+static bool Web_OnMessage( int, const EmscriptenWebSocketMessageEvent *event, void *userData ) {
+	webPortState_t *state = static_cast<webPortState_t *>( userData );
+	if ( state == NULL || state->closing || state->socket != event->socket ) {
+		return true;
+	}
+	if ( event->isText ) {
+		Web_DispatchText( reinterpret_cast<const char *>( event->data ), static_cast<int>( event->numBytes ) );
+		return true;
+	}
+	if ( event->numBytes > WEB_MAX_DATAGRAM_BYTES ||
+			!state->incoming.Push( state->remote, event->data, static_cast<int>( event->numBytes ) ) ) {
+		state->droppedIncoming++;
+		if ( state->droppedIncoming == 1 || ( state->droppedIncoming & 63 ) == 0 ) {
+			common->Warning( "WebSocket UDP gateway dropped %u incoming frame(s)", state->droppedIncoming );
+		}
+	}
+	return true;
+}
+
+static bool Web_OnError( int, const EmscriptenWebSocketErrorEvent *event, void *userData ) {
+	webPortState_t *state = static_cast<webPortState_t *>( userData );
+	if ( state == NULL || state->closing || state->socket != event->socket ) {
+		return true;
+	}
+	common->DPrintf( "WebSocket UDP gateway error; reconnect scheduled\n" );
+	Web_DeleteSocket( state, true );
+	Web_ScheduleReconnect( state );
+	return true;
+}
+
+static bool Web_OnClose( int, const EmscriptenWebSocketCloseEvent *event, void *userData ) {
+	webPortState_t *state = static_cast<webPortState_t *>( userData );
+	if ( state == NULL || state->closing || state->socket != event->socket ) {
+		return true;
+	}
+	common->DPrintf( "WebSocket UDP gateway closed (%u: %s); reconnect scheduled\n",
+			static_cast<unsigned int>( event->code ), event->reason );
+	Web_DeleteSocket( state, false );
+	Web_ScheduleReconnect( state );
+	return true;
+}
+
+static bool Web_OpenSocket( webPortState_t *state ) {
+	if ( state == NULL || state->closing || !state->hasRemote || state->socket > 0 ) {
+		return state != NULL && state->socket > 0;
+	}
+	if ( !emscripten_websocket_is_supported() ) {
+		state->phase = WEB_SOCKET_RETRY;
+		return false;
+	}
+
+	const int remotePort = state->remote.port != 0 ? state->remote.port : PORT_SERVER;
+	char url[ 1024 ];
+	if ( !Web_BuildGatewayUrl( remotePort, url, sizeof( url ) ) ) {
+		common->Warning( "WebSocket UDP gateway URL is too long" );
+		Web_ScheduleReconnect( state );
+		return false;
+	}
+
+	EmscriptenWebSocketCreateAttributes attributes;
+	emscripten_websocket_init_create_attributes( &attributes );
+	attributes.url = url;
+	attributes.protocols = NULL;
+	attributes.createOnMainThread = true;
+	state->socket = emscripten_websocket_new( &attributes );
+	if ( state->socket <= 0 ) {
+		state->socket = 0;
+		Web_ScheduleReconnect( state );
+		return false;
+	}
+
+	state->phase = WEB_SOCKET_CONNECTING;
+	emscripten_websocket_set_onopen_callback( state->socket, state, Web_OnOpen );
+	emscripten_websocket_set_onmessage_callback( state->socket, state, Web_OnMessage );
+	emscripten_websocket_set_onerror_callback( state->socket, state, Web_OnError );
+	emscripten_websocket_set_onclose_callback( state->socket, state, Web_OnClose );
+	return true;
+}
+
+static void Web_EnsureSocket( webPortState_t *state ) {
+	if ( state == NULL || state->closing || !state->hasRemote || state->socket > 0 ) {
+		return;
+	}
+	if ( state->phase == WEB_SOCKET_RETRY && Web_Now() < state->retryAt ) {
+		return;
+	}
+	Web_OpenSocket( state );
+}
+
+static bool Web_SendNow( webPortState_t *state, const void *data, int size ) {
+	if ( state == NULL || state->phase != WEB_SOCKET_OPEN || state->socket <= 0 ) {
+		return false;
+	}
+	size_t bufferedAmount = 0;
+	if ( emscripten_websocket_get_buffered_amount( state->socket, &bufferedAmount ) != EMSCRIPTEN_RESULT_SUCCESS ||
+			bufferedAmount > WEB_BACKPRESSURE_BYTES ) {
+		return false;
+	}
+	if ( emscripten_websocket_send_binary( state->socket, const_cast<void *>( data ), size ) != EMSCRIPTEN_RESULT_SUCCESS ) {
+		Web_DeleteSocket( state, true );
+		Web_ScheduleReconnect( state );
+		return false;
+	}
+	if ( state->owner != NULL ) {
+		state->owner->packetsWritten++;
+		state->owner->bytesWritten += size;
+	}
+	return true;
+}
+
+static void Web_FlushOutgoing( webPortState_t *state ) {
+	if ( state == NULL ) {
+		return;
+	}
+	while ( state->phase == WEB_SOCKET_OPEN ) {
+		const webDatagram_t *entry = state->outgoing.Front();
+		if ( entry == NULL || !Web_SendNow( state, entry->data, entry->size ) ) {
+			break;
+		}
+		state->outgoing.Pop();
+	}
+}
+
+static bool Web_ParsePort( const char *text, unsigned short &port ) {
+	if ( text == NULL || text[0] == '\0' ) {
+		return false;
+	}
+	char *end = NULL;
+	const long value = strtol( text, &end, 10 );
+	if ( end == text || *end != '\0' || value < 0 || value > 65535 ) {
+		return false;
+	}
+	port = static_cast<unsigned short>( value );
+	return true;
+}
+
+static bool Web_SplitAddress( const char *text, char *host, int hostSize, unsigned short &port ) {
+	if ( text == NULL || text[0] == '\0' || host == NULL || hostSize <= 1 ) {
+		return false;
+	}
+	port = 0;
+	if ( text[0] == '[' ) {
+		const char *end = strchr( text + 1, ']' );
+		if ( end == NULL || end == text + 1 || end - text - 1 >= hostSize ) {
+			return false;
+		}
+		memcpy( host, text + 1, end - text - 1 );
+		host[ end - text - 1 ] = '\0';
+		if ( end[1] == '\0' ) {
+			return true;
+		}
+		return end[1] == ':' && Web_ParsePort( end + 2, port );
+	}
+
+	const char *firstColon = strchr( text, ':' );
+	const char *lastColon = strrchr( text, ':' );
+	if ( firstColon != NULL && firstColon == lastColon ) {
+		if ( firstColon == text || firstColon - text >= hostSize || !Web_ParsePort( firstColon + 1, port ) ) {
+			return false;
+		}
+		memcpy( host, text, firstColon - text );
+		host[ firstColon - text ] = '\0';
+		return true;
+	}
+
+	if ( static_cast<int>( strlen( text ) ) >= hostSize ) {
+		return false;
+	}
+	idStr::Copynz( host, text, hostSize );
+	return true;
+}
+
+static void Web_HostnameAddress( const char *host, netadr_t *address ) {
+	// Browsers intentionally do not expose DNS results. Map names into the
+	// RFC 2544 benchmarking range so comparisons remain stable without
+	// pretending that the synthetic address is routable from JavaScript.
+	uint32_t hash = 2166136261u;
+	for ( const unsigned char *cursor = reinterpret_cast<const unsigned char *>( host ); *cursor; cursor++ ) {
+		hash ^= static_cast<uint32_t>( tolower( *cursor ) );
+		hash *= 16777619u;
+	}
+	address->type = NA_IP;
+	address->ip[0] = 198;
+	address->ip[1] = static_cast<unsigned char>( 18 + ( ( hash >> 16 ) & 1 ) );
+	address->ip[2] = static_cast<unsigned char>( hash >> 8 );
+	address->ip[3] = static_cast<unsigned char>( hash );
+}
+
+} // namespace
+
+extern "C" EMSCRIPTEN_KEEPALIVE int Q4_WebSocketSendText( const char *text ) {
+	webPortState_t *state = webTextChannelState;
+	if ( state == NULL || state->closing || state->phase != WEB_SOCKET_OPEN || state->socket <= 0 || text == NULL ) {
+		return 0;
+	}
+	return emscripten_websocket_send_utf8_text( state->socket, text ) == EMSCRIPTEN_RESULT_SUCCESS ? 1 : 0;
+}
+
+bool Sys_StringToNetAdr( const char *text, netadr_t *address, bool doDNSResolve ) {
+	if ( address == NULL ) {
+		return false;
+	}
+	memset( address, 0, sizeof( *address ) );
+	char host[ 512 ];
+	unsigned short port = 0;
+	if ( !Web_SplitAddress( text, host, sizeof( host ), port ) ) {
+		return false;
+	}
+
+	if ( idStr::Icmp( host, "localhost" ) == 0 ) {
+		address->type = NA_LOOPBACK;
+		address->ip[0] = 127;
+		address->ip[3] = 1;
+	} else if ( inet_pton( AF_INET, host, address->ip ) == 1 ) {
+		address->type = address->ip[0] == 127 ? NA_LOOPBACK : NA_IP;
+	} else if ( inet_pton( AF_INET6, host, address->ip6 ) == 1 ) {
+		address->type = NA_IP6;
+	} else if ( doDNSResolve ) {
+		Web_HostnameAddress( host, address );
+	} else {
+		return false;
+	}
+	address->port = port;
+	return true;
+}
+
+const char *Sys_NetAdrToString( const netadr_t address ) {
+	static char strings[4][128];
+	static int index = 0;
+	char *text = strings[ index++ & 3 ];
+	if ( address.type == NA_LOOPBACK ) {
+		idStr::snPrintf( text, sizeof( strings[0] ), address.port ? "localhost:%u" : "localhost", address.port );
+	} else if ( address.type == NA_IP ) {
+		idStr::snPrintf( text, sizeof( strings[0] ), "%u.%u.%u.%u:%u", address.ip[0], address.ip[1],
+				address.ip[2], address.ip[3], address.port );
+	} else if ( address.type == NA_IP6 ) {
+		char ipv6[ INET6_ADDRSTRLEN ];
+		if ( inet_ntop( AF_INET6, address.ip6, ipv6, sizeof( ipv6 ) ) == NULL ) {
+			idStr::Copynz( ipv6, "::", sizeof( ipv6 ) );
+		}
+		if ( address.port ) {
+			idStr::snPrintf( text, sizeof( strings[0] ), "[%s]:%u", ipv6, address.port );
+		} else {
+			idStr::Copynz( text, ipv6, sizeof( strings[0] ) );
+		}
+	} else if ( address.type == NA_BROADCAST ) {
+		idStr::snPrintf( text, sizeof( strings[0] ), "broadcast:%u", address.port );
+	} else {
+		idStr::Copynz( text, "bad", sizeof( strings[0] ) );
+	}
+	return text;
+}
+
+bool Sys_IsLANAddress( const netadr_t address ) {
+	if ( address.type == NA_LOOPBACK ) {
+		return true;
+	}
+	if ( address.type == NA_IP ) {
+		return address.ip[0] == 10 || address.ip[0] == 127 ||
+				( address.ip[0] == 169 && address.ip[1] == 254 ) ||
+				( address.ip[0] == 172 && address.ip[1] >= 16 && address.ip[1] <= 31 ) ||
+				( address.ip[0] == 192 && address.ip[1] == 168 );
+	}
+	if ( address.type == NA_IP6 ) {
+		static const byte loopback[16] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 };
+		return memcmp( address.ip6, loopback, sizeof( loopback ) ) == 0 ||
+				( address.ip6[0] == 0xfe && ( address.ip6[1] & 0xc0 ) == 0x80 ) ||
+				( address.ip6[0] & 0xfe ) == 0xfc;
+	}
+	return false;
+}
+
+bool Sys_CompareNetAdrBase( const netadr_t left, const netadr_t right ) {
+	if ( left.type != right.type ) {
+		return false;
+	}
+	if ( left.type == NA_LOOPBACK ) {
+		return true;
+	}
+	if ( left.type == NA_IP ) {
+		return memcmp( left.ip, right.ip, sizeof( left.ip ) ) == 0;
+	}
+	if ( left.type == NA_IP6 ) {
+		return left.scopeId == right.scopeId && memcmp( left.ip6, right.ip6, sizeof( left.ip6 ) ) == 0;
+	}
+	return false;
+}
+
+void Sys_InitNetworking() {
+	Web_InstallNetworkApi();
+	if ( !emscripten_websocket_is_supported() ) {
+		common->Warning( "This browser does not provide WebSocket networking" );
+	}
+}
+
+void Sys_ShutdownNetworking() {
+}
+
+idPort::idPort() : packetsRead( 0 ), bytesRead( 0 ), packetsWritten( 0 ), bytesWritten( 0 ), netSocket( 0 ), netSocket6( 0 ) {
+	memset( &bound_to, 0, sizeof( bound_to ) );
+}
+
+idPort::~idPort() {
+	Close();
+}
+
+bool idPort::InitForPort( int portNumber ) {
+	Close();
+	webPortState_t *state = new webPortState_t;
+	if ( state == NULL ) {
+		return false;
+	}
+	state->owner = this;
+	webTextChannelState = state;
+	netSocket = static_cast<int>( reinterpret_cast<intptr_t>( state ) );
+	netSocket6 = 0;
+	bound_to.type = NA_LOOPBACK;
+	bound_to.ip[0] = 127;
+	bound_to.ip[3] = 1;
+	// GetPort() is also the engine's initialized flag. Browser sockets have no
+	// observable UDP source port, so reserve one for PORT_ANY.
+	bound_to.port = portNumber == PORT_ANY ? 1 : static_cast<unsigned short>( portNumber );
+	return true;
+}
+
+void idPort::Close() {
+	webPortState_t *state = netSocket != 0
+			? reinterpret_cast<webPortState_t *>( static_cast<intptr_t>( netSocket ) ) : NULL;
+	netSocket = 0;
+	netSocket6 = 0;
+	memset( &bound_to, 0, sizeof( bound_to ) );
+	if ( state != NULL ) {
+		if ( webTextChannelState == state ) {
+			webTextChannelState = NULL;
+		}
+		state->closing = true;
+		state->owner = NULL;
+		Web_DeleteSocket( state, true );
+		delete state;
+	}
+}
+
+bool idPort::GetPacket( netadr_t &from, void *data, int &size, int maxSize ) {
+	size = 0;
+	if ( netSocket == 0 || data == NULL || maxSize <= 0 ) {
+		return false;
+	}
+	webPortState_t *state = reinterpret_cast<webPortState_t *>( static_cast<intptr_t>( netSocket ) );
+	Web_EnsureSocket( state );
+	Web_FlushOutgoing( state );
+	const webDatagram_t *entry = state->incoming.Front();
+	if ( entry == NULL ) {
+		return false;
+	}
+	from = entry->from;
+	size = Min( maxSize, entry->size );
+	if ( size > 0 ) {
+		memcpy( data, entry->data, size );
+	}
+	state->incoming.Pop();
+	packetsRead++;
+	bytesRead += size;
+	return true;
+}
+
+bool idPort::GetPacketBlocking( netadr_t &from, void *data, int &size, int maxSize, int ) {
+	// Blocking the browser main thread prevents WebSocket callbacks from being
+	// delivered, so the web backend is deliberately poll-only.
+	return GetPacket( from, data, size, maxSize );
+}
+
+void idPort::SendPacket( const netadr_t to, const void *data, int size ) {
+	if ( netSocket == 0 || to.type == NA_BAD || to.type == NA_BOT || size < 0 ||
+			size > WEB_MAX_DATAGRAM_BYTES || ( size > 0 && data == NULL ) ) {
+		common->Warning( "idPort::SendPacket: invalid WebSocket datagram" );
+		return;
+	}
+	webPortState_t *state = reinterpret_cast<webPortState_t *>( static_cast<intptr_t>( netSocket ) );
+	if ( !state->hasRemote || !Web_EqualAddress( state->remote, to ) ) {
+		Web_DeleteSocket( state, true );
+		state->incoming.Clear();
+		state->outgoing.Clear();
+		state->remote = to;
+		state->hasRemote = true;
+		state->phase = WEB_SOCKET_IDLE;
+		state->retryAttempt = 0;
+		state->retryAt = 0.0;
+	}
+
+	Web_EnsureSocket( state );
+	Web_FlushOutgoing( state );
+	if ( state->outgoing.Count() == 0 && Web_SendNow( state, data, size ) ) {
+		return;
+	}
+	if ( !state->outgoing.Push( to, data, size ) ) {
+		state->droppedOutgoing++;
+		if ( state->droppedOutgoing == 1 || ( state->droppedOutgoing & 63 ) == 0 ) {
+			common->Warning( "WebSocket UDP gateway dropped %u outgoing datagram(s)", state->droppedOutgoing );
+		}
+	}
+}
+
+idTCP::idTCP() : fd( 0 ) {
+	memset( &address, 0, sizeof( address ) );
+}
+
+idTCP::~idTCP() {
+	Close();
+}
+
+bool idTCP::Init( const char *, short ) {
+	common->Warning( "idTCP is unavailable in the browser build; use the HTTP filesystem path" );
+	return false;
+}
+
+void idTCP::Close() {
+	fd = 0;
+	memset( &address, 0, sizeof( address ) );
+}
+
+int idTCP::Read( void *, int ) {
+	return -1;
+}
+
+int idTCP::Write( void *, int ) {
+	return -1;
+}
diff --git a/src/sys/linux/main.cpp b/src/sys/linux/main.cpp
index 65aebf9..7bcf405 100644
--- a/src/sys/linux/main.cpp
+++ b/src/sys/linux/main.cpp
@@ -27,6 +27,10 @@ If you have questions concerning this license or the applicable additional terms
 */
 #include "../../idlib/precompiled.h"
 #include "../posix/posix_public.h"
+#ifdef __EMSCRIPTEN__
+#include <emscripten/emscripten.h>
+#include <emscripten/wasmfs.h>
+#endif
 #include "../sys_local.h"
 
 #include <pthread.h>
@@ -1055,6 +1059,37 @@ static void Sys_HandlePendingQuitSignal( void ) {
 	common->Quit();
 }
 
+#ifdef __EMSCRIPTEN__
+static void Sys_BrowserFrame( void * ) {
+	static bool browserVideoSettingsNormalized = false;
+	if ( !browserVideoSettingsNormalized && cvarSystem != NULL ) {
+		// Browser canvases cannot use SDL desktop fullscreen, and the low-spec
+		// resolution-scale path depends on optional desktop shader assets.
+		cvarSystem->SetCVarBool( "r_fullscreen", false );
+		cvarSystem->SetCVarInteger( "r_screenFraction", 100 );
+		cvarSystem->SetCVarInteger( "r_resolutionScaleMode", 0 );
+		cvarSystem->SetCVarInteger( "r_swapInterval", 1 );
+		browserVideoSettingsNormalized = true;
+	}
+
+	common->Async();
+	common->Frame();
+}
+
+extern "C" EMSCRIPTEN_KEEPALIVE int Q4_WebExecuteCommand( const char *text ) {
+	if ( text == NULL || text[0] == '\0' || cmdSystem == NULL ) {
+		return 0;
+	}
+	cmdSystem->BufferCommandText( CMD_EXEC_APPEND, text );
+	cmdSystem->BufferCommandText( CMD_EXEC_APPEND, "\n" );
+	return 1;
+}
+
+extern "C" EMSCRIPTEN_KEEPALIVE int Q4_WebInputState( void ) {
+	return usercmdGen != NULL && usercmdGen->KeyState( K_MOUSE1 ) ? 1 : 0;
+}
+#endif
+
 /*
 ===============
 main
@@ -1068,21 +1103,46 @@ int main(int argc, const char **argv) {
 #endif
 	
 	Posix_EarlyInit( );
+#ifdef __EMSCRIPTEN__
+	backend_t opfsBackend = wasmfs_create_opfs_backend();
+	if ( wasmfs_create_directory( "/opfs", 0777, opfsBackend ) != 0 && errno != EEXIST ) {
+		Sys_Printf( "could not mount OPFS at /opfs: %s\n", strerror( errno ) );
+		return EXIT_FAILURE;
+	}
+#else
 	Sys_ReportWaylandRuntime();
 	Sys_ShowSplash();
+#endif
 
 	if ( argc > 1 ) {
 		common->Init( argc-1, &argv[1], NULL );
 	} else {
 		common->Init( 0, NULL, NULL );
 	}
+#ifdef __EMSCRIPTEN__
+	EM_ASM({
+		var shell = globalThis.Q4Shell;
+		if (shell && typeof shell.markReady === 'function') {
+			Promise.resolve(shell.markReady(Module)).catch(function(error) {
+				if (typeof shell.fail === 'function') shell.fail(error);
+			});
+		}
+	});
+#endif
+#ifndef __EMSCRIPTEN__
 	Sys_DestroySplash();
+#endif
 
 	Sys_HandlePendingQuitSignal();
 	Posix_LateInit( );
 
+#ifdef __EMSCRIPTEN__
+	emscripten_set_main_loop_arg( Sys_BrowserFrame, NULL, 0, true );
+	return EXIT_SUCCESS;
+#else
 	while (1) {
 		Sys_HandlePendingQuitSignal();
 		common->Frame();
 	}
+#endif
 }
diff --git a/src/sys/posix/posix_main.cpp b/src/sys/posix/posix_main.cpp
index d9d4b01..03bf82d 100644
--- a/src/sys/posix/posix_main.cpp
+++ b/src/sys/posix/posix_main.cpp
@@ -930,7 +930,11 @@ void Posix_LateInit( void ) {
 	common->Printf( "Video memory: %s\n", Sys_FormatMemoryMB( Sys_GetVideoRam() ).c_str() );
 #endif
 	Posix_ConsoleLateInit();
+#ifndef __EMSCRIPTEN__
 	Posix_StartAsyncThread( );
+#else
+	common->Printf( "Async ticks run on the browser main loop\n" );
+#endif
 }
 
 /*
diff --git a/src/sys/posix/posix_net.cpp b/src/sys/posix/posix_net.cpp
index 5bcf128..d804e6f 100644
--- a/src/sys/posix/posix_net.cpp
+++ b/src/sys/posix/posix_net.cpp
@@ -46,6 +46,12 @@ If you have questions concerning this license or the applicable additional terms
 
 #include "../../idlib/precompiled.h"
 
+#if defined( __EMSCRIPTEN__ ) && !defined( TEMP_FAILURE_RETRY )
+// musl does not expose glibc's retry macro. The final browser transport uses
+// WebSockets; this keeps the remaining POSIX TCP helpers buildable meanwhile.
+#define TEMP_FAILURE_RETRY( expression ) ( expression )
+#endif
+
 idPort clientPort, serverPort;
 
 idCVar net_ip( "net_ip", "localhost", CVAR_SYSTEM, "local IP address" );
diff --git a/src/sys/sdl3/sdl3_backend.cpp b/src/sys/sdl3/sdl3_backend.cpp
index 622ed38..16bec9f 100644
--- a/src/sys/sdl3/sdl3_backend.cpp
+++ b/src/sys/sdl3/sdl3_backend.cpp
@@ -5137,6 +5137,19 @@ static void SDL3_MoveCompatibilityFallbacksToFront(rendererContextCandidate_t *c
 }
 
 static int SDL3_BuildGLContextCandidates(rendererContextCandidate_t *candidates, int maxCandidates) {
+#if defined(__EMSCRIPTEN__)
+	if (candidates == NULL || maxCandidates <= 0) {
+		return 0;
+	}
+	memset(&candidates[0], 0, sizeof(candidates[0]));
+	candidates[0].major = 3;
+	candidates[0].minor = 0;
+	candidates[0].profile = RENDERER_CONTEXT_PROFILE_CORE;
+	candidates[0].explicitVersion = true;
+	candidates[0].debugContext = false;
+	idStr::Copynz(candidates[0].label, "WebGL 2 / GLES 3.0", sizeof(candidates[0].label));
+	return 1;
+#else
 	const rendererTierPreference_t preference = RendererTierPreference_FromString(r_glTier.GetString());
 	const bool keepAutoCompatibility = preference == RENDERER_TIER_PREF_AUTO;
 	const int candidateCount = RendererContextLadder_Build(
@@ -5160,6 +5173,7 @@ static int SDL3_BuildGLContextCandidates(rendererContextCandidate_t *candidates,
 	}
 
 	return candidateCount;
+#endif
 }
 
 static int SDL3_NormalizeMSAASampleFallback(const int samples) {
@@ -5217,9 +5231,13 @@ static void SDL3_SetGLAttributesForCandidate(glimpParms_t parms, const rendererC
 		(void)SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, candidate.minor);
 		(void)SDL_GL_SetAttribute(
 			SDL_GL_CONTEXT_PROFILE_MASK,
+#if defined(__EMSCRIPTEN__)
+			SDL_GL_CONTEXT_PROFILE_ES);
+#else
 			candidate.profile == RENDERER_CONTEXT_PROFILE_CORE
 				? SDL_GL_CONTEXT_PROFILE_CORE
 				: SDL_GL_CONTEXT_PROFILE_COMPATIBILITY);
+#endif
 	}
 	(void)SDL_GL_SetAttribute(
 		SDL_GL_CONTEXT_FLAGS,
diff --git a/src/sys/sys_local.cpp b/src/sys/sys_local.cpp
index acd883c..fef9125 100644
--- a/src/sys/sys_local.cpp
+++ b/src/sys/sys_local.cpp
@@ -287,6 +287,9 @@ void idSysLocal::DLL_GetFileName( const char *baseName, char *dllName, int maxLe
 	}
 #elif defined( MACOS_X )
 	idStr::snPrintf( dllName, maxLength, "%s.dylib", baseName );
+#elif defined( __EMSCRIPTEN__ )
+	// Emscripten dlopen resolves the GameLib side module from WasmFS.
+	idStr::snPrintf( dllName, maxLength, "%s.wasm", baseName );
 #else
 #error OS define is required
 #endif
@@ -367,4 +370,3 @@ const char *Sys_TimeStampToStr( ID_TIME_T timeStamp ) {
 
 	return timeString;
 }
-
diff --git a/src/sys/sys_public.h b/src/sys/sys_public.h
index 3bc0477..3c75820 100644
--- a/src/sys/sys_public.h
+++ b/src/sys/sys_public.h
@@ -33,6 +33,32 @@ If you have questions concerning this license or the applicable additional terms
 
 class idStr;
 
+#if defined(__EMSCRIPTEN__)
+
+#define BUILD_STRING                    "web-wasm32"
+#define BUILD_OS_ID                     3
+#define CPUSTRING                       "wasm"
+#define CPU_EASYARGS                    1
+
+#define ALIGN16( x )                    x
+#define PACKED                          __attribute__((packed))
+
+#define _alloca                         alloca
+#define _alloca16( x )                  ((void *)((((uintptr_t)alloca( (x)+15 )) + 15) & ~15))
+
+#define PATHSEPERATOR_STR               "/"
+#define PATHSEPERATOR_CHAR              '/'
+
+#define __cdecl
+#define ASSERT                          assert
+#define ID_INLINE                       inline
+#define ID_INLINE_EXTERN                inline
+#define ID_STATIC_TEMPLATE
+
+#define assertmem( x, y )
+
+#endif
+
 /*
 ===============================================================================
 
diff --git a/tools/build/meson_setup.ps1 b/tools/build/meson_setup.ps1
index 647b6f0..3bdccd9 100644
--- a/tools/build/meson_setup.ps1
+++ b/tools/build/meson_setup.ps1
@@ -420,7 +420,7 @@ function Test-GamelibsStageRefreshNeeded {
 
     $resolvedGameLibsRepo = Get-openQ4GameLibsRepoPath -RepoRoot $RepoRoot -ConfiguredRepo $GameLibsRepo
     $sourceGameDir = Join-Path $resolvedGameLibsRepo "src\game"
-    $stagedGameDir = Join-Path $RepoRoot ".tmp\gamelibs_stage\src\game"
+    $stagedGameDir = Join-Path $BuildDir "gamelibs_stage\src\game"
 
     if (-not (Test-Path $sourceGameDir)) {
         return $false
diff --git a/tools/build/meson_sources.py b/tools/build/meson_sources.py
index 4890c22..d6cca37 100644
--- a/tools/build/meson_sources.py
+++ b/tools/build/meson_sources.py
@@ -115,6 +115,22 @@ SDL3_LINUX_SOURCES = (
     "sys/linux/stack.cpp",
 )
 
+EMSCRIPTEN_PLATFORM_SOURCES = (
+    "sys/posix/posix_main.cpp",
+    "sys/emscripten/wasm_net.cpp",
+    "sys/posix/posix_signal.cpp",
+    "sys/posix/posix_syscon.cpp",
+    "sys/posix/posix_threads.cpp",
+    "sys/linux/linux_sdl3.cpp",
+    "sys/linux/main.cpp",
+    "sys/linux/stack.cpp",
+)
+
+EMSCRIPTEN_RENDERER_SOURCES = (
+    "renderer/webgl2/WebGL2Backend.cpp",
+    "renderer/webgl2/WebGL2GLShims.cpp",
+)
+
 LINUX_X11_HELPER_SOURCES = (
     "sys/linux/libXNVCtrl/NVCtrl.c",
 )
@@ -221,7 +237,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
     parser = argparse.ArgumentParser()
     parser.add_argument(
         "--host-system",
-        choices=("windows", "linux", "darwin"),
+        choices=("windows", "linux", "darwin", "emscripten"),
         default="windows",
         help="Meson host system for source selection.",
     )
@@ -316,6 +332,11 @@ def main(argv: list[str]) -> int:
             )
             for rel_path in platform_sources:
                 add_required_source(source_set, ordered_sources, source_root, rel_path)
+        elif args.host_system == "emscripten":
+            for rel_path in EMSCRIPTEN_PLATFORM_SOURCES:
+                add_required_source(source_set, ordered_sources, source_root, rel_path)
+            for rel_path in EMSCRIPTEN_RENDERER_SOURCES:
+                add_required_source(source_set, ordered_sources, source_root, rel_path)
         else:
             print(f"Unsupported host system: {args.host_system}", file=sys.stderr)
             return 1
diff --git a/tools/build/stage_gamelibs.py b/tools/build/stage_gamelibs.py
index f8df11b..f71a805 100644
--- a/tools/build/stage_gamelibs.py
+++ b/tools/build/stage_gamelibs.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""Stage openQ4-game game sources into a temporary local tree."""
+"""Stage openQ4-game SP and MP sources into an isolated build tree."""
 
 from __future__ import annotations
 
@@ -90,13 +90,21 @@ def is_relative_to(path: Path, parent: Path) -> bool:
     return True
 
 
-def validate_stage_root(project_root: Path, gamelibs_root: Path, stage_root: Path) -> None:
+def validate_stage_root(
+    project_root: Path,
+    gamelibs_root: Path,
+    stage_root: Path,
+    allowed_stage_parent: Path,
+) -> None:
     if stage_root.is_symlink():
         raise RuntimeError(f"refusing to stage into symlink: {stage_root}")
 
-    project_tmp = project_root / ".tmp"
-    if not is_relative_to(stage_root, project_tmp) or stage_root == project_tmp:
-        raise RuntimeError(f"stage root must be under openQ4 .tmp: {stage_root}")
+    if not is_relative_to(stage_root, allowed_stage_parent) or stage_root == allowed_stage_parent:
+        if allowed_stage_parent == project_root / ".tmp":
+            raise RuntimeError(f"stage root must be under openQ4 .tmp: {stage_root}")
+        raise RuntimeError(
+            f"stage root must be under allowed stage parent {allowed_stage_parent}: {stage_root}"
+        )
 
     if is_relative_to(project_root, stage_root) or is_relative_to(gamelibs_root, stage_root):
         raise RuntimeError(f"refusing to stage over source repository: {stage_root}")
@@ -195,9 +203,9 @@ def prepare_stage_root(stage_root: Path) -> None:
 
 
 def main(argv: list[str]) -> int:
-    if len(argv) != 4:
+    if len(argv) not in (4, 5):
         print(
-            "usage: stage_gamelibs.py <project-root> <gamelibs-root> <stage-root>",
+            "usage: stage_gamelibs.py <project-root> <gamelibs-root> <stage-root> [allowed-stage-parent]",
             file=sys.stderr,
         )
         return 2
@@ -218,20 +226,31 @@ def main(argv: list[str]) -> int:
         print(f"error: refusing to stage into symlink: {raw_stage_root}", file=sys.stderr)
         return 1
     stage_root = raw_stage_root.resolve()
+    raw_allowed_stage_parent = Path(argv[4]) if len(argv) == 5 else project_root / ".tmp"
+    if raw_allowed_stage_parent.is_symlink():
+        print(
+            f"error: allowed stage parent must not be a symlink: {raw_allowed_stage_parent}",
+            file=sys.stderr,
+        )
+        return 1
+    allowed_stage_parent = raw_allowed_stage_parent.resolve()
 
     source_game_dir = gamelibs_root / "src" / "game"
-    if not source_game_dir.is_dir():
-        print(f"error: game source directory not found: {source_game_dir}", file=sys.stderr)
-        return 1
+    source_mpgame_dir = gamelibs_root / "src" / "mpgame"
+    for label, source_dir in (("game", source_game_dir), ("mpgame", source_mpgame_dir)):
+        if not source_dir.is_dir():
+            print(f"error: {label} source directory not found: {source_dir}", file=sys.stderr)
+            return 1
 
     if not project_root.is_dir():
         print(f"error: openQ4 root not found: {project_root}", file=sys.stderr)
         return 1
 
     try:
-        validate_stage_root(project_root, gamelibs_root, stage_root)
+        validate_stage_root(project_root, gamelibs_root, stage_root, allowed_stage_parent)
         prepare_stage_root(stage_root)
         staged_files = copy_game_sources(source_game_dir, stage_root / "src" / "game")
+        staged_files += copy_game_sources(source_mpgame_dir, stage_root / "src" / "mpgame")
         staged_files += mirror_project_support_dirs(project_root, stage_root)
         write_stage_manifest(project_root, gamelibs_root, stage_root, staged_files)
         validate_stage_manifest(stage_root)
diff --git a/tools/tests/gamelibs_staging.py b/tools/tests/gamelibs_staging.py
index 5d53fa1..d14b6f1 100644
--- a/tools/tests/gamelibs_staging.py
+++ b/tools/tests/gamelibs_staging.py
@@ -23,9 +23,17 @@ def sha256(path: Path) -> str:
     return digest.hexdigest()
 
 
-def run_stage(project_root: Path, gamelibs_root: Path, stage_root: Path) -> subprocess.CompletedProcess[str]:
+def run_stage(
+    project_root: Path,
+    gamelibs_root: Path,
+    stage_root: Path,
+    allowed_stage_parent: Path | None = None,
+) -> subprocess.CompletedProcess[str]:
+    command = [sys.executable, str(STAGE_SCRIPT), str(project_root), str(gamelibs_root), str(stage_root)]
+    if allowed_stage_parent is not None:
+        command.append(str(allowed_stage_parent))
     return subprocess.run(
-        [sys.executable, str(STAGE_SCRIPT), str(project_root), str(gamelibs_root), str(stage_root)],
+        command,
         check=False,
         stdout=subprocess.PIPE,
         stderr=subprocess.PIPE,
@@ -47,6 +55,8 @@ def make_minimal_workspace(work: Path) -> tuple[Path, Path, Path]:
     write_file(project_root / "src" / "renderer" / "RenderWorld.h", "// renderer\n")
     write_file(gamelibs_root / "src" / "game" / "Game_local.cpp", "// game\n")
     write_file(gamelibs_root / "src" / "game" / "gamesys" / "SysCvar.cpp", "// cvar\n")
+    write_file(gamelibs_root / "src" / "mpgame" / "Game_local.cpp", "// mpgame\n")
+    write_file(gamelibs_root / "src" / "mpgame" / "Game_network.cpp", "// tagged snapshots\n")
     return project_root, gamelibs_root, stage_root
 
 
@@ -66,6 +76,8 @@ def validate_manifest(stage_root: Path) -> None:
     for rel in (
         "src/game/Game_local.cpp",
         "src/game/gamesys/SysCvar.cpp",
+        "src/mpgame/Game_local.cpp",
+        "src/mpgame/Game_network.cpp",
         "src/idlib/idlib_public.h",
         "src/renderer/RenderWorld.h",
     ):
@@ -111,6 +123,35 @@ def validate_stage_root_guard(work: Path) -> None:
         raise AssertionError(f"unexpected stage-root guard message: {result.stderr}")
 
 
+def validate_build_directory_isolation(work: Path) -> None:
+    project_root, gamelibs_root, _stage_root = make_minimal_workspace(work)
+    build_one = work / "build-one"
+    build_two = work / "build-two"
+    stage_one = build_one / "gamelibs_stage"
+    stage_two = build_two / "gamelibs_stage"
+
+    first = run_stage(project_root, gamelibs_root, stage_one, build_one)
+    if first.returncode != 0:
+        raise AssertionError(f"first build-local stage failed: {first.stderr}")
+    first_snapshot = (stage_one / "src" / "game" / "Game_local.cpp").read_text(encoding="utf-8")
+    first_mp_snapshot = (stage_one / "src" / "mpgame" / "Game_local.cpp").read_text(encoding="utf-8")
+
+    write_file(gamelibs_root / "src" / "game" / "Game_local.cpp", "// game updated\n")
+    write_file(gamelibs_root / "src" / "mpgame" / "Game_local.cpp", "// mpgame updated\n")
+    second = run_stage(project_root, gamelibs_root, stage_two, build_two)
+    if second.returncode != 0:
+        raise AssertionError(f"second build-local stage failed: {second.stderr}")
+
+    if (stage_one / "src" / "game" / "Game_local.cpp").read_text(encoding="utf-8") != first_snapshot:
+        raise AssertionError("staging the second build modified the first build's source snapshot")
+    if (stage_one / "src" / "mpgame" / "Game_local.cpp").read_text(encoding="utf-8") != first_mp_snapshot:
+        raise AssertionError("staging the second build modified the first build's MP source snapshot")
+    if (stage_two / "src" / "game" / "Game_local.cpp").read_text(encoding="utf-8") != "// game updated\n":
+        raise AssertionError("second build did not receive its own updated source snapshot")
+    if (stage_two / "src" / "mpgame" / "Game_local.cpp").read_text(encoding="utf-8") != "// mpgame updated\n":
+        raise AssertionError("second build did not receive its own updated MP source snapshot")
+
+
 def validate_source_contracts() -> None:
     script = STAGE_SCRIPT.read_text(encoding="utf-8")
     meson = (ROOT / "meson.build").read_text(encoding="utf-8")
@@ -152,6 +193,7 @@ def main() -> None:
         validate_successful_stage(work / "success")
         validate_symlink_rejection(work / "symlink")
         validate_stage_root_guard(work / "stage-root")
+        validate_build_directory_isolation(work / "build-isolation")
         validate_source_contracts()
     finally:
         shutil.rmtree(work, ignore_errors=True)
diff --git a/tools/tests/webgl2_shader_smoke.mjs b/tools/tests/webgl2_shader_smoke.mjs
new file mode 100644
index 0000000..79eb912
--- /dev/null
+++ b/tools/tests/webgl2_shader_smoke.mjs
@@ -0,0 +1,172 @@
+#!/usr/bin/env node
+
+import { spawn } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import http from 'node:http';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const repository = fileURLToPath(new URL('../..', import.meta.url));
+const backendPath = path.join(repository, 'src/renderer/webgl2/WebGL2Backend.cpp');
+const backend = readFileSync(backendPath, 'utf8');
+const shaderPattern = /static const char \*(web\w+Shader) = R"glsl\(([\s\S]*?)\)glsl";/g;
+const shaders = {};
+
+for (const match of backend.matchAll(shaderPattern)) {
+  shaders[match[1]] = match[2];
+}
+
+const programs = [
+  ['zfill', 'webZFillVertexShader', 'webZFillFragmentShader'],
+  ['interaction', 'webInteractionVertexShader', 'webInteractionFragmentShader'],
+  ['ambient', 'webAmbientVertexShader', 'webAmbientFragmentShader'],
+  ['cube', 'webCubeVertexShader', 'webCubeFragmentShader'],
+  ['shadow', 'webShadowVertexShader', 'webShadowFragmentShader'],
+  ['fog', 'webFogVertexShader', 'webFogFragmentShader'],
+  ['blend', 'webFogVertexShader', 'webFogFragmentShader'],
+];
+
+for (const [, vertex, fragment] of programs) {
+  if (!shaders[vertex] || !shaders[fragment]) {
+    throw new Error(`could not extract ${vertex}/${fragment} from ${backendPath}`);
+  }
+}
+
+const page = `<!doctype html>
+<meta charset="utf-8">
+<canvas id="canvas" width="32" height="32"></canvas>
+<pre id="result" data-status="running"></pre>
+<script>
+const sources = ${JSON.stringify(shaders)};
+const programs = ${JSON.stringify(programs)};
+const result = document.getElementById('result');
+
+function compile(gl, type, source, label) {
+  const shader = gl.createShader(type);
+  gl.shaderSource(shader, source);
+  gl.compileShader(shader);
+  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+    throw new Error(label + ': ' + gl.getShaderInfoLog(shader));
+  }
+  return shader;
+}
+
+try {
+  const gl = document.getElementById('canvas').getContext('webgl2', {
+    antialias: false,
+    depth: true,
+    stencil: true,
+  });
+  if (!gl) throw new Error('WebGL2 context creation failed');
+
+  const linked = [];
+  for (const [name, vertexName, fragmentName] of programs) {
+    const vertex = compile(gl, gl.VERTEX_SHADER, sources[vertexName], name + ' vertex');
+    const fragment = compile(gl, gl.FRAGMENT_SHADER, sources[fragmentName], name + ' fragment');
+    const program = gl.createProgram();
+    gl.attachShader(program, vertex);
+    gl.attachShader(program, fragment);
+    gl.linkProgram(program);
+    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+      throw new Error(name + ' link: ' + gl.getProgramInfoLog(program));
+    }
+    linked.push(name);
+    gl.deleteProgram(program);
+    gl.deleteShader(vertex);
+    gl.deleteShader(fragment);
+  }
+
+  result.textContent = JSON.stringify({
+    programs: linked,
+    renderer: gl.getParameter(gl.RENDERER),
+    shadingLanguage: gl.getParameter(gl.SHADING_LANGUAGE_VERSION),
+  });
+  result.dataset.status = 'pass';
+} catch (error) {
+  result.textContent = error && error.stack ? error.stack : String(error);
+  result.dataset.status = 'fail';
+}
+</script>`;
+
+const server = http.createServer((_request, response) => {
+  response.writeHead(200, {
+    'Content-Type': 'text/html; charset=utf-8',
+    'Cache-Control': 'no-store',
+  });
+  response.end(page);
+});
+
+await new Promise((resolve, reject) => {
+  server.once('error', reject);
+  server.listen(0, '127.0.0.1', resolve);
+});
+
+const chrome = [
+  process.env.CHROME,
+  '/usr/bin/google-chrome',
+  '/usr/bin/chromium',
+  '/usr/bin/chromium-browser',
+].find((candidate) => candidate && existsSync(candidate));
+
+if (!chrome) {
+  server.close();
+  throw new Error('Chrome/Chromium was not found; set CHROME=/path/to/browser');
+}
+
+const profile = mkdtempSync(path.join(os.tmpdir(), 'openq4-webgl2-smoke-'));
+const address = server.address();
+const url = `http://127.0.0.1:${address.port}/`;
+const args = [
+  '--headless=new',
+  '--no-sandbox',
+  '--disable-dev-shm-usage',
+  '--enable-unsafe-swiftshader',
+  '--use-angle=swiftshader',
+  `--user-data-dir=${profile}`,
+  '--dump-dom',
+  url,
+];
+
+let stdout = '';
+let stderr = '';
+let exitCode;
+try {
+  exitCode = await new Promise((resolve, reject) => {
+    const child = spawn(chrome, args, { stdio: ['ignore', 'pipe', 'pipe'] });
+    const timeout = setTimeout(() => {
+      child.kill('SIGKILL');
+      reject(new Error('Chrome shader smoke timed out after 30 seconds'));
+    }, 30_000);
+    child.stdout.setEncoding('utf8');
+    child.stderr.setEncoding('utf8');
+    child.stdout.on('data', (chunk) => { stdout += chunk; });
+    child.stderr.on('data', (chunk) => { stderr += chunk; });
+    child.once('error', (error) => {
+      clearTimeout(timeout);
+      reject(error);
+    });
+    child.once('close', (code) => {
+      clearTimeout(timeout);
+      resolve(code);
+    });
+  });
+} finally {
+  await new Promise((resolve) => server.close(resolve));
+  rmSync(profile, { recursive: true, force: true });
+}
+
+const match = stdout.match(/<pre id="result" data-status="pass">([^<]*)<\/pre>/);
+if (exitCode !== 0 || !match) {
+  process.stderr.write(stderr);
+  process.stderr.write(stdout);
+  process.exitCode = 1;
+} else {
+  const detail = match[1]
+    .replaceAll('&quot;', '"')
+    .replaceAll('&lt;', '<')
+    .replaceAll('&gt;', '>')
+    .replaceAll('&amp;', '&');
+  console.log(`WebGL2 shader smoke passed: ${programs.length}/${programs.length} programs linked`);
+  console.log(detail);
+}
