Skip to content

Export Game (game.html) ​

Export Game (game.html) writes your game (scene, meshes, textures, sounds, materials, scripts, and the engine itself) into one self-contained HTML file you can double-click to play or upload anywhere.

There are no sidecar files, no asset folder and no server requirement. Everything the game needs is inlined into game.html: the player runtime as a classic <script>, the compressed scene as a base64 string, and the compiled scripts as globals. It opens from file://, so you can send it as an email attachment or upload it to itch.io or any static host.

This page walks the build step by step, because what gets baked in explains both the file's size and its one operational dependency (a pre-built player template).

The build pipeline ​

Export Game runs buildGameHtmlAsync (apps/editor/src/state/project.ts), which shares the whole payload path (buildShipPayload) with Preview, the folder export and the mobile export. They differ only at the delivery step.

1. Choose the options ​

Every export and Preview first opens the Export game dialog (Preview game for Preview):

  • Script protection: No obfuscation, Light obfuscation or Heavy obfuscation (see Minification and obfuscation). The choice is the project default and is saved in the .awaken file.
  • Debug - performance overlay: injects the Debug overlay into this build only. It is never saved.
  • When App Settings have no icon, a line reads "Using the default icon. Set one in App Settings…", which opens the dialog.

Export… (or Enter) continues; Cancel (or Esc) runs nothing.

The Export game dialog: the Script protection radio group (No obfuscation / Light obfuscation / Heavy obfuscation, each with its explanation), the Debug - performance overlay checkbox, the default-icon note, and Cancel / Export….

2. Checks, then the save location ​

Before any work, the export fetches the player template and refuses one that was changed on the way (see A template changed on the way is refused), and checks App Settings: an invalid value refuses the export before a file is asked for, so a refusal never leaves an empty file behind.

A big scene's encode can take longer than the browser's ~5-second user-activation window, after which showSaveFilePicker would throw. So the editor asks where to save while the click is still fresh, then does the heavy work. On Chromium you pick a filename up front (suggested game.html); on other browsers the file is downloaded at the end.

3. Collect only the used assets ​

An imported asset pack can hold hundreds of meshes and textures you never placed. Shipping all of them would bloat game.html and can pass V8's ~512 MB string limit. So the export trims the project first:

  • Imported prefabs are dropped. Importing a pack adds its prefabs as a placement palette; placing one bakes its entities into the scene, so its meshes ship through the scene. Prefabs you made yourself (no import source) still ship, since a script may spawn them.
  • Imported demo scenes are dropped, except the entry scene. The open scene and scenes you made always ship.

Then collectUsedAssets (@awaken/assets) walks what is left (the open scene, the scene library, the prefabs, material presets) and keeps only what is referenced:

  • Meshes and textures referenced by a MeshRenderer, a Collider shape, a material, or a particle effect's sprite.
  • Animation clips, skeletons and skins the scene plays: a character's Animator clip and skeleton, and every clip bound on its AnimStateMachine. Scrubbing an animation pack in the inspector's preview bakes each clip you look at into the project; those bakes do not ship unless a character binds them. A skin ships only with its skinned mesh, and per-mesh import materials only with their mesh.
  • Textures a script names. No component walk can see api.ui.setImage(name, "menu_bg") or a sprite swapped at runtime, so tick Always include in exports in that texture's Inspector. Ticked textures ship whether or not anything references them.
  • Terrains ship as their asset (heightfield, maps, instance lists); the player rebuilds the chunk and vegetation meshes at load.

Everything else is left out.

Some asset kinds ship whole, because a script names them by a bare string that no walk can see, or because they are small:

Ships wholeWhy
SoundsAn AudioSource clip or api.playSound("hit") names them by string. They ship as imported (Opus, AAC, MP3 ...).
UI images and fontsapi.ui.setImage names images by string. Fonts register as @font-face rules at each face's weight and style; the licence stays in the project.
Particle effectsapi.burst("sparks") names them by string. Effects from an imported pack are dropped, like its prefabs.
UI animation clips, compute shadersSmall, and named by string.
Render textures, SimulationsTheir descriptions, plus every compiled update shader when the project has a Simulation (the player has no graph compiler). See Simulations.

A clip you play only from a script

If a script calls api.playClip("someClip") on a clip that is not bound on an AnimStateMachine (and is not the Animator's clip), the filter cannot see it, so it does not ship and plays as the bind pose in the game. Bind such clips on the graph, or set one as the Animator's clip, so they ship.

You can see the mesh and texture split live in the Viewport stats overlay, which reports "used" bytes (what an export bakes) separately from the total resident bytes.

4. Compact the scene graph ​

Components that a plugin registers (a terrain generator's seed and sliders, say) are editor tooling, so they are removed from every shipped scene and prefab; the player would only log them as unknown. Then compactSceneForShip drops component fields that equal their defaults and rounds floats. The player fills defaults back in on load, so this is lossless for the game. On a large scene the graph is a large share of the bytes. Project saves are never compacted, only exports.

5. Encode textures ​

Only textures the game uses, and only raw-RGBA ones, are re-encoded for shipping (device-encoded textures are skipped, matching what the editor viewport can display). Each is encoded by role:

  • Albedo → WebP at quality 0.9 (lossy). A browser whose canvas cannot write WebP gets PNG instead, and the recorded type says which.
  • Normal / mask → PNG (lossless, so surface detail is exact).
  • Point-filtered textures (a Unity texture imported with filter mode Point, such as a flat-swatch palette) → PNG from a pure-JS writer, never a canvas: a canvas stores premultiplied colour and would zero the RGB of every pixel whose alpha is 0.

The role travels with each texture so the player uploads normal and mask maps as linear, not sRGB. A headless export has no canvas, so every texture ships as lossless PNG there.

6. Compile scripts to IIFEs ​

Your scripts are compiled ahead of time with esbuild, so the shipped game needs no TypeScript compiler. One <script> tag opens with window.__AWAKEN_BEHAVIORS__={};window.__AWAKEN_MODULE_WASM__={};, then each script becomes an IIFE that registers its default-exported class:

js
try {
  (function () {
    /* compiled, minified script code */
    window.__AWAKEN_BEHAVIORS__["<scriptId>"] = __awakenBehavior.default;
  })();
} catch (e) {
  console.error("[awaken] script " + "<scriptId>" + " failed", e);
}

Details:

  • Editor-only scripts and plugins are stripped. A script whose kind is "editor" or "plugin" is an editor tool and is never compiled into the game. See Editor-Only Scripts.
  • Baked, not compiled at runtime. The player registers these precompiled behaviours, which is what lets game.html run from file:// with no compiler.
  • Modules ship as binaries, never as compiled script. A wasm module's source is AssemblyScript or C; its compiled binary rides along base64 in window.__AWAKEN_MODULE_WASM__.
  • Libraries are emitted in dependency order and publish at once. Scripts that import a TypeScript library bind its namespace at module scope, while their own script tag parses, long before the player boots. So a library registers straight into the runtime's registry (globalThis.__awaken_set_script_ns), and each // @uses import is rewritten to the same binding the editor uses (esbuild would otherwise lower it to a require that a browser does not have).
  • A literal </script inside compiled code is escaped after obfuscation, so it cannot close the tag early.

What a game does not carry ​

  • Physics WASM is left out when no scene, library scene or shipped prefab has a collider, rigidbody or ragdoll (the player's own rule for loading physics; a ragdoll builds its bodies with Rapier, so it counts on its own). That is Rapier's WASM, about 2 MB of base64, and the page carries window.__AWAKEN_NO_PHYSICS__ so the player knows it was deliberate. The folder and mobile exports use the same rule: Crown Quest's mobile game.html went from 3.62 MB to 1.53 MB.
  • Importer code is tree-shaken out. Only the runtime entry of @awaken/assets is reachable from the player. pnpm size:player bundles the player and fails when an importer, the shader-graph compiler or editor-only code appears in it, or when the bundle without physics passes 480 KB minified.
  • Shader-graph node functions are emitted per material, so a material that calls none carries none.

Minification and obfuscation ​

Shipped scripts are always minified (esbuild: locals renamed, whitespace and comments stripped). You can also obfuscate them with javascript-obfuscator, chosen per project in the Export game dialog. It applies only to your game scripts: the engine is never obfuscated (it is open source, and it runs every frame).

LevelWhat it doesRuntime cost
None (default)Minified only. Locals are already renamed by minification, but strings and structure are readable in devtools.None added
LightIdentifiers renamed (hexadecimal) and string literals moved into a base64-encoded lookup table. Control flow is unchanged.Each string literal becomes a decoder call, so hot code that touches many strings per frame pays for it
HeavyLight, plus control-flow flattening and dead-code injection: each function becomes a state-machine dispatcher.Larger: onUpdate runs through a dispatcher on every call

Plain minification (None) already gives the renaming for free; hiding strings (Light) or logic (Heavy) always adds per-access work. Both levels keep the runtime contract: method names (onUpdate) and Inspector-configured fields still resolve, and __AWAKEN_BEHAVIORS__ is never renamed. Measure the cost on your own game with the Debug overlay's per-script timing.

Per-script override ​

The dialog level is the project default. Obfuscation cost is uneven: a quest or dialogue script costs almost nothing per frame at any level, while a tight per-frame loop (a cloth or particle simulation) can pay several milliseconds under Heavy. So each script can override the default. Open a script in the Code panel and pick its level from the Obfuscation dropdown:

  • Inherit (‹project level›) (default): use the project level.
  • None / Light / Heavy: force that level for this script. An explicit None wins over a Heavy project default, which is how you exempt a hot simulation.

The override is saved with the script (in the .awaken project and in an exported .awakenscript). Editor scripts, plugins and wasm modules have no dropdown. Pair it with the Debug overlay: run a Debug preview, find the expensive script, and drop just that one to None.

The Code panel with the per-script Obfuscation dropdown (Inherit (none)) at the right of its header bar.

Deterrence, not security

Client-side JavaScript is always recoverable: it runs in the player's browser. Obfuscation raises the bar against casual copying; it never hides code. Do not put secrets, API keys, or licence or anti-cheat checks in shipped scripts and expect them to stay private.

7. Pack the binary container ​

encodeSceneContainer (packages/assets/src/shipContainer.ts) builds a glTF-.glb-style container: a 12-byte header (magic AWSC, container version 2, metadata length), a JSON metadata chunk (scene graph, prefabs, scene library, materials, render settings, skeletons, particle effects, UI clips, and base64 textures, fonts, sounds and UI images), then one binary blob. Scripts are not in the container; they ride in their own tag (step 6).

The blob holds:

  • Mesh geometry, quantized: positions Uint16 over the mesh AABB, normals octahedral Int8, UVs Uint16 over the UV bounds, indices Uint16 below 65,536 vertices and Uint32 above. About 32 to 12 bytes per vertex. Meshes imported from a pack were already welded losslessly at import (bit-identical vertices merged; skinned meshes are not welded), which removes the duplicate vertices of de-indexed source files.
  • Baked animation clips, packed (clipPack.ts): every clip, channel and key kept, each value as a 16-bit step of its own track's range, stored as the difference from the previous frame. A bone rotation keeps about 0.002 degrees. 8.9 MB of clip JSON ships as 2.1 MB. Project files keep full precision; only the shipped game carries this.
  • Terrain arrays and the sky cubemap faces.

Raw bytes let the compressor key on real byte patterns instead of base64's 4/3 inflation. Meshes dequantize to Float32 at load, so the renderer's vertex layout is unchanged. Version 1 containers (clips as JSON) still decode. The scene metadata inside carries SCENE_FILE_VERSION (1); see Data Formats.

8. Compress once with Zstd ​

The whole container is compressed with Zstd at level 19 in a Web Worker (state/zstdWorker.ts), so the CPU-bound pass (about 20 seconds on a large scene) never freezes the editor. Zstd's window is megabytes against gzip's 32 KB, so it finds repeated geometry across the whole scene. The progress dialog shows a ticking Compressing… (Ns) while it runs. The player decompresses with fzstd, a pure-JS decoder that works from file://. Preview uses level 3 instead, since its file is thrown away.

NOTE

Meshes dominate an exported game's size; textures are small once WebP-encoded. The biggest lever on game.html size is geometry: fewer or simpler meshes, and shared geometry, compress better than any texture change.

9. Stamp into the template ​

The player template gets the game's page settings first: the physics WASM removed when unused, then App Settings (the name as the page title, a 32 px and a 16 px favicon and the 180 px apple-touch-icon as data URLs, the loading screen in the launch look), then the page settings every web export carries so it behaves like an app on a phone (see The Player Runtime). The engine part is checked again, then the store's page config, the scene and the scripts go in just before </head>:

html
<script>window.__AWAKEN_SCENE_BIN__="…base64…"</script>
<script>window.__AWAKEN_BEHAVIORS__={};window.__AWAKEN_MODULE_WASM__={}; /* compiled scripts */</script>

The injection uses a replacer function, so a $& that esbuild emits in minified code is never treated as a replacement pattern. The result is written to your chosen file (or downloaded as game.html), and the Console logs "Exported game.html - double-click to play, or upload it anywhere (itch.io, any host)". On boot the player finds __AWAKEN_SCENE_BIN__, base64-decodes it, decompresses it with fzstd and decodes the container, all in the browser with no network.

When App Settings has products in its Store tab with a test mode on, the export logs that the test store answers only where you test the page (this computer, a file, an automated browser); see In-app purchases.

The size chart ​

Every game.html, folder and mobile export ends on a size chart, one bar per stage, so you can see where the bytes go. The payload grows as parts are gathered, each measured as the saved project stores it, then shrinks through each step:

BarWhat it measures
MeshesThe used meshes as the project stores them: already quantized.
+ SceneAdds the entity graph, components, prefabs, materials, animation, particle effects and render settings as JSON.
+ TexturesAdds the used textures as the project stores them: lossless PNG files.
+ AudioAdds the sounds as imported. Only when the game has sounds; no later step shrinks them. The last grow bar is the peak.
CompactedScene JSON after defaults are dropped and floats rounded.
EncodedTextures after encoding (albedo to WebP).
CompressedThe whole container after Zstd.
game.html (or Files)The shipped file, byte for byte: the scene as base64 text, the scripts and the player engine. The folder and mobile exports show Files: scene.fg as it is plus game.html.

Every bar's slot is laid out and the scale is locked to the peak before the first bar fills, so nothing rescales while the export runs. Hover any bar for its size, the change from the previous bar, and what that step did.

The export size-story chart: Meshes / +Scene / +Textures grow to the assembled peak, then Compacted / Encoded / Quantized / Compressed shrink to the shipped size

Hovering the Encoded bar: texture encoding took the payload from 335.5 MB to 228.9 MB (−32%) - often the single biggest drop

Under the chart:

  • A line splitting the file into its parts, which add up to the file to the hundredth of a MB, for example "game.html 9.05 MB = engine 0.66 MB + scene 6.64 MB (the 4.98 MB compressed scene, written into the page as base64 text, a third larger) + scripts 1.75 MB". When physics was left out, the engine part says "(physics left out: no colliders in the scene)".
  • Scripts - obfuscation cost. When any script is obfuscated, a small bar pair compares the scripts Minified and Obfuscated, and how many times larger the obfuscated ones are. Scripts are kilobytes against megabytes of meshes, so they cannot register on the main bars.
  • The dialog holds on the chart until you click Done. Tick Auto-close next time to skip the hold on later exports.

The player template dependency ​

game.html is not built from scratch on each export. Your scene is injected into a pre-built player template: a single self-contained HTML build of the standalone player at apps/assets/player-template.html, served by the editor at /player-template.html. The export fetches it and inserts the scene before </head>.

pnpm build:player-template generates it (the editor's own build, pnpm build, runs it first too). The script builds the player with AWAKEN_SINGLE=1, rewrites its module script to a classic script (so it runs from file://), strips the favicon links, fails if any <script type="module"> survives or if a compression codec is found in the bytes, and stamps the build time and engine commit into the page.

The stale-template gotcha ​

WARNING

The exported game.html embeds a pre-built player template. If you change engine or player code and export without rebuilding the template, your export runs the old runtime: a feature can work in the editor and be missing from the exported game.

After any change to the engine (packages/*) or the player (apps/player), run:

sh
pnpm build:player-template

Then export again.

A built editor serves its own copy. pnpm build:player-template writes apps/assets/player-template.html, which the dev server serves. A built editor serves apps/editor/dist/player-template.html; the script refreshes that copy too whenever it exists, so the two cannot drift apart.

Every export reports the template's age. The template carries a build stamp (<meta name="awaken-template-built">), and the Console line after an export or Preview names it when it is a day old or more, with the command to fix it. A template with no stamp predates stamping, which is itself the stale case, and says so. A template built from a git checkout also carries the engine commit (<meta name="awaken-template-commit">, with -dirty when the engine's sources had uncommitted changes), which a mobile project's release check prints. The headless mobile export goes further and refuses a stale template.

A template changed on the way is refused ​

Every export copies the template word for word, so whatever the server put into it ships too. This happened: an editor served from a Vercel preview deployment had Vercel's toolbar injected into every HTML page, the template included, and the exported game.html loaded https://vercel.live/_next-live/feedback/feedback.js. An app that loads remote code fails App Store review (rule 2.5.2) and its privacy labels.

So Preview, Export Game, the folder export and the mobile export all refuse a template that has a <script> with a src (the built template is one self-contained file and has none) or anything after its closing </html>. Script bodies are skipped when looking, so the player's own code can hold the text of a script tag. The refusal is logged before any file is asked for, for example:

text
Export refused: the player template loads a script from https://vercel.live/_next-live/feedback/feedback.js, which the build never adds. Something between the build and the editor changed it, such as a hosting toolbar injected into every page. Export from a local editor (pnpm dev), or turn the injection off on the deployment, and rebuild the template with pnpm build:player-template.

The export refuses rather than strips: a toolbar can inject inline code and markup as well as its script tag, and a page served with any of it is not the file the build wrote. The engine part of the page is checked again after App Settings are applied, before the game's code goes in.

Fallback: scene.awaken.json ​

If the editor serves no player template (a page without the scene marker, such as a deployment answering with its own index.html), Export Game cannot produce a runnable single file. It exports a scene.awaken.json instead: the JSON SceneFile (base64 meshes, no engine) that a hosted player build fetches. The Console says which you got: "Exported scene.awaken.json - no player template; drop it beside a hosted player build to ship". A template that is served but changed on the way is refused, never swapped for this fallback.

When to use it ​

Use game.html for the simplest artifact: one file, no server, runs when double-clicked, uploads to any host that takes an HTML file. For the smallest download from a static host, use Export Folder, which keeps the scene as a raw sidecar instead of base64 (a third smaller). To export from a script or CI, see Headless Export.

See also ​

Awaken: browser-native WebGPU game engine.