Current section
Files
Jump to
Current section
Files
src/mascarpone.gleam
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:,
update:,
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)) -> 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.empty(id: \"Scene\", transform: transform.identity, children: [
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,
postprocessing: 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:,
update:,
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)) -> 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.empty(id: \"Scene\", transform: transform.identity, children: [
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,
postprocessing: 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
Scene
}
pub type Model {
Model
}
pub type Msg {
Tick
}
pub fn main() -> Nil {
tiramisu.run(
dimensions: option.None,
background: background.Color(0x1a1a2e),
init:,
update:,
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, ctx.delta_time)
#(model, effect.tick(Tick), option.Some(new_physics_world))
}
}
}
fn view(_model: Model, ctx: tiramisu.Context(Id)) -> 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.empty(id: Scene, transform: transform.identity, children: [
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,
postprocessing: 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" })
}