MMO build planner desktop app Tauri Rust vs Electron
Ship a single-file MMO build planner as a Tauri desktop app backed by public theory-craft JSON tables — ~3MB binary vs Electron's 100MB+, no game-client integration, fully external calculator.
Every MMO player who has spent a weekend min-maxing a build knows the moment. You open a third-party planner in the browser, the page nags you for cookie consent, an ad reloads inside the stat table, and the form forgets your last selection when you refresh. Then you reach for the desktop app the community recommends, and your Activity Monitor reports it taking 280 MB of memory to render a form with twelve dropdowns and one DPS readout. That memory budget belongs to the game, not to the calculator.
This article ships a working external build calculator instead. The core is a small Rust crate that loads public stat tables from JSON, computes set bonuses, and prints a derived damage profile. The desktop wrapper is Tauri v2. On macOS and Windows the released binary lands around 3 MB. The same UI through Electron sits closer to 120 MB because it ships Chromium inside the bundle. For a calculator that does not need a browser specific feature, Tauri is the smaller, honest choice.
One clarification before any code. This piece is about an external calculator: a tool that takes a list of item ids and returns a stat table. It does not capture the game screen, synthesize keystrokes, read game memory, or contact a remote service. Theory-craft calculators sit cleanly inside the publisher-permitted scope for MMO companion tools, which is the line this niche cares about. Everything below stays on that side of the line.
The companion repository is vytharion/mmo-build-planner-tauri-vs-electron. Each lesson below maps to one commit. Clone the repo, check out the commit hash for a lesson, and you will see exactly what the article describes at that step.
Lesson 1: Three primitives carry the whole calculator
The mistake most planners make is starting with a giant "Item" struct that knows about every possible game mechanic. That struct grows boundlessly as patches add new stat types, and every change ripples through the calculator. The fix is to make three primitives carry the whole model:
Stat: every effect a piece of gear can contribute, expressed as an enum with one variant per stat type.Slot: where the item is equipped. Head, chest, weapon, accessory, and so on.Item: a row from the public item table. Each item has a slot and a list of(Stat, i32)rolls. That is it.
The whole core fits in one file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Stat {
Attack, Defense, Hp,
CritRate, CritDamage,
ElementFire, ElementWater,
ResistFire, ResistWater,
Speed,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Item {
pub id: String,
pub name: String,
pub slot: Slot,
pub rolls: Vec<(Stat, i32)>,
#[serde(default)]
pub set_id: Option<String>,
}
A few choices are worth defending here. Stat is an enum, not a string key, because the calculator needs exhaustive matches later in the damage code; if you add Stat::ElementWind the compiler will list every match arm that has not handled it yet. The rolls field is a Vec<(Stat, i32)> instead of a HashMap<Stat, i32> because real items in MMOs can have duplicate stat lines with different sources, and a vector preserves the original order for display.
Try it: d05e8fe. The two round-trip tests check that an item parses from JSON exactly as you typed it, and that set_id is optional so non-set items keep working.
Lesson 2: Ship the stat tables inside the binary
The next decision is where the stat tables live. A web planner answers "in a hosted database, fetched at startup". A desktop calculator does not have to. The data set for a single MMO patch fits comfortably in a few hundred kilobytes of JSON. Use include_str! to bake it into the binary at compile time and the app starts in milliseconds with zero network calls.
const BUNDLED_ITEMS: &str = include_str!("../data/items.json");
const BUNDLED_SETS: &str = include_str!("../data/sets.json");
impl BuildDatabase {
pub fn bundled() -> Result<Self, serde_json::Error> {
let items_vec: Vec<Item> = serde_json::from_str(BUNDLED_ITEMS)?;
let sets_vec: Vec<SetDef> = serde_json::from_str(BUNDLED_SETS)?;
Ok(Self::from_vecs(items_vec, sets_vec))
}
}
Two file shapes feed the loader. data/items.json is an array of item rows. data/sets.json lists set definitions with tier thresholds: at 2 pieces you get one bonus, at 4 pieces you get another, layered on top.
Why static JSON over SQLite for around fifty rows? Three reasons. JSON survives git diff cleanly so balance changes show up in code review. The whole file is searchable with grep. And the binary needs no runtime database driver. SQLite earns its place when the data set grows past a few thousand rows or when the app supports user-edited content; for a per-patch stat table that ships read-only, static JSON wins on every axis.
The integrity tests in this lesson are the real value: every_item_set_id_resolves_in_sets_table walks every shipped item and asserts that its set_id, if present, exists in the sets table. Catching a typo at cargo test time saves a player from a panic in the calculator.
Step through commit cacc75c to see the full loader.
Lesson 3: The calculation pipeline is three pure functions
The temptation in a planner is to mix the damage math with the UI layer so the form can show partial results as the user edits. Resist it. Keep the calculator as pure functions of inputs to outputs, and let the UI re-run the whole pipeline on every change. The pipeline takes microseconds.
Three stages, three functions:
pub fn sum_item_rolls(build: &EquippedBuild<'_>) -> HashMap<Stat, i32> {
let mut totals: HashMap<Stat, i32> = HashMap::new();
for item in build.slots.values() {
for (stat, value) in &item.rolls {
*totals.entry(*stat).or_insert(0) += value;
}
}
totals
}
The set bonus stage is the most opinionated part of the model. Real MMO set bonuses are cumulative across tier thresholds: a 4-piece Emberforge build keeps the 2-piece +40 fire bonus and adds the 4-piece +100 attack on top. The first version of this calculator chose the highest tier only and a test caught it on the first run. The fixed loop reads:
for tier in &set_def.tiers {
if n >= tier.pieces {
for (stat, value) in &tier.bonus {
*totals.entry(*stat).or_insert(0) += value;
}
}
}
The final stage, derive_damage, converts a stat total into a single DamageProfile { base, crit_chance, crit_multiplier }. The expected DPS formula is base * (1 + crit_chance * (crit_multiplier - 1)). That is enough to compare two builds. Players who care about more granular damage models can swap their game's published coefficients into the same function shape without touching anything upstream.
A property test in this lesson asserts that doubling crit chance from 5% to 50% lifts expected DPS by more than 10%. The point is not to validate the formula against any specific game; it is to lock the calculator's behavior so future refactors do not silently change the comparative output between two builds.
Commit fc61e55 holds the full engine plus four tests.
Lesson 4: A CLI driver before the GUI
Tauri is fun. So is shipping something that builds and runs in ten seconds. Lesson 4 wires the same calculation pipeline behind a CLI binary, which is the right interface to validate the math before any window code shows up. The binary takes a build description as JSON and prints the same data the desktop UI will eventually render.
fn main() -> ExitCode {
let raw = std::fs::read_to_string(&path).unwrap();
let spec: BuildSpec = serde_json::from_str(&raw).unwrap();
let db = BuildDatabase::bundled().unwrap();
let mut build = EquippedBuild::new();
for item_id in &spec.items {
build.equip(db.items.get(item_id).expect("unknown item"));
}
let raw_totals = sum_item_rolls(&build);
let set_totals = applied_set_bonuses(&build, &db);
let final_totals = merge_totals(raw_totals.clone(), set_totals.clone());
let damage = derive_damage(&final_totals);
println!("expected dps: {:.1}", damage.expected_dps());
ExitCode::SUCCESS
}
Running the sample Emberforge build in examples/sample_build.json prints an expected DPS around 650 with a 4-piece Emberforge bonus stacked on top of a Twin Ring and Swift Boots. On the release build the stripped binary weighed in at 386 KB for the planner-cli alone. That is the number you can quote when someone asks "but how big is the actual logic" before any UI code is added.
The CLI has a second job: it doubles as the testbed for the Tauri command handler in the next lesson. The body of main() is almost line-for-line what #[tauri::command] fn calc_build will execute. Locking the shape here means the GUI integration becomes a thin wrapper.
Commit e6191e8.
Lesson 5: Wrap it in Tauri, not Electron
The desktop wrapper recipe lives in tauri/ of the repo, deliberately outside the cargo workspace so the core crate keeps building fast. To turn the wrapper on for real, run cargo create-tauri-app in a sibling directory, add the core crate as a path dependency, and copy the wrapper file in. The Tauri command stub looks like this:
#[tauri::command]
fn calc_build(item_ids: Vec<String>) -> Result<CalcResult, String> {
let db = BuildDatabase::bundled().map_err(|e| e.to_string())?;
let mut build = EquippedBuild::new();
for id in &item_ids {
let it = db.items.get(id).ok_or_else(|| format!("unknown: {}", id))?;
build.equip(it);
}
let raw = sum_item_rolls(&build);
let sets = applied_set_bonuses(&build, &db);
let final_totals = merge_totals(raw.clone(), sets.clone());
let dmg = derive_damage(&final_totals);
Ok(CalcResult { /* ... */ })
}
Why Tauri over Electron specifically? Tauri uses the host operating system's existing webview (WebKit on macOS, WebView2 on Windows, WebKitGTK on Linux) instead of bundling Chromium with every installer. The official Tauri documentation lists a typical Tauri app at 2-10 MB versus 120-200 MB for an equivalent Electron build. For a build planner where the UI is mostly forms and tables, that 30-60x size delta is free. The host webview is also the one the user's browser already uses, so security fixes from the OS roll forward without a planner update.
Tauri v2 also tightens the IPC story. The tauri.conf.json in this lesson sets a strict Content Security Policy that allows script-src 'self' only and forbids the asset protocol. Combined with #[tauri::command] being the only path from the JS side back into Rust, the attack surface for a calculator is essentially the surface of calc_build itself. That is the kind of boundary that is much harder to draw inside an Electron app where Node integration historically bled into the renderer.
Commit 48134f0 holds tauri.conf.json, the wrapper command file, and a README explaining how to assemble the full desktop app.
Repository
Full source at https://github.com/vytharion/mmo-build-planner-tauri-vs-electron.
- Scaffold → 00e6c28 – initial Cargo project, README, and
.gitignore. - Lesson 1 → d05e8fe –
Stat,Slot, andItemtypes with serde tests. - Lesson 2 → cacc75c – bundled
items.jsonandsets.jsonplusBuildDatabase::bundled(). - Lesson 3 → fc61e55 – pure calc engine with cumulative set bonuses.
- Lesson 4 → e6191e8 – the planner-cli binary plus a sample build.
- Lesson 5 → 48134f0 – the Tauri v2 wrapper recipe.
Clone the repo, check out any of the commits above, and cargo run --release --bin planner-cli -- examples/sample_build.json should print the same stat table you see in the article. From there the UI is yours: a React frontend hooked into the existing calc_build command, or a Leptos/Yew Rust frontend if you want to keep the whole stack in one language.
Where to take it next
The core is intentionally small enough to retarget. Swap the stat enum and the items file for your game's data, rerun the integrity tests, and the calculator works. The Tauri wrapper recipe stays the same regardless of which MMO you are theory-crafting for; the size budget stays under 5 MB; the publisher-permitted boundary stays clean because nothing in this code touches the game client. Ship the DMG, the MSI, and the .deb from a single tauri build invocation, and you have a desktop app that respects the player's machine.