Arenic
This book is the home for Arenic's documentation. It has two halves:
- Design System — the reusable design tokens (colours, typography, space,
radii, elevation, motion) that style every surface of the game and its tools.
The tokens are ported from the engmanager.xyz
CSS theming system and re-expressed as Rust in the
arenic_game::themecrate, so the same vocabulary drives both the web reference and the Bevy UI. - Game — the rulebook and the units/scale reference.
The pages in the Design System section are themed by the tokens themselves. Toggle the book theme (the paintbrush, top-left) between light and dark and the swatches re-tone live — the same trick the tokens give the game.
Where the tokens live
| Surface | Source of truth |
|---|---|
| Web reference | engmanager.xyz critical.css (@layer fp.tokens / fp.themes) |
| This book | theme-css/arenic.css (the CSS tokens, reused verbatim) |
| The game & storybook | crates/arenic_game/src/theme/ (Rust) |
| Live, interactive | the storybook binary — cargo run -p arenic_storybook |
The storybook renders the same style guide as a native Bevy app, with a theme switcher across all nine palettes.
Design System — Overview
A small, themeable token system with three tiers. The rule that makes it work: components only ever read the semantic layer, so swapping the primitive palette re-themes everything with no component edits.
Primitives Semantic aliases Components
────────── ──────────────── ──────────
--color-base-100 → --surface-1 → cards, panels
--color-base-content
├─ α .78 → --text-2 → body copy
├─ α .55 → --text-muted → captions
└─ α .12 → --border-subtle → dividers
--color-primary → --brand / --focus-ring → buttons, links, focus
The three tiers
- Primitives — a per-theme palette of raw
oklchcolours (base-100/200/300,base-content,primary,secondary,accent,neutral, and the status colours), plus structural knobs--border,--depthand the--radius-*values. Nine palettes ship: light, dark, synthwave, cyberpunk, forest, lo-fi, dracula, catppuccin, luxury. - Semantic aliases — surfaces, text, borders (faint → bold), brand, focus, and interaction tints (hover / press / selected). Most are the base colour at a set alpha, so they track the theme automatically.
- Scales — theme-independent type, space, radius, weight and line-height
steps. On the web these are fluid
clamp()ranges; in the game they become fixed pixels (the range's upper bound).
Using the tokens in Rust
The arenic_game::theme crate mirrors this exactly:
#![allow(unused)] fn main() { use arenic_game::theme::{ActiveTheme, ThemeId, scale}; // Pick a palette (a Resource you can swap at runtime to re-theme). let theme = ThemeId::Dark.palette(); // Semantic tokens are methods; primitives are fields. let card_bg = theme.surface_2(); let body_text = theme.text_2(); let divider = theme.border_subtle(); let button_bg = theme.brand(); // Scales are plain pixel constants. let gap = scale::space::M; // 30.0 let heading = scale::font_size::F4; // 39.06 let rounded = scale::radius::L; // 16.0 }
See the live, switchable version in the storybook:
cargo run -p arenic_storybook
Colors
Colour flows in three tiers — primitive oklch palettes per theme, semantic aliases derived from them, and components that consume only the semantic layer. Switch the book theme (top-left) and the semantic swatches below re-tone.
Semantic tokens
What code references (never the raw primitives):
Theme palettes
All nine primitive palettes, colour-accurate (oklch copied verbatim from source):
Typography
One fluid type scale (~1.25 / major-third ratio at the top of each range),
Archivo for body and display. The web sizes are clamp() ranges; the Rust
constants in scale::font_size take each range's upper bound as fixed pixels.
| Token | Rust | px (game) | Use |
|---|---|---|---|
--font-size-00 | F00 | 12.8 | fine print, captions |
--font-size-0 | F0 | 16 | body baseline |
--font-size-1 | F1 | 20 | lead, sub-nav |
--font-size-2 | F2 | 25 | card titles |
--font-size-3 | F3 | 31 | section heads |
--font-size-4 | F4 | 39 | page heads |
--font-size-5–8 | F5–F8 | 49–95 | display |
The scale
Families & weights
- Sans / body:
Archivo, thensystem-uifallbacks (--font-sans). - Display:
Monument Extended, falling back to the sans stack (--font-display). - Mono: the platform mono stack (
--font-mono).
Weights run 100–900 (scale::weight::W1–W9). Bevy renders text in the
loaded font's own weight, so in the game the weight scale is documentation until
weighted font assets are wired up; on the web the variable font honours all nine.
Line-heights tighten as type grows: 1.5 for body (LH0/LH1) down to 1.0
for the largest display (LH7).
Space & Radii
Space
A single rhythm for gaps, padding and margins — 3xs (5px) up to 4xl (160px).
Rust: scale::space::{XS3, XS2, XS, S, M, L, XL, XL2, XL3, XL4}.
Radii
scale::radius::{XS3 … XL2}, plus PILL for fully-rounded ends (for a circle
use Val::Percent(50.0)). Each theme also carries structural radii
(radius_selector / radius_field / radius_box) — that's why themes like
synthwave and cyberpunk read as sharp (all 0), while dark and
forest soften (0.5rem / 1rem).
Elevation & Motion
Elevation
Five soft shadows (--shadow-1 → --shadow-5), each derived from the theme's
text colour so they re-tone automatically — dark themes get paler shadows. Their
presence is gated by the per-theme --depth knob (some themes are deliberately
flat).
The last card is the neobrutalist flat-offset shadow the components lean on
(a 2px border in --text-1 plus a hard 3px 3px 0 shadow). The Bevy storybook
reproduces both the soft and the hard styles with BoxShadow.
Motion
Durations and easings, composed into named motion presets:
| Preset | Duration | Easing | For |
|---|---|---|---|
--motion-press | 80ms (fastest) | ease-3 | button press |
--motion-hover | 150ms (fast) | ease-3 | hover states |
--motion-enter | 250ms (base) | ease-out-3 | elements entering |
--motion-exit | 150ms (fast) | ease-in-3 | elements leaving |
--motion-emphasized | 400ms (slow) | ease-spring-2 | emphasized, springy moves |
Base durations: fastest 80ms · fast 150ms · base 250ms · slow 400ms. Easings
range from gentle (ease-1…ease-5) to expressive (ease-elastic-3,
ease-squish-2, the ease-spring-2 linear() spring).
ARENIC: Complete Game Instructions
Game Overview
Arenic is an innovative tactical strategy game where you command up to 320 heroes across 8 simultaneous 40-person raids. Master the revolutionary Record & Replay system to build layered ghost recordings, coordinate complex strategies, and conquer challenging boss encounters. Every action matters in this deterministic world where victory comes through precise timing, strategic positioning, and masterful orchestration of your guild's abilities.
Core Game Concept
The Innovation: Record & Replay System
The heart of Arenic is its unique recording mechanism:
- Record individual hero actions and movements in 2-minute cycles
- Replay those recordings as "ghost" characters that automatically repeat their actions
- Layer up to 40 ghost recordings per arena to build massive coordinated raids
- Master complex strategies by synchronizing multiple heroes across different timelines
The Goal
Build a guild powerful enough to simultaneously manage 8 different arenas, each with its own boss and unique challenges. Progress through increasingly difficult tiers (Normal → Heroic → Mythic) while growing your roster through strategic gacha recruitment.
Getting Started
Your First Steps
- Arena Selection: Begin in the Guild House (arena 1) with your starting hero
- Movement: Use the arrow keys to move your hero one tile at a time on the grid
- Character Selection: Press Tab to switch between available heroes in your current arena
- Basic Combat: In combat arenas, your hero automatically attacks nearby enemies based on their abilities (the Guild House is a safe hearth with none)
Understanding the Interface
- Grid-Based Movement: Each arena is a 66×31 tile battlefield
- Multi-Arena View: Access all 9 arenas through the navigation system
- Character Status: The selected hero carries a glowing selection ring; ghosts carry a blue ring
- Boss Visibility: Each combat arena holds one large themed relic boss with a glowing core (see Arena-Specific Boss Types); the Guild House has none
Controls Reference
| Input | Action |
|---|---|
| Arrow Keys | Move selected character (tile-by-tile grid movement) |
| Tab | Switch to next character in current arena |
| Shift+Tab | Switch to previous character in current arena |
| 1-4 | Activate character abilities (when available) |
| R | Record key (context-sensitive): start the 3s countdown, or open a modal — Record new / Replay previous / Cancel (prior recording), Record new / Cancel (selected ghost), Commit partial / Keep recording / Discard (while recording) |
| [ / ] | Cycle through arenas (0-8, wraps around) |
| P | Toggle between single-arena view and all-arenas overview |
| Esc | Cancel an open modal / return to the title screen |
| Enter | Open guild house management menu |
| Mouse | Arena selection and UI interaction (optional — every modal is fully keyboard-driven) |
| Space | Interact with objects and chests |
Modal Controls (keyboard-first)
Every recording decision is a TUI-style modal driven entirely from the keyboard — think terminal permission prompts or Caves of Qud's dialogs. Mouse clicks work, but are never required:
- Options lay out horizontally with numbered hotkeys — press 1-9 to choose one instantly
- ← / → (or Tab / Shift+Tab) move the focus highlight between options
- Enter confirms the focused option; Esc always takes the safe/cancel option
- A hint line on the modal spells out exactly these keys (
←→ focus · Enter confirm · Esc cancel) - While a modal is open, world input (movement, abilities, R, Tab) is gated off — the number and arrow keys belong to the modal — and only that arena's clock pauses
Input System Rules
🎯 CRITICAL: Single Keypress Action System
Arenic uses a single keypress input system - every action requires exactly one key press:
- Movement: Each arrow-key press moves your character exactly one tile
- Abilities: Each number key (1-4) press activates the ability once
- No Held Keys: Holding down movement keys does NOT create continuous movement
- Intent-Based: The recording system captures when you pressed a key, not how long you held it
- Precision Required: Strategic timing comes from when you choose to press keys, not how fast you can spam them
Why This Matters for Recording:
- No duplicate events are ever recorded (impossible with single keypress system)
- Ghost replays are perfectly deterministic and precise
- Strategic depth comes from timing your keypresses, not reaction speed
- Recordings are highly compressed and efficient
Example:
- ❌ Wrong: Hold ↑ to move multiple tiles → Only moves one tile on initial press
- ✅ Correct: Press ↑, ↑, ↑, ↑ to move four tiles → Each press moves one tile
The Record & Replay System (Core Mechanic)
The recording system allows you to capture 2-minute sequences of character actions that replay as autonomous "ghosts" every arena cycle. Think of it as sheet music: every arena cycle is a fixed 2-minute score, and each character owns a per-arena staff — a stream of intent events (moves and abilities). Committing a recording folds that staff into the arena's single master timeline, which choreographs all of the arena's ghosts (up to 40) at once.
How Recording Works
Starting a Recording
- Press R while controlling the selected character; only one recording can be in progress at a time
- No prior recording for this arena → the 3-second countdown starts immediately (no modal)
- A prior recording exists for this arena → a modal asks Record new / Replay previous / Cancel
- R on a selected ghost → a modal asks Record new / Cancel (recording anew unfolds the ghost's old events first)
- During the countdown the recording arena is held at 0:00 — the other arenas keep playing
- All input is ignored during the countdown — except R, which aborts it; movement and abilities register only once capture begins at 0:00
- Capture runs as the clock ticks 0:00 → 2:00; all movements (arrow keys) and abilities (1-4 keys) are captured as intent, stamped with the arena's current tick
During Recording
- You have exactly 120 seconds to record your strategy
- A red "REC" indicator and the arena clock show in the HUD while recording
- Movement stays live inside the arena; stepping past an edge that has an adjacent arena opens the Like the recording? modal instead of silently moving you (at the outer world border the step simply clamps, recorded as played)
- Tab is ignored while recording; the recording always belongs to the character who started it
- Recording captures your input intent, not character position - ensuring perfect replay regardless of physics changes
Ending a Recording
- At the 2-minute mark the clock pauses and a modal asks Commit / Discard
- Press R mid-recording to stop early: a modal asks Commit partial / Keep recording / Discard
- A partial commit is simply a shorter event list — the ghost replays it, then idles for the rest of each cycle
- On commit: the draft is cached in the character's per-arena library, folded into the arena's master timeline, the character becomes a ghost, and the arena restarts at 0:00
- On discard: the draft is thrown away and the character stays selected
Recording Interruptions
- Tab is ignored while recording — a recording cannot be switched away from
- Stepping past the arena edge opens the Like the recording? modal: Continue recording (abort the step) / Cancel recording & walk out (discard the draft, then perform the step) / Commit (fold the draft — you become a ghost and stay)
- Camera keys ([ / ] and P) never stop a recording — watching another arena is safe
- While any modal is open, only the recording arena's clock pauses
Ghost Playback System
Autonomous Replay
- Each arena owns a single master timeline: every committed recording is cloned, tagged with its ghost, and folded into one tick-sorted event stream
- Ghosts don't replay individual timelines — the arena plays its master timeline and drives all of its ghosts (up to 40) from it
- Each arena maintains its own independent clock (0:00 to 2:00)
- Ghosts in off-screen arenas continue advancing — their arenas never stop ticking
- Whenever an arena (re)starts at 0:00 — natural clock wrap, recording start, commit, or replaying a stored recording — every folded ghost snaps to its recorded start tile and the score replays (breaking a ghost out does not restart the arena)
Visual Indicators
- Ghosts carry a blue ring; the selected character carries the white selection ring
- A red "REC" label and the arena clock show in the HUD while recording; a countdown digit shows during the 3-second countdown
- Cannot directly control ghosts - they follow the arena's master timeline (Tab can re-select one; press R on it to record anew, or an arrow key to break it out)
Breaking a Ghost Out
- Arrow keys never move a ghost directly — pressing one with a ghost selected opens the Break out? modal (pausing only that arena):
- Take control: the ghost's events are unfolded from the master timeline, the Ghost state is removed, and the arena resumes right where it left off (no restart) — the character stays at its current position under free control
- Restart arena: the arena restarts at 0:00 and every ghost snaps to its start — the character stays a folded ghost
- Cancel: nothing changes; playback resumes
- A broken-out character keeps its cached recording: press R for Record new / Replay previous / Cancel — Replay previous folds it back in and restarts the arena
Timeline Accuracy
- Recording stores movement intent (arrow-key presses) not positions — transforms are derived on playback
- Events are stamped in ticks on the fixed 60 Hz timestep (7,200 ticks per 2-minute cycle), never in seconds
- Each recording remembers its start tile, so every cycle replays from the same origin
- Abilities trigger at exact recorded ticks
- Perfect deterministic replay every cycle
Multi-Arena Coordination
Independent Timers
- Each of the 9 arenas has its own 2-minute cycle clock
- Ghosts use their parent arena's clock for playback
- Switching arenas ([ ]) doesn't affect other arena clocks
- While a modal is open, only the selected arena's clock pauses — the other eight keep ticking
- During a recording countdown, only the recording arena is held at 0:00
Cross-Arena Strategy
- Record complementary ghosts across multiple arenas
- Use the arena status panel to track ghost counts per arena
- Maximum of 40 ghosts per arena, 320 total across all arenas
- (Future) rendering may scale down visual update rates for distant arenas; the simulation always ticks every arena at the fixed 60 Hz timestep
Ghost Replay Feature
Arena-Specific Recordings
- Each character keeps a per-arena library of recordings (a hashmap keyed by arena) — its "staff" of sheet music per arena
- A character is only ever a ghost in the arena it currently occupies; its other recordings wait in the library
- Re-committing a recording for an arena replaces the old one (unfold first, then fold — always idempotent)
Travel: Leaving and Returning
- Travel is edge-walking: stepping past an arena edge re-parents the hero into the adjacent arena at the opposite edge
- Edge-walking only applies between adjacent arenas — at the outer border of the 3×3 grid movement is clamped (the world does not wrap)
- Ghosts never edge-walk: break the ghost out first (see Breaking a Ghost Out) — its events leave the master timeline at break-out and that arena keeps playing without restarting — then walk it wherever you like
- When a hero edge-walks into an arena where it has a cached recording, a modal asks:
- Replay previous: fold the stored recording back into this arena's master timeline — the hero becomes a ghost and the arena restarts
- Continue without: stay a regular character (press R later to record new or replay via the R modal)
- This allows the same character to have different tactical roles in different arenas
Strategic Applications
- Create arena-specific strategies with the same character
- Build up recordings progressively as you learn each arena
- Reuse successful recordings when returning to farm or practice
- (Future) maintain separate recordings per difficulty tier — today the library is keyed by arena only
Recording Best Practices
Planning Your Recording
- Think through the full 2-minute sequence before starting
- Consider boss attack patterns and timing windows
- Position yourself safely at the end for smooth looping
- Test ability combinations before committing
Optimization Tips
- The single-keypress system means recordings are already tiny — one small intent event per press
- There are no keyframes: positions are derived on playback by replaying intent from the recorded start tile
- Ability events are always preserved at full fidelity
Common Patterns
- Tank Loop: Record a warrior continuously taunting and blocking
- Healer Rotation: Set up heal timings to match damage spikes
- DPS Burst: Align multiple damage dealers for boss vulnerability phases
- Resource Gathering: Create forager ghosts to maintain mushroom gardens
Advanced Recording Features
State Management
- Recording state machine: Idle → Countdown (3s / 180 ticks) → Recording; modals are tracked separately and gate input while open
- Exactly one recording can be in progress at a time; the global draft timeline is empty unless one is
- Character states: Selected (controlled, input-driven) and Ghost (folded into its arena's master timeline, input ignored)
- Commit transitions Selected → Ghost; "Record new" on a ghost or the break-out modal's "Take control" transitions Ghost → Selected after unfolding ("Take control" keeps the arena playing; "Record new" restarts it into a countdown)
- While a modal is open only the selected arena pauses — there is no global pause
Performance Scaling (future, render-side only)
- The simulation always advances every arena on the fixed 60 Hz tick — determinism is never traded away
- Rendering / visual update rates may later scale down for distant arenas (e.g. 30 FPS adjacent, 10-15 FPS distant)
- Automatic visual quality adjustment when performance drops
Technical Details
- Simulation runs on the fixed 60 Hz timestep:
CYCLE_TICKS = 7_200,COUNTDOWN_TICKS = 180; all timeline math is in ticks, never seconds, usingstrict_*/wrapping_*/%arithmetic - An event is intent, not position:
TimelineEvent { tick: u32, action: Action }withAction::Move(IVec2)orAction::Ability(u8)— tiny andCopy - A recording is
Recording { start, events: Arc<[TimelineEvent]> }— the start tile anchors every replay;Arcmakes timeline sharing cheap - Each character carries a
RecordingLibrary(hashmap: arena → recording); lookups by key only — the sim never iterates it - Each arena root carries the master timeline: a tick-sorted
Vec<GhostEvent>(event + owning ghost entity) with a playback cursor; fold merges a recording in, unfold retains everything but one ghost's events - Commits are idempotent by construction: unfold first, then fold; the library insert replaces
Character Classes & Abilities
The 8 Character Classes
Each class brings unique tactical advantages and 4 specialized abilities:
Hunter - Ranged Precision Specialist
- Auto Shot: Automatically fires at closest enemy every 2.5 seconds
- Poison Shot: Toxic projectile with knockback and damage over time
- Sniper: Long-range precision shots targeting bosses
- Trap: Explosive area denial placement system
Alchemist - Support Through Transformation
- Ironskin Draft: Defensive potion granting damage reduction
- Acid Flask: Area denial through persistent acid pools
- Transmute: Resource conversion and material transformation
- Siphon: Life-draining channeled ability targeting allies
Cardinal - Divine Healer and Protector
- Heal: Smart-targeting restoration ability
- Barrier: Round-robin ally protection system
- Beam: Piercing divine damage with healing properties
- Resurrect: Ultimate revival and telegraph enhancement
Warrior - Frontline Tank and Protector
- Block: Directional projectile defense system
- Bash: Offensive shield strike with damage mitigation
- Taunt: Threat redirection and aggro management
- Bulwark: Frontal barrier and area denial defense
Thief - Stealth and Mobility Expert
- Shadow Step: Evasive teleportation with invulnerability frames
- Smoke Screen: Concealment and safe passage utility
- Backstab: Positional damage enhancement passive
- Pickpocket: Resource extraction and buff theft
Bard - Team Enhancement Specialist
- Dance: Rhythm-based offensive quick-time event
- Helix: Dual-mode aura providing healing or haste
- Cleanse: Team-wide debuff removal utility
- Mimic: Passive ability copying from adjacent allies
Forager - Terrain Manipulation Expert
- Dig: Multi-tile excavation and terrain preparation
- Boulder: Rolling stone offensive with resource collection
- Border: Projectile-deflecting earth barriers
- Mushroom: Healing garden creation on prepared terrain
Merchant - Economic Warfare Specialist
- Dice: Stackable critical chance enhancement
- Coin Toss: Economic risk-reward projectile system
- Fortune: Team luck enhancement aura
- Vault: Area-effect critical damage amplification
Arena System & Boss Battles
Arena Structure
- Grid Size: 66×31 tiles per arena (2,046 tiles × 9 arenas = 18,414 total battlefield)
- Boss Positioning: Each arena contains one major boss matching its class theme
- Multi-Arena Management: All 9 arenas run independently with separate timers
- Scaling Difficulty: Normal → Heroic → Mythic progression tiers
Arena Names & Layout
Arenic features 9 distinct arenas arranged in a 3×3 grid layout. Each arena has a unique name and thematic identity:
| Index | Arena Name | Grid Position | Theme |
|---|---|---|---|
| 0 | Labyrinth | Top-Left (0,0) | Hunter — precision, traps, sightlines |
| 1 | Guild House | Top-Center (1,0) | Guildmaster — the guild's home & safe hearth (not a combat arena) |
| 2 | Sanctum | Top-Right (2,0) | Cardinal — divine magic |
| 3 | Mountain | Middle-Left (0,1) | Forager — terrain, geology, foraging |
| 4 | Bastion | Middle-Center (1,1) | Warrior — strength & defense (the fortress) |
| 5 | Pawnshop | Middle-Right (2,1) | Thief — stealth & fencing |
| 6 | Crucible | Bottom-Left (0,2) | Alchemist — transformation (the vessel) |
| 7 | Casino | Bottom-Center (1,2) | Merchant — economic warfare |
| 8 | Gala | Bottom-Right (2,2) | Bard — rhythm & performance (the festival) |
Arena Navigation
Grid Layout (3×3):
[0] Labyrinth [1] Guild House [2] Sanctum
[3] Mountain [4] Bastion [5] Pawnshop
[6] Crucible [7] Casino [8] Gala
Technical Implementation
- ArenaName Enum: Each arena uses a strongly-typed
ArenaNameenum instead of raw numeric indices - Type Safety: Prevents invalid arena references and provides human-readable names
- Index Conversion:
ArenaName::as_u8()provides the numeric index (0-8) when needed for calculations - Error Handling: Invalid arena indices are handled gracefully with proper error messages
Arena Navigation & Camera System
- Arena Selection: Use [ and ] keys to cycle through arenas (0-8, wraps around)
- Camera Zoom: Press P to toggle between single arena view and all-arenas overview
- Visual Indicators: Current arena highlighted with a white border when zoomed out
- Smart Focus: Camera automatically positions on current arena when zooming in
- Character Memory: Each arena remembers its last selected hero for seamless transitions
Character Management Systems
- Selection Toggle: Tab cycles the Selected marker through heroes in the current arena (requires 2+ heroes)
- Cross-Arena Movement: Arrow-key movement seamlessly transitions heroes between adjacent arenas (edge-walk; opens the interrupt modal while recording)
- Arena Boundaries: Movement past edges teleports the character to the opposite side of the adjacent arena; the outer border of the 3×3 grid is clamped (no wraparound)
- Re-parenting System: Characters automatically become children of their current arena entity
- State Preservation: Heroes keep their selection when edge-walking between arenas
Arena Update Logic
- Event-Driven Updates: Arena state refreshes on camera changes or arena transitions
- Zoom-Out Behavior: The overview changes only the camera and HUD theme — selection (and its ring) is untouched
- Zoom-In Behavior: Focuses the current arena; the selected hero keeps its ring
- Empty Arena Handling: Gracefully handles arenas with no characters present
- Selection Visuals: A glowing ring marks the selected hero; ghosts carry a blue ring
Boss Mechanics
- 2-Minute Cycles: Bosses operate on the same timing as your recordings
- Deterministic Patterns: Each boss has predictable, repeatable attack sequences
- Telegraphed Attacks: Visual warnings appear on grid tiles before major attacks
- Pattern Recognition: Success requires learning and countering boss rotations
- No Enrage Timer: Bosses reset each cycle without becoming stronger over time
Arena-Specific Boss Types
- Labyrinth (Hunter): precision ranged attacks and deadly trap mechanics — the Hollow Obelisk
- Guild House (Guildmaster): the guild's home — a safe hearth, not a true boss; an abstract house/pyramid object
- Sanctum (Cardinal): healing denial, purification attacks, and divine shields — the Torus Halo
- Mountain (Forager): dynamically reshapes the battlefield through terrain manipulation and foraging — the Stepped Pyramid / Ziggurat
- Bastion (Warrior): heavily armored defenses, charge attacks, and area damage — the Hexagonal Prism (the fortress)
- Pawnshop (Thief): stealth mechanics, teleportation strikes, and ambush tactics — the Triangular Prism (wedge)
- Crucible (Alchemist): elemental transformation and area denial through toxic pools — the Truncated Cone (the vessel)
- Casino (Merchant): risk-reward mechanics and economic warfare strategies — the Hollow Icosphere (geode)
- Gala (Bard): timing-sensitive responses to rhythmic attack patterns — the Capsule Resonator (the festival)
Guild Management & Progression
Gacha Recruitment System
- Arena-Specific Recruitment: Each arena only recruits heroes matching its class type
- Battle Triggers: Active combat in an arena generates gacha opportunities
- Quality Tiers: Heroes come in different rarity levels with enhanced abilities
- Guild House Access: Open recruitment boxes by pressing Enter and visiting guild house
- Strategic Collection: Build balanced rosters across all 8 character classes
Character Development
- Experience Growth: Heroes gain levels through active participation in battles
- Death Consequences: Character death results in de-leveling, not permanent loss
- Ability Evolution: Higher-tier characters possess enhanced versions of base abilities
- Equipment Systems: Gear improvements provide statistical bonuses to character performance
Guild House Operations
- Management Hub: Central location for all administrative functions
- Recruitment Review: Open and evaluate new character acquisitions
- Global Buff Activation: Use acquired consumables that affect all arenas simultaneously
- Strategic Planning: Review arena status and plan multi-arena coordination
- Travel Planning: Decide which arenas need heroes — travel itself is edge-walking
Advanced Strategies
Multi-Arena Coordination
- Temporal Management: Balance recording time across multiple arenas efficiently
- Resource Allocation: Distribute your best characters across priority arenas
- Progressive Difficulty: Master easier arenas before advancing to heroic/mythic tiers
- Cross-Arena Learning: Apply successful strategies from one arena to others
Recording Optimization
- Ability Timing: Synchronize powerful abilities to create devastating combinations
- Positioning Mastery: Plan movement routes that maximize safety and effectiveness
- Death Recovery: Build recordings that account for potential character deaths
- Cycle Efficiency: Design recordings that smoothly transition between 2-minute cycles
Team Composition Strategy
- Tank-Healer-DPS: Maintain classic MMO role balance in each arena
- Class Synergy: Combine complementary abilities for enhanced effectiveness
- Backup Systems: Include redundant healing and protection in case of deaths
- Specialized Builds: Develop arena-specific team compositions for unique challenges
Combat Mechanics Deep Dive
Grid-Based Tactical Combat
- Tile Movement: All positioning occurs on discrete grid squares
- Line of Sight: Abilities and attacks can be blocked by terrain features
- Area of Effect: Many abilities affect multiple adjacent tiles
- Collision Rules: Multiple characters can occupy the same grid cell
- Environmental Hazards: Some tiles contain traps, buffs, or damage zones
Ability System Details
- Cooldown Management: Each ability has individual cooldown periods
- Resource Costs: Some abilities consume mana, stamina, or special resources
- Cast Times: Abilities have animation periods during which characters are vulnerable
- Target Requirements: Abilities may require specific targets (enemies, allies, empty tiles)
- Upgrade Paths: Higher-tier characters possess enhanced ability versions
Death and Revival Mechanics
- Death Consequences: Characters lose levels and must restart from guild house
- Revival Abilities: Cardinals and other healers can resurrect fallen allies
- Grid-Based Revival: Revival spells target specific tiles rather than characters
- Timing Requirements: Revival must occur when dead character is present at target location
- Recording Integration: (Future) deaths and revivals will replay deterministically each cycle — derived from the intent timeline against the boss's fixed pattern, not stored as timeline events
Progression & Victory Conditions
Arena Mastery Progression
- Normal Tier: Basic boss mechanics and standard difficulty
- Heroic Tier: Enhanced boss abilities and additional mechanics
- Mythic Tier: Maximum challenge with complex multi-phase encounters
- Perfect Runs: Complete mastery demonstrated through flawless execution
Long-Term Goals
- Full Guild Development: Recruit and develop a full roster of 320 heroes across all classes
- Multi-Arena Excellence: Successfully manage all 8 arenas simultaneously
- Strategic Mastery: Create sophisticated recording combinations across multiple cycles
- Narrative Discovery: Uncover the deeper story behind the arena conflicts
Success Metrics
- Boss Defeat Frequency: Consistent victories across multiple 2-minute cycles
- Character Survival: Minimize deaths and level loss through strategic planning
- Efficiency Optimization: Achieve maximum damage/healing with minimal resource expenditure
- Creative Problem Solving: Develop innovative solutions to complex encounter mechanics
Tips for New Players
Essential Early Game Strategy
- Start Simple: Master basic movement and single-character combat before recording
- Learn One Arena: Focus on understanding one boss thoroughly before expanding
- Record Conservatively: Create safe, reliable recordings rather than ambitious ones
- Study Patterns: Observe boss timing carefully before committing to recordings
- Plan Positioning: Always end recordings in safe locations for the next cycle
Common Mistakes to Avoid
- Rushing Recordings: Take time to understand arena dynamics before committing
- Ignoring Death Positions: Don't leave ghosts in positions where they'll die repeatedly
- Overcomplicating Early: Simple, effective recordings outperform complex failures
- Neglecting Other Arenas: Passive arena management is crucial for overall progress
- Poor Resource Management: Balance advancement speed with character safety
Mastery Development Path
- Movement Mastery: Perfect grid-based positioning and timing
- Single-Character Combat: Excel with individual heroes before team coordination
- Basic Recording: Create simple, effective 2-minute action sequences
- Multi-Character Coordination: Layer multiple recordings for enhanced effectiveness
- Advanced Strategy: Develop sophisticated cross-arena management techniques
Design Philosophy & Player Experience
Intended Experience
Arenic transforms the complexity of managing massive raids into a solo experience that rewards strategic thinking, pattern recognition, and creative problem-solving. The game emphasizes:
- Tactical Depth: Every decision has cascading consequences across multiple timelines
- Creative Expression: Players develop unique solutions through recording combinations
- Progressive Mastery: Continuous improvement through iteration and refinement
- Strategic Patience: Success rewards careful planning over reactive gameplay
Emotional Journey
- Discovery Phase: Wonder and experimentation with the recording system
- Mastery Phase: Growing confidence as patterns become familiar
- Coordination Phase: Satisfaction from successful multi-arena management
- Innovation Phase: Creative fulfillment from developing advanced strategies
The game rewards both analytical optimization and creative experimentation, providing a deeply satisfying experience for players who enjoy complex strategic challenges presented through elegant, accessible mechanics.
Technical Architecture Principles
Selected Character Query Pattern
Architectural Rule: All recording system operations query for the selected character (the Selected marker component) dynamically rather than storing or passing character entities as parameters.
Implementation Principle
#![allow(unused)] fn main() { // ✅ CORRECT - Query the Selected character when needed pub enum RecordingRequest { Start, // Query the Selected character when processing Stop { reason: StopReason }, ShowModal, // Query the Selected character when showing the modal Commit, // Query the Selected character when committing Clear, // Query the Selected character when clearing } // ❌ INCORRECT - Storing/passing entities pub enum RecordingRequest { Start { entity: Entity }, // Don't store entities ShowModal { character: Entity }, // Don't pass character data } }
Rationale
- Single Source of Truth: The
Selectedmarker determines which character receives input - R Key Behavior: "R always operates on whoever is CURRENTLY selected when pressed"
- Dynamic Modal Behavior: The modal always reflects the currently selected character
- Type Safety: The
Singlesystem param guarantees exactly one selected character - Simplicity: Eliminates entity synchronization and parameter passing complexity
Query Pattern
#![allow(unused)] fn main() { // Standard query for selected-character operations (Bevy 0.18 Single param) selected_q: Single<(Entity, Option<&Ghost>), With<Selected>> // Usage in recording systems let (character_entity, ghost_marker) = *selected_q; }
Benefits
- Architectural Consistency: All recording operations follow the same pattern
- Reduced Coupling: No entity parameters between systems
- Automatic Updates: Modals and UI automatically reflect the currently selected character
- Performance: O(1) queries with minimal overhead
- Maintainability: Eliminates entity storage synchronization bugs
Application Scope
This pattern applies to:
- All
RecordingRequestvariants - Modal state management (modal open ⇒ only the selected arena's clock pauses)
- Recording state tracking
- UI systems that need character context
- Any system that operates on "the current character"
Conclusion
Arenic offers a unique gaming experience that combines the strategic depth of MMO raiding with innovative single-player mechanics. Through mastering the Record & Replay system, understanding character class synergies, and developing sophisticated multi-arena strategies, players can achieve the satisfaction of commanding massive coordinated raids while maintaining complete control over every aspect of their guild's performance.
Success in Arenic comes not from quick reflexes, but from careful planning, pattern recognition, and the ability to think several moves ahead across multiple simultaneous battlefields. Every recording matters, every position counts, and every strategic decision ripples across your entire guild's effectiveness.
Welcome to Arenic—where strategic mastery meets creative expression in the ultimate raid simulation experience.
Units & Scale Reference — Camera, Game World, Blender, Window
A single source of truth for how pixels, game world units, and Blender units relate in Arenic. Use this whenever you author art (Blender → glTF), place entities, tune the camera, or reason about on-screen sizes.
STATUS — read first. This documents the target 3D camera/scale model, mirrored from the reference project
arenic_bevy(Bevy 0.16.1). As of 2026-06-01, thisarenicrepo (Bevy 0.18.1) is still a 2D placeholder —src/main.rsspawns only aCamera2dunder bareDefaultPlugins; the constants, perspective camera, and window override below do not exist in this repo'ssrc/yet. Treat this as the spec to implement. Every file path captionedarenic_bevy/...is in the reference repo, not here. All Rust snippets have been converted to Bevy 0.18 APIs (the reference's 0.16 code differs — see §8).
TL;DR
| Thing | Value |
|---|---|
| 1 game world unit | 1 meter by convention (Bevy doesn't enforce it; glTF mandates meters) = 1 Blender unit |
| Tile size | 0.25 units (a 0.25 × 0.25 × 0.25 cube) |
| Player / character | Sphere radius 0.125 → diameter 0.25 = one tile |
| Boss | Sphere radius 0.5 (0.125 × 4) → diameter 1.0 = 4 tiles across |
| Projectile | Sphere radius 0.0625 → diameter 0.125 = ½ tile |
| Window resolution | 1280 × 720 physical px (16:9) |
| Camera | Perspective, vertical FOV π/8 (22.5°), at z = 24 (in) / z = 72 (out) |
| On-screen scale | ≈ 75.4 px / unit → 1 tile ≈ 19 px (zoomed in) |
A "19 × 19 px" player token = one tile = a 0.25-unit-diameter shape.
The 19 px is emergent from the camera, not an authored constant.
Mental model
The whole game is a flat board on the XY plane viewed by a camera parked far back on +Z, looking straight down −Z. Because the board is perpendicular to the view and the lens is narrow (telephoto-like), the perspective camera behaves almost exactly like an orthographic one: a fixed number of pixels per world unit at a given zoom.
camera (eye at z = 24, looks toward −Z)
|\
| \ vertical FOV = 22.5°
| \
visible | \___ visible_height = 2·d·tan(fov/2)
height <----|---/ = 2·24·tan(11.25°) = 9.5478 units
maps to | / → mapped onto 720 px → 75.4 px/unit
720 px | /
|/
==============+============== arena on the XY plane
0 8.125 16.5 (16.5 × 7.75 units, +Z points at camera)
x →
Everything else in this doc is just turning that picture into numbers.
1. Game world units
Bevy world space is right-handed, Y-up: +X right, +Y up, +Z toward the
viewer (so a camera's "forward" is −Z). Arenic lays the board flat on the
XY plane, which is why +Z "points at the camera."
"1 unit = 1 meter" is a convention Arenic adopts (so physics, lighting, and glTF imports behave naturally), not something Bevy enforces — Bevy and Godot leave the meaning of a unit to you. glTF does mandate meters, which is what makes Blender↔Bevy round-trip cleanly (see §5).
All gameplay distances derive from a single constant
(arenic_bevy/src/arena/constants.rs):
#![allow(unused)] fn main() { pub const GRID_WIDTH: u32 = 66; pub const GRID_HEIGHT: u32 = 31; pub const TILE_SIZE: f32 = 0.25; // units per tile pub const ARENA_WIDTH: f32 = GRID_WIDTH as f32 * TILE_SIZE; // 16.50 units pub const ARENA_HEIGHT: f32 = GRID_HEIGHT as f32 * TILE_SIZE; // 7.75 units }
- Tiles are full cubes:
Cuboid::new(TILE_SIZE, TILE_SIZE, TILE_SIZE). - One arena =
66 × 31tiles =16.5 × 7.75units. There are9arenas in a3 × 3layout (TOTAL_ARENAS = 9), tiled byARENA_WIDTH/ARENA_HEIGHT. - Grid → world:
get_local_tile_space(col, row, z) = Vec3::new(col * 0.25, row * 0.25, z). Notecol/roware scaled byTILE_SIZEbutzis raw units — callers pass the entity's radius aszso a sphere sits centered one radius above the board (e.g. a character atz = 0.125, a boss atz = 0.5).
Entity sizes (all spheres in the reference)
| Entity | Radius (units) | Diameter | In tiles | ≈ px @ zoom-in |
|---|---|---|---|---|
| Character / Player | 0.125 (= TILE_SIZE/2) | 0.25 | 1 × 1 | ≈ 19 px |
| Boss | 0.5 (= 0.125 × 4) | 1.0 | 4 across | ≈ 75 px |
| Projectile | 0.0625 (= TILE_SIZE/4) | 0.125 | ½ × ½ | ≈ 9.4 px |
The player is exactly one tile wide (radius = ½ tile). Any new player art must keep a
0.25-unit footprint to stay grid-aligned and read as ~19 px.
2. Window resolution & pixel kinds (logical vs physical)
#![allow(unused)] fn main() { // Bevy 0.18 — note u32, not f32 (0.16 took floats): WindowResolution::new(1280, 720) // 16:9, PHYSICAL pixels }
In Bevy 0.18, WindowResolution::new(physical_width: u32, physical_height: u32)
sets physical pixels — the actual size of the render target / framebuffer the
camera draws into. There are two pixel kinds, and mixing them up is the classic
HiDPI footgun:
| Kind | What it is | In Arenic |
|---|---|---|
| Physical pixels | Real pixels in the framebuffer / on the panel | 1280 × 720 (pinned by new) |
| Logical pixels | DPI-independent "CSS px"; what UI layout uses | physical / scale_factor |
The documented relationship is physical = logical × scale_factor, where
scale_factor (a.k.a. device-pixel-ratio) comes from the monitor/OS.
Which pixels is "19 px"? Physical. The camera projects into the framebuffer, and
new(1280, 720)pins that framebuffer at720physical rows regardless of monitor DPI. So one tile is ~19 physical framebuffer pixels on every display. On a Retina/HiDPI Mac (scale_factor = 2.0) the window merely appears physically smaller and its logical size is640 × 360(so the tile is ~9.5 logical px), but the render resolution — and the 75.4 px/unit math — stays anchored to the 720 physical rows.⚠️ This only holds because the resolution is pinned in physical pixels. If you instead drive the window by a logical size or make it freely resizable, the framebuffer becomes
logical × scale_factorand the tile's physical px count would scale with DPI. Pin physical px if you want the 19 px to be stable.
If the framebuffer height changes, px/unit scales linearly with height (the
vertical FOV is fixed), so all figures below scale by framebuffer_height / 720.
3. Camera
#![allow(unused)] fn main() { use std::f32::consts::FRAC_PI_8; // Bevy 0.18: components, not a *Bundle. Projection is a Component enum. commands.spawn(( Camera3d::default(), Projection::Perspective(PerspectiveProjection { fov: FRAC_PI_8, // 22.5° — the VERTICAL field of view, in radians near: 0.05, far: 150.0, ..default() // aspect_ratio is auto-managed — see the gotcha below }), // eye at (cx, cy, 24), looking straight down −Z onto the board: Transform::from_xyz(cx, cy, 24.0).looking_at(Vec3::new(cx, cy, 0.0), Vec3::Y), )); // Reference (arenic_bevy/src/arena_camera/camera.rs): pub const ZOOM: (f32, f32) = (24.0, 72.0); // (zoomed-in z, zoomed-out z) }
- The camera sits at
(cx, cy, z)andlook_ats(cx, cy, 0)— eye and target share X/Y and differ only in Z, so the view direction is exactly(0, 0, −1): straight down −Z onto the XY board. Its z-distance is the "zoom":24framed on one arena,72to frame all nine (re-centered on arena index 4). - It's a true Perspective projection, but the board is a flat plane perpendicular to the view and the lens is narrow, so it reads as near-orthographic (quantified below).
Two FOV gotchas
fovis the vertical FOV (Bevy convention), in radians. Horizontal FOV follows from the aspect ratio:hFOV = 2·atan(tan(vFOV/2) · width/height).- The hardcoded
aspect_ratiois dead config. Bevy'scamera_systemoverwritesPerspectiveProjection.aspect_ratioevery frame with the actual viewportwidth/height. So the reference'saspect_ratio: 16.0/9.0literal is cosmetic (it only seeds frame 0). A useful consequence: because Bevy keeps the camera aspect equal to the viewport aspect, px/unit is identical on both axes by construction — there's no anamorphic (non-uniform) stretch. Tunefov, not the aspect literal.
Deriving pixels-per-unit (the "19 px" number)
At zoom distance d, the visible vertical extent on the board is:
visible_height = 2 · d · tan(fov / 2)
= 2 · 24 · tan(11.25°)
= 9.5478 units (shown rounded to 9.55 elsewhere)
Map onto the 720-physical-px-tall framebuffer:
px_per_unit = 720 / 9.5478 ≈ 75.4 px/unit
1 tile = 0.25 units → 0.25 × 75.41 = 18.85 ≈ 19 px
player = 0.25 units (1 tile) ≈ 19 px diameter
boss = 1.0 units (4 tiles) ≈ 75 px diameter
projectile = 0.125 units (½ tile) ≈ 9.4 px diameter
Re-derive with the unrounded
9.5478. Dividing by the rounded9.55gives75.39, not75.4— a cosmetic discrepancy, not an error.
No-distortion proof. Horizontally, visible_width = 9.5478 × 16/9 = 16.974
units → 1280 / 16.974 = 75.41 px/unit. That's identical to the vertical
75.41 (difference exactly 0), so pixels are square and the image is not
stretched — guaranteed by the aspect auto-match above.
| Zoom | Camera z | Visible h (units) | Visible w (units) | px / unit | 1 tile | Frames |
|---|---|---|---|---|---|---|
| In | 24 | 9.5478 | 16.974 | 75.41 | ≈ 19 px | one arena (16.5×7.75 fits, width-bound, ~0.47u margin) |
| Out | 72 | 28.643 | 50.92 | 25.14 | ≈ 6.28 px | full 3×3 (49.5×23.25) |
Rule of thumb at default zoom:
1 tile ≈ 19 px,≈ 75 px per unit.
Why z = 24 and z = 72? (reverse-derivation)
These aren't arbitrary — they're the distances that frame the content, with a
small margin. Solve visible_width = target_width for d:
d_fit = target_width / (2 · tan(fov/2) · aspect)
zoom-in : 16.5 / (2·tan(11.25°)·16/9) = 23.33 units → rounded up to 24
zoom-out: 49.5 / (2·tan(11.25°)·16/9) = 69.99 units → 72 (= exactly 3 × 24)
- Width is the binding dimension (the arena is wider relative to 16:9 than it is tall), so framing is set by width; height always has slack.
24rounds23.33up to a clean number, leaving a deliberate ~2.9% (0.47-unit total) safety margin so arena edges aren't flush with the screen.72 = 3 × 24frames the3 × 3tiling cleanly and preserves the same ~2.9% margin. Changefov,TILE_SIZE, the grid, or the aspect and these recompute.
How near-orthographic, exactly?
This perspective-at-distance trick is perspective / telephoto compression
("flattening"): a narrow FOV from far away minimizes foreshortening. The 22.5°
vertical FOV is equivalent to a ~60 mm lens on a full-frame (24 mm-tall)
sensor — a short-telephoto/portrait focal length — i.e. focal = 12 / tan(11.25°) ≈ 60 mm. (A 0° FOV would be exactly orthographic.)
The residual error is tiny: a character sphere spans z = −0.125 … +0.125, so
its near face is 23.875 from the camera and its far face 24.125. Apparent
size ∝ 1/distance, so near-vs-far size ratio = 24.125 / 23.875 = 1.0105 —
about 1% across the object's whole depth (< 0.2 px on a 19 px token).
Conclusion: treat px/unit as constant at a given zoom. (If you ever want it
mathematically exact, switch to Projection::Orthographic.)
4. Why the precision matters: local space & UI
This is the reason TILE_SIZE is a clean, uniform 0.25 rather than some
arbitrary value — it has to keep two systems in exact agreement: the
local-space hierarchy (how entities are positioned) and the UI (how
screen overlays line up with them).
Scope check (honesty). The reference repo (
arenic_bevy) realizes the local-space half fully, but has no screen-space UI at all — itsuimodule is empty; it renders only 3D meshes plus one world-space gizmo border. The UI half (§4.2) is therefore the integration contract for when a UI layer overlays the grid — which matters because thisarenicworkspace already ships a UI layer (crates/arenic_game/src/ui.rs+ the scene crates) while the 3D arena/grid isn't ported here yet. §4 is where the two will meet.
4.1 Local space — the realized reason for precision
The scene is a three-level parent/child hierarchy (ChildOf + Children):
BattleGround (root, Transform default)
└── Arena ×9 Transform = WORLD offset: (col·16.5, −row·7.75, 0)
├── tiles Transform = LOCAL: (col·0.25, row·0.25, 0)
└── characters/ Transform = LOCAL: get_local_tile_space(col, row, radius)
bosses
Children hold local transforms; Bevy's transform propagation computes
GlobalTransform = arena_world_offset + local. Placement is always local:
#![allow(unused)] fn main() { // arenic_bevy/src/arena/mod.rs pub fn get_local_tile_space(col: f32, row: f32, z: f32) -> Vec3 { Vec3::new(col * TILE_SIZE, row * TILE_SIZE, z) // z is a raw lift (= radius), NOT scaled } }
Why a clean 0.25 is what makes this work:
- Drift-free snapping.
0.25 = 2⁻²is exactly representable in IEEE-754, so integer grid coordNmaps to localN·0.25with zero rounding error, and movement (translation += dir * TILE_SIZE, ±0.25/step) never accumulates drift — 65 successive+0.25steps equal16.25exactly. A messy size like0.3would de-center entities over time. Characters stay dead-center on tiles. - One layout, nine arenas. The same constants and the same
get_local_tile_spacebuild every arena's grid and place characters identically — because child coords are local, the same local position is correct in any arena. - Edge-to-edge tiling. Each arena's world offset is an exact integer multiple
of
ARENA_WIDTH = 16.5/ARENA_HEIGHT = 7.75, so the 3×3 arenas abut with no gap or overlap and the camera framing (col·ARENA_WIDTH) lands perfectly. - Clean seams on reparenting. Walk off an edge and the character is
re-
ChildOf'd to the neighbor arena and teleported to the opposite local edge (max_x = (GRID_WIDTH−1)·0.25 = 16.25, exact) — no sub-tile seam offset. - Free zoom / repositioning. Moving or zooming an arena edits only the Arena
entity's
Transform; children never move (the zoom-out in §3 is purely a camera move).
Rule: write
Transform(local) to place things on the grid; readGlobalTransformwhen you need true world coordinates (e.g. cross-arena distance/targeting, asauto_shotdoes). Confusing the two is the classic hierarchy bug.
4.2 UI — screen space, and how it aligns to the grid
The crux is a coordinate-space mismatch you must respect:
Bevy UI is a separate screen-space pass laid out in logical pixels — it does NOT inherit the 3D camera's projection. A
Nodesized withVal::Pxis positioned by the flexbox solver in logical px (top-left origin); the "≈19 px/tile" world scale from §3 is never applied to UI automatically.
Two consequences:
- The pixel kinds differ — ties back to §2.
The
≈19 px/tileis physical framebuffer px;Val::Pxis logical px. On a 2× display the same tile is≈9.5logical px. Hardcoding either number into UI breaks on the other DPI — so never hardcode "a tile is 19 px" in aNode. - Align by projecting, not by constants. To pin a UI element to a tile or
entity, project its world position through the camera. Bevy returns the result
in logical px, so it drops straight into
Val::Pxwith no scale-factor math:
#![allow(unused)] fn main() { // Pin a UI Node over a world entity. Bevy 0.18 (fallible system). fn track_entity_with_ui( camera: Single<(&Camera, &GlobalTransform)>, target: Single<&GlobalTransform, With<Character>>, mut label: Single<&mut Node, With<TrackingLabel>>, ) -> Result { let (cam, cam_xf) = *camera; let screen = cam.world_to_viewport(cam_xf, target.translation())?; // logical px, top-left label.left = Val::Px(screen.x); label.top = Val::Px(screen.y); Ok(()) } }
Why precise, uniform sizing makes UI tractable:
- Every tile is identical and the camera scale is fixed, so every tile projects to the same logical-px size at a given zoom — UI authored in tile multiples (a 1-tile cursor, a selection ring, a 1-tile-tall health bar) lines up consistently across the whole board.
- Positions are grid-snapped in local space (§4.1), so their projected screen positions are stable and quantized — attached UI doesn't jitter sub-pixel.
Gotchas when projecting:
UiScale ≠ 1.0multipliesVal::Pxa second time (UI-only). If you set it, divide the projected value byUiScale.- Don't feed physical inputs into
Val::Px(e.g. rawphysical_viewport_sizeor physical cursor coords) without dividing byscale_factorfirst. world_to_viewportreturnsResult— handle off-screen/behind-camera via?.
Two ways to draw a grid overlay. A world-space overlay (a gizmo or mesh — like the reference's
Gizmos.rectarena border sizedARENA_WIDTH × ARENA_HEIGHT) lives in the 3D scene and inherits the 19 px/tile scale for free. A screen-space overlay (Node/TextHUD) does not — it must be projected. Use world-space for things glued to the board (tile highlights, ability ranges); screen-space for things glued to the screen (HUD, panels, tooltips).
5. Blender → game
1 Blender unit = 1 game unit = 1 meter. Model at true game scale so glTF imports need no rescaling.
Why the units round-trip: the glTF standard
The pipeline format is glTF 2.0 (GL Transmission Format, the Khronos open
standard, "the JPEG of 3D"). It is right-handed, +Y up, with all linear
distances in METERS — which is exactly why 1 Blender unit = 1 m = 1 game unit
survives export untouched. .gltf is JSON text; .glb is the single-file
binary container (what we use).
Axis / orientation gotcha (read this)
Blender is Z-up; glTF — and therefore Bevy — is Y-up. With the glTF
exporter's default +Y Up option on, Blender bakes a Z-up → Y-up axis
conversion to match the spec:
| In Blender (Z-up) | After glTF import into Bevy (Y-up) |
|---|---|
| Lies flat in XY, faces +Z | Lies flat in XZ, faces +Y (top-down) |
Footnote on "forward": glTF defines an asset's front as +Z, while Bevy defines
Transform::forward()as −Z. The importer reconciles this; you rarely touch it, but it's why "forward" looks inverted between the two.
Arenic's board is on the XY plane with the camera on +Z, so a flat token that should face the camera needs to end up facing +Z in Bevy. Two ways:
- Fix at spawn (recommended, non-destructive):
#![allow(unused)] fn main() { Transform::from_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2)) } - Pre-rotate in Blender so it imports already facing +Z (then apply transforms to bake it in).
This token is a static flat mesh, oriented once at spawn — it is not a billboard. Billboarding re-rotates a quad every frame to track the camera; we don't need or want that here, because the camera looks straight down a fixed axis at a perpendicular plane.
Authoring checklist
- Scale to the footprint, not to pixels. A player token is a
0.25-unit-diameter shape (radius0.125). "19 px" is the result of that size under the camera in §3 — never model in pixels. (Unlike a 2D engine's per-asset Pixels-Per-Unit import setting — Unity defaults to 100 PPU — Arenic's ~75.4 px/unit is emergent from the camera, not a slider.) - Center the origin at the object's pivot (world origin) for clean transforms.
- Apply (a.k.a. freeze) transforms — Blender
Ctrl+A → All Transforms(Maya: Freeze Transformations) — bakes scale/rotation into the mesh and resets the object transform to identity. Caveat: do not apply transforms on armatures/skinned meshes — it can corrupt skin weights. Static tokens only. - Keep meshes low/clean and texel density modest. A tile is only ~19 px on screen, so high-res geometry/textures are wasted; keep texel density (texels per meter) consistent across tokens so the grid reads uniformly.
Export settings
- Format: glTF 2.0 Binary (
.glb). - Path:
assets/models/<name>.glb(loaded via BevySceneRoot). - Include: selected mesh, apply modifiers,
+Y Up(default — leave it on).
6. Quick conversions & worked example
units → tiles : units / 0.25
tiles → units : tiles * 0.25
units → pixels: units * 75.4 (default zoom, 720 physical px tall)
pixels → units : pixels / 75.4
pixels → tiles : pixels / 18.85
| You want… | Use |
|---|---|
| A 1-tile token (~19 px) | 0.25-unit diameter shape (radius 0.125) |
| A 2×2 token (~38 px) | 0.5-unit diameter (radius 0.25) |
Place at grid (col, row) | Vec3::new(col * 0.25, row * 0.25, z), z = radius |
| Boss-sized (~75 px) | 1.0-unit diameter (radius 0.5) |
Worked example — a 2×2-tile mini-boss token, end to end:
- Size: 2 tiles =
2 × 0.25 = 0.5units diameter → radius0.25. - On-screen:
0.5 × 75.4 ≈ 38physical px at zoom-in. - Blender: model it
0.5 macross, origin centered,Ctrl+A → All Transforms, exportassets/models/miniboss.glbwith+Y Up. - Spawn (Bevy 0.18): at grid
(col, row)resting on the board —#![allow(unused)] fn main() { commands.spawn(( SceneRoot(asset_server.load("models/miniboss.glb#Scene0")), Transform::from_translation(Vec3::new(col * 0.25, row * 0.25, 0.25)) // z = radius .with_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2)), // face +Z )); }
7. Glossary
- World unit — one coordinate unit;
1 unit = 1 mby convention here. - PPU (Pixels Per Unit) — texture px mapped to one world unit at scale 1 (a
2D-engine import setting; Unity defaults to 100). Arenic's
75.4 px/unitis the emergent camera analog, not an import constant — don't look for a slider. - Vertical / horizontal FOV — angular extent the camera sees, per axis.
Bevy's
fovis vertical;hFOV = 2·atan(tan(vFOV/2) · aspect). - View frustum / near & far clip planes — the truncated pyramid the camera
sees; geometry outside
near(0.05) …far(150) is clipped. The projection maps it through clip space → NDC (−1…+1) → the viewport. - Perspective / telephoto compression ("flattening") — narrow FOV at large distance approximating an orthographic look; the basis of the camera here.
- 35 mm-equivalent focal length — FOV expressed as the lens that gives the same view on a full-frame sensor; our 22.5° vFOV ≈ a ~60 mm short-telephoto.
- Logical vs physical pixels / scale_factor (DPI) —
physical = logical × scale_factor. Our px figures are physical (pinned byWindowResolution). - Z-up vs Y-up — Blender is Z-up; glTF/Bevy are Y-up; the exporter converts.
- Apply / Freeze transforms — bake an object's transform into its mesh and reset it to identity (skip for skinned meshes).
- Billboarding — per-frame rotating a quad to face the camera. We do not do this; our tokens are statically oriented.
- SceneRoot — Bevy 0.18 component that instantiates a loaded glTF scene.
8. Bevy 0.16 → 0.18 porting notes
The reference (arenic_bevy) is Bevy 0.16; this repo is 0.18. When
porting the scale/camera code here, consult the
bevy-018 skill and note at minimum:
WindowResolution::newtakesu32physical pixels in 0.18 (new(1280, 720)), notf32— the reference'snew(1280.0, 720.0)won't compile here.- No
*Bundletypes — spawn the camera as components (Camera3d,Projection::Perspective(..),Transform); required components fill the rest. PerspectiveProjection.aspect_ratiois auto-managed bycamera_system— don't rely on the hardcoded16.0/9.0; set the window aspect instead.- Re-verify every other Bevy symbol against the skill before assuming it ports unchanged (the pure-Rust constants in §1 do).
Known discrepancies & caveats (from source audit)
- Camera vertical centering is off by one tile. The reference hardcodes the
per-arena center base as
(8.125, 3.5). The X value8.125is exactly correct (= midpoint of tile-center X0…16.25). But the true Y midpoint is3.75(tile-center Y0…7.5), so3.5sits0.25units (oneTILE_SIZE) too low.draw_arena_borderpartially compensates with+TILE_SIZE/2 = 0.125(reaching3.625), still0.125short. If you port this, prefer3.75. - Startup arena is
GuildHouse(index 1), so the initial camera is offset oneARENA_WIDTHin X from the base — not arena 0. - Rows grow in −Y: per-arena
offset_yis subtracted, so the3×3grid descends in−Y.
Everything here flows from four inputs: TILE_SIZE, the framebuffer height,
the camera fov, and the ZOOM distances. If any change, recompute the derived
px/unit numbers — the formulas in §3 and §6 show how.