Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -- --nocapture
- name: Audio, capture and live reads across all adapters
Expand Down
96 changes: 94 additions & 2 deletions Engine/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 68 additions & 5 deletions Engine/apps/editor/src/project_build.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Project distribution UI and shell-free packaging jobs.
mod hosted;
mod portable;
use eframe::egui;
use engine_editor::AuthoringDocument;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -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 are packaged automatically. Upload the generated website folder to any static host. No web source folder or game server is needed.");
ui.radio_value(&mut self.settings.website_mode, WebsiteMode::Hosted, "Hosted runtime (all scripting languages)");
ui.radio_value(&mut self.settings.website_mode, WebsiteMode::Static, "Static WebAssembly (Lua 5.4, JavaScript, HTML/CSS)");
if self.settings.website_mode == WebsiteMode::Hosted {
ui.label("Packages a website and native game server. Run start-website on a matching host with the game’s language toolchains and a compatible GPU. Players use their browser; each connection gets its own game session. Static hosting alone cannot run this mode.");
} else {
ui.label("Packages the WebAssembly game runtime for any static host. No game server is needed. External desktop scripting languages require Hosted runtime mode.");
}
}
Target::Apk | Target::Aab => {
field(ui, "Android package ID", &mut self.settings.android_package);
Expand Down Expand Up @@ -229,7 +246,7 @@ impl ProjectBuild {
.project()
.directory(VirtualDirectory::Builds)
.map_err(|e| e.to_string())?;
if self.settings.target != Target::Windows {
if uses_browser_runtime(&self.settings) {
portable::validate_scripts(&root)?;
}
let staging = tempfile::Builder::new()
Expand All @@ -251,7 +268,7 @@ impl ProjectBuild {
let settings = self.settings.clone();
let runtime = std::env::current_exe()
.map_err(|e| e.to_string())?
.with_file_name("rustic-runtime.exe");
.with_file_name(native_runtime_name());
let (sender, receiver) = mpsc::channel();
std::thread::Builder::new()
.name("project-build".into())
Expand Down Expand Up @@ -425,6 +442,19 @@ fn available(program: &str, argument: &str) -> bool {
.is_ok_and(|o| o.status.success())
}

fn native_runtime_name() -> &'static str {
if cfg!(windows) {
"rustic-runtime.exe"
} else {
"rustic-runtime"
}
}

fn uses_browser_runtime(settings: &Settings) -> bool {
matches!(settings.target, Target::Apk | Target::Aab)
|| (settings.target == Target::Website && settings.website_mode == WebsiteMode::Static)
}

fn readiness(root: &Path, settings: &Settings) -> Vec<String> {
let mut missing = Vec::new();
match settings.target {
Expand All @@ -444,7 +474,14 @@ fn readiness(root: &Path, settings: &Settings) -> Vec<String> {
missing.push("The custom Inno Setup compiler path does not exist. Use ISCC.exe for automatic setup.".into());
}
}
Target::Website => {}
Target::Website => {
if settings.website_mode == WebsiteMode::Hosted
&& !std::env::current_exe()
.is_ok_and(|exe| exe.with_file_name(native_runtime_name()).is_file())
{
missing.push(format!("{} beside the editor.", native_runtime_name()));
}
}
Target::Apk | Target::Aab => {
if java_executable().is_none() {
missing.push("JAVA_HOME pointing to a JDK with bin/java.".into());
Expand All @@ -463,7 +500,7 @@ fn readiness(root: &Path, settings: &Settings) -> Vec<String> {
}
}
}
if settings.target != Target::Windows {
if uses_browser_runtime(settings) {
if !std::env::current_exe()
.is_ok_and(|exe| portable::runtime_available(&portable::runtime_directory(&exe)))
{
Expand Down Expand Up @@ -595,6 +632,15 @@ fn build(root: &Path, output: &Path, settings: &Settings, runtime: &Path) -> Res
}
}
}
Target::Website if settings.website_mode == WebsiteMode::Hosted => {
hosted::export(
&output.join("data"),
&output.join("website"),
runtime,
&settings.name,
)?;
fs::remove_dir_all(output.join("data")).map_err(|e| e.to_string())?;
}
Target::Website => {
portable::export_website(
&output.join("data"),
Expand Down Expand Up @@ -819,6 +865,23 @@ Name: "desktopicon"; Description: "Create a desktop shortcut"; Flags: unchecked
mod tests {
use super::*;
#[test]
fn old_website_settings_stay_static_and_new_builds_offer_all_languages() {
let previous: Settings =
serde_json::from_str(r#"{"target":"Website","name":"Old Game"}"#).unwrap();
assert!(previous.website_mode == WebsiteMode::Static);
assert!(uses_browser_runtime(&previous));
let current = Settings {
target: Target::Website,
..Settings::default()
};
assert!(current.website_mode == WebsiteMode::Hosted);
assert!(!uses_browser_runtime(&current));
assert!(uses_browser_runtime(&Settings {
target: Target::Apk,
..current
}));
}
#[test]
fn rejects_installer_directive_and_path_injection() {
for name in [
"../escape",
Expand Down
42 changes: 42 additions & 0 deletions Engine/apps/editor/src/project_build/hosted-readme.md
Original file line number Diff line number Diff line change
@@ -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 `--website-address 0.0.0.0:8080`. Static hosts such as GitHub Pages cannot run
this package. Use the engine's Static WebAssembly mode for those hosts, with its
smaller set of supported languages.

The host renders 640 × 360 frames at up to 30 fps and streams PNG-compressed
frames and stereo 48 kHz audio. Bandwidth depends on scene complexity; latency
and bandwidth affect playability. Click/tap the canvas to enable browser audio.
Keyboard, primary mouse and touch input work. There is a maximum of eight
connections and idle connections time out after 30 seconds. Each player has an
independent simulation and temporary writable game copy; disconnecting removes
that copy. Saves do not persist between connections. Game UI follows the native
runtime's HTML/CSS/PHP rendering rules. Microphone and device selection refer to
the host, not the player's device; browser microphone forwarding is unavailable.

Only the packaged game's scripts execute. Do not run untrusted game packages:
native scripts have the host account's ordinary privileges. The listener exposes
only the player, game title and play endpoint, never the game data or scripts.
Use a dedicated host account and your reverse proxy's access controls when needed.

If startup fails, read the terminal output for missing toolchains, snapshot
integrity errors or GPU errors. Runtime initialization errors also appear in the
browser. If the browser disconnects behind a proxy, check WebSocket forwarding,
matching Host/Origin, timeouts and bandwidth. Close the launcher to stop hosting.
Loading
Loading