I spent three days trying to port my HTML5 game to desktop. Electron kept throwing memory errors, my WebGL contexts were dying, and save files weren't persisting correctly. After testing five different frameworks, NW.js solved everything. Here's what broke and how I fixed it.
Step 1: Understanding Common JS Game Porting Errors
When you try to run a browser-based JavaScript game as a desktop app, you hit issues that never appeared in Chrome DevTools.
// This works fine in browser but fails in Electron
const canvas = document.getElementById('gameCanvas');
const gl = canvas.getContext('webgl2');
// Error in Electron console:
// Uncaught TypeError: Cannot read property 'getContext' of null
The canvas element exists, but the timing is wrong. Desktop wrappers initialize differently than browsers. Your DOM might not be ready when your game engine starts.
Another common issue is file system access. Browser games use localStorage, but desktop apps need real file operations:
// Browser approach - works locally, fails on desktop
localStorage.setItem('saveGame', JSON.stringify(gameState));
// Desktop attempt with Node.js fs module in Electron
const fs = require('fs');
fs.writeFileSync('./save.json', JSON.stringify(gameState));
// Error: EACCES: permission denied
The error happens because Electron's renderer process has limited Node.js access by default. You need IPC communication between renderer and main process, adding complexity.
Step 2: Why Electron Caused Problems
I tried Electron first because it's popular. The setup seemed straightforward:
$ npm install electron --save-dev
$ npm install electron-builder --save-dev
But my game uses Phaser 3 with WebGL, and I immediately hit issues:
// main.js - Electron main process
const { app, BrowserWindow } = require('electron');
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 720,
webPreferences: {
nodeIntegration: false,
contextIsolation: true
}
});
win.loadFile('index.html');
}
app.whenReady().then(createWindow);
The game loaded but crashed within seconds:
Error: WebGL context lost
at WebGLRenderer.onContextLost
at HTMLCanvasElement.dispatch
The WebGL context was getting garbage collected because Electron's renderer process management conflicted with Phaser's internal pooling. I tried enabling nodeIntegration: true, but that created security warnings and didn't fix the WebGL issue.
File saving was worse. To access the filesystem properly in Electron, you need this setup:
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
saveGame: (data) => ipcRenderer.invoke('save-game', data)
});
// main.js
const { ipcMain } = require('electron');
const fs = require('fs').promises;
ipcMain.handle('save-game', async (event, data) => {
await fs.writeFile('./save.json', data);
return { success: true };
});
// renderer (game code)
await window.electronAPI.saveGame(JSON.stringify(gameState));
This works, but it's three separate files just to save data. Every filesystem operation needs this bridge setup.
Step 3: Switching to NW.js and Fixing Everything
NW.js takes a different approach. Browser and Node.js contexts merge, so you can use DOM APIs and Node.js modules in the same script.
Installation:
$ npm install nw --save-dev
Create a package.json in your game folder:
{
"name": "my-game",
"version": "1.0.0",
"main": "index.html",
"window": {
"width": 1280,
"height": 720,
"resizable": true
},
"chromium-args": "--enable-webgl --ignore-gpu-blocklist"
}
The chromium-args line is crucial. It ensures WebGL runs with full GPU acceleration and doesn't get blocked by desktop-specific GPU restrictions.
Now run your game:
$ npx nw .
The WebGL context issue disappeared immediately. NW.js doesn't isolate contexts the way Electron does, so Phaser's WebGL renderer works identically to the browser version.
Step 4: Fixing File System Operations
File operations in NW.js are straightforward because you have direct Node.js access:
// game.js - runs in your HTML page
const fs = require('fs');
const path = require('path');
// Get the app's data directory
const userDataPath = nw.App.dataPath;
const saveFilePath = path.join(userDataPath, 'save.json');
function saveGame(gameState) {
try {
fs.writeFileSync(saveFilePath, JSON.stringify(gameState, null, 2));
console.log('Game saved to:', saveFilePath);
} catch (error) {
console.error('Save failed:', error);
}
}
function loadGame() {
try {
if (fs.existsSync(saveFilePath)) {
const data = fs.readFileSync(saveFilePath, 'utf8');
return JSON.parse(data);
}
return null;
} catch (error) {
console.error('Load failed:', error);
return null;
}
}
The nw.App.dataPath gives you a proper user data directory that works across Windows, macOS, and Linux:
- macOS:
~/Library/Application Support/my-game/ - Windows:
C:\Users\Username\AppData\Local\my-game\ - Linux:
~/.config/my-game/
No IPC bridges, no preload scripts, no security context juggling.
Step 5: Handling Asset Loading
Browser games load assets via HTTP. Desktop apps need local file paths. This broke my asset loader:
// This fails in desktop apps
const image = new Image();
image.src = '/assets/player.png'; // Can't find file
NW.js handles this automatically if your assets are in the same directory structure, but you might need to adjust paths:
const path = require('path');
// Get the app's root directory
const appPath = path.dirname(process.execPath);
function loadAsset(relativePath) {
const fullPath = path.join(appPath, relativePath);
const image = new Image();
image.src = 'file://' + fullPath;
return image;
}
// Usage
const playerSprite = loadAsset('assets/player.png');
For Phaser specifically, this wasn't necessary because Phaser's loader handles file:// URLs correctly. But if you're using custom asset loading, this pattern works.
Step 6: Fixing Audio Issues
Desktop audio APIs differ from browser audio. My game's background music stuttered:
// Original browser code
const audio = new Audio('music/background.mp3');
audio.loop = true;
audio.play();
The problem was audio decoding happening on the main thread. In NW.js, use Web Audio API for better performance:
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();
async function loadMusic(url) {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return audioBuffer;
}
let musicSource = null;
async function playBackgroundMusic() {
const musicBuffer = await loadMusic('music/background.mp3');
musicSource = audioContext.createBufferSource();
musicSource.buffer = musicBuffer;
musicSource.loop = true;
musicSource.connect(audioContext.destination);
musicSource.start(0);
}
function stopBackgroundMusic() {
if (musicSource) {
musicSource.stop();
musicSource = null;
}
}
This eliminated all stuttering and gave me better control over audio playback.
Step 7: Building for Distribution
Creating distributable builds is simpler in NW.js than Electron. Install the builder:
$ npm install nw-builder --save-dev
Add a build script to package.json:
{
"scripts": {
"build": "nwbuild -p win64,osx64,linux64 -o ./dist ."
}
}
Run the build:
$ npm run build
This creates standalone executables for Windows, macOS, and Linux in the dist folder. Each platform gets its own directory with all necessary files.
For macOS specifically, you need to sign the app to avoid security warnings:
$ codesign --deep --force --verify --verbose --sign "Developer ID Application: Your Name" ./dist/osx64/my-game.app
Windows doesn't require signing for testing, but users will see SmartScreen warnings without a certificate.
Step 8: Performance Optimization
Desktop apps should feel faster than browser versions, but poorly configured wrappers can be slower. Enable GPU acceleration explicitly:
{
"chromium-args": "--enable-webgl --ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy"
}
For games with many DOM elements, enable hardware acceleration:
/* Add to your main CSS file */
* {
-webkit-transform: translateZ(0);
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
}
Monitor memory usage in your game loop:
setInterval(() => {
if (window.performance && window.performance.memory) {
const used = window.performance.memory.usedJSHeapSize / 1048576;
const total = window.performance.memory.totalJSHeapSize / 1048576;
console.log(`Memory: ${used.toFixed(2)} MB / ${total.toFixed(2)} MB`);
}
}, 5000);
If memory grows continuously, you have a leak. Check for unremoved event listeners and unreleased WebGL textures.
Additional Tips and Common Gotchas
Window Management: NW.js windows behave differently than browser tabs. To detect when the window closes:
nw.Window.get().on('close', function() {
saveGame(currentGameState);
this.close(true);
});
Update Checking: Implement auto-updates using the built-in API:
nw.App.manifest.version; // Current version
async function checkForUpdates() {
const response = await fetch('https://yourgame.com/version.json');
const data = await response.json();
if (data.version !== nw.App.manifest.version) {
// Show update dialog
}
}
Native Menus: Create proper application menus instead of relying on HTML buttons for core functions:
const menubar = new nw.Menu({ type: 'menubar' });
const fileMenu = new nw.Menu();
fileMenu.append(new nw.MenuItem({
label: 'Save Game',
click: () => saveGame(currentGameState)
}));
menubar.append(new nw.MenuItem({
label: 'File',
submenu: fileMenu
}));
nw.Window.get().menu = menubar;
Debugging Production Builds: Enable DevTools in production for debugging:
{
"window": {
"toolbar": false
},
"chromium-args": "--remote-debugging-port=9222"
}
Then open localhost:9222 in Chrome to access DevTools.
NW.js solved every porting issue I encountered. The unified context eliminated IPC complexity, WebGL worked flawlessly, and file operations required no architectural changes. If you're porting a JavaScript game to desktop and hitting walls with other frameworks, try NW.js first.