- Rust 78.1%
- Python 17.7%
- JavaScript 1.8%
- CSS 0.9%
- HTML 0.5%
- Other 0.9%
Drops gitignore exclusions for /assets/extracted, /assets/zips, /assets/_sketchfab_inbox/*.zip, /assets/_cgtrader_inbox/*.zip, and src/generated/world/*.bin (voxel/biome/elevation overrides). Forgejo on a self-hosted server has no per-file or repo size cap, so the GitHub 100 MB constraints no longer apply. |
||
|---|---|---|
| assets | ||
| characters | ||
| crates | ||
| docker/web | ||
| emblems | ||
| icons | ||
| scripts | ||
| src | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| atmosphere_assets.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| dalewatch_redesign.md | ||
| index.html | ||
| README.md | ||
| TODO.md | ||
Vaern
Solo-developer hardcore two-faction persistent-coop RPG.
Stack: Rust · Bevy 0.18 · Lightyear 0.26 · bevy_egui 0.39 Mechanics: D&D 3.5-inspired Pipeline: AI-assisted
Status
Pre-alpha-shaped MMO that reads as a place. 541 workspace tests passing.
TL;DR
- Menu → character create (race / body / pillar) → live 3D world with a gear-driven Quaternius character mesh.
- PBR-dressed Dalewatch on a chunked SDF voxel ground (Poly Haven trees / rocks / shrubs / ground cover scattered across 1200×1200u, plus ~55 hand-authored hub props).
- Full combat → gear → loot → currency → vendor loop, with chat, parties, shared XP, nameplates, emotes, corpse-run death penalty, level banding, side quests, and a code-complete pseudo-dungeon (Drifter's Lair).
- Server-authoritative over UDP via lightyear + netcode. Multi-client. Client prediction + interpolation + zone-scoped AoI replication. Hostable build with env-driven config, panic forensics, auto-reconnect, and SQLite-backed accounts.
World & rendering
- 10 starter zones (1 per race) on a 2800u ring; each player spawns in their race's zone.
- Dalewatch Marches (Mannin starter) is the showcase zone — full starter-scale scope: 4 hubs, 12 sub-zones, 10-step main chain, 2 side chains, 20 side quests, 24 mob types in a ~2×2 km box. Other 9 zones still ~15 mobs / 2 hubs / 1 chain / 5 nodes.
- Voxel ground —
vaern-voxelcrate, hand-rolled Surface Nets, 32³+1-padding chunks streamed around every active player on both server and client. 8 swappable algorithm layers. Server seeds 5×3×5 around each player; client streams 11×3×11 around the camera with world-XZ UVs + MikkTSpace tangents. - Voronoi hub biomes — 9-biome CC0 ambientCG PBR table; nearest-hub resolver with 900u influence radius; chunk-aligned transitions (32u tiles).
- F10 voxel stomp is fully server-authoritative — client
ServerEditStroke→ server validates (≤12u radius, ≤40u from sender) → applies sphere-subtract brush → broadcasts up to 8VoxelChunkDelta/tick. Reconnecting clients catch up at 4 chunks/tick. - Sky — Bevy 0.18
AtmospherePlugin(procedural scattering) +DistanceFog(1500u visibility) +Bloom::NATURAL+Tonemapping::TonyMcMapface+Exposure::SUNLIGHT+Hdr. - PBR world dressing — 57-asset Poly Haven CC0 pack, deterministic seeded Poisson scatter per zone, 250u distance cull, 1500-prop safety cap. Dalewatch dressed with ~55 authored hub props + 5 scatter rules. Trees are saplings only (hero-tree photoscans excluded). No collision on dressing props yet.
- Cartography (parchment SVG maps) —
vaern-cartographycrate + uv-shebang seed scripts (balance_world_layout.py,seed_connections.py,seed_geography.py,audit_quest_landmarks.py). Voronoi-tessellated continent (28 zone cells clipped to a hand-authored coastline), sub-Voronoi biome pockets keyed off landmark names (croft → fields, grove → forest, fen → marsh, etc.), tier-density procedural farmhouse glyphs clustered along roads/rivers, auto-generated dirt-path spurs from every off-road landmark/hub to the nearest road. Three CLI bins:vaern-validate(cross-file rules incl. graph reachability + level-band gap),vaern-render-zone <id>,vaern-render-world. Byte-deterministic across runs (golden test enforced). - Procedural heightfield —
vaern-cartography::heightfield::PolygonIndexis the single elevation source for SVG, editor preview, server, and client. Layers: biome-polygon SDF blend (smoothstep rim, no polygon-edge cliffs) + river carve + Gaussian terrain stamps at lore-tagged hubs/landmarks (cairn / ridge / scarp → +8 m hill, keep / fortress → +18 m mound, ford / bridge → −2 m basin, croft / hollow → valley, all auto-derived from name + description) + 3-octave hand-rolled simplex FBM seeded by FNV-1a(zone_id) at ±1.5 m baseline scaling to ±15 m inside mountain biomes. Pure deterministic function — no.binfiles, no per-frame state.vaern_core::terrain::register_resolverwires it into the runtime so editor + server + client share byte-identical chunks for AoI replication. - Hillshade on the parchment — SVG render samples the heightfield on an 8 m grid, computes Lambertian shade against a NW sun, emits a
mix-blend-mode=multiplyrect grid under the biome regions. Mountains and ridges read as relief instead of flat polygons. - Sparse paint deltas — editor brush edits stamp into per-zone
world/zones/<id>/elevation_edits.bin/biome_edits.bin(bincode, sparse, kB-scale). Empty diff against cartography → file deleted. The bulk legacybiome_overrides.bin/elevation_overrides.binfiles are gone. - Roads as ground — kingsroad / track / dirt path are rasterised into the biome map at zone-load time (
rasterize_road_stripstamps cobblestone or dirt sub-cells along the polyline at the road's width). Renders as part of the voxel-ground PBR blend pipeline — no separate ribbon mesh, no Z-fighting, no terrain-floating; voxel sculpt edits carry the road texture automatically. - Async chunk SDF generation —
dispatch_seed_tasksspawns chunk generation ontoAsyncComputeTaskPool(32 in-flight tasks);collect_completed_seedsintegrates ≤8 per frame on the main thread. Nearest-to-camera dispatched first via Chebyshev-distance sort. No more main-thread stall when the camera flies into a fresh region.
Character rendering
All humanoids (own player, remote players, humanoid NPCs) are Quaternius modular meshes on the UE Mannequin skeleton, driven by a shared UAL AnimationGraph.
-
Gear-driven outfit.
outfit_from_equippedmaps each primary armor slot'sArmorTypeto a Quaternius outfit family + color: cloth→Wizard, gambeson→Peasant V2, leather→Ranger, mail→KnightCloth V3, plate→Knight V2. Unequipped = Peasant BaseColor (the reserved "naked rags" identity). -
Respawn on change.
sync_own_player_visualwatchesOwnEquipped; resolution change → despawn old mesh, spawn new. Rings / trinkets are a no-op visually. -
AnimState → UAL clip driver — three sibling drivers (own / remote / NPC) consume
AnimState+ cast school + mainhand school each frame:State Clip Idle Idle_Loop(unarmed) /Sword_Idle(armed)Walking / Running Walk_Loop/Jog_Fwd_Loop(speed=+1.0 forward, -1.0 reverse-played for back-pedal — UAL has noWalk_Bwd_Loop)Casting Sword_Idle(physical) /Spell_Simple_Idle_Loop(magic)Blocking Idle_Shield_Loop(weapon-agnostic)Attacking Sword_Attack→Sword_Regular_A/B/Cround-robin (physical) /Spell_Simple_Shoot(non-physical)Hit Hit_Chest(<35 dmg) /Hit_Knockback(≥35 dmg)Dead Death01 -
Transient clips (Attacking, Hit) play one-shot and hold until
ActiveAnimation::is_finished()— even after the server-side 250msAnimOverridereverts to Idle — so the full swing reads on-screen.AnimSlotdistinguishes Play / AdjustSpeed / Hold for forward↔reverse flips.
Combat
- Mouse-look camera — cursor locked + hidden in-game; LeftAlt frees cursor for UI clicks. Cursor auto-frees whenever an egui panel opens. Camera is ground-clamped via
vaern_voxel::query::ground_y. - Target lock — Tab cycles combat NPCs within 40u, prefers camera's front cone. Esc clears. Locked players continuously turn toward their target.
- Combat shapes (per-ability YAML):
target,aoe_on_target,aoe_on_self,cone,line,projectile. Friendly fire on. Channeled cones/lines/projectiles snapshotrangeontoCastingso heavy attack doesn't sweep to infinity. - Hotbar — 6 keybind + 2 mouse-bound (LMB light auto-attack, RMB heavy). No GCD.
- Cast bar — bottom-center, school-colored, for abilities with
cast_secs > 0. - Stats-aware damage pipeline — caster: weapon roll × global mult × crit roll. Target: armor mitigation
armor / (armor + 200)+ per-channel resistresist_total[dt] × 0.005(80% cap, supports negative for vulnerability). - NPC stats from bestiary — creature_type resists + armor_class reductions fold into
CombinedStats, scaled by rarity (Combat 1.0× / Elite 1.25× / Named 1.5×). - NPC AI — per-type aggro, threat-table targeting, roaming idle, leash-home. Slow-aware.
Combat depth
- Timed status effects —
StatusEffects(Vec<StatusEffect>)on every combat-capable entity. Variants:Dot,Stance,Slow,StatMods { damage_mult_add, resist_adds[12] }.compute_damagereads StatMods on both sides; consumables push timed StatMods. Refresh-on-reapply. - YAML-driven effect riders — flavored ability variants declare
applies_effect: { id, duration_secs, kind, dps, tick_interval, speed_mult }. Parry-negated hits skip the rider. Seeded: fire→burning, frost→chilled, shadow→decay, blood→bleeding at tiers 25 + 50. - Active Block (Q hold) — drains 15 stamina/s, 60% frontal / 25% flank / 0% rear damage reduction. Breaks at zero stamina.
- Active Parry (E tap) — 0.35s window, 20 stamina on consume (free to miss). Fully negates damage and rider debuff.
- Stamina pool — 100/100, 12/s regen. Separate from mana.
- Slow-aware movement — both players and NPCs move at
speed_mult × basewhile chilled. Strongest slow wins; doesn't stack.
Animation state
- Replicated
AnimStateon players + NPCs. Derived every FixedUpdate from Transform delta + Casting + StatusEffects + Health. - Transient flashes — every CastEvent triggers a 250ms
AnimOverrideto prevent the derive loop from clobbering the flash. - Visible in nameplates — small grey
[running]/[blocking]/[attacking]tag.
Gear & item system (Model B — compositional)
- Items are composed at runtime from four orthogonal tables: bases (piece shape) × materials (substance + stat mults) × qualities (craft roll) × affixes (stat deltas).
- 222 bases · 25 materials · 7 qualities · 27 affixes → ~5,000+ resolvable combinations.
- Affixes roll on world drops (weight-pool filtered by tier + base kind), stack as prefix ("Enchanted") + suffix ("of Warding") in the resolved name. 5 shard-only affixes (weight 0, soulbinds on apply) reserved for boss-token imprint.
- Materials carry per-channel
resist_adds— silver vs necrotic/radiant, dragonscale vs fire, shadowsilk vs radiant penalty. - Runes — caster magical-ward gear in
EquipSlot::Focus. Drain mana via negative mp5 in exchange for heavy magical resist. - Rarity = affix slot count (Common 0 → Legendary 4). Pre-rolled drops leave 1 slot open for crafter polish.
Inventory + equipment UI (I)
- 30-slot inventory grid (3×10) with stack merging keyed on full
ItemInstanceidentity. - 20-slot paper doll on the right — 11 armor slots (head→feet) + 9 accessory/weapon/focus.
- Rarity-colored item names (genre-standard palette: grey / white / green / blue / purple / orange).
- Hover tooltip cards — bold name in rarity color, rarity + kind line, nonzero stats, per-channel resists, soulbound tag in gold italic, weight.
- Left-click → auto-equip (gear) or consume (potions/elixirs/food).
- Right-click paper-doll slot → unequip.
- Two-hander displaces offhand; Focus rejects non-runes; armor slot-id validation.
Consumables
- Every
Consumablebase carries a YAML-authoredConsumeEffect:HealHp/HealMana/HealStamina(clamp-add) orBuff { id, duration_secs, damage_mult_add, resist_adds[12] }(timed StatMods). - Real amounts — Minor Healing +40 HP, Major Healing +450 HP. Same pattern for mana/stamina.
- Elixirs — Might / Finesse / Arcana: +15% damage 5min. Giant's: +25% damage 2min. Stack additively.
- Warding Elixir — +15 resist across all 12 channels for 5min.
- Per-channel Resist Potions (24 bases, 12 channels × lesser/greater): +30/+60 for 3min. Capped at the 80% resist ceiling. Prep-before-boss loop.
Consumable belt (keys 7/8/9/0)
- 4-slot strip below the hotbar, owned by
ConsumableBelton the server. Bindings store theItemInstancetemplate (not an inventory index) so they survive stack rearrangement. - Bind: right-click a potion → "Bind to Slot 1/2/3/4".
- Fire: 7/8/9/0 quaffs the bound potion. Server applies the
ConsumeEffect, decrements one charge. - Strip shows bound name +
×count(grey when zero stacks remain).
Stat screen (C)
- Live
CombinedStatsfold — pillars + gear → derived primaries + armor + 12 resist channels + utility. - MP5 flags "(rune drain)" when negative.
- Pillar progress bars showing banked XP toward the next pillar point.
Loot flow
- Mob dies →
vaern-lootrolls rarity curve + base + material + affixes →LootContainerat corpse, owned by top-threat player. - Client sees yellow gizmo → walks within 5u →
Gopens loot window. - Click items individually or "Take all" → server moves to inventory. Container auto-despawns at 5min or empty.
Currency loop (closed earn → spend)
PlayerWallet { copper: u64 }component on every player. Lives invaern-economy.- Mob kills drop coin in addition to items, scaled by
(material_tier, NpcTier): combat 2-10c → 8-46c at T6, elite 15-50c → 63-210c, named 100-300c → 420-1260c. Independent of item drop_chance — a no-item kill still pays. Credited directly to the top-threat player's wallet. - Quest rewards pay copper — step
gold_reward_copperon progress + chaingold_bonus_copperon completion. WalletSnapshotS→C onChanged<PlayerWallet>only.- Gold displayed in the inventory panel under the Inventory heading as
"12g 34s 56c"— not in the unit frame (currency is an inventory concern). - Persisted as
PersistedCharacter.wallet_copper: u64with#[serde(default)]for legacy saves.
Vendor NPCs (10 per starter capital)
- One general-goods vendor per capital hub (Merchant Kell at Dalewatch Keep, Merchant Seyla at Shadegrove Spire, etc.), seeded from
src/generated/vendors.yaml. ~12 items each: minor potions, food, scroll of recall, linen cloth, two copper weapons. NpcKind::Vendor— cool-blue nameplate; excluded from Tab-targeting; non-combat.- F within 5u opens Buy/Sell window. Buy tab uses server-computed
vendor_buy_price; Sell tab usesvendor_sell_price(60% spread). Soulbound /no_vendoritems show grey "(no sale)". - Auto-close on walk-out (5u) via
VendorClosedNotice. VendorIdTagstamped at startup so wire ids stay stable across reconnects.
Chat (Enter)
- Five channels: Say (20u proximity), Zone (whole-zone AoI room), Whisper (by display name), Party (cross-zone), System.
- Prefix parser: no prefix = Say;
/s /say,/z /zone,/p /party,/w /whisper /tell /msg <name>. Unknown/foo→ Say. - Party commands (
/invite /inv /leave /disband /kick) intercept before chat parsing. - Emotes:
/wave /bow /sit /cheer /dance /pointtranslate to a Say-channel send with body*waves.*/ etc. Animation playback is post-pre-alpha. - Rate-limited — 5 msg/sec/sender (rolling 1s window), 256-char truncate. Server-authoritative
from. - Speech bubbles — render above speakers on Say + Zone only. 5s lifetime, 1s fade, 72-char ellipsis truncate, one-bubble-per-speaker. Anchored at
head + 2.8u; nameplate at+2.1u. ChatInputFocusedsuppresses WASD / Tab / Esc / 1-6 / LMB/RMB / Q/E / K / V while typing.- History — 50-line ring bottom-left. Channel colors: Say white, Zone mint-green, Party cool-blue, Whisper magenta (received) / pink (echo), System yellow.
Party system v1 (strict-coop)
- Invite by display name —
/invite Brenn. Server validates target exists, isn't already partied, party has room (max 5). 60s invite TTL. - Party frame top-left under the unit frame — name + level + HP bar +
[L]leader tag + Leave button. Rebuilt fromPartySnapshot(broadcast on join/leave/kick/disband; dirty-set gated, not per-tick). - Leave / kick / disband —
/leaveor button;/kick <name>(leader-only); auto-disband when size drops below 2. Leader-leave promotesmembers[0]first. - Shared XP — splits across party members within
PARTY_SHARE_RADIUS = 40uof the killer. Killer gets full base; partners get small-group multiplier1.0 / 0.7 / 0.55 / 0.45 / 0.38×for 1/2/3/4/5 sharers. Total payout rises with party size (5-party ≈ 1.9× solo) but never linearly to 5×. - Party chat routes through
ChatChannel::Party— cross-zone, same 5/sec limit.
Nameplates (V toggle)
- Every entity with
Healthgets a nameplate, projected fromhead + 2.1u. - Label =
DisplayNamefor both players and NPCs. Pillar label only for anonymous spawns. - 60u culling so dense crowds don't become letter soup.
- V toggles on/off (gated on chat focus). When off, also hides chat bubbles.
- Color by kind — players + combat mobs white, quest-givers gold, vendors cool-blue, elites violet, named pink.
"!"quest marker over quest-giver plates. - State tag under HP bar reads
[idle]/[running]/[blocking]/[attacking]— live from replicatedAnimState.
Quest flow
- Walk up to a gold "!" NPC →
F→ Accept → quest log (L). - Server hard-refuses accept if
chain.steps[0].level > player.level + 3— no entry appears in the log. - 5 side-quest givers in Dalewatch — Quartermaster Hayes (capital), Captain Morwen (Harrier's), Innkeeper Bel (Ford), Smith Garrick (Kingsroad), Mistress Pell (Miller's). Each at the NW 4u offset of their hub.
- Quest polish (Slice 9) — talk / deliver / investigate steps now turn in via authored NPC reply text + a contextual click-through button (e.g. "Take the leather kit") and grant gear-ladder rewards on the player's click.
- Multi-kill objectives track
2/3in the tracker and only advance on the final required kill. - Investigate steps spawn cyan
?POI markers at landmark coordinates that the player F-presses to advance. - Server validates 5.0u proximity to the right NPC / waypoint before honoring
ProgressQuest.
Mob level banding
- Dalewatch tiers mobs by level: L1-2 around the keep, L3-4 around Harrier's Rest + Kingsroad, L5-6 around Miller's + Ford, L7+ at the fixed
(470, 80)Drifter's Lair anchor east of Ford. - Per-rarity scatter radius (named 110u / elite 90u / common 70+jitter).
- Other zones still ring around zone origin (legacy procedural fallback).
- Per-kind respawn timers: combat=180s / elite=600s / named=1800s (was a flat 30s).
Felt level progression
level_xp_multiplierscales kill XP bymob_level - killer_level: parity = 1.0×, +5 = 1.5× (cap), -3 = 0.5×, -6+ = 0.0× (grey).- Each level-up grants +1 pillar point auto-targeted at your committed pillar (highest-cap, tie-break Might > Finesse > Arcana).
- Both kill and quest XP paths flow through
grant_xp_with_levelup_bonusso every level-up gives the bonus. - Client renders a centered "LEVEL UP / Level N" banner with 0.35s gold flash + 2.5s fade.
Gear-reward ladder
- 5-tier per-pillar ladder on the main Dalewatch chain (
chain_dalewatch_first_ridesteps 4/6/7/8 + chain capstone):- Might: gambeson → leather → mail → plate
- Finesse: leather → mail
- Arcana: cloth wool → silk → mageweave
- Full silhouette flips at the ArmorType-change tiers.
Drifter's Lair pseudo-dungeon (Slice 6, code-complete, awaits 2-client playtest)
- Open-world spawn region anchored at zone-local
(470, 80)east of Ford of Ashmere. - 16 hand-authored boulders / dead trees / lanterns mark the threshold.
- Master Drifter Halen (mini-boss, L9) and Grand Drifter Valenn (capstone boss, L10), flanked by L8-L10 drifter brutes / acolytes / fanatics in 4-mob pulls.
- Shared Need-Before-Greed-Pass loot rolls — boss kills with ≥2 party members within
PARTY_SHARE_RADIUS=40uspawn aLootRollContainer(no single owner) and broadcastLootRollOpento every eligible client. Per-item Need/Greed/Pass votes resolve via puredecide_roll_winner(Need beats Greed beats Pass; ties d100; all-Pass = no winner). Winner gets the item directly. 60s deadline auto-settles. Solo / out-of-radius kills bypass to the existing single-ownerLootContainerflow. - Open Need — no pillar gating; any party member can roll Need on any item.
- One-tier-above gear ladder on Valenn — full 4-piece mithril plate (Might) / dragonscale leather (Finesse) / shadowsilk cloth (Arcana) at
quality: exceptional. - Halen drops one chest piece per pillar at steel / wyvern / mageweave + exceptional.
- Boss-drop bonus stacks on top of the existing chain-final reward (Slice 4e capstone set still lands deterministically on chain step 10).
Death penalty (corpse-run)
- Die → respawn at home with 25% HP.
- Your corpse stays at the death site for 10 minutes.
- Walk back to it (3u proximity) → full HP restored.
- Visual marker is post-MVP — players navigate from memory of their death position.
Server-side accounts (Slice 8e)
- SQLite at
~/.config/vaern/server/accounts.dbwith bcrypt-hashed passwords. - Case-insensitive uniqueness on username + character name.
- Client login/register/create-character UI behind
AppState::Authenticating+CharacterSelect. CharacterSummarypopulated fromPersistedCharacterso the roster shows real race/pillar/level.- Gated by
VAERN_REQUIRE_AUTH=1(default off so the dev loop keeps working without credentials).
Map editor (vaern-editor)
Standalone Bevy binary, sibling of vaern-client. Authoring tool for the same world data the runtime reads — saved edits round-trip into the live game.
cargo run -p vaern-editor -- --zone dalewatch_marches
- Free-fly camera (WASD + Q/E + RMB-look + scroll-speed) over the active zone. Spawns at the cartography Voronoi anchor —
WorldLayout::zone_originfromworld.yaml— same coordinates as the runtime client/server. - Voxel terrain sculpt — Mode 3: LMB carves (Subtract), Shift+LMB raises (Union), inspector slider for radius (0.5–32u). Uses the same
EditStroke::applypipeline as the runtime F10 stomp. - Asset placement — Mode 2 + palette: pick a Poly Haven slug from the left panel, LMB on ground spawns a new prop into the nearest hub at hub-local offset.
- Selection + edit — Mode 1: LMB picks a prop via scene-mesh AABB raycast (handles big stretched assets like castle doors). Inspector edits offset / rotation / scale / Y-override; Delete button or Delete key removes.
- Biome paint (Mode 4) — sub-cell brush at 8m resolution (4×4 cells per chunk). LMB-drag continuous paint,
[/]resize,Bpaint mode,Eerase (revert to default Grass),Iarm eyedropper. Inspector picks shape (Circle/Square), falloff, biome. Brush footprint shown as immediate-mode gizmo on the terrain. 9-channel per-vertex weights blend in a customExtendedMaterial<StandardMaterial, BiomeBlendExt>shader — every chunk uses one shared material handle, no per-vertex flat-interp variance, no chunk-boundary color lines.BiomeKey::from_yamlaccepts both legacy hub-YAML keys and cartography vocabulary (fields,forest,mountain,cobblestone,cropland, etc.) — cartography keys collapse-map into the 9-slot palette (forest→Mossy, mountain→Rocky, cropland→Dirt, cobblestone→Stone, …). - Cartography → editor importer (
cargo run -p vaern-cartography --bin vaern-import-editor [-- --zone <id>] [--clean]) — rasterizesgeography.yaml::biome_regionspolygons to 8m sub-cells and writes bothbiome_overrides.bin(biome paint) andelevation_overrides.bin(signed-cm height offsets per cell). Rivers carve −3 m channels with a 6 m taper band; mountain/highland/ridge/ashland/coastal-cliff biomes raise terrain (mountain +30 m, highland +12 m, ridge +7 m, ashland +4 m, coastal cliff +15 m); marsh sinks −1.5 m. Idempotent merge by default;--cleandiscards prior overrides. For Dalewatch: 138,085 biome cells + 35,717 elevation cells (~1.6 MB on disk combined). - Cartography overlay — visual layer of roads + floating labels in the 3D viewport (
crates/vaern-editor/src/cartography_overlay/). One ground-projected ribbon mesh pergeography.yaml::roadwith per-type styling (kingsroad wide light brown, dirt path narrow dark brown), Y fromterrain::height + elevation::lookupso ribbons hug the surface. One screen-space egui label per hub + landmark, projected viaCamera::world_to_viewport, distance-culled at 1.5 km.CartographyOverlaySettingsresource has 3 toggle booleans (roads / hubs / landmarks). - Cartography-aware heightfield —
EditorHeightfield::sample(p) = p.y - (GROUND_BIAS_Y + elevation::lookup(p.x, p.z)). The lookup readselevation_overrides.bininto a process-globalOnceLockon Startup; reads are lock-free after. Generator staysCopy + Defaultso no churn through theWorldGeneratortrait. - Diagnostic panel — left "Environment" panel exposes: live
ChunkStoresize + dirty queue + in-flight async tasks + render entities + drawn-after-frustum-cull count + FPS / frame time + per-system µs timings (rolling 1s window, sorted by mean) + isolation toggles (hide-chunks / skip-eviction / skip-streamer / disable-biome-blend / debug-viz mode). - Performance — 120+ FPS at draw distance 64 (~2km radius) on RX 7900 XTX + 7950X3D. Was 10 FPS at draw=16 before the perf pass. Three load-bearing fixes: (1) idempotent fast-path in
ensure_chunk_mesh_attributes— skips the 48KB position-clone + UV-rebuild + biome-weight recompute when the mesh already has the four custom attributes attached (was 87ms / 1173 chunks every frame); (2)process_pending_blend_attachescapped at 16/frame as defense against the spurious-Changed<Mesh3d>re-mark loop; (3)mark_chunks_needing_blend_refreshreads the actual mesh asset state to filter spuriousChanged<Mesh3d>events — only marks + hides chunks whose mesh asset genuinely lacksATTRIBUTE_BIOME_WEIGHTS_LO. Plus: chunk seed rate-limited at 256/frame, mesh task budget 16/frame, sparseVoxelChunk(Uniform(f32)vsDense(Box<[f32]>)— air/solid stack chunks shrink from 157 KB to 4 bytes), async meshing onAsyncComputeTaskPool, atmosphere is now a startup-only decision (toggling at runtime crashed wgpu via PBR-pipeline bind-group cache mismatch). Default draw distance is 16 chunks (~512m radius); slider goes to 64. - Save — toolbar button writes both:
src/generated/world/voxel_edits.bin— bincodeVec<ChunkDelta>of every chunk that diverged from the heightfield baseline.src/generated/world/biome_overrides.bin—OverridesFileV2: sub-cell-keyed biome paint state with N=4 sub-cells per chunk. Legacy V1 (per-chunk-XZ) files auto-upscale on load.src/generated/world/zones/<zone>/hubs/<hub>.yaml— theprops:array spliced into each touched hub's YAML viaserde_yaml::Value(preserves all other fields).
- Round-trip to runtime — server reads
voxel_edits.binon Startup and registers every chunk in the existingEditedChunksset, so connecting clients receive the deltas through the establishedqueue_reconnect_snapshotspath. Hub YAML edits land via the existing clientOnEnter(InGame)reader. Biome overrides are editor-only for now (runtime client still uses the legacy single-StandardMaterial-per-chunk path).
Bundle splitting — scripts/split_polyhaven_bundle.py peels a multi-mesh Poly Haven glTF into one glTF per top-level node, sharing the original .bin + textures via relative URIs. Already run on modular_fort_01 (22 piece slugs in the catalog: tower_round + thick/thin walls + walkways + stairs).
Open scars:
voxel_edits.bincan balloon (saw 832 MB / 1.3 GB in earlier sessions) becausediff_against_generatorwalks every chunk inChunkStorerather than only chunks that have actually been edited. Reset viarm src/generated/world/{voxel_edits,biome_overrides,elevation_overrides}.bin.Random crashdiagnosed + fixed: wasvaern-voxel/src/persistence.rs::sync_pair_along_axisunwrappingstore.get(coord_a)when the caller passed an unverified -axis neighbor near streaming-radius edges. Now guardscoord_apresence at function entry, mirroring the existingcoord_bcheck.Changed<Mesh3d>fires every frame for every chunk (root cause unknown — possibly something downstream ofcollect_completed_meshesinvaern-voxel, or a Bevy 0.18 quirk withMut<Mesh3d>deref-mut even when the new handle equals the old). Mitigated by reading actual asset state inmark_chunks_needing_blend_refreshso spurious change events don't trigger marker re-add. Real root-cause hunt is a follow-up — diagnostic is to logChanged<Mesh3d>match counts per frame.- Sculpt brush bursts can cause brief invisibility on >16 chunks at once. The capped attribute-attach passes 16 per frame; chunks beyond the cap render as
Visibility::Hiddenuntil process catches up (1-N frames). Brush gizmo stays visible throughout. Acceptable for now; raising the cap risks reintroducing per-frame Commands churn. - Sharp polygon edges between cartography elevation regions (e.g. ridge_scrub at +7 m abuts fields at 0 m as a step). Hand-smooth with the Mode-3 voxel brush, or fold a Gaussian / distance-falloff pass into the importer. Cosmetic, not blocking.
- Runtime client doesn't yet read
elevation_overrides.bin— hills + river channels are editor-only for now. The runtime still usesvaern_core::terrain::height(small-amplitude noise). Match-up is a separate slice. - Shader hardcoded to 9 biome slots —
compute_blend_weights -> [f32; 9]and the WGSL fragment shader'sarray<f32, 9>cap the editor's biome palette at 9 variants. Eight unused ambientCG texture sets sit on disk underassets/extracted/terrain/{forest,mountain_rock,sand,mud,cropland,pasture,cobblestone,tilled_soil}/ready for a future shader-stack expansion.
Stubbed (slots reserved): scatter preview, voxel undo for biome paint (snapshot infra is in place), transform gizmo, splatmap upgrade for biome blend.
Hostable build (Slice 8)
- Netcode key resolved from
VAERN_NETCODE_KEY(release rejects unset / all-zero / wrong-length). - Server bind via
--bind/VAERN_BIND(default0.0.0.0:27015). - Client target via
--server/VAERN_SERVER. - Server panics write a forensics report to
~/.local/share/vaern/server/crash_<unix_ts>.log. - Client auto-reconnect with exponential backoff (1s → 2s → 4s → 8s, 5 attempts max) when the lightyear
Connectedmarker is removed mid-game. Replays the last successful credentials so a server bounce underVAERN_REQUIRE_AUTH=1resumes without a re-prompt. Falls back to MainMenu on auth failure or exhausted attempts.
Quick start
# One server, any number of clients
./target/debug/vaern-server # terminal 1
./target/debug/vaern-client # terminal 2 — goes through the menu
VAERN_CLIENT_ID=1001 ./target/debug/vaern-client # terminal 3 — second client
# Or the dev-fast script that skips the menu via env vars
./scripts/run-multiplayer.sh
In-game controls
Movement & camera
WASD— camera-relative movement (W = forward relative to camera)- Mouse — camera yaw/pitch (cursor locked); scroll — zoom
LeftAlt— hold to free cursor for UI clicks + disable mouse-look
Combat
LMB— light attack (fast cone, 0.5s cd)RMB— heavy attack (0.4s windup cone, 1.5s cd)1-6— hotbar abilities (no GCD; chain freely with LMB/RMB)7 8 9 0— consumable belt (bound potions, quaffable mid-fight)Q— hold for Active Block (drains stamina; 60% frontal damage reduction)E— tap for Active Parry (0.35s window, 20 stamina on successful negate)Tab— cycle target (40u, prefers camera front cone, QuestGivers + Vendors excluded)Esc— clear current target / close focused panel
Panels & interaction
I— inventory + paper doll (wallet shown under Inventory heading)C— character / stat screenK— spellbookL— quest logG— loot nearest container within 5uH— harvest nearest resource node within 3.5uF— talk to nearest quest-giver OR open nearest vendor's Buy/Sell window (≤5u)V— toggle nameplates + chat bubbles (gated on chat focus — typing "V" in chat is safe)☰top-right — logout / quit
Chat
Enter— open chat input. Type + Enter sends.Esccancels.- No prefix =
/say(20u)./z= zone./p= party./w <name>= whisper. /invite <name>,/leave,/kick <name>— party commands (also work from this input)./wave /bow /sit /cheer /dance /point— emotes (third-person bubble: "Brenn waves.").- While the chat input has focus, WASD / hotbar / Tab / Q / E / K / V are all suppressed.
- No prefix =
Debug
F10— voxel stomp: carve a 6u-radius sphere crater at the camera's forward focus.
Architecture
Workspace of eighteen crates + modular client + modular server + standalone editor.
Crates
crates/
├── vaern-core/ Pillar, ClassPosition, Morality, Faction, School,
│ DamageType (12 variants), terrain height field,
│ Voronoi partition + Catmull-Rom spline (voronoi.rs)
├── vaern-voxel/ chunked SDF voxel world (hand-rolled, not fast-surface-nets)
├── vaern-data/ YAML loaders: schools, classes, abilities, flavored,
│ bestiary, races, world, dungeons, quest chains
├── vaern-protocol/ SharedPlugin: lightyear registration, channels, every
│ network message + replicated component
├── vaern-combat/ Bevy plugin: Health, Stamina, abilities + AbilityShape,
│ Casting, Projectile, damage.rs, effects.rs, anim.rs
├── vaern-character/ Experience, PlayerRace, XpCurve (leaf)
├── vaern-stats/ Pillar identity + 3-tier stat pool, CombinedStats
├── vaern-items/ Compositional model: ItemBase × Material × Quality ×
│ Affix → ResolvedItem; ContentRegistry
├── vaern-economy/ Vendor pricing math; GoldSinkKind ledger enum
├── vaern-equipment/ 20-slot paper doll (+ Focus); validate_slot_for_item
├── vaern-inventory/ PlayerInventory: slot grid, stack merging
├── vaern-loot/ Drop tables + roll_drop; rarity emerges from material
│ + quality; affix pool filtered by base + tier
├── vaern-professions/Profession enum (11), ProfessionSkills, NodeKind (15)
├── vaern-server/ UDP server: data / connect / npc / quests / xp /
│ player_state / combat_io / movement / starter_gear /
│ stats_sync / inventory_io / loot_io / consume_io /
│ belt_io / resource_nodes / aoi / voxel_world /
│ wallet_io / vendor_io / chat_io / party_io / respawn
├── vaern-client/ DefaultPlugins + 22 focused modules (see below)
├── vaern-sim/ headless deterministic sim — reserved for PPO training
├── vaern-assets/ shared Bevy plugin: Meshtint + Quaternius + UAL animation
├── vaern-museum/ two bins: vaern-museum (composer) + vaern-atlas (taxonomy)
└── vaern-editor/ standalone Bevy authoring tool: voxel sculpt, prop placement,
hub YAML write-back, voxel-delta save-to-disk → runtime load
vaern-voxel detail
sdf/ (Sphere/BoxSdf/Capsule/Plane + Union/Subtract/Intersect/SmoothUnion/SmoothSubtract) · chunk/ (32³+1 padding = 34³ samples, sparse HashMap store, sparse VoxelChunk storage with Uniform(f32) / Dense(Box<[f32]>) enum, DirtyChunks) · mesh/ (4 swappable algorithm layers: IsoSurfaceExtractor + VertexPlacement + NormalStrategy + QuadSplitter + MeshSink) · edit/ (Brush + EditStroke with halo sync) · generator/ (HeightfieldGenerator bridges terrain::height) · query/ (ground_y + raycast) · replication/ (ChunkDelta FullSnapshot | SparseWrites, version-numbered + replay-safe) · perf/ (per-system frame-time profiler). Async meshing on AsyncComputeTaskPool. 87 tests pass.
Two load-bearing fixes: ChunkShape::MESH_MIN = PADDING - 1 to close static chunk seams; chunks_containing_voxel enumeration extended from {-1, 0} to {-1, 0, +1} so halo writes propagate across chunk boundaries (without it, a textured "cap" floats over every carved crater).
vaern-server::respawn detail
Corpse-run death penalty: spawn server-only Corpse entity at death pos, 25% HP respawn, walk-back restoration at 3u proximity, 10-min expiry. CorpseOnDeath marker makes the shared apply_deaths skip players.
Client modules
All gated on AppState::InGame; main.rs is ~80 lines.
src/
├── main.rs App bootstrap + plugin registration only
├── shared.rs marker components, attach_mesh / attach_character
├── menu.rs egui main menu · char create/select · ☰ logout
├── net.rs lightyear client entity + ClientHello (race_id)
├── scene.rs mouse-look camera, 3D ground/light, own-player mesh,
│ CastFiredLocal relay, AnimState overlay + driver
├── input.rs WASD + motion-controller yaw, LMB/RMB + 1-6 cast,
│ Tab cycle / Esc clear
├── hotbar_ui.rs egui hotbar + spellbook + icon cache
├── attack_viz.rs shape telegraph flashes + projectile mesh rendering
├── unit_frame.rs top-left player frame (portrait/name/L#/HP/XP)
├── combat_ui.rs Bevy-native cast bar + target frame + swing flash
├── vfx.rs impact flashes, cast-beam gizmos, gold target ring
├── nameplates.rs world-space HP plates (DisplayName label, 60u cull,
│ V-toggle) + floating damage numbers + "!" quest-giver
│ markers + chat speech bubbles
├── hud.rs compass strip
├── quests.rs loads chain YAMLs, drains QuestLogSnapshot
├── interact.rs [F] quest-giver dialogue, [L] quest log
├── inventory_ui.rs [I] inventory + equipment + wallet line
├── vendor_ui.rs [F] vendor Buy/Sell window, NearbyVendor detect
├── chat_ui.rs Enter input + 50-line history + prefix parser +
│ ChatInputFocused gate + ChatBubbleEvent emit
├── party_ui.rs Party frame + invite popup + party-command parser
├── belt_ui.rs 4-slot consumable belt strip (keys 7/8/9/0)
├── loot_ui.rs [G] loot window + pending-loot gizmo markers
├── stat_screen.rs [C] character stats (pillars + CombinedStats)
├── harvest_ui.rs [H] resource-node markers + harvest-proximity
├── voxel_biomes.rs BiomeResolver: nearest-hub biome table
├── voxel_demo.rs Voxel ground plugin: streams 11×3×11 chunk cube,
│ attaches per-biome StandardMaterial, F10 stomp
├── level_up_ui.rs Centered "LEVEL UP" banner + screen-flash overlay
├── scene/dressing.rs Loads world YAML, walks scatter rules + props,
│ deterministic Poisson scatter (splitmix64)
└── diagnostic.rs periodic snapshot + connect/disconnect logs
Dependency graph (roughly)
vaern-core→ nothingvaern-voxel→ core (bridgesterrain::heightvia HeightfieldGenerator; bevy 0.18, serde, thiserror only — no fast-surface-nets / ndshape / glam)vaern-combat→ core + statsvaern-character→ core (leaf)vaern-stats→ core (leaf)vaern-items→ core + stats (re-exports SecondaryStats for affix stat_delta)vaern-economy→ itemsvaern-equipment→ itemsvaern-inventory→ itemsvaern-loot→ items + combat (for NpcKind)vaern-professions→ bevy + serde only (leaf)vaern-protocol→ everything abovevaern-server/vaern-client/vaern-sim→ all of the above + data
Networking model
- Server-authoritative over UDP via lightyear + netcode.
- Shared 32-byte private key resolved at boot via
vaern_protocol::config::resolve_netcode_key: release builds requireVAERN_NETCODE_KEY(hex) and reject all-zero / wrong-length; debug builds fall back to a zero dev key with a warning. - Server bind from
--bind <addr>/VAERN_BIND(default0.0.0.0:27015). Client target from--server <addr>/VAERN_SERVER(default127.0.0.1:27015).
Replicated components
Transform (prediction + linear/slerp interpolation), Health, ResourcePool, Casting (MapEntities), Experience, PlayerRace, PlayerTag, DisplayName, NpcKind, QuestGiverHub, ProjectileVisual, NodeKind, NodeState, AnimState.
Messages
- Combat:
ClientHello(C→S),CastIntent(C→S, MapEntities),StanceRequest(C→S:SetBlock(bool)/ParryTap),CastFired { caster, target, school, damage }(S→C, MapEntities),HotbarSnapshot(S→C). - Quests:
AcceptQuest/AbandonQuest/ProgressQuest(C→S),QuestLogSnapshot(S→C). - State:
PlayerStateSnapshot(S→C every tick — HP/pool/XP/cast + pillar scores/caps/banked XP × 3 + stamina + is_blocking + is_parrying). - Inventory + equip:
InventorySnapshot,EquippedSnapshot(S→C on change);EquipRequest,UnequipRequest(C→S). - Loot:
PendingLootsSnapshot(S→C onPendingLootsDirtyflag),LootWindowSnapshot/LootClosedNotice(S→C),LootOpenRequest/LootTakeRequest/LootTakeAllRequest(C→S). - Harvest:
HarvestRequest(C→S, MapEntities). Node state via component replication. - Voxel edits:
ServerEditStroke { center, radius, mode }(C→S);VoxelChunkDelta(ChunkDelta)(S→C). Server applies viaEditStroke::new(SphereBrush).apply(); broadcasts up to 8 deltas/tick; reconnecting clients catch up at 4/tick. - Wallet + vendors:
WalletSnapshot(S→C onChanged<PlayerWallet>only).VendorOpenRequest/VendorBuyRequest/VendorSellRequest(C→S).VendorWindowSnapshot/VendorClosedNotice(S→C). - Chat:
ChatSend { channel, text, whisper_target? }(C→S).ChatMessage { channel, from, to, text, timestamp_unix }(S→C). Server stampsfromfrom sender'sDisplayName. Rate-limited 5/sec on rolling 1s window. - Party:
PartyInviteRequest/PartyInviteResponse/PartyLeaveRequest/PartyKickRequest(C→S).PartyIncomingInvite/PartySnapshot/PartyDisbandedNotice(S→C). Snapshot broadcast is dirty-set gated, not per-tick.
Area-of-interest replication
One lightyear Room per starter zone. NPCs + resource nodes carry NetworkVisibility and join their zone's room at spawn; each client's link migrates between rooms as its player crosses zones. Players + projectiles stay globally visible. Pre-AoI, 603 NPCs × 60Hz Transform replication saturated the kernel UDP buffer on localhost and caused NPC rubber-banding. RoomPlugin must be added explicitly — it's not in lightyear's SharedPlugins.
Prediction & own-player state
- Own player on a
Predictedcopy;buffer_wasd_input→ActionState<Inputs>withcamera_yaw_mradbundled. - Own-player state via message, not replication. Lightyear 0.26 gives the owning client only a
Predictedcopy — filter(With<Replicated>, Without<Predicted>)matches zero. HP/pool/XP/cast/stamina/stance + inventory + equipped + pending-loots all push via per-tick messages. - Dynamic insertion/removal of predicted components (e.g.
AnimOverride) is also unreliable on the Predicted copy, so own-player transient animation flashes are driven client-side from theCastFiredmessage — server sets the flash, sendsCastFired { caster, … }, client inspectscaster == own_playerand stampsAnimState::Attacking+ a localAnimOverride. CastFiredlocal relay. Lightyear'sMessageReceiver::receive()drains on read. A singlerelay_cast_firedsystem is the soleMessageReceiver<CastFired>reader and re-emits viaMessageWriteras a Bevy-localCastFiredLocal. All downstream consumers (vfx, nameplates, animation, diagnostics) readMessageReader<CastFiredLocal>.
Other
- Loot containers are server-only (not replicated). Clients see them only through
PendingLootsSnapshotsummaries owned by the top-threat player. - Respawnable component on players resets HP/position/pool instead of despawning. Players carry
CorpseOnDeath, which makes the sharedapply_deathsskip them —respawn::apply_player_corpse_runis the sole player-death handler. - Server tick-rate logger prints
[tick] 60 Hz avg_frame=16.72ms max_frame=16.74mseach second; catches Update-loop stretch.
Combat model
- Abilities are entities with
AbilitySpec(damage, cooldown_secs, cast_secs, resource_cost, school, threat_multiplier, range, shape, aoe_radius, cone_half_angle_deg, line_width, projectile_speed, projectile_radius, applies_effect),AbilityCooldown,Caster. - Shapes:
Target,AoeOnTarget,AoeOnSelf,Cone,Line,Projectile. Friendly fire on. - No GCD — per-ability cooldowns only.
- Projectiles server-simulated in
FixedUpdate::tick_projectileswith swept-sphere collision. - Channeled casts snapshot
rangeontoCastingso cones/lines/projectiles stay bounded.
Stats-aware damage pipeline (vaern-combat::damage)
compute_damage → apply_stances:
- Caster: weapon min/max dmg roll (physical schools),
(melee_mult + spell_mult) × 0.5global multiplier, crit roll againsttotal_crit_pct→ ×1.5. Reads caster'sCombinedStatsif present. - Target: armor mitigation
armor / (armor + 200), per-channel resistresist_total[dt] × 0.005(capped 80%, supports negative for vulnerability amplification). - Stance layer (
apply_stances): active Parry → full negate (damage → 0, consumes parry, debits stamina); active Block → frontal/flank/rear damage reduction based on caster→target hit angle. - Rider effects: if
final_damage > 0and the ability hasapplies_effect, attach the DoT / Slow. Parried / blocked-to-zero hits don't apply riders. - School → DamageType lookup covers physical (blade→slashing, blunt→bludgeoning, etc.) and magical (fire/cold/light/shadow/frost/arcane/etc.) identically.
- Called at all three damage sites via
resolve_hit: instantselect_and_fire, channeledprogress_castscompletion,tick_projectileshit. - Missing
CombinedStatson either side falls through to raw damage.
NPC stats: npc_combined_stats(creature_type, armor_class, NpcKind) derives armor (inverse of mitigation formula from physical_reduction) + per-channel resists (magical base from magic_reduction + per-school bumps). Rarity mult: Combat 1.0× / Elite 1.25× / Named 1.5×.
Pillar XP on cast: every CastEvent credits XP to the caster's pillar via GameData.schools lookup; dedupe by (caster, ability) per frame so AoEs don't multiply. sync_hp_max_to_pillars updates Health.max on pillar gain, preserving HP-fraction.
CombinedStats denormalization: sync_combined_stats watches Changed<Equipped> | Changed<PillarScores>, resolves every equipped ItemInstance, folds SecondaryStats + DerivedPrimaries + (zeroed) TertiaryStats into CombinedStats as a Component.
NPC AI: per-mob AggroRange + LeashRange (8u common / 11u elite / 14u named), threat-table target selection, RoamState wander, leash warp-home + HP reset on over-extend.
Target lock + motion controller
- Target selection:
Tabcycles combat NPCs within 40u, prefers camera's front cone (80° half-angle). Falls back to nearest-overall in-range. Filters outNpcKind::QuestGiver.Escapeclears. Stale targets (despawned) clear next frame. - Smooth follow: while locked, camera yaw + mesh rotation drift toward target via a kinematic motion controller (brake-plan velocity capped by √(2·a·d)). Mouse yaw suppressed; pitch still mouse-driven.
- Motion params (
input.rs):IDLE_TURN_RATE = 0.3 rad/s,CAST_TURN_RATE = 12 rad/s,TURN_ACCEL = 20 rad/s². - On cast (any
CastAttempted): velocity kicks tomin(brake_peak, CAST_TURN_RATE)— a ~0.26s swoosh on 180°, not a teleport.
Status effects + stances + stamina
StatusEffects(Vec<StatusEffect>)on every combat-capable entity. Variants:Dot { damage_per_tick, school, threat_multiplier },Stance(Block | Parry),Slow { speed_mult },StatMods { damage_mult_add }. Refresh-on-reapply.tick_status_effectsdecrements, fires DoT ticks, drains Block stamina, auto-removes the component when empty.- Active Block (Q hold) —
StanceRequest::SetBlock(true/false)on press/release. Drains 15 stamina/s. 60% frontal → 25% flank → 0% rear. Breaks at zero stamina. Refused if pool already empty. - Active Parry (E tap) —
StanceRequest::ParryTapopens 0.35s window. First in-window hit fully negates and blocks rider debuff. Consumes 20 stamina on the negate, not on the tap. Parry wins over Block when both active. Stamina { current, max, regen_per_sec }— separate fromResourcePool(mana). Players: 100/100, 12/s. Exposed viaPlayerStateSnapshot.stamina_current/max + is_blocking + is_parrying.- YAML-driven effect riders — flavored variants accept
applies_effect: { id, duration_secs, kind: dot|slow, dps, tick_interval, speed_mult }. Parsed asFlavoredEffectin vaern-data, converted toEffectSpecinapply_flavored_overrides. Seeded: fire→burning, frost→chilled, shadow→decay, blood→bleeding at tiers 25 + 50. - Slow-aware movement:
StatusEffects::move_speed_mult()returns the strongest (lowest)Slow.speed_mult. Doesn't stack — deepest wins.
Animation state
AnimStateenum replicated:Idle / Walking / Running / Casting / Blocking / Attacking / Hit / Dead.derive_anim_statein FixedUpdate — priorityDead > Blocking > Casting > Running > Walking > Idlefrom Transform-delta speed + Casting + StatusEffects + Health. XZ-projected speed thresholds: walk = 0.5 u/s, run = 3.0 u/s.- Transient flashes:
mark_attack_and_hitreads eachCastEvent— flashes caster toAttacking, target toHit(only whendamage > 0and target ≠ caster). Paired withAnimOverride { remaining_secs: 0.25 }.tick_anim_overrideremoves when expired. - Visualized as a small grey
[idle]/[casting]/[running]etc. tag under every nameplate.
Gear & loot flow
- Mob dies → server rolls drop via
vaern-loot::roll_dropagainstDropTable::for_npc(kind, tier). Rarity emerges from rolled material + quality. - Server spawns a
LootContainerat mob position, owned by top-threat player. Not replicated; carries contents + despawn timer. - Client receives
PendingLootsSnapshotper tick → pulsing yellow gizmo at each position. - Walk in range (5u) →
G→LootOpenRequest→LootWindowSnapshot→ egui window. - Click an item or "Take all" →
LootTakeRequest/LootTakeAllRequest→ server moves stack toPlayerInventory→ broadcasts updatedInventorySnapshot+LootWindowSnapshot. Full-inventory items stay in container. - Container auto-despawns at 5min or when empty (sends
LootClosedNotice).
Item resolution pipeline (ContentRegistry::resolve)
Given ItemInstance { base_id, material_id, quality_id, affixes }:
- Look up
ItemBase,Quality, optionalMaterial. Unknown id →ResolveError::UnknownBase/Material/Quality. - Validate pairing:
base.armor_type ∈ material.valid_for/material.weapon_eligible/material.shield_eligible. Fail →InvalidPairing. - Resolve affixes: look up by id, check
applies_tomatches base kind. Fail →UnknownAffix/InvalidAffix. - Compute
weight_kg,rarity(material.base_rarity + quality.rarity_offset clamped),stats(base kind's scaling × material × quality, then per-affixstat_deltafolded). - Compose display name:
{quality} {prefixes*} {material} {piece} {suffixes*}. Compose id:{quality?}_{material?}_{base}+{affixes...}. - Soulbound = base.soulbound OR any applied affix's
soulbinds: true.
World & data
All design data is YAML under src/generated/, compiled from Python seed scripts (see scripts/seed_*.py). Bulk writes ≥15 files always go through a seed script, never per-file edits.
src/generated/
├── archetypes/ 15 class positions (barycentric M/A/F triangle)
├── abilities/ per-pillar/category ability tiers (25/50/75/100)
├── flavored/ school-flavored variants + per-ability stat overrides
├── schools/ 27 schools with morality + pillar
├── factions/ faction-gating rules
├── races/ 10 playable races with creature_type refs
├── bestiary/ 11 creature_types + 10 armor_classes
├── institutions/ + archetypes/*/orders/ flavored Order system
├── items/ composition tables for the runtime resolver
│ ├── bases/{armor,weapons,shields,runes,consumables,materials}
│ ├── materials.yaml 25 substances (copper → adamantine, linen → voidcloth)
│ ├── qualities.yaml 7 craft-roll tiers (crude → masterful)
│ └── affixes.yaml 27 affixes (11 suffix, 6 elemental banes, 5 prefixes,
│ 5 shard-only soulbinding)
└── world/
├── world.yaml + progression/
├── biomes/, continents/, zones/<id>/, dungeons/<id>/
Item seeder (scripts/seed_items.py) is a package — scripts/items/{armor,weapons,shields,runes,consumables,crafting,materials,qualities,affixes}.py — each module owns its table + seed().
Totals: 28 zones · 79 hubs · 612 mobs · 32 dungeons · 105 bosses · 30 quest chains (28 main + 2 side) · 11 creature_types · 15 class kits · 222 item bases · 25 materials · 7 qualities · 27 affixes.
Quest schema (chain YAML): hand-curated chains have an npcs: registry naming each contact + their hub + dialogue; steps reference NPCs by id (e.g. npc: warden_telyn). Procedural chains still work via target_hint parsing at the capital hub.
Chain hand-curation status: dalewatch_marches (mannin/human) is the showcase zone — fully hand-curated. Other 9 starter zones use procedural target_hints until curated.
Hub placement schema: hub YAMLs accept an optional offset_from_zone_origin: { x, z } for big-zone layouts. Zones without it keep the legacy 8u-radius tight layout. Non-hub sub-zones live in landmarks.yaml (used as display hints for investigate-step location: targets).
Design principles
- Abstract first, flavor second. Math (class position, capability tiers, school mechanics) is faction-neutral. Flavor (faction names, order affiliations, player-facing class names) is a separable layer.
- Math-first, sim-validated balance. Combat simulator will use PPO-trained rotations to validate class parity. Outcome equivalence, not hand-tuning.
- Mechanical vs narrative identity. ~30 sim profiles are the balance budget. Flavor variants (Orders, race skins, named identities) are unlimited on top.
- Strict morality gating. No oxymorons (no undead priests). Evil schools → evil faction; good → good; neutral → both. Each mechanical role has ≥1 morally-accessible school per faction.
- Hybrid-first classes. Most classes are dual-role-capable; pure tank/heal/DPS are "advanced cooperative" designated.
- Strict coop, no solo content. Target: close-friend / household groups. Every activity requires ≥2 players. Combat is continuous action-style (New World reference), not tick-based.
- Bestiary inheritance. Every mob and playable race references a
creature_type(beast / humanoid / undead / demon / aberration / elemental / construct / fey / giant / dragonkin / living_construct). HP scaling, default armor, resistances, school affinities all inherit from the type. Validator catches "light-devotion ashwolf" / "poison golem" incoherence.
Class position system
Every character sits at a position in a quantized barycentric triangle:
- Might — physical: armor, weapons, endurance, threat
- Arcana — magical: spells, rituals, wards, control
- Finesse — cunning: stealth, precision, evasion, crafting
Each pillar ∈ {0, 25, 50, 75, 100}, summing to 100. 15 valid positions.
Internal labels (Fighter, Paladin, Cleric, Druid, Wizard, Sorcerer, Warlock, Bard, Rogue, Ranger, Monk, Barbarian, Duskblade, Mystic, Warden) are dev-facing only; player-facing names come from faction/Order flavor.
Testing
cargo test --workspace
496 tests pass. Coverage: class position invariants, combat parity (GCD-aware), stats-aware damage pipeline, YAML loads, item composition, affix validation, loot drops, inventory stacking, equipment slot validation, economy / wallet, profession skills, NPC stat derivation, party split-XP, chat rate-limit + parser, persistence round-trip; plus the slice 1-9 additions (PolyHavenCatalog / dressing / scatter / side-quest givers / mob banding / level XP curve / emote parser / corpse-run / netcode-key / panic-handler / auto-reconnect / SQLite accounts / quest polish); plus Slice 6:
- boss-drop loader (3) — Valenn 12-piece, Halen 3-piece, unknown-mob = none
decide_roll_winner(6) — need beats greed, single-need auto-win, single-greed when no need, tied-need d100, tied-greed d100, all-pass = no winner, empty = no winnerRollItemState::all_voted(1)eligible_for_roll(3) — in-radius partners + killer, killer-not-in-party, non-party-in-radius- YAML guards — Halen L9, Valenn L10, drifters_lair dungeon yaml, step 10 targets Valenn at L10
4 pre-existing vaern-combat failures (attacker_kills_dummy, resource_gate_delays_kill, parity.rs × 2) all stem from apply_deaths being moved to the server-only schedule — the common::headless_app test harness loads only the shared CombatPlugin which has detect_deaths without its follow-up despawn. Unrelated to runtime gameplay.
Re-seed items:
python3 scripts/seed_items.py
Open TODOs
Design
- Faction naming — bind
faction_a/faction_bplaceholders to Concord / Rend - Order system delivery — in-world organizations that teach schools; how you join
- Progression mechanics — how characters move between class positions
- Numeric balance — damage, CDs, cast times, resistance multipliers (sim-driven)
- Race × class modifiers — small racial tweaks on class stats
- Blood counterpart beyond devotion — audit remaining evil-school mechanical gaps
MMO-feel (pre-alpha Tier-1)
- Currency loop —
PlayerWallet+ coin drops + quest gold +WalletSnapshoton change. Persisted asPersistedCharacter.wallet_copper. - Live vendor NPCs — 10 general-goods vendors at starter capitals.
- Text chat — Say (20u) / Zone (AoI room) / Whisper / Party / System; rate-limited 5/sec; 256-char truncate; server-authoritative
from. - Party system v1 — invite/accept/leave/kick by name, dirty-set snapshot broadcast, party frame with member HP, shared XP within 40u, party chat cross-zone.
- Player nameplates —
DisplayName, 60u culling, V-toggle, chat-input-aware gating. - Chat bubbles — 5s speech balloons on Say + Zone only, 1s fade, 72-char truncate.
- World dressing (Slice 1) — Poly Haven scatter + ~55 authored Dalewatch hub props.
- Mob level banding (Slice 3) — Dalewatch L1-2/3-4/5-6/7+ tiers; per-kind respawn 3min/10min/30min.
- Felt level progression (Slice 4a-c) —
level_xp_multipliercurve, pillar-point on level-up, "LEVEL UP" banner + flash. - Text emotes (Slice 7) —
/wave /bow /sit /cheer /dance /pointride chat-bubbles. - Death penalty (Slice 5) — corpse-run MVP: 25% HP respawn, walk back for full restore, 10-min expiry.
- Drifter's Lair pseudo-dungeon (Slice 6, code-complete + tests green, awaits 2-client playtest).
- Shipping hardening (Slice 8) — env netcode key + configurable bind + panic handler + auto-reconnect + local SQLite accounts.
Quest + content gaps
- Dalewatch Marches redesigned to full starter-scale scope.
- Gold / item quest rewards —
gold_reward_copper+gold_bonus_copperwired. - Side-quest givers spawn (Slice 2). Dalewatch seeded with 5 (Hayes / Morwen / Bel / Garrick / Pell).
- Level-gated quest accept (Slice 4d). Server hard-refuses if
chain.steps[0].level > player.level + 3. - Hand-curate remaining 9 starter chains — out of pre-alpha scope (Mannin-only spawn).
- Auto-advance talk/investigate/deliver objectives (kill-step works).
- Quest state persistence — server
QuestLogpersists viaPersistedCharacter.quest_log. - Quest item rewards (Slice 4e) — only XP + gold today; rolled-item rewards pending. Blocks Slice 6.
- Multi-kill objectives (
count > 1) — currently advance on first kill.
Gear / loot / crafting next steps
- Boss shard items —
ItemKind::Shard { affix_id }droppable by specific bosses, consumable at a crafter rite to imprint the shard's affix onto an item with open slots (converts to BoP). - Crafter rite + recipe system — apply shards, reroll affixes, fill slots, rarify. Recipes YAML per profession.
- Gathering polish — skill gains on harvest, tool requirement, world-authored node placements per zone.
- Crafting professions wired — Alchemy first, then Blacksmithing / Leatherworking / Tailoring / Enchanting / Jewelcrafting / Bowyery.
- Order tier sets — per-order materials ("Frostsilver") + rite-only acquisition + unique set-bonus mechanics.
- Item icons — keyed by
base_id, same pipeline as hotbar icons. - Drag-and-drop inventory ↔ paper doll.
Combat depth
- DoTs / status effects —
StatusEffectsinfra + YAML riders (fire/frost/shadow/blood seeded). Slow-aware movement. - Active Block / Active Parry stances — Q/E bindings; stance-aware damage pipeline; parry blocks rider debuffs.
- Animation state — replicated
AnimState+ derive + transient flash on attack/hit. - Haste → cooldown/cast reduction —
vaern_stats::formula::cast_speed_scale(h) = 1/(1+h/100). - Generic buffs (StatMods) — consumables push timed StatMods. Elixirs of Might/Finesse/Arcana/Giants seeded.
- Threat decoupled from damage —
threat_multiplierexists but scales off damage; tanks should hold aggro while dealing less. - Ability-category shape tuning —
might/offensehand-tuned; rest fall back to defaults.
Voxel world
vaern-voxelcrate landed — 8 swappable algorithm layers, sparseVoxelChunkstorage, async meshing, 87/87 tests.- Client streaming + F10 stomp — voxels stream around camera, F10 issues server-authoritative edit.
- Server-authoritative edits —
ValidatedEditStrokepipeline. ChunkDeltareplication — up to 8 chunks/tick live + 4/tick reconnect catch-up.- Retire the legacy ground plane — 8000u plane +
scene/hub_regions.rsoverlay deleted. - Server Y-snap via voxel query — server
movement+npc::aiand clientpredicted_player_movementall callvaern_voxel::query::ground_ywithterrain::heightfallback. - Biome-aware voxel materials —
BiomeResolver+ per-biome cachedStandardMaterials. 9 CC0 ambientCG sets. - Seam closure —
ChunkShape::MESH_MIN = PADDING - 1+chunks_containing_voxel{-1, 0, +1}. - Chunk eviction — earlier per-frame distance evictor made the whole 3D scene go dark when enabled (unknown render-pipeline interaction). Disabled. Memory grows monotonically until root-caused.
- Zone-scoped delta broadcast — today every
VoxelChunkDeltagoes to every client. - Sparse delta encoding — broadcast uses
ChunkDelta::full_snapshot(~150 KB/chunk).encode_delta(old, new, writes)exists in the crate but needs per-sample write tracking throughEditStroke. - Roads on voxel ground — recoverable from
git log -- crates/vaern-client/src/scene/hub_regions.rs; would port as a "dirt-road" biome override along each road path. - Teardown — chunk entities don't carry
GameWorld, so they persist across logout. - F10 bandwidth / re-mesh lag — few-tick visual delay between stomp and textured cap despawning. Just network RTT +
MESHING_BUDGET=64/framedraining.
Infrastructure / polish
- Area-of-interest replication — zone-scoped lightyear rooms.
- Per-tick broadcast spam — InventorySnapshot / EquippedSnapshot / PendingLootsSnapshot gated on change.
- Server tick-rate logger — Hz + max-frame telemetry every second.
- Own-player character mesh — Quaternius modular outfit driven by equipped armor; gender picker in char-create.
- Own-player animation — UAL clip pipeline. Transient one-shot swings hold until clip finishes.
- Ground pipeline — chunked SDF voxel world streamed around camera.
- Atmosphere + fog + bloom + tonemapping + HDR.
- Loot container visual —
assets/extracted/props/Bag.gltf. - Quest-giver humanoid skins — hashed fallback picks one of 12 Quaternius archetypes.
- Replace zeroed netcode private key before public exposure.
- Server-side character persistence —
PersistedCharacterJSON; 5s wall-clock flush + save-on-disconnect observer. - Zone transitions / portals / dungeon entry UI (32 dungeon YAMLs exist, not instanced).
- Ground mesh / fancy visuals for resource nodes (still gizmo spheres).
- HDRI-based skybox + IBL (3 Poly Haven
.hdrfiles downloaded; needs equirectangular → cubemap bake). - Player-follow / tiled ground (ground is a finite 8000u plane; content past ±4000u would reveal the edge).
- PPO balance trainer in
vaern-sim. - Remote player + NPC Quaternius mesh — all visible characters render as Quaternius on the UE-Mannequin skeleton.
- Weapon overlay on Quaternius rig —
QuaterniusWeaponOverlayattaches MEGAKIT props tohand_r/hand_lbones viaassets/quaternius_weapon_grips.yaml. MEGAKIT only ships 5 props so bow/staff/wand still render empty. - Clip per weapon / ability category —
Sword_Attackis used for every physical cast. UAL hasSword_Regular_A/B/C + Combo; bow needs a separate clip set (none ship in UAL).
Known rough edges
Casting+AnimOverridecomponents are registered for prediction but dynamic insertion on the own-player Predicted copy is unreliable in lightyear 0.26. Cast bar + transient-anim flashes are driven byPlayerStateSnapshot/CastFiredLocalmessages instead.- Auto-attack light/heavy specs are hardcoded blade cones; should branch on equipped weapon school.
- NPCs don't have their own CombinedStats-derived melee damage yet — raw
attack_damageon the spawn slot. - Starter gear + hotbar are pillar-keyed (Might / Finesse / Arcana × 1 kit each). Archetype-specific kits land with the archetype-unlock path.
- Paper doll is two columns of slot buttons; no real character silhouette yet.
- Character gender is client-local only; no server-side storage or replication.
- Party HP updates between snapshots rely on join/leave/kick to re-broadcast — a future 500ms heartbeat would keep frame bars live during combat.
- Own player's Replicated + Predicted copies both spawn their own nameplate (double plate over own head in third-person); fix by filtering out own entity on spawn.
- 4 pre-existing
vaern-combattest failures fromapply_deathsliving on the server-only schedule. Unrelated to gameplay runtime.
World & lore
src/world_theory.yaml contains the original design: Vaern island-continent geography, Concord (Veyr, defenders) vs Rend (Hraun, arrivals), race list, 4-layer mystery-revelation system, hardcore death design.
Deprecated sections in that file: classes, multiclass_system, build_totals — superseded by the class position system above.
Compendium (static web browser)
Standalone static site at web/ that browses the entire design corpus —
10 races · 28 zones (with hubs + landmarks) · 9 biomes · 33 dungeons (with all 107 bosses) · 15 institutions (with 89 orders) · 27 schools · 436 spells — with hash-routed detail pages, faction-tinted vertical row layouts, and generated atmospheric images per zone / hub / landmark / dungeon / boss / race.
Data flow
src/generated/{races,factions,biomes,zones,dungeons,...}/**/*.yaml
+
src/generated/world/{zones,dungeons}/<id>/prose.yaml ← description + prompt overlay
+
assets/meshy/<slug>/image_*.png ← generated landscape / portrait shots
↓ scripts/build_web_data.py
web/data.json ← single ~860 KB blob the SPA fetches
↓ fetch + render in browser
web/{index.html, compendium.html, app.js, styles.css}
build_web_data.py overlays prose.yaml (description / prompt / vibe) onto the canonical core.yaml for each entity, attaches matching assets/meshy/ image paths, and emits web/data.json. Re-run after any YAML edit.
Image generation (Meshy.ai)
scripts/generate_meshy.py orchestrates Meshy's text-to-image API (nano-banana-pro at 1:1; gpt-image-2 would unlock 3:2 / 2:3 but is account-gated).
Slug convention:
biome__<id> biome establishing shot
<zone>__zone zone establishing shot
<zone>__<hub_id> hub establishing shot
<zone>__<landmark_id> landmark establishing shot
dungeon__<id> dungeon interior shot
boss__<id> boss portrait
race__<id>__<gender> race portrait
Bulk flags scope by --zone / --dungeon when set, otherwise cover everything:
# auth check (free)
python3 scripts/generate_meshy.py --ping
# full world pass — 320+ jobs, ~2900 credits, idempotent (skips done slugs)
python3 scripts/generate_meshy.py --all --workers 8
# scoped runs
python3 scripts/generate_meshy.py --zone dalewatch_marches --all-hubs --all-landmarks
python3 scripts/generate_meshy.py --all-bosses --workers 8
# one-shot race portraits via dedicated script
python3 scripts/regen_race_portraits.py
Each job writes a PNG, an API-response task.json, and the literal prompt.txt to assets/meshy/<slug>/. _log.csv aggregates all runs. Reruns auto-skip slugs that already have an image (override with --no-skip-existing). 8 parallel workers cap at 1–3 minutes per Meshy job.
Local viewing
# from the repo root, serving web/ as the doc root
python3 -m http.server -d web 8080
# → http://localhost:8080
Symlinks under web/ (icons → ../icons, etc.) make the relative asset paths resolve regardless of whether the doc-root is web/ or the repo root.
Production image (Docker)
Two-stage build: Bun validates data.json, minifies app.js, and transcodes every PNG/JPG asset (1089 files, ~1.7 GB) to WebP via cwebp at per-tree max-side dimensions (icons 256, emblems 384, characters 768, meshy shots 1024, all q82). The .png references in app.js + data.json get sed-rewritten to .webp. nginx:alpine serves the result with gzip on text + 30-day immutable cache on images.
| Source | Final | ||
|---|---|---|---|
| Bundle on disk | 1.7 GB | 32 MB | 57× smaller |
| Image (uncompressed) | — | 92.5 MB | |
| Image (registry compressed) | — | 53 MB |
Two variants ship from the same Dockerfile via --build-arg:
| Variant | Image | URL prefix | Wordmark |
|---|---|---|---|
vaern (default) |
traagel/vaern-mmo-web:latest |
/ |
VAERN |
lexi (parody) |
traagel/vaern-mmo-web-lexi:latest |
/lexi-returns/ |
NEW WORLD 2: LEXI RETURNS |
# build + push (multi-arch via buildx by default)
docker login -u traagel
./scripts/push-web.sh # vaern · :latest
./scripts/push-web.sh v0.1.0 # vaern · :v0.1.0 + :latest
./scripts/push-web.sh --variant lexi # lexi · :latest
./scripts/push-web.sh --no-push # local build only, single-arch
# run locally (either variant)
docker run -d --rm -p 8080:80 traagel/vaern-mmo-web:latest
docker run -d --rm -p 8081:80 traagel/vaern-mmo-web-lexi:latest
# lexi root path 302-redirects to /lexi-returns/
The lexi variant is fed identity overrides (SITE_TITLE, SITE_PRETTY, SITE_TAGLINE_SPLASH, BASE_PATH=/lexi-returns/) via Dockerfile ARGs and build.ts substitutes them into HTML + adds <base href="/lexi-returns/"> + rewrites world.setting_name in data.json so the runtime overview heading also reads the new name. Same content, different deployment skin.
Memory
Claude Code persistent memory at ~/.claude/projects/-home-mart-git-rust-mmo-project/memory/. Encodes design principles, working context, and non-obvious architectural decisions established across sessions.