When you’re building games that need both the power of Unity’s 3D engine and the flexibility of modern web technologies, you quickly discover that making them communicate isn’t as straightforward as it seems. After building several hybrid web-Unity applications, I’ve learned quite a few lessons about what works, what doesn’t, and what will make you want to throw your keyboard out the window.
This guide walks you through building a robust communication bridge between a TypeScript/JavaScript web frontend and Unity WebGL builds. I’ll share the architecture we settled on, the mistakes we made along the way, and the solutions we found.
Why Build a Hybrid Architecture?
Before diving into the technical details, let’s address the “why.” You might be wondering: if Unity can build for WebGL, why not just use Unity for everything?
In our case, we were building multiple games that shared common functionality:
- Authentication & session management – handled by our backend SDK
- Internationalization – 16+ languages with dynamic switching
- UI framework – consistent menus, settings panels, modals across games
- Audio system – web audio API with Howler.js for UI sounds
- State persistence – localStorage, session handling
Each game had unique 3D gameplay, but 80% of the surrounding infrastructure was identical. Building all of this in Unity for each game would mean:
- Duplicating shared code across projects
- Longer iteration cycles (Unity builds take time)
- Larger bundle sizes (Unity UI is heavy)
- Less flexibility in web-specific features
Our solution: a shared web engine that handles the “chrome” around the game, while Unity handles the 3D gameplay. The web layer sends commands to Unity (“start the game”, “play this seed”), and Unity sends results back (“game complete”, “animation finished”).
The Architecture at a Glance
Here’s the high-level flow:
Web (TypeScript) Unity WebGL
----------------- -----------
GameplayManager GameController.cs
| ^
v |
UnityInterface -------SendMessage-------> WebInterface.cs
| |
| <------ExternalCall-------- |
v v
GameUI Game Scene
The communication is bidirectional:
-
Web to Unity : Uses Unity’s
SendMessage()API -
Unity to Web : Uses
Application.ExternalCall()(or the modernjslibapproach)
Messages are JSON strings in both directions, giving us type safety and flexibility.
The Key Mistakes We Made (So You Don’t Have To)
Mistake #1: Inconsistent GameObject Naming
Unity’s SendMessage() API requires you to specify a GameObject name:
unityInstance.SendMessage('WebInterface', 'StartGame', data);
Sounds simple, right? Here’s where we messed up: different developers named the receiving GameObject differently across projects. One called it “WebInterface”, another “WebBridge”, another “GameManager”.
When we tried to create a reusable engine, nothing worked because each game expected a different name.
Solution: Establish a strict naming convention and stick to it. We settled on pattern-based names:
– WebSeedInterfaces – for seed-based games
– WebRoundInterfaces – for round-based games
– WebSettingsInterface – for audio/language/settings
The “Interfaces” suffix (plural) reminds us it’s a collection of interface methods, not a single one.
Mistake #2: Complex JSON Message Structures
Our first implementation sent messages like this:
// Web side - sending
unityInstance.SendMessage('WebInterface', 'ReceiveMessage', JSON.stringify({
action: 'setDifficulty',
data: {
difficulty: 'hard',
timestamp: Date.now()
}
}));
// Unity side - receiving
public void ReceiveMessage(string json) {
var msg = JsonUtility.FromJson<WebMessage>(json);
switch(msg.action) {
case "setDifficulty":
var data = JsonUtility.FromJson<DifficultyData>(msg.data);
SetDifficulty(data.difficulty);
break;
// ... 20 more cases
}
}
This meant:
– Unity needed to parse JSON twice (outer message + inner data)
– Giant switch statements that grew with every feature
– Type definitions on both sides had to stay in sync
– Debugging was painful
Solution: Use direct method calls with simple values. Unity’s
SendMessage()can call any public method directly:// Web side - much simpler! unityInstance.SendMessage('WebRoundInterfaces', 'WebSetDifficulty', 'hard'); unityInstance.SendMessage('WebRoundInterfaces', 'WebStartRound', ''); unityInstance.SendMessage('WebSettingsInterface', 'WebSetLanguage', 'en');
// Unity side - clean methods
public void WebSetDifficulty(string difficulty) {
_difficulty = difficulty;
OnDifficultyChanged?.Invoke(difficulty);
}
public void WebStartRound(string _unused) {
StartRound();
}
public void WebSetLanguage(string languageCode) {
SetLanguage(languageCode);
}
We adopted a `Web[Verb][Noun]` naming pattern for all web-callable methods. This makes it immediately clear which methods are called from the web side.
* * *
### Mistake #3: Not Handling the “Unity Not Ready” State
Unity WebGL takes time to load. If your web code tries to send messages before Unity is ready, they simply disappear into the void.
Our first “fix” was to add arbitrary delays:
// Don't do this
setTimeout(() => {
unityInstance.SendMessage('WebInterface', 'Initialize', '');
}, 3000); // Hope 3 seconds is enough...
Spoiler: it wasn’t always enough. And sometimes it was too much.
> **Solution:** Implement a message queue that holds messages until Unity signals it’s ready:
class UnityManager {
private messageQueue: Array<{methodName: string; value?: any}> = [];
private connectionState: 'disconnected' | 'loading' | 'ready' = 'disconnected';
async sendMessage(methodName: string, value?: any): Promise<void> {
if (this.connectionState !== 'ready') {
// Queue message for later
this.messageQueue.push({ methodName, value });
return;
}
await this.sendMessageToUnity(methodName, value);
}
// Called when Unity sends 'gameReady' message
private onUnityReady(): void {
this.connectionState = 'ready';
// Process queued messages
this.processMessageQueue();
}
private async processMessageQueue(): Promise<void> {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
await this.sendMessageToUnity(message.methodName, message.value);
}
}
}
On the Unity side, send a “ready” signal when the game is initialized:
void Start() {
// Send ready signal to web
SendToWeb("gameReady", null);
}
public void SendToWeb(string action, object data) {
var message = JsonUtility.ToJson(new { action, data });
Application.ExternalCall("UnityMessageHandler", message);
}
* * *
### Mistake #4: Forgetting That SendMessage Only Takes Strings
This one bit us multiple times. Unity’s `SendMessage()` can only pass string, int, or float parameters. No objects, no booleans, no arrays.
// This silently fails or behaves unexpectedly
unityInstance.SendMessage('WebInterface', 'SetEnabled', true);
unityInstance.SendMessage('WebInterface', 'SetConfig', { foo: 'bar' });
> **Solution:** Always convert to strings on the web side, parse on the Unity side:
// Web side
private async sendMessageToUnity(methodName: string, value?: any): Promise {
// Convert value to string - Unity SendMessage always expects string
const messageData = value !== undefined ? String(value) : '';
this.unityInstance.SendMessage(this.gameObjectName, methodName, messageData);
}
// Unity side - parse boolean from string
public void WebSetTurbo(string boolValue) {
bool enabled = bool.Parse(boolValue); // "true" -> true
SetTurboMode(enabled);
}
// Unity side - parse number from string
public void WebSetRound(string roundNumber) {
int round = int.Parse(roundNumber); // "5" -> 5
SetRound(round);
}
## Unity Build Settings That Matter
Your Unity WebGL build settings significantly impact how well the bridge works. Here’s what we learned:
### Player Settings
Edit > Project Settings > Player > WebGL Settings
Product Name: Game // Use a generic name for reusability
Compression Format: Gzip // Best balance of size and compatibility
Decompression Fallback: Yes // For older browsers
Run In Background: Yes // Keep running when tab loses focus
The `Product Name` becomes part of your build filenames (`Game.wasm`, `Game.framework.js`, etc.). Using a generic name like “Game” means your web engine doesn’t need per-game configuration.
### Build Configuration
File > Build Settings > WebGL
Development Build: [checked for dev, unchecked for prod]
Code Optimization: Speed (for production)
Enable Exceptions: Explicitly Thrown Only
Strip Engine Code: Yes (reduces file size)
### Vite Configuration for Unity WebGL
If you’re using Vite (or similar bundler), you need specific configuration to handle Unity files:
// vite.config.ts
export default defineConfig({
server: {
headers: {
// Required for Unity WebGL SharedArrayBuffer support
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin'
}
},
build: {
assetsInlineLimit: 0, // Don't inline Unity assets
chunkSizeWarningLimit: 10000, // Unity files can be large
rollupOptions: {
output: {
assetFileNames: (assetInfo) => {
// Keep Unity files with their original names
if (assetInfo.name?.endsWith('.data') ||
assetInfo.name?.endsWith('.wasm') ||
assetInfo.name?.endsWith('.framework.js')) {
return '[name][extname]';
}
return 'assets/[name]-[hash][extname]';
}
}
}
}
});
## Silencing Unity’s Console Noise
Unity WebGL builds are _chatty_. Like, really chatty. Open your browser console and you’ll see hundreds of messages about memory allocation, WebGL state, physics initialization, and more.
This isn’t just annoying – it can hide actual errors and slow down the browser’s developer tools.
### The Console Filter Approach
We intercept console methods _before_ Unity loads and filter out the noise:
(function() {
// Store original console methods
const originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console)
};
// Detect build mode (replaced by build tool)
const isProduction = __VITE_BUILD_MODE__ ;
// Unity internal patterns to suppress
const unityPatterns = [
/\[UnityMemory\]/, /\[Physics::Module\]/, /memorysetup-/,
/Loading player data/, /Initialize engine version/, /Creating WebGL/,
/^Renderer:/, /^Vendor:/, /^GLES:/, /OPENGL LOG:/,
/UnloadTime:/, /JS_FileSystem_Sync/, /Configuration Parameters/,
/\$func\d+ @ Game\.wasm/, /Module\._main @ Game\.framework\.js/
];
// Custom Debug.Log patterns to KEEP (your game's logs)
const customPatterns = [
/\[WebRoundInterface\]/, /\[GameplayController\]/,
/\[[A-Z][A-Za-z]*(?:Interface|Controller|Manager)\]/
];
function shouldSuppress(message) {
// Production: suppress everything
if (isProduction) return true;
// Development: suppress Unity internal, keep custom
const isUnityInternal = unityPatterns.some(p => p.test(message));
if (isUnityInternal) {
const isCustomLog = customPatterns.some(p => p.test(message));
return !isCustomLog;
}
return false;
}
// Override console methods
['log', 'warn', 'error'].forEach(level => {
console[level] = function(...args) {
const message = args.map(String).join(' ');
if (!shouldSuppress(message)) {
originalConsole[level](...args);
}
};
});
// Store original for emergency access
window.__originalConsole = originalConsole;
})();
### Suppressing WebGL Warnings at the Source
Some WebGL warnings happen _during_ API calls, before any console filtering can catch them. Unity queries texture formats that may not be supported, and Chrome helpfully logs `WebGL: INVALID_ENUM` warnings.
The nuclear option: patch the WebGL API itself:
if (typeof WebGL2RenderingContext !== 'undefined') {
const original = WebGL2RenderingContext.prototype.getInternalformatParameter;
// Known invalid formats Unity queries
const invalidFormats = new Set([
36756, 36757, 36759, 36760, 36761, 36763 // Compressed texture formats
]);
WebGL2RenderingContext.prototype.getInternalformatParameter = function(
target, internalformat, pname
) {
// Block Unity's known invalid queries before they trigger warnings
if (invalidFormats.has(internalformat)) {
return null;
}
return original.call(this, target, internalformat, pname);
};
}
### Post-Build Processing
For production builds, we also modify Unity’s generated files:
// post-unity-build.js - Run after Unity build
import fs from 'fs';
const frameworkFile = './public/unity/Build/Game.framework.js';
const loaderFile = './public/unity/Build/Game.loader.js';
// Read Unity framework file
let framework = fs.readFileSync(frameworkFile, 'utf8');
// Prepend WebGL fix
const webglFix = ;
(function () {
const original = WebGL2RenderingContext.prototype.getInternalformatParameter;
const invalid = new Set([36756, 36757, 36759, 36760, 36761, 36763]);
WebGL2RenderingContext.prototype.getInternalformatParameter = function(t, i, p) {
if (invalid.has(i)) return null;
return original.call(this, t, i, p);
};
})();
// Replace console.log/warn with void 0
framework = webglFix + framework.replace(/(\W)console.(log|warn)([^)]*);/g, '$1void 0;');
fs.writeFileSync(frameworkFile, framework);
// Suppress Unity Analytics in loader
let loader = fs.readFileSync(loaderFile, 'utf8');
const analyticsFix = ;
(function() {
const originalFetch = window.fetch;
window.fetch = function(...args) {
if (typeof args[0] === 'string' && args[0].includes('unity3d.com')) {
return Promise.reject(new Error('Analytics disabled'));
}
return originalFetch.apply(this, args);
};
})();
loader = analyticsFix + loader.replace(/console.(log|warn)([^)]*)/g, 'void 0');
fs.writeFileSync(loaderFile, loader);
console.log('Unity build post-processed successfully');
## The Final Architecture
After all these iterations, here’s what our architecture looks like:
### Web Side (TypeScript)
// UnityManager - Low-level communication
class UnityManager {
private messageQueue: Message[] = [];
private connectionState: ConnectionState = 'disconnected';
async initialize(config: UnityConfig): Promise<void> { /* ... */ }
async loadUnityGame(canvas?: HTMLCanvasElement): Promise<void> { /* ... */ }
async sendMessage(methodName: string, value?: any): Promise<void> { /* ... */ }
}
// Domain-specific interfaces
class UnitySeedInterface {
async startSpin(): Promise { /* WebStartSpin / }
async startRevealing(seed: string): Promise { / WebStartRevealing / }
async startPayout(amount: string): Promise { / WebStartPayout / }
async completeSpin(): Promise { / WebCompleteSpin */ }
}
class UnitySettingsInterface {
async setSound(enabled: boolean): Promise { /* WebToggleSound / }
async setLanguage(lang: string): Promise { / WebSetLanguage / }
async setTurbo(enabled: boolean): Promise { / WebSetTurbo */ }
}
### Unity Side (C#)
public class WebSeedInterface : MonoBehaviour
{
public UnityEvent OnSpinStarted;
public UnityEvent OnRevealing;
public UnityEvent OnPayout;
public UnityEvent OnSpinCompleted;
void Start() => SendToWeb("gameReady", null);
public void WebStartSpin(string _) => OnSpinStarted?.Invoke();
public void WebStartRevealing(string seed) => OnRevealing?.Invoke(seed);
public void WebStartPayout(string amount) => OnPayout?.Invoke(amount);
public void WebCompleteSpin(string _) => OnSpinCompleted?.Invoke();
public void SendToWeb(string action, object data) {
var json = JsonUtility.ToJson(new { action, data });
Application.ExternalCall("UnityMessageHandler", json);
}
}
// WebSettingsInterface.cs - Audio, language, turbo
public class WebSettingsInterface : MonoBehaviour
{
public static event Action OnSoundToggled;
public static event Action OnLanguageChanged;
public static event Action OnTurboModeToggled;
public void WebToggleSound(string val) => OnSoundToggled?.Invoke(bool.Parse(val));
public void WebSetLanguage(string lang) => OnLanguageChanged?.Invoke(lang);
public void WebSetTurbo(string val) => OnTurboModeToggled?.Invoke(bool.Parse(val));
}
## Key Takeaways
1. **Standardize GameObject names** – Pick a naming convention and enforce it across all projects.
2. **Use direct method calls** – Skip the JSON wrapper for simple values. `WebSetDifficulty('hard')` beats `ReceiveMessage('{"action":"setDifficulty","data":"hard"}')`
3. **Queue messages until ready** – Never assume Unity is loaded. Always queue messages and process them when Unity signals readiness.
4. **Everything is a string** – Remember that `SendMessage()` can only pass strings. Convert on web, parse on Unity.
5. **Filter console noise early** – Set up console filtering before Unity loads, and patch WebGL APIs if necessary.
6. **Post-process Unity builds** – Remove console calls and Unity analytics from production builds.
Building a web-Unity bridge isn’t rocket science, but the devil is in the details. These patterns have served us well across multiple games, and I hope they save you some of the headaches we experienced along the way.
* * *
_Have questions or improvements? Feel free to reach out. Happy coding!_
The post [Building a Web-Unity WebGL Bridge: A Practical Guide](https://www.richardfu.net/building-a-web-unity-webgl-bridge-a-practical-guide/) appeared first on [Richard Fu](https://www.richardfu.net).
Top comments (0)