import argv import filepath import gleam/erlang/process import gleam/io import gleam/list import gleam/option.{None, Some} import gleam/result import gleam/string import operating_system import shellout import shore import shore/key import shore/style import shore/ui import simplifile import snag.{type Result as SnagResult} import tom pub fn main() { // Check command-line arguments for bundle command case argv.load().arguments { ["bundle"] -> handle_bundle_command() _ -> run_interactive_mode() } } fn run_interactive_mode() -> Nil { let exit = process.new_subject() case get_project_name() { Ok(project_name) -> { let assert Ok(_actor) = shore.spec( init: fn() { init(project_name) }, update: update, view: view, exit: exit, keybinds: shore.default_keybinds(), redraw: shore.on_update(), ) |> shore.start exit |> process.receive_forever } Error(err) -> { io.println_error("\nāŒ Error: " <> snag.pretty_print(err)) } } } fn handle_bundle_command() -> Nil { io.println("šŸŽ® Starting desktop bundle build...") io.println("") case run_bundle_build() { Ok(_) -> { io.println("") io.println("āœ… Desktop bundle build complete!") io.println(" Check the build directory for platform distributions.") } Error(err) -> { io.println_error("") io.println_error("āŒ Bundle build failed: " <> snag.pretty_print(err)) } } } /// Locates the bun executable based on configuration priority: /// 1. gleam.toml [tools.mascarpone.bin] configuration /// 2. System bun in PATH /// 3. Bundled bun from lustre_dev_tools fn locate_bun() -> SnagResult(String) { // First check gleam.toml configuration case read_bun_config() { Ok(config) -> case config { "system" -> { // Config says use system bun case try_system_bun() { Ok(path) -> { io.println("šŸ” Using system bun (configured in gleam.toml)") Ok(path) } Error(_) -> Error(snag.new( "gleam.toml specifies 'system' bun, but bun not found in PATH", )) } } custom_path -> { // Config specifies a custom path case simplifile.is_file(custom_path) { Ok(True) -> { io.println("šŸ” Using custom bun path from gleam.toml: " <> custom_path) Ok(custom_path) } Ok(False) | Error(_) -> Error(snag.new( "gleam.toml specifies bun path '" <> custom_path <> "', but file not found", )) } } } Error(_) -> { // No config, use auto-detection: system first, then bundled case try_system_bun() { Ok(path) -> { io.println("šŸ” Using system bun") Ok(path) } Error(_) -> { io.println("šŸ” Using bundled bun from lustre_dev_tools") get_bundled_bun_path() } } } } } /// Reads the bun configuration from gleam.toml fn read_bun_config() -> SnagResult(String) { let root = find_root(".") let toml_path = filepath.join(root, "gleam.toml") use content <- result.try( simplifile.read(toml_path) |> snag.map_error(fn(_) { "Could not read gleam.toml" }), ) use toml <- result.try( tom.parse(content) |> snag.map_error(fn(_) { "Could not parse gleam.toml" }), ) // Try to read [tools.mascarpone.bin] bun setting tom.get_string(toml, ["tools", "mascarpone", "bin", "bun"]) |> snag.map_error(fn(_) { "No bun configuration found in gleam.toml" }) } /// Attempts to find bun in the system PATH fn try_system_bun() -> SnagResult(String) { // Try to run `bun --version` to check if bun is available in PATH case shellout.command(run: "bun", with: ["--version"], in: ".", opt: []) { Ok(_) -> Ok("bun") Error(_) -> Error(snag.new("System bun not found")) } } /// Gets the path to the bundled bun executable fn get_bundled_bun_path() -> SnagResult(String) { let root = find_root(".") use platform <- result.try(detect_platform()) let arch = detect_architecture() let platform_str = get_bun_platform_string(platform) let arch_str = get_bun_arch_string(arch) let bun_path = filepath.join( root, ".lustre/bin/bun-" <> platform_str <> "-" <> arch_str <> "/bun", ) // Verify bundled bun exists use bun_exists <- result.try( simplifile.is_file(bun_path) |> snag.map_error(fn(error) { "Could not check for bun executable: " <> simplifile.describe_error(error) }), ) case bun_exists { False -> Error(snag.new( "Bun executable not found at " <> bun_path <> " and not found in system PATH. Make sure lustre_dev_tools is properly installed or install bun globally.", )) True -> Ok(bun_path) } } fn run_bundle_build() -> SnagResult(Nil) { let root = find_root(".") // Locate bun (system or bundled) use bun_path <- result.try(locate_bun()) io.println("šŸ“¦ Running bun run build...") // Run bun run build shellout.command(run: bun_path, with: ["run", "build"], in: root, opt: []) |> snag.map_error(fn(error) { "Failed to run bun build command: " <> error.1 }) |> result.replace(Nil) } // Model types type Step { Welcome LustreChoice TemplateChoice DesktopBundleChoice Generating(List(GenerationStep)) Complete Failed(String) } type GenerationStep { StepPending(String) StepInProgress(String) StepComplete(String) StepFailed(String, String) } type Template { TwoDGame ThreeDGame PhysicsDemo } type Model { Model( step: Step, project_name: String, include_lustre: Bool, include_physics: Bool, template: option.Option(Template), bundle_desktop: Bool, ) } type Msg { NextStep SetLustre(Bool) SetTemplate(Template) SkipTemplate SetDesktopBundle(Bool) StartGeneration InstallLustreDevTools UpdateGleamToml InstallNpmPackages CreateGitignore CreateMainFile InstallNwBuilder SetupDesktopBundle GenerationComplete GenerationFailed(String) } // Shore Application fn init(project_name: String) -> #(Model, List(fn() -> Msg)) { #( Model( step: Welcome, project_name: project_name, include_lustre: True, include_physics: False, template: None, bundle_desktop: False, ), [], ) } fn update(model: Model, msg: Msg) -> #(Model, List(fn() -> Msg)) { case msg { NextStep -> { let next_step = case model.step { Welcome -> LustreChoice LustreChoice -> TemplateChoice TemplateChoice -> DesktopBundleChoice DesktopBundleChoice -> Complete Generating(_) -> Complete Complete -> Complete Failed(_) -> Complete } #(Model(..model, step: next_step), []) } SetLustre(value) -> { #(Model(..model, include_lustre: value, step: TemplateChoice), []) } SetTemplate(template) -> { #(Model(..model, template: Some(template), step: DesktopBundleChoice), []) } SkipTemplate -> { #(Model(..model, template: None, step: DesktopBundleChoice), []) } SetDesktopBundle(value) -> { let updated_model = Model(..model, bundle_desktop: value) let steps = generate_steps_list(updated_model) #(Model(..updated_model, step: Generating(steps)), [ fn() { StartGeneration }, ]) } StartGeneration -> #(model, [fn() { UpdateGleamToml }]) InstallLustreDevTools -> { let updated_model = update_step_status( model, "Installing Lustre dev tools", StatusInProgress, ) case install_lustre_dev_tools() { Ok(_) -> #( update_step_status( updated_model, "Installing Lustre dev tools", StatusComplete, ), [fn() { InstallNpmPackages }], ) Error(err) -> #( update_step_status( updated_model, "Installing Lustre dev tools", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } UpdateGleamToml -> { let updated_model = update_step_status(model, "Updating gleam.toml", StatusInProgress) case update_gleam_toml(model.project_name, model.include_lustre) { Ok(_) -> #( update_step_status( updated_model, "Updating gleam.toml", StatusComplete, ), [fn() { InstallLustreDevTools }], ) Error(err) -> #( update_step_status( updated_model, "Updating gleam.toml", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } InstallNpmPackages -> { let updated_model = update_step_status( model, "Installing Three.js and Rapier3D", StatusInProgress, ) case install_npm_packages() { Ok(_) -> #( update_step_status( updated_model, "Installing Three.js and Rapier3D", StatusComplete, ), [fn() { CreateGitignore }], ) Error(err) -> #( update_step_status( updated_model, "Installing Three.js and Rapier3D", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } CreateGitignore -> { let updated_model = update_step_status(model, "Creating .gitignore", StatusInProgress) case create_gitignore() { Ok(_) -> #( update_step_status( updated_model, "Creating .gitignore", StatusComplete, ), [ fn() { case model.template { Some(_) -> CreateMainFile None -> case model.bundle_desktop { True -> InstallNwBuilder False -> GenerationComplete } } }, ], ) Error(err) -> #( update_step_status( updated_model, "Creating .gitignore", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } CreateMainFile -> { let updated_model = update_step_status(model, "Creating main game file", StatusInProgress) case create_main_file( model.project_name, option.unwrap(model.template, ThreeDGame), ) { Ok(_) -> case model.bundle_desktop { True -> #( update_step_status( updated_model, "Creating main game file", StatusComplete, ), [fn() { InstallNwBuilder }], ) False -> #( update_step_status( updated_model, "Creating main game file", StatusComplete, ), [fn() { GenerationComplete }], ) } Error(err) -> #( update_step_status( updated_model, "Creating main game file", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } InstallNwBuilder -> { let updated_model = update_step_status(model, "Installing nw-builder", StatusInProgress) case install_nwbuilder() { Ok(_) -> #( update_step_status( updated_model, "Installing nw-builder", StatusComplete, ), [fn() { SetupDesktopBundle }], ) Error(err) -> #( update_step_status( updated_model, "Installing nw-builder", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } SetupDesktopBundle -> { let updated_model = update_step_status(model, "Setting up desktop bundle", StatusInProgress) case setup_desktop_bundle(model.project_name) { Ok(_) -> #( update_step_status( updated_model, "Setting up desktop bundle", StatusComplete, ), [fn() { GenerationComplete }], ) Error(err) -> #( update_step_status( updated_model, "Setting up desktop bundle", StatusFailed(snag.pretty_print(err)), ), [fn() { GenerationFailed(snag.pretty_print(err)) }], ) } } GenerationComplete -> #(Model(..model, step: Complete), []) GenerationFailed(err) -> #(Model(..model, step: Failed(err)), []) } } type StepStatus { StatusPending StatusInProgress StatusComplete StatusFailed(String) } fn generate_steps_list(model: Model) -> List(GenerationStep) { let base_steps = [ StepPending("Installing Lustre dev tools"), StepPending("Updating gleam.toml"), StepPending("Installing Three.js and Rapier3D"), StepPending("Creating .gitignore"), StepPending("Creating index.html"), ] let with_template = case model.template { Some(_) -> list.append(base_steps, [StepPending("Creating main game file")]) None -> base_steps } case model.bundle_desktop { True -> list.append(with_template, [ StepPending("Installing nw-builder"), StepPending("Setting up desktop bundle"), ]) False -> with_template } } fn update_step_status( model: Model, step_name: String, status: StepStatus, ) -> Model { case model.step { Generating(steps) -> { let updated_steps = list.map(steps, fn(step) { case step { StepPending(name) if name == step_name -> case status { StatusInProgress -> StepInProgress(name) StatusComplete -> StepComplete(name) StatusFailed(err) -> StepFailed(name, err) StatusPending -> step } StepInProgress(name) if name == step_name -> case status { StatusComplete -> StepComplete(name) StatusFailed(err) -> StepFailed(name, err) _ -> step } _ -> step } }) Model(..model, step: Generating(updated_steps)) } _ -> model } } fn view(model: Model) -> shore.Node(Msg) { case model.step { Welcome -> view_welcome() LustreChoice -> view_lustre_choice() TemplateChoice -> view_template_choice() DesktopBundleChoice -> view_desktop_bundle_choice() Generating(steps) -> view_generating(steps) Complete -> view_complete(model) Failed(msg) -> view_error(msg) } } // Views fn view_welcome() -> shore.Node(Msg) { ui.col([ ui.text_styled( "╔═══════════════════════════════════╗", Some(style.Cyan), None, ), ui.text_styled( "ā•‘ šŸŽ® Tiramisu Project Creator šŸŽ® ā•‘", Some(style.Cyan), None, ), ui.text_styled( "ā•‘ Gleam 3D Game Engine ā•‘", Some(style.Cyan), None, ), ui.text_styled( "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•", Some(style.Cyan), None, ), ui.text(""), ui.text("Welcome to Tiramisu! šŸ°"), ui.text(""), ui.text("This wizard will help you set up Tiramisu for your game project."), ui.text(""), ui.hr(), ui.text(""), ui.text_styled("Press Enter to continue", Some(style.Yellow), None), ui.text(""), ui.button("Continue", key.Enter, NextStep), ]) } fn view_lustre_choice() -> shore.Node(Msg) { ui.col([ ui.text_styled("Lustre Integration", Some(style.Cyan), None), ui.text(""), ui.text("Lustre allows you to create UI overlays for your game"), ui.text("(menus, HUDs, dialogs, etc.) using a reactive framework."), ui.text(""), ui.hr(), ui.text(""), ui.text("Include Lustre?"), ui.text(""), ui.button("[Y] Yes (recommended)", key.Char("y"), SetLustre(True)), ui.button("[N] No", key.Char("n"), SetLustre(False)), ]) } fn view_template_choice() -> shore.Node(Msg) { ui.col([ ui.text_styled("Project Template", Some(style.Cyan), None), ui.text(""), ui.text_styled( "āš ļø WARNING: Selecting a template is DESTRUCTIVE!", Some(style.Red), None, ), ui.text_styled( "It will OVERWRITE your existing game code in src/", Some(style.Red), None, ), ui.text(""), ui.text("If you're setting up NW.js for an existing project,"), ui.text("press [S] to skip template selection."), ui.text(""), ui.hr(), ui.text(""), ui.button( "[1] 2D Game - Orthographic camera and sprite setup", key.Char("1"), SetTemplate(TwoDGame), ), ui.button( "[2] 3D Game - Perspective camera with lighting", key.Char("2"), SetTemplate(ThreeDGame), ), ui.button( "[3] Physics Demo - Physics-enabled objects", key.Char("3"), SetTemplate(PhysicsDemo), ), ui.text(""), ui.button( "[S] Skip - Don't create/overwrite game files", key.Char("s"), SkipTemplate, ), ]) } fn view_desktop_bundle_choice() -> shore.Node(Msg) { ui.col([ ui.text_styled("Desktop Bundle for NW.js", Some(style.Cyan), None), ui.text(""), ui.text("Bundle for desktop will:"), ui.text(" • Install nw-builder to manage NW.js builds"), ui.text(" • Configure package.json with NW.js settings"), ui.text(" • Set up build configuration for all platforms"), ui.text(""), ui.text("After setup, run 'bun run build' to create platform builds"), ui.text(""), ui.hr(), ui.text(""), ui.text("Bundle for desktop?"), ui.text(""), ui.button("[Y] Yes", key.Char("y"), SetDesktopBundle(True)), ui.button("[N] No", key.Char("n"), SetDesktopBundle(False)), ]) } fn view_generating(steps: List(GenerationStep)) -> shore.Node(Msg) { let header = [ ui.text_styled( "╔═══════════════════════════════════╗", Some(style.Cyan), None, ), ui.text_styled( "ā•‘ Setting up your project... ā•‘", Some(style.Cyan), None, ), ui.text_styled( "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•", Some(style.Cyan), None, ), ui.text(""), ] let step_views = list.map(steps, fn(step) { case step { StepPending(name) -> ui.text_styled(" āø " <> name, Some(style.White), None) StepInProgress(name) -> ui.text_styled(" ā³ " <> name <> "...", Some(style.Yellow), None) StepComplete(name) -> ui.text_styled(" āœ“ " <> name, Some(style.Green), None) StepFailed(name, _err) -> ui.text_styled(" āœ— " <> name, Some(style.Red), None) } }) ui.col(list.append(header, step_views)) } fn view_complete(model: Model) -> shore.Node(Msg) { let template_text = case model.template { Some(t) -> template_name(t) None -> "None (skipped)" } let base_items = [ ui.text_styled("āœ… Project setup complete!", Some(style.Green), None), ui.text(""), ui.text("Project: " <> model.project_name), ui.text("Template: " <> template_text), ui.text( "Lustre: " <> case model.include_lustre { True -> "Yes" False -> "No" }, ), ui.text( "Physics: " <> case model.include_physics { True -> "Yes" False -> "No" }, ), ui.text( "Desktop Bundle: " <> case model.bundle_desktop { True -> "Yes" False -> "No" }, ), ui.text(""), ui.hr(), ui.text(""), ui.text("Next steps:"), ui.text(""), ] let next_steps = case model.bundle_desktop { True -> [ ui.text("1. Build platform distributions:"), ui.text_styled(" bun run build", Some(style.Cyan), None), ui.text(""), ui.text( "2. Find your builds in ./" <> model.project_name <> "_desktop_bundle/:", ), ui.text( " • " <> model.project_name <> "_desktop_bundle/nwjs-v*-linux-x64/", ), ui.text( " • " <> model.project_name <> "_desktop_bundle/nwjs-v*-win-x64/", ), ui.text( " • " <> model.project_name <> "_desktop_bundle/nwjs-v*-osx-arm64/", ), ui.text(""), ui.text("3. Or run in dev mode:"), ui.text_styled(" gleam run -m lustre/dev start", Some(style.Cyan), None), ui.text(" Then open http://localhost:1234 in your browser"), ] False -> [ ui.text("1. Start the dev server:"), ui.text_styled(" gleam run -m lustre/dev start", Some(style.Cyan), None), ui.text(""), ui.text("2. Open http://localhost:1234 in your browser"), ] } let footer = [ ui.text(""), ui.text("Happy game development! šŸŽ®"), ui.text(""), ui.text("Press Ctrl + X to leave"), ] ui.col(list.flatten([base_items, next_steps, footer])) } fn view_error(msg: String) -> shore.Node(Msg) { ui.col([ ui.text_styled("āŒ Error occurred", Some(style.Red), None), ui.text(""), ui.text(msg), ]) } fn template_name(template: Template) -> String { case template { TwoDGame -> "2D Game" ThreeDGame -> "3D Game" PhysicsDemo -> "Physics Demo" } } // Utility functions fn install_lustre_dev_tools() -> SnagResult(Nil) { let root = find_root(".") shellout.command( run: "gleam", with: ["run", "-m", "lustre/dev", "add", "bun"], in: root, opt: [], ) |> result.map_error(fn(error) { snag.new("Failed to install Lustre dev tools: " <> error.1) }) |> result.replace(Nil) } fn get_project_name() -> SnagResult(String) { let root = find_root(".") let toml_path = filepath.join(root, "gleam.toml") use content <- result.try( simplifile.read(toml_path) |> snag.map_error(fn(_) { "Could not read gleam.toml" }), ) use toml <- result.try( tom.parse(content) |> snag.map_error(fn(_) { "Could not parse gleam.toml" }), ) use name <- result.try( tom.get_string(toml, ["name"]) |> snag.map_error(fn(_) { "Could not find project name in gleam.toml" }), ) Ok(name) } fn find_root(path: String) -> String { let toml = filepath.join(path, "gleam.toml") case simplifile.is_file(toml) { Ok(False) | Error(_) -> find_root(filepath.join(path, "..")) Ok(True) -> path } } fn update_gleam_toml( project_name: String, include_lustre: Bool, ) -> SnagResult(Nil) { let root = find_root(".") let toml_path = filepath.join(root, "gleam.toml") // Read existing gleam.toml use content <- result.try( simplifile.read(toml_path) |> snag.map_error(fn(_) { "Could not read gleam.toml" }), ) // Add target = "javascript" if not present let content = case string.contains(content, "target =") { True -> content False -> { // Add after name line string.replace( content, "name = \"" <> project_name <> "\"", "name = \"" <> project_name <> "\"\ntarget = \"javascript\"", ) } } // Write back the updated target use _ <- result.try( simplifile.write(toml_path, content) |> snag.map_error(fn(_) { "Could not write gleam.toml" }), ) // Add dependencies using gleam add use _ <- result.try( shellout.command(run: "gleam", with: ["add", "tiramisu"], in: root, opt: []) |> snag.map_error(fn(error) { "Failed to add tiramisu dependency: " <> error.1 }), ) use _ <- result.try( shellout.command(run: "gleam", with: ["add", "vec"], in: root, opt: []) |> snag.map_error(fn(error) { "Failed to add vec dependency: " <> error.1 }), ) use _ <- result.try( shellout.command( run: "gleam", with: ["add", "--dev", "lustre_dev_tools"], in: root, opt: [], ) |> snag.map_error(fn(error) { "Failed to add lustre_dev_tools dependency: " <> error.1 }), ) // Add lustre if requested use _ <- result.try(case include_lustre { True -> shellout.command(run: "gleam", with: ["add", "lustre"], in: root, opt: []) |> snag.map_error(fn(error) { "Failed to add lustre dependency: " <> error.1 }) False -> Ok("") }) // Read the updated gleam.toml to add lustre HTML config use content <- result.try( simplifile.read(toml_path) |> snag.map_error(fn(_) { "Could not read gleam.toml" }), ) // Add lustre HTML config if not already present let lustre_config = "\n\n[tools.lustre.html] scripts = [ { type = \"importmap\", content = \"{ \\\"imports\\\": { \\\"three\\\": \\\"https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js\\\", \\\"three/addons/\\\": \\\"https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/\\\", \\\"@dimforge/rapier3d-compat\\\": \\\"https://cdn.jsdelivr.net/npm/@dimforge/rapier3d-compat@0.11.2/+esm\\\" } }\" } ] stylesheets = [ { content = \"body { margin: 0; padding: 0; overflow: hidden; }\" } ] " let final_content = case string.contains(content, "[tools.lustre.html]") { True -> content False -> content <> lustre_config } simplifile.write(toml_path, final_content) |> snag.map_error(fn(_) { "Could not write gleam.toml" }) } fn create_gitignore() -> SnagResult(Nil) { let root = find_root(".") let gitignore_path = filepath.join(root, ".gitignore") let content = "*.beam *.ez /build erl_crash.dump /priv .DS_Store node_modules/ .lustre/ dist/ *_desktop_bundle/" simplifile.write(gitignore_path, content) |> snag.map_error(fn(_) { "Could not write .gitignore" }) } fn create_main_file(project_name: String, template: Template) -> SnagResult(Nil) { let root = find_root(".") let src_dir = filepath.join(root, "src") let main_path = filepath.join(src_dir, project_name <> ".gleam") let content = case template { TwoDGame -> generate_2d_template() ThreeDGame -> generate_3d_template() PhysicsDemo -> generate_physics_template() } simplifile.write(main_path, content) |> snag.map_error(fn(_) { "Could not write main file" }) } // Template generators fn generate_2d_template() -> String { "/// 2D Game Example - Orthographic Camera import gleam/float import gleam/option import tiramisu import tiramisu/background import tiramisu/camera import tiramisu/effect.{type Effect} import tiramisu/geometry import tiramisu/light import tiramisu/material import tiramisu/scene import tiramisu/transform import vec/vec3 pub type Model { Model(time: Float) } pub type Msg { Tick } pub fn main() -> Nil { tiramisu.run( dimensions: option.None, background: background.Color(0x1a1a2e), init: init, update: update, view: view, ) } fn init(_ctx: tiramisu.Context(String)) -> #(Model, Effect(Msg), option.Option(_)) { #(Model(time: 0.0), effect.tick(Tick), option.None) } fn update( model: Model, msg: Msg, ctx: tiramisu.Context(String), ) -> #(Model, Effect(Msg), option.Option(_)) { case msg { Tick -> { let new_time = model.time +. ctx.delta_time /. 1000.0 #(Model(time: new_time), effect.tick(Tick), option.None) } } } fn view(model: Model, ctx: tiramisu.Context(String)) -> List(scene.Node(String)) { let cam = camera.camera_2d( width: float.round(ctx.canvas_width), height: float.round(ctx.canvas_height), ) let assert Ok(sprite_geom) = geometry.plane(width: 50.0, height: 50.0) let assert Ok(sprite_mat) = material.basic(color: 0xff0066, transparent: False, opacity: 1.0, map: option.None) [ scene.camera( id: \"camera\", camera: cam, transform: transform.at(position: vec3.Vec3(0.0, 0.0, 20.0)), look_at: option.None, active: True, viewport: option.None, ), scene.light( id: \"ambient\", light: { let assert Ok(light) = light.ambient(color: 0xffffff, intensity: 1.0) light }, transform: transform.identity, ), scene.mesh( id: \"sprite\", geometry: sprite_geom, material: sprite_mat, transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)) |> transform.with_euler_rotation(vec3.Vec3(0.0, 0.0, model.time)), physics: option.None, ), ] } " } fn generate_3d_template() -> String { "/// 3D Game Example - Perspective Camera with Lighting import gleam/option import tiramisu import tiramisu/background import tiramisu/camera import tiramisu/effect.{type Effect} import tiramisu/geometry import tiramisu/light import tiramisu/material import tiramisu/scene import tiramisu/transform import vec/vec3 pub type Model { Model(time: Float) } pub type Msg { Tick } pub fn main() -> Nil { tiramisu.run( dimensions: option.None, background: background.Color(0x1a1a2e), init: init, update: update, view: view, ) } fn init(_ctx: tiramisu.Context(String)) -> #(Model, Effect(Msg), option.Option(_)) { #(Model(time: 0.0), effect.tick(Tick), option.None) } fn update( model: Model, msg: Msg, ctx: tiramisu.Context(String), ) -> #(Model, Effect(Msg), option.Option(_)) { case msg { Tick -> { let new_time = model.time +. ctx.delta_time #(Model(time: new_time), effect.tick(Tick), option.None) } } } fn view(model: Model, _ctx: tiramisu.Context(String)) -> List(scene.Node(String)) { let assert Ok(cam) = camera.perspective(field_of_view: 75.0, near: 0.1, far: 1000.0) let assert Ok(sphere_geom) = geometry.sphere(radius: 1.0, width_segments: 32, height_segments: 32) let assert Ok(sphere_mat) = material.new() |> material.with_color(0x0066ff) |> material.build let assert Ok(ground_geom) = geometry.plane(width: 20.0, height: 20.0) let assert Ok(ground_mat) = material.new() |> material.with_color(0x808080) |> material.build [ scene.camera( id: \"camera\", camera: cam, transform: transform.at(position: vec3.Vec3(0.0, 5.0, 10.0)), look_at: option.Some(vec3.Vec3(0.0, 0.0, 0.0)), active: True, viewport: option.None, ), scene.light( id: \"ambient\", light: { let assert Ok(light) = light.ambient(color: 0xffffff, intensity: 0.5) light }, transform: transform.identity, ), scene.light( id: \"directional\", light: { let assert Ok(light) = light.directional(color: 0xffffff, intensity: 0.8) light }, transform: transform.at(position: vec3.Vec3(10.0, 10.0, 10.0)), ), scene.mesh( id: \"sphere\", geometry: sphere_geom, material: sphere_mat, transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)), physics: option.None, ), scene.mesh( id: \"ground\", geometry: ground_geom, material: ground_mat, transform: transform.at(position: vec3.Vec3(0.0, -2.0, 0.0)) |> transform.with_euler_rotation(vec3.Vec3(-1.57, 0.0, 0.0)), physics: option.None, ), ] } " } fn generate_physics_template() -> String { "/// Physics Demo - Falling Cubes /// Demonstrates physics simulation with Rapier3D import gleam/option import tiramisu import tiramisu/background import tiramisu/camera import tiramisu/effect.{type Effect} import tiramisu/geometry import tiramisu/light import tiramisu/material import tiramisu/physics import tiramisu/scene import tiramisu/transform import vec/vec3 pub type Id { Camera Ambient Directional Ground Cube1 Cube2 } pub type Model { Model } pub type Msg { Tick } pub fn main() -> Nil { tiramisu.run( dimensions: option.None, background: background.Color(0x1a1a2e), init: init, update: update, view: view, ) } fn init(_ctx: tiramisu.Context(Id)) -> #(Model, Effect(Msg), option.Option(_)) { // Initialize physics world with gravity let physics_world = physics.new_world( physics.WorldConfig(gravity: vec3.Vec3(0.0, -9.81, 0.0)), ) #(Model, effect.tick(Tick), option.Some(physics_world)) } fn update( model: Model, msg: Msg, ctx: tiramisu.Context(Id), ) -> #(Model, Effect(Msg), option.Option(_)) { let assert option.Some(physics_world) = ctx.physics_world case msg { Tick -> { let new_physics_world = physics.step(physics_world) #(model, effect.tick(Tick), option.Some(new_physics_world)) } } } fn view(_model: Model, ctx: tiramisu.Context(Id)) -> List(scene.Node(Id)) { let assert option.Some(physics_world) = ctx.physics_world let assert Ok(cam) = camera.perspective(field_of_view: 75.0, near: 0.1, far: 1000.0) let assert Ok(cube_geom) = geometry.box(width: 1.0, height: 1.0, depth: 1.0) let assert Ok(cube1_mat) = material.new() |> material.with_color(0xff4444) |> material.build let assert Ok(cube2_mat) = material.new() |> material.with_color(0x44ff44) |> material.build let assert Ok(ground_geom) = geometry.box(width: 20.0, height: 0.2, depth: 20.0) let assert Ok(ground_mat) = material.new() |> material.with_color(0x808080) |> material.build [ scene.camera( id: Camera, camera: cam, transform: transform.at(position: vec3.Vec3(0.0, 10.0, 15.0)), look_at: option.Some(vec3.Vec3(0.0, 0.0, 0.0)), active: True, viewport: option.None, ), scene.light( id: Ambient, light: { let assert Ok(light) = light.ambient(color: 0xffffff, intensity: 0.5) light }, transform: transform.identity, ), scene.light( id: Directional, light: { let assert Ok(light) = light.directional(color: 0xffffff, intensity: 2.0) light }, transform: transform.at(position: vec3.Vec3(5.0, 10.0, 7.5)), ), // Ground (static physics body) scene.mesh( id: Ground, geometry: ground_geom, material: ground_mat, transform: transform.at(position: vec3.Vec3(0.0, 0.0, 0.0)), physics: option.Some( physics.new_rigid_body(physics.Fixed) |> physics.with_collider(physics.Box(transform.identity, 20.0, 0.2, 20.0)) |> physics.with_restitution(0.0) |> physics.build(), ), ), // Falling cube 1 (dynamic physics body) scene.mesh( id: Cube1, geometry: cube_geom, material: cube1_mat, transform: case physics.get_transform(physics_world, Cube1) { Ok(t) -> t Error(Nil) -> transform.at(position: vec3.Vec3(-2.0, 5.0, 0.0)) }, physics: option.Some( physics.new_rigid_body(physics.Dynamic) |> physics.with_collider(physics.Box(transform.identity, 1.0, 1.0, 1.0)) |> physics.with_mass(1.0) |> physics.with_restitution(0.5) |> physics.with_friction(0.5) |> physics.build(), ), ), // Falling cube 2 (dynamic physics body) scene.mesh( id: Cube2, geometry: cube_geom, material: cube2_mat, transform: case physics.get_transform(physics_world, Cube2) { Ok(t) -> t Error(Nil) -> transform.at(position: vec3.Vec3(2.0, 7.0, 0.0)) }, physics: option.Some( physics.new_rigid_body(physics.Dynamic) |> physics.with_collider(physics.Box(transform.identity, 1.0, 1.0, 1.0)) |> physics.with_mass(1.0) |> physics.with_restitution(0.6) |> physics.with_friction(0.3) |> physics.build(), ), ), ] } " } // Desktop bundling functions type Platform { Linux MacOS Windows } type Architecture { X64 Arm64 Aarch64 } fn detect_platform() -> SnagResult(Platform) { case operating_system.name() { "windowsnt" -> Ok(Windows) "darwin" -> Ok(MacOS) _ -> Ok(Linux) } } fn detect_architecture() -> Architecture { // Default to aarch64 on macOS (Apple Silicon), x64 elsewhere case operating_system.name() { "darwin" -> Aarch64 _ -> X64 } } fn get_bun_platform_string(platform: Platform) -> String { case platform { Linux -> "linux" MacOS -> "darwin" Windows -> "windows" } } fn get_bun_arch_string(arch: Architecture) -> String { case arch { X64 -> "x64" Arm64 -> "arm64" Aarch64 -> "aarch64" } } fn install_npm_packages() -> SnagResult(Nil) { let root = find_root(".") // Locate bun (system or bundled) use bun_path <- result.try(locate_bun()) // Run bun add to install packages use _ <- result.try( shellout.command( run: bun_path, with: ["add", "three@^0.180.0"], in: root, opt: [], ) |> snag.map_error(fn(error) { "Failed to install three.js 0.180.0: " <> error.1 }), ) shellout.command( run: bun_path, with: ["add", "@dimforge/rapier3d-compat@^0.11.2"], in: root, opt: [], ) |> snag.map_error(fn(error) { "Failed to install Rapier3D: " <> error.1 }) |> result.replace(Nil) } fn install_nwbuilder() -> SnagResult(Nil) { let root = find_root(".") // Locate bun (system or bundled) use bun_path <- result.try(locate_bun()) // Install nw-builder shellout.command( run: bun_path, with: ["add", "--dev", "nw-builder@^4.16.0"], in: root, opt: [], ) |> snag.map_error(fn(error) { "Failed to install nw-builder: " <> error.1 }) |> result.replace(Nil) } fn create_package_json(project_name: String, with_nwjs: Bool) -> String { let nwjs_config = case with_nwjs { True -> ", \"main\": \"index.html\", \"window\": { \"title\": \"" <> project_name <> "\", \"width\": 1920, \"height\": 1080, \"nodejs\": true }, \"scripts\": { \"build\": \"gleam run -m lustre/dev build && cp package.json dist/ && nwbuild --glob=false dist\" }, \"nwbuild\": { \"flavor\": \"sdk\", \"srcDir\": \"dist\", \"mode\": \"build\", \"glob\": false, \"logLevel\": \"info\", \"app\": { \"icon\": \"\", \"LSApplicationCategoryType\": \"public.app-category.games\", \"NSHumanReadableCopyright\": \"Copyright Ā© 2025\", \"NSLocalNetworkUsageDescription\": \"This application uses the local network.\" }, \"outDir\": \"./" <> project_name <> "_desktop_bundle\", \"macCategory\": \"public.app-category.games\", \"cacheDir\": \"./node_modules/nw\" }, \"devDependencies\": { \"nw-builder\": \"^4.16.0\" }, \"node-remote\": [\"https://cdn.jsdelivr.net/*\"]" False -> "" } "{ \"name\": \"" <> project_name <> "\", \"version\": \"1.0.0\"" <> nwjs_config <> ", \"dependencies\": { \"three\": \"^0.180.0\", \"@dimforge/rapier3d-compat\": \"^0.11.2\" } }" } fn setup_desktop_bundle(project_name: String) -> SnagResult(Nil) { let root = find_root(".") // Create package.json with NW.js configuration let package_json_path = filepath.join(root, "package.json") let package_json_content = create_package_json(project_name, True) simplifile.write(package_json_path, package_json_content) |> snag.map_error(fn(_) { "Could not write package.json" }) }