You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
@@ -0,0 +1,42 @@
+# Hosted website game
+
+This package runs all Rustic gameplay scripting languages through the native game
+runtime. Players need only a browser with WebSocket and Canvas support. The host
+needs the same OS/architecture as this exported runtime, a compatible GPU/driver,
+and the external language toolchains used by the game (Luau, Python, C/C++, .NET,
+JDK including javac, and/or PHP). Lua 5.4 and JavaScript are bundled. The Luau host
+and sibling runtime libraries are copied when present. No scripts are translated
+or silently omitted.
+
+1. Keep the complete `website/` folder together.
+2. On Windows run `start-website.cmd`. On Linux/macOS run `sh start-website.sh`.
+3. Open `http://127.0.0.1:8080` in a browser. The game starts automatically.
+4. To choose a port, pass `--website-address 127.0.0.1:9090` to the launcher.
+
+For remote players, run the package on a compatible GPU-capable server and put an
+HTTPS reverse proxy in front of the listening address. Forward WebSocket upgrades
+for `/play` and preserve the external Host and Origin headers. Host at the origin
+root. The listener defaults to loopback; to listen on a server network interface,
+pass `--webs
@@ -117,6 +117,23 @@ These are optional references, not installed dependencies or engine API specific
- Broader design/accessibility reference catalog: https://github.com/jeremylongworth-source/AgentSkills/tree/main/skills
- Blender workflow reference: https://github.com/Chara-Xh/blender-modeling-skill/tree/main/skills/blender-modeling
+## Exporting a game website
+
+Use File → Build Project… → Website (playable game). Hosted runtime supports all
+Rustic scripting languages through the native runtime; it packages launchers and
+a verified game snapshot, and requires a matching GPU-capable host with the used
+external language toolchains. Players need only a browser. Run start-website.cmd
+(Windows) or sh start-website.sh (Unix), then open http://127.0.0.1:8080. Remote
+hosting requires an HTTPS reverse proxy with /play WebSocket forwarding and
+matching Host/Origin. Each player gets temporary isolated game files; saves do
+not persist. PNG frames are streamed at 640 × 360/up to 30 fps; bandwidth varies by scene.
+Static WebAssembly mode needs no game server but only supports Lua 5.4,
+JavaScript and HTML/CSS; external languages and standalone PHP pages must not be
+silently omi
@@ -21,7 +21,59 @@ The game opens its own window and isolated simulation, forwards keyboard, primar
## Playable website
-Select **Website (playable game)** and click **Build Project**. The editor exports the current game automatically: its scene snapshot, registered scripts, assets, HTML/CSS game UI, browser player and compiled WebAssembly runtime. **Do not create a `web/` folder or prepare a separate website.** No game server is required.
+Select **Website (playable game)** in **File → Build Project…**. Choose the website mode:
+
+| Mode | Languages | Hosting |
+| --- | --- | --- |
+| Hosted runtime (default for new build settings) | Lua 5.4, Luau, JavaScript, Python, C, C++, C#, Java, PHP, HTML/CSS | Native runtime on a matching OS/architecture with GPU and required language toolchains |
+| Static WebAssembly | Lua 5.4, JavaScript, HTML/CSS | Any static HTTP/HTTPS host |
+
+Existing saved build settings retain static mode until you select hosted mode.
+
+### Hosted runtime: all scripting languages
+
+Choose **Hosted runtime (all scripting languages)** and click **Build Project**.
+The generated `website/` folder contains the native runtime, available sibling
+runtime librari
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Add hosted website builds for all scripting languages
Download RusticGameEngine-Setup-0.1.0-x64-20261102-072701.exe
Updated documentation
Release · All changed files and full diff · Download patch
Files changed
.github/workflows/quality.yml— modified, +4 / -0Diff preview
@@ -126,6 +126,8 @@ jobs: npm exec --prefix tools/browser-tests -- playwright install --with-deps chromium - name: Play an exported game without a game server run: npm test --prefix tools/browser-tests + - name: Hosted native website rendering and Python input + run: EGL_PLATFORM=surfaceless cargo test --locked -p rustic-runtime --profile dev-fast native_website_streams_rendered_frames_and_executes_python_input -- --ignored rust-security: name: Rust dependency security and licenses @@ -198,6 +200,8 @@ jobs: - run: rustup show active-toolchain - run: cargo build --locked --manifest-path apps/luau-host/Cargo.toml --target-dir target - run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-scripting external_runtime -- --nocapture + - name: Hosted website sessions across all adapters + run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play streamed_sessions_execute_available_native_language_adapters -- --nocapture - name: Scene environment scripting across all adapters run: PATH="$PWD/target/debug:$PATH" cargo test --locked -p engine-play environment -- --nocaptSee the file link for the complete diff.
Engine/Cargo.lock— modified, +94 / -2Diff preview
See the file link for the complete diff.
Engine/apps/editor/src/project_build.rs— modified, +68 / -5Diff preview
@@ -1,4 +1,5 @@ //! Project distribution UI and shell-free packaging jobs. +mod hosted; mod portable; use eframe::egui; use engine_editor::AuthoringDocument; @@ -31,6 +32,13 @@ impl Target { } } +#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +enum WebsiteMode { + #[default] + Static, + Hosted, +} + #[derive(Clone, Serialize, Deserialize)] #[serde(default)] struct Settings { @@ -48,6 +56,8 @@ struct Settings { android_package: String, android_version_code: u32, compiler: String, + #[serde(default)] + website_mode: WebsiteMode, } impl Default for Settings { fn default() -> Self { @@ -66,6 +76,7 @@ impl Default for Settings { android_package: "org.rustic.game".into(), android_version_code: 1, compiler: "ISCC.exe".into(), + website_mode: WebsiteMode::Hosted, } } } @@ -174,7 +185,13 @@ impl ProjectBuild { } } Target::Website => { - ui.label("Turns the current game into a playable website: scenes, scripts, assets and the WebAssembly runtime areSee the file link for the complete diff.
Engine/apps/editor/src/project_build/hosted-readme.md— added, +42 / -0Diff preview
See the file link for the complete diff.
Engine/apps/editor/src/project_build/hosted.rs— added, +116 / -0Diff preview
@@ -0,0 +1,116 @@ +//! Native website packages retain the ordinary gameplay adapters for every language. +use super::copy_tree; +use std::{fs, path::Path}; + +pub(super) fn export( + data: &Path, + destination: &Path, + runtime: &Path, + name: &str, +) -> Result<(), String> { + engine_play::PlaySnapshot::open(data).map_err(|e| e.to_string())?; + if !runtime.is_file() { + return Err("The native game runtime is missing beside the editor.".into()); + } + fs::create_dir_all(destination).map_err(|e| e.to_string())?; + fs::copy(runtime, destination.join(super::native_runtime_name())).map_err(|e| e.to_string())?; + if let Some(parent) = runtime.parent() { + for entry in fs::read_dir(parent).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_type().map_err(|e| e.to_string())?.is_file() + && (entry + .path() + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("dll") || e == "so" || e == "dylib") + || entry.file_name() == "rustic-luau-host.exe" + || entry.file_name() ==See the file link for the complete diff.
Engine/apps/editor/src/project_build/portable.rs— modified, +1 / -1Diff preview
@@ -32,7 +32,7 @@ pub(super) fn validate_scripts(root: &Path) -> Result<(), String> { ScriptLanguage::Lua54 | ScriptLanguage::JavaScript | ScriptLanguage::Web ) { return Err(format!( - "{} uses {}. Direct website/Android exports currently support Lua 5.4, JavaScript and HTML/CSS behaviors; external desktop toolchains cannot run on these targets.", + "{} uses {}. Static website/Android exports support Lua 5.4, JavaScript and HTML/CSS behaviors; external desktop toolchains cannot run on these targets. Choose Hosted runtime for website builds.", entry.relative_path.display(), entry.language.display_name() ));See the file link for the complete diff.
Engine/apps/runtime/Cargo.toml— modified, +4 / -0Diff preview
@@ -16,6 +16,10 @@ glam.workspace = true resvg.workspace = true winit.workspace = true tempfile.workspace = true +image.workspace = true +serde.workspace = true +serde_json.workspace = true +tungstenite = { version = "0.28", default-features = false, features = ["handshake"] } [dev-dependencies] engine-world = { path = "../../crates/engine-world", version = "0.1.0" }See the file link for the complete diff.
Engine/apps/runtime/src/main.rs— modified, +17 / -0Diff preview
@@ -24,6 +24,7 @@ use winit::window::{Window, WindowId}; mod game_ui; mod standalone; +mod website; const TOKEN_ENVIRONMENT_VARIABLE: &str = "RUSTIC_RUNTIME_IPC_TOKEN"; @@ -73,6 +74,22 @@ fn main() -> ExitCode { } fn run(arguments: &[OsString]) -> Result<(), String> { + if arguments.first().is_some_and(|value| value == "--website") { + let address = match arguments { + [_] => "127.0.0.1:8080", + [_, option, address] if option == "--website-address" => address + .to_str() + .ok_or("Website address is not valid UTF-8")?, + _ => { + return Err("Usage: rustic-runtime --website [--website-address <IP:port>]".into()); + } + }; + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + return website::run( + executable.parent().ok_or("Missing runtime directory")?, + address, + ); + } if arguments.is_empty() { let executable = std::env::current_exe().map_err(|e| e.to_string())?; return standalone::run(&executable);See the file link for the complete diff.
Engine/apps/runtime/src/website.rs— added, +547 / -0Diff preview
@@ -0,0 +1,547 @@ +//! A hosted website runs the native language adapters; the browser transports input, +//! pixels and audio. Each WebSocket owns a disposable snapshot and game session. +use engine_play::{game_session::GameSession, pointer_input::PointerEvent}; +use image::ImageEncoder as _; +use renderer_wgpu::{BackendRequest, SceneViewportRenderer}; +use serde::Deserialize; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::time::{Duration, Instant}; +use tungstenite::handshake::server::{ErrorResponse, Request, Response}; +use tungstenite::{Message, WebSocket, accept_hdr_with_config, protocol::WebSocketConfig}; + +const MAX_CONNECTIONS: usize = 8; +const WIDTH: u32 = 640; +const HEIGHT: u32 = 360; +const MAX_INPUT: usize = 64 * 1024; + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Input { + keys: Vec<String>, + pointers: Vec<PointerEvent>, + reset: bool, +} + +pub(super) fn run(package: &Path, address: &str) -> Result<(), String> { + let data = package.join("data"); + engine_play::PlaySnapshot::openSee the file link for the complete diff.
Engine/apps/runtime/src/website/player.html— added, +4 / -0Diff preview
@@ -0,0 +1,4 @@ +<!doctype html> +<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"><title>Rustic Game</title> +<style>html,body{margin:0;width:100%;height:100%;overflow:hidden}body{display:grid;place-items:center}#game{width:100vw;height:100vh}canvas{max-width:100%;max-height:100%;object-fit:contain;touch-action:none}#status{position:absolute;top:0;left:0;white-space:pre-wrap}</style></head> +<body><canvas id="game" width="640" height="360" tabindex="0" aria-label="Game"></canvas><p id="status" role="alert" hidden></p><script src="player.js"></script></body></html>See the file link for the complete diff.
Engine/apps/runtime/src/website/player.js— added, +110 / -0Diff preview
@@ -0,0 +1,110 @@ +"use strict"; +const canvas = document.getElementById("game"); +const status = document.getElementById("status"); +const context = canvas.getContext("2d"); +fetch("game-info.json").then(r => r.json()).then(info => { document.title = info.name; }).catch(() => {}); +const keys = new Set(); +const pointers = []; +let reset = false; +let audio; +let nextAudioTime = 0; +let running = true; +function fail(error) { + running = false; + status.textContent = `Game could not run: ${error?.message || String(error)}`; + status.hidden = false; + socket?.close(); +} +function enableAudio() { + try { + audio ??= new AudioContext(); + if (audio.state === "suspended") audio.resume().catch(() => {}); + } catch { /* Graphics and gameplay remain available without an audio device. */ } +} +// Browsers require a player gesture for sound, but gameplay starts immediately. +canvas.addEventListener("pointerdown", enableAudio); +canvas.addEventListener("keydown", enableAudio); +function playAudio(bytes) { + if (!audio || audio.state !== "running" || bytes.length < 8) return; + if (nextAudioTime > audio.currentTime + 0.3) return; + const samples = new DataView(bytes.buffer, bSee the file link for the complete diff.
Engine/crates/engine-play/src/audio_output.rs— modified, +54 / -0Diff preview
@@ -1,6 +1,24 @@ //! Native device handles and streams stay on the audio thread, outside scene locks. +use std::sync::{Arc, Mutex}; +pub(crate) type StreamAudio = Arc<Mutex<Vec<f32>>>; +thread_local! { + static STREAM_AUDIO: std::cell::RefCell<Option<StreamAudio>> = const { std::cell::RefCell::new(None) }; +} + +// New scene contexts inherit the connection's sink without changing other players. +pub(crate) fn with_stream_audio<T>(sink: &StreamAudio, operation: impl FnOnce() -> T) -> T { + struct Restore(Option<StreamAudio>); + impl Drop for Restore { + fn drop(&mut self) { + STREAM_AUDIO.with(|slot| *slot.borrow_mut() = self.0.take()); + } + } + let _restore = Restore(STREAM_AUDIO.with(|slot| slot.replace(Some(Arc::clone(sink))))); + operation() +} #[derive(Default)] pub(crate) struct AudioOutput { + stream: Option<StreamAudio>, #[cfg(windows)] sender: Option<std::sync::mpsc::SyncSender<Command>>, } @@ -22,6 +40,13 @@ enum Command { impl AudioOutput { pub fn new() -> Self { + if let Some(stream) = STREAM_AUDIO.with(|slot| slot.borrow().clone()) { + return Self { + stream: Some(streaSee the file link for the complete diff.
Engine/crates/engine-play/src/game_session.rs— modified, +130 / -2Diff preview
@@ -8,6 +8,7 @@ use std::path::Path; pub struct GameSession { scripts: RuntimeScripts, accumulator: f64, + stream_audio: Option<crate::audio_output::StreamAudio>, } impl GameSession { /// Load verified packaged data and initialize the same gameplay callbacks as local play. @@ -19,9 +20,32 @@ impl GameSession { Ok(Self { scripts: RuntimeScripts::load(root, &source)?, accumulator: 0.0, + stream_audio: None, }) } - /// Advance bounded real time without launching threads or child processes. + /// Load a native session whose mixed audio is returned to a website player. + /// # Errors + /// Returns the same validation and initialization errors as `load`. + pub fn load_streamed(root: &Path) -> Result<Self, String> { + let audio = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut session = crate::audio_output::with_stream_audio(&audio, || Self::load(root))?; + session.stream_audio = Some(audio); + Ok(session) + } + + /// Drain bounded interleaved stereo 48 kHz samples for a hosted player. + pub fn take_stream_audio(&self) -> Vec<f32> { +See the file link for the complete diff.
Engine/crates/engine-scripting/src/agent_authoring.md— modified, +17 / -0Diff preview
See the file link for the complete diff.
Engine/docs/PROJECT_BUILDS.md— modified, +57 / -5Diff preview
See the file link for the complete diff.
Engine/tools/browser-tests/hosted-player.test.mjs— added, +82 / -0Diff preview
@@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { after, before, test } from "node:test"; +import { chromium } from "playwright"; + +const source = new URL("../../apps/runtime/src/website/", import.meta.url); +let browser, png; +before(async () => { + browser = await chromium.launch({ executablePath: process.env.RUSTIC_CHROMIUM || undefined, headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"] }); + const page = await browser.newPage(); + png = Buffer.from(await page.evaluate(() => { + const canvas = document.createElement("canvas"); + canvas.width = 640; canvas.height = 360; + const context = canvas.getContext("2d"); + context.fillStyle = "red"; context.fillRect(0, 0, 640, 360); + return [...Uint8Array.from(atob(canvas.toDataURL().split(",")[1]), c => c.charCodeAt(0))]; + })); + await page.close(); +}); +after(async () => { await browser?.close(); }); + +async function openPlayer(t, { error, invalidFrame = false } = {}) { + const context = await browser.newContext({ hasTouch: true }); + t.after(() => context.close()); + const page = await context.newPage(); + const inputs = [], erSee the file link for the complete diff.
Engine/tools/browser-tests/package.json— modified, +1 / -1Diff preview
@@ -2,6 +2,6 @@ "name": "rustic-browser-export-tests", "private": true, "type": "module", - "scripts": { "test": "npm run test:player && node test.mjs", "test:player": "node --test player.test.mjs" }, + "scripts": { "test": "npm run test:player && node test.mjs", "test:player": "node --test player.test.mjs hosted-player.test.mjs" }, "devDependencies": { "playwright": "1.64.0" } }See the file link for the complete diff.
All reactions