Skip to content

The Player Runtime ​

The player is Awaken's standalone runtime - a small, React-free app (apps/player) that boots a scene and runs your game. It is the same runtime the editor uses for Play, packaged to run on its own.

Every way you run a game - editor Play, Preview, a double-clicked game.html, or a hosted folder - is this player over the same @awaken/runtime. That shared runtime is what makes Awaken's edit == play == ship guarantee real: there is no separate "release build" of the engine that can drift from what you tested.

One runtime, everywhere ​

The player's main.ts constructs a PlayController over DEFAULT_SYSTEMS, registers your script behaviours into a ScriptRegistry, wires a RapierBackend physics runner, and drives it all from a requestAnimationFrame loop. That is the identical stack the editor Viewport builds for Play mode. The only differences a shipped game shows are cosmetic: no ground grid, no editor gizmos, and it is always "playing".

Two builds from one player ​

The same player source compiles into two shapes, selected by an environment flag in apps/player/vite.config.ts:

BuildCommandOutputLoads its scene fromTypeScript compiler
Hostedvite builddist/ (normal HTML + assets)fetch("./scene.awaken.json")Bundled (can compile raw TS)
Single-fileAWAKEN_SINGLE=1 vite builddist-single/index.html (everything inlined)Inline globalsDead-code-eliminated

The single-file build is the important one for shipping. It uses vite-plugin-singlefile to inline every asset into one HTML file, and sets __AWAKEN_INLINE_ONLY__ so the esbuild-wasm TypeScript compiler is stripped out (a shipped game carries baked behaviours, so it never needs to compile). It also emits a classic IIFE <script>, not <script type="module"> - because Chrome refuses to execute ES-module scripts from a file:// origin, so a double-clicked module player would silently never boot.

This single-file build is what becomes the player template that game.html is stamped from - see Build & Player Template and the stale-template gotcha.

NOTE

The dev port for the standalone player is 5181 (the editor runs on 5180). You rarely run the player directly - the editor's Preview and exports build it for you.

Booting a scene ​

On startup the player looks for a scene in a fixed preference order, so one player binary handles every export shape. The first source present wins:

The player booting a game: the Awaken splash with its loading bar while the scene loads.

  • __AWAKEN_SCENE_BIN__ - the current single-file game.html: a Zstd-compressed binary container, base64-inlined. Its meshes come out already dequantized, bypassing the JSON mesh path.
  • __AWAKEN_SCENE_GZ__ / __AWAKEN_SCENE__ - older gzip'd or raw JSON SceneFile exports, still supported.
  • ./scene.fg - the folder export sidecar, fetched when the game is served (blocked on file://).
  • ./scene.awaken.json - the legacy hosted JSON scene.

The inflater auto-detects the compression by magic bytes (Zstd 28 B5… vs gzip 1F 8B…) and decompresses in-browser - Zstd via fzstd (pure JS, so it works from file://), gzip via the native DecompressionStream. The whole boot - decode, mesh upload, texture upload, physics prewarm - is driven behind a splash screen with a progress bar that fades out once the first frame is on screen. A project with a launch screen in App Settings ships its own splash in the launch colour and logo first, which by default cross-fades to the Awaken splash and then stays at least 3 seconds before the game shows.

Baked behaviours vs compiled TypeScript ​

Scripts can arrive two ways, and the player prefers the baked form:

  1. Baked (shipped game.html) - behaviour classes are registered onto window.__AWAKEN_BEHAVIORS__ by an inline classic <script> (no dynamic import, so it runs from file://). The player registers these directly.
  2. Compiled TypeScript (hosted/dev) - for scenes that ship raw script source, the hosted player compiles the TS at load using its bundled compiler.

Because a double-clicked game runs from file:// - where ES modules and dynamic imports are blocked - the baked path is what makes single-file export work at all.

What the player provides at runtime ​

Beyond stepping the runtime, the standalone player wires up the pieces a finished game needs:

  • GPU picker - on a hybrid laptop with two GPUs, a small corner dropdown lets a stuck player switch GPUs (single-GPU machines never see it).
  • Input - keyboard events plus click-to-lock pointer for FPS-style mouse-look.
  • Touch gestures - see On a phone for what the page refuses and which touch reaches scripts. On a touch-primary device (detected by pointer: coarse, not by sniffing the user agent), swipes and taps are mapped onto the same synthesized key presses the keyboard produces, so a keyboard-driven script works on a touchscreen with no changes. Pointer lock is skipped there, since mobile browsers reject it and there is no mouse to lock. A synthesized key is held for about 100 ms: a script's edge detector is down && prev !== true, so a key pressed and released inside one tick would never fire an edge and the gesture would silently do nothing.
  • Module jobs - the worker that runs wasm modules is bundled into the player, so a shipped game runs its jobs exactly as the editor does.
  • In-game UI - a DOM overlay reconciled from UINode entities each frame. The game's fonts register as @font-face rules at each face's weight and style, UI images are drawn from their own files and never reach the GPU, UI animation clips advance through driveUIAnimators, and the UI scales with the window (a screen's design size, else RenderSettings.uiReferenceHeight, else raw pixels). See In-Game UI.
  • Quality levels - the level from the URL, the game's saved choice or the default, applied at start and when a script calls api.quality.set. See Performance.
  • A resting frame loop - frames that would show nothing new are skipped, and an idle game ticks ten times a second instead of every display frame. See Performance.
  • Physics on demand - Rapier's WASM only loads if the scene actually has colliders, rigidbodies or ragdolls, and a physics failure is caught so it never white-screens the game.
  • A plain "can't run here" screen - when the game cannot start, the player shows plain words instead of the error's own text, and in the mobile app tells the shell (see When the game cannot run).
  • Scripts that throw do not stop the game - each onStart, onUpdate, timer, collision callback and onDestroy call is caught on its own, so the other scripts and the frame carry on (see ScriptApi hooks).
  • Persistent save state - per-title saved data (high scores, etc.), namespaced by the game's title.
  • Debug overlays - F3 / ` toggles the stats readout; F4 draws collider wireframes - the same tools as the editor, so you can profile the shipped game.
  • Screenshots - F2 saves what the player sees as a PNG: the frame and the game's UI, without the debug overlays or api.setHud text. Shift+F2 saves the frame alone. If the browser refuses to read the UI back, the frame alone is saved.

On a phone ​

A web export behaves like an app on a phone by default, with nothing to switch on (apps/player/index.html, apps/player/src/webApp.ts):

  • The whole screen, never zoomed. The viewport meta is width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover: the canvas and backgrounds run under the notch, and the UI and Fit-contain cameras keep clear of it through the safe area. The body is fixed to the screen with no overscroll, so the page never scrolls, bounces or pulls to refresh.
  • No browser gestures on the game. A second finger's move (pinch zoom, which iOS performs even with user-scalable=no), Safari's gesture events, a double tap's zoom, the long-press callout and context menu, and text selection are refused page-wide. A text field keeps all of them.
  • Installable. The Home Screen metas are in the page, and a web app manifest is built at start-up from App Settings (name, short name, orientation, launch colour, 192 and 512 px icons), so a one-file game.html carries one too. It asks for fullscreen display, falling back to standalone.
  • Awake. The screen stays on while the game is shown (Screen Wake Lock, where the browser has it), and is let go when the page is hidden.
  • Haptics. api.app.haptic vibrates through navigator.vibrate where the browser has it (Chrome on Android, after the player's first tap). iOS Safari has none, so haptics do nothing there.

Touch, as the game sees it:

  • The canvas, the UI overlay and every UI element use touch-action: none, so a drag that starts on a button is not taken over by the browser as a pan.
  • Only the primary touch reaches scripts as the pointer (api.input.pointerX/Y, presses and releases). Multi-touch is not exposed yet.
  • api.ui.onPress(name, cb) fires as the finger goes down on any element; api.ui.hitTest(x, y) says which element a point is over, so a script can tell a drag released over its HUD from one over the game. See UI from Scripts.

The manifest's orientation is App Settings' orientation, or any when it has none. The native app's orientation falls back to landscape instead (resolveApp), so a project with App Settings but no orientation set gets a landscape app and a web manifest that allows any.

When the game cannot run ​

The player starts the GPU first. When that fails, or the page holds no scene, or anything else stops the start, it shows plain words over everything, inside the safe area, with Try again (which reloads the page):

CodeWhenWords
no-webgputhe browser has no WebGPU (no navigator.gpu, or no webgpu canvas context)This device can't run the game. It needs a newer graphics system.
no-adapterWebGPU offers no GPU (requestAdapter answered null or threw)the same
device-failedthe GPU would not start (requestDevice or the canvas setup failed)the same
no-scenethe page holds no sceneThe game could not start. Some of its files are missing or damaged.
script-erroranything else failed while startingThe game could not start. Something went wrong while it was starting.

initGPU (@awaken/render) throws a WebGPUUnsupportedError carrying the code. The error's own text goes to the console, and under the words when the developer readout is on. In the mobile app the page also posts {"type": "awaken:fatal", "code": ...}, and the shell shows its own screen in the launch colour with the logo (see Export for Mobile).

Errors that do not stop the game. An error nothing catches (a script's UI callback, a promise nobody waited for) shows over the game in a browser, as before, the first three of them. In the mobile app it is not painted over gameplay: it goes to the console and to the shell as {"type": "awaken:report", "kind", "message", "repeats"}, once and then at most every 10 seconds, 20 a minute at most. With the developer readout on, the app shows them as a browser does. Everywhere, the game's scripts hear each one, script errors and lost saves and GPU errors too, as awaken:error (see Outbox & App Events). A game that could not start raises its code at the next launch that starts.

See also ​

Awaken: browser-native WebGPU game engine.