One Cocos Build, Two Distribution Routes: Interactive Movies on Web H5 and Steam via Electron
Shipping an Interactive Movie Game to Web H5 and Windows/Steam from One Cocos Codebase
Version note: Cocos Creator 2.4.15 (2.x series); Electron 27+; electron-builder 24+. The whole text is based on 2.4.15; 3.x differences are called out where relevant. Content type: advanced solution + pitfall log. Prerequisites: basic Cocos Creator build process and Node.js.
1. Why Cocos + Electron
Interactive movie / FMV games are characterized by pre-rendered video + branch choices + lightweight interaction: assets are mostly video, logic is not complex, but distribution channels are varied — you may want to put it on an official site/CDN for direct browser playback, and also release a Windows version on Steam.
Two seemingly different paths can converge into the same artifact:
- Web H5 route: Cocos builds
web-mobile→ deploy to server/CDN → open in a browser. - Windows/Steam route: the same
web-mobiledirectory → Electron acts as a shell to load the local H5 → package into an.exe/installer → submit to Steam.
Core conclusion: Cocos only needs to produce one web-mobile static directory, and Electron only needs to open it as a webpage on the desktop. There is one codebase; the only difference is who serves this webpage.
flowchart LR
A[Cocos Creator project] -->|build Web Mobile| B[web-mobile static directory]
B -->|deploy to server/CDN| C[Web H5 version]
B -->|Electron local HTTP loading| D[Windows desktop app]
D -->|submit| E[Steam store Windows version]
2. Cocos side: Build Web Mobile
In Cocos Creator (2.4.15 here), open Project → Build... and choose Web Mobile:
- Render mode: default is fine.
- MD5 cache: recommended, so CDN cache updates work on the web side.
- First package / remote resources: interactive movie videos are large; do not go into the complex remote-resource subpackage configuration. Keep it simple (see the resource strategy in Section 5).
- After the build, the output is in
build/web-mobile/:
web-mobile/
├── index.html # entry
├── main.js # startup script
├── src/ # engine and settings (settings.json, etc.)
├── assets/ # atlases, scripts, config
└── videos/ # your movie videos, put here (recommended as a separate directory)
Key constraint: Cocos loads src/settings.json and other files with fetch/XHR on startup. This means web-mobile cannot be opened directly with the file:// protocol (the browser blocks cross-origin requests when origin is null, causing a blank screen). This directly determines the Electron loading method in Section 3.
3. Electron side: three options for loading local H5
Option A: win.loadFile() (file://) — ⚠️ not recommended for Cocos
mainWindow.loadFile(path.join(__dirname, 'web-mobile/index.html'));
This is the simplest, but loadFile is still the file:// protocol. Cocos's fetch resource loading is blocked by CORS under file://. This option is basically unusable for Cocos output, and is only suitable for pages that are purely static and have no fetch.
Option B: local HTTP server (recommended)
Start a minimal static server in the main process and load it with loadURL('http://127.0.0.1:PORT'). This completely avoids the file:// restriction, and the H5 behavior is identical on desktop.
// main.js
const { app, BrowserWindow } = require('electron');
const http = require('http');
const fs = require('fs');
const path = require('path');
const mime = { '.html':'text/html', '.js':'application/javascript',
'.json':'application/json', '.mp4':'video/mp4', '.png':'image/png',
'.wasm':'application/wasm' };
// Locate the web-mobile directory: in dev use the in-project path, after packaging read from resources
function getWebRoot() {
const dev = path.join(__dirname, 'web-mobile');
const packed = path.join(process.resourcesPath, 'web-mobile');
return fs.existsSync(packed) ? packed : dev;
}
function startServer(root) {
return new Promise(resolve => {
const server = http.createServer((req, res) => {
let urlPath = decodeURIComponent(req.url.split('?')[0]);
if (urlPath === '/') urlPath = '/index.html';
const file = path.join(root, urlPath);
if (!file.startsWith(root) || !fs.existsSync(file)) {
res.writeHead(404); return res.end('Not Found');
}
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] || 'application/octet-stream' });
fs.createReadStream(file).pipe(res);
});
server.listen(0, '127.0.0.1', () => resolve(server));
});
}
async function createWindow() {
const root = getWebRoot();
const server = await startServer(root);
const { port } = server.address();
const win = new BrowserWindow({
width: 1280, height: 720,
webPreferences: { contextIsolation: true, nodeIntegration: false }
});
// Interactive movies need autoplay: disable the autoplay gesture restriction
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required');
win.loadURL(`http://127.0.0.1:${port}/index.html`);
}
app.whenReady().then(createWindow);
3.1 Tell Cocos the local server address (video loading address handling, key point)
Option B is used here. Under this option, video files are served as standalone files by the local server, not through Cocos's asset system. In Cocos 2.4.15, the VideoPlayer component needs an absolute URL based on the local server address to load video (resourceType = REMOTE + remoteURL), otherwise the file cannot be found, or it falls back to asset loading and fails after packaging on desktop.
Because the local server port is dynamically allocated (server.listen(0)), the actual address must be passed to the renderer (Cocos) at runtime. Two recommended ways:
Method 1: inject a global variable through preload (recommended, cleanest)
// preload.js
const base = process.argv.find(a => a.startsWith('--base='))?.split('=')[1];
window.__LOCAL_BASE__ = base || '';
The main process passes the address as an argument when creating the window:
const win = new BrowserWindow({
width: 1280, height: 720,
webPreferences: {
contextIsolation: true, nodeIntegration: false,
preload: path.join(__dirname, 'preload.js'),
additionalArguments: [`--base=http://127.0.0.1:${port}`]
}
});
Method 2: pass it through a URL query
win.loadURL(`http://127.0.0.1:${port}/index.html?base=http://127.0.0.1:${port}`);
On the Cocos side (2.4.15 TypeScript), join the address when initializing the video:
// script on a video node
import { _decorator, Component, VideoPlayer } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('MoviePlayer')
export class MoviePlayer extends Component {
@property(VideoPlayer) videoPlayer: VideoPlayer = null;
onLoad() {
const base = (window as any).__LOCAL_BASE__ || ''; // local server base; empty when deployed as H5
const url = `${base}videos/ep1.mp4`; // the relative path also works on H5
this.videoPlayer.resourceType = VideoPlayer.ResourceType.REMOTE;
this.videoPlayer.remoteURL = url;
this.videoPlayer.play();
}
}
Key points:
- When
baseis empty (pure H5 deployment),videos/ep1.mp4is just the address relative to the current page, which has the same semantics as the local server path. The same code works on both ends. - Videos must be placed in
web-mobile/videos/(externalized withextraResources). Do not attach them to components as clip resources, so they are not packed into assets/asar and the desktop side can still stream them.
Option C: custom protocol app://
Register a private protocol with protocol.registerFileProtocol. Security is slightly better, but each path must be handled individually, so the complexity is higher than Option B. For serving an entire directory, as in an interactive movie, Option B is more convenient.
Conclusion: use Option B. It makes the desktop and web runtime environments fully equivalent and makes troubleshooting the simplest.
4. Implementation points for interactive movies inside Cocos
- Video playback (Cocos 2.4.15): use the
VideoPlayercomponent, setresourceTypetoREMOTE, and buildremoteURLfromlocal server base + relative path(see 3.1). Put video files inweb-mobile/videos/as standalone files served by the local server; do not attach them to components as clip resources, to avoid packing them into assets/asar and preventing streaming on desktop. When deployed as H5, the base is empty and the relative pathvideos/ep1.mp4still works; the code is identical on both ends. - Branch choices: use ordinary UI buttons plus scripts to control node visibility and scene transitions; this is the same as a normal Cocos project.
- Saving: prefer
cc.sys.localStorage; on the Electron desktop side you can also write the save JSON toapp.getPath('userData')for cross-session persistence (expose an interface through preload). - First frame and black screen: interactive movies are sensitive to
click and playexperience; recommend initializing video after the first user gesture, to avoid black screens caused by browser/Electron autoplay policies.
5. Resource strategy: video and the asar trade-off
Interactive movie videos are often hundreds of MB. asar packaging (the default) compresses files into a single archive, which causes:
- large files to read slowly and makes streaming playback impossible;
- updates to require replacing the whole archive.
Recommended approach:
// package.json (key electron-builder fields)
"build": {
"appId": "com.yourstudio.yourmovie",
"productName": "Your Interactive Movie",
"directories": { "output": "dist" },
"files": ["main.js", "preload.js"],
"extraResources": [
{ "from": "build/web-mobile", "to": "web-mobile" }
],
"win": {
"target": ["nsis", "zip"],
"icon": "build/icon.ico"
}
}
Put web-mobile (including videos/) entirely into extraResources, outside asar, and let the local server read it with streaming fs calls; this gives the best performance and compatibility. For H5 web deployment, upload the whole web-mobile directory in the same way, and the path semantics are identical.
6. Steam launch points
- Entry: the Steam launch option points to the
.exepackaged by Electron; no game logic changes are required. - steam_appid.txt: put
steam_appid.txt(containing your AppID) in the project root during development for local debugging. - Steamworks integration (optional): for achievements, cloud saves, and friends, you can use Greenworks (a Node wrapper for the Steamworks SDK) from the Electron main/renderer process. A pure narrative interactive movie can also skip integration for now.
- Build upload: configure the Depot and Build in the Steamworks console, then use the SDK's ContentBuilder (SteamCMD) to upload the
dist/output (includingresources/web-mobile). Two issues need to be solved: how to obtain the key parameters and how to automate multiple branches.
6.1 How to get the key parameters
| Parameter | Meaning | Where to get it |
|---|---|---|
AppID |
Unique application identifier | In the Steamworks console, the appid=xxxx in the URL of the Application Management home page, or the Technical Tools page |
DepotID |
Content repository ID | Create and view it under Steamworks console SteamPipe → Depot configuration |
| Release branch (Branch) | Release channel (default/beta/test) | Configure in Steamworks console SteamPipe → Branches; password-protected branches are supported |
| ContentBuilder path | Upload tool | In the Steamworks SDK under tools/ContentBuilder/ (contains steamcmd.exe and sample scripts) |
| Release account | Steam account with release permission | Authorize in the console's User Management; first login requires a Steam Guard one-time code |
6.2 Automated multi-branch packaging and build configuration
Steam's multiple branches are distinguished by the SetLive field in app_build_xxxx.vdf. Maintain one app_build configuration per branch (only SetLive differs), then combine them with a single upload script.
depot_build_12345.vdf (content manifest, shared by all branches):
"DepotBuildConfig"
{
"DepotID" "12345"
"ContentRoot" "../../dist/win-unpacked"
"FileExclusion" "*.pdb"
"IncludeAllRootFiles" "1"
}
app_build_12345_beta.vdf (beta branch release):
"AppBuild"
{
"AppID" "12345"
"Desc" "Interactive movie beta"
"BuildOutput" "../../output"
"Depots"
{
"12345" "depot_build_12345.vdf"
}
"SetLive" "beta"
}
(For the default branch, change SetLive to default; same for test.)
Automation chain: use an npm script to string together Cocos command-line build → electron-builder package → SteamCMD upload into one command, and use the STEAM_BRANCH environment variable to choose the branch:
"scripts": {
"build:h5": "CocosCreator --project . --build platform=web-mobile",
"build:win": "electron-builder --win nsis",
"steam:upload": "node scripts/steam-upload.js",
"release": "npm run build:h5 && npm run build:win && npm run steam:upload"
}
scripts/steam-upload.js selects the corresponding vdf based on the branch and calls SteamCMD:
const { execFileSync } = require('child_process');
const branch = process.env.STEAM_BRANCH || 'default';
const vdf = `scripts/app_build_12345_${branch}.vdf`;
execFileSync('tools/ContentBuilder/builder/steamcmd.exe', [
'+login', process.env.STEAM_USER, process.env.STEAM_PASS,
'+run_app_build', `../${vdf}`,
'+quit'
], { stdio: 'inherit' });
Run it with: STEAM_BRANCH=beta STEAM_USER=xxx STEAM_PASS=yyy npm run release.
Advanced: connect release to GitHub Actions / Jenkins so that when a tag is pushed (for example beta-v1.2), the branch is uploaded automatically, achieving one commit, automatic build and Steam upload.
- Size and download: interactive movies are large; it is recommended to configure depot sub-packages and on-demand downloads in the Steam console.
Reference (visual Steam console launch flow): beyond the technical solution, there is a complete illustrated tutorial for Steam store onboarding and console operations (account/tax verification, getting AppID and DepotID, using ContentBuilder to submit builds, generating versions and selecting branches, setting the startup .exe and supported systems, release and pricing, switching test branches, enabling cloud saves, applying for serial numbers, etc.): https://blog.csdn.net/qq_30100043/article/details/158468223. That post complements this one: this text focuses on Electron engineering packaging and multi-branch automated upload, while that one focuses on visual Steamworks console operations; together they cover the full path from packaging to launch.
7. Common problems and pitfalls
| Problem | Root cause | Solution |
|---|---|---|
| Blank screen on desktop | Loaded with file://; Cocos's fetch is blocked by CORS |
Switch to the local HTTP server (Option B) |
| Video cannot play | Video is packed into asar and cannot be streamed | Externalize with extraResources, keep it out of asar |
| Autoplay blocked | Browser/Electron requires a user gesture by default | autoplay-policy=no-user-gesture-required |
| Development/packaged path mismatch | The meaning of __dirname changes inside asar |
Use process.resourcesPath to locate extraResources |
| Web and desktop behavior differ | Video uses absolute/remote paths | Use relative paths consistently for resources |
| Windows decoding errors | H5 depends on the browser decoder; desktop uses the system decoder | Output H.264 MP4 uniformly for the best compatibility |
8. Pre-release checklist (template)
- Cocos version marked in the title/body (2.4.15 here)
-
web-mobilecan be opened standalone in a browser (not viafile://) - Electron loads it with a local HTTP server, not
loadFile -
web-mobile(includingvideos/) is configured asextraResourcesand is not in asar - All resource references use relative paths
- Electron has passed the local server address (dynamic port) to Cocos, and
VideoPlayer.remoteURLis built from it - Video format is uniformly H.264 MP4
- Autoplay policy handled (required for interactive movies)
- Installer icon,
appId, andproductNameare configured - Steam
steam_appid.txtand Depot configuration are ready - Both the web and desktop versions have been tested through to the ending branch
9. Summary
The Cocos + Electron combination lets interactive movie games build once and distribute to two ends: Cocos produces the web-mobile static directory, the H5 route deploys it directly, and the Windows/Steam route uses Electron to start a local server that loads the same directory. The three key technical points are: reject file:// and use a local HTTP service; put videos and other resources in extraResources outside asar; use relative paths uniformly. With these in place, the same codebase can cover both web and Steam at low cost. The video load address is dynamically constructed by Cocos at runtime from the local server base in remoteURL; in H5 deployment the base is empty and falls back to a relative path, so the two-end logic is identical. In addition, Android and iOS can load the same H5 link through the system WebView (or open web-mobile as a local resource through WebView), extending the boundary of one codebase, multi-end distribution. Welcome to try it at http://www.huyingpai.com/#/home, and the Steam link is: https://store.steampowered.com/app/5129220/_/