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::theme crate, 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

SurfaceSource of truth
Web referenceengmanager.xyz critical.css (@layer fp.tokens / fp.themes)
This booktheme-css/arenic.css (the CSS tokens, reused verbatim)
The game & storybookcrates/arenic_game/src/theme/ (Rust)
Live, interactivethe 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

  1. Primitives — a per-theme palette of raw oklch colours (base-100/200/300, base-content, primary, secondary, accent, neutral, and the status colours), plus structural knobs --border, --depth and the --radius-* values. Nine palettes ship: light, dark, synthwave, cyberpunk, forest, lo-fi, dracula, catppuccin, luxury.
  2. 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.
  3. 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):

surface-1
surface-2
surface-3
text-1
text-2
text-muted
border-subtle
border-strong
brand
focus-ring
selected-tint
hover-tint

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.

TokenRustpx (game)Use
--font-size-00F0012.8fine print, captions
--font-size-0F016body baseline
--font-size-1F120lead, sub-nav
--font-size-2F225card titles
--font-size-3F331section heads
--font-size-4F439page heads
--font-size-5–8F5–F849–95display

The scale

F0 · 16The quick brown fox
F1 · 20The quick brown fox
F2 · 25The quick brown fox
F3 · 31The quick brown fox
F4 · 39Arenic
F5 · 49Arenic
F6 · 61Arenic

Families & weights

  • Sans / body: Archivo, then system-ui fallbacks (--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}.

3xs · 5
2xs · 10
xs · 15
s · 20
m · 30
l · 40
xl · 60
2xl · 80
3xl · 120

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).

3xs · 3
2xs · 4
xs · 6
s · 8
m · 12
l · 16
xl · 24
2xl · 32

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).

shadow-1
shadow-2
shadow-3
shadow-4
shadow-5
hard 3,3,0

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:

PresetDurationEasingFor
--motion-press80ms (fastest)ease-3button press
--motion-hover150ms (fast)ease-3hover states
--motion-enter250ms (base)ease-out-3elements entering
--motion-exit150ms (fast)ease-in-3elements leaving
--motion-emphasized400ms (slow)ease-spring-2emphasized, 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

  1. Arena Selection: Begin in the Guild House (arena 1) with your starting hero
  2. Movement: Use the arrow keys to move your hero one tile at a time on the grid
  3. Character Selection: Press Tab to switch between available heroes in your current arena
  4. 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

InputAction
Arrow KeysMove selected character (tile-by-tile grid movement)
TabSwitch to next character in current arena
Shift+TabSwitch to previous character in current arena
1-4Activate character abilities (when available)
RRecord 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)
PToggle between single-arena view and all-arenas overview
EscCancel an open modal / return to the title screen
EnterOpen guild house management menu
MouseArena selection and UI interaction (optional — every modal is fully keyboard-driven)
SpaceInteract with objects and chests

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, using strict_*/wrapping_*/% arithmetic
  • An event is intent, not position: TimelineEvent { tick: u32, action: Action } with Action::Move(IVec2) or Action::Ability(u8) — tiny and Copy
  • A recording is Recording { start, events: Arc<[TimelineEvent]> } — the start tile anchors every replay; Arc makes 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:

IndexArena NameGrid PositionTheme
0LabyrinthTop-Left (0,0)Hunter — precision, traps, sightlines
1Guild HouseTop-Center (1,0)Guildmaster — the guild's home & safe hearth (not a combat arena)
2SanctumTop-Right (2,0)Cardinal — divine magic
3MountainMiddle-Left (0,1)Forager — terrain, geology, foraging
4BastionMiddle-Center (1,1)Warrior — strength & defense (the fortress)
5PawnshopMiddle-Right (2,1)Thief — stealth & fencing
6CrucibleBottom-Left (0,2)Alchemist — transformation (the vessel)
7CasinoBottom-Center (1,2)Merchant — economic warfare
8GalaBottom-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 ArenaName enum 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

  1. Labyrinth (Hunter): precision ranged attacks and deadly trap mechanics — the Hollow Obelisk
  2. Guild House (Guildmaster): the guild's home — a safe hearth, not a true boss; an abstract house/pyramid object
  3. Sanctum (Cardinal): healing denial, purification attacks, and divine shields — the Torus Halo
  4. Mountain (Forager): dynamically reshapes the battlefield through terrain manipulation and foraging — the Stepped Pyramid / Ziggurat
  5. Bastion (Warrior): heavily armored defenses, charge attacks, and area damage — the Hexagonal Prism (the fortress)
  6. Pawnshop (Thief): stealth mechanics, teleportation strikes, and ambush tactics — the Triangular Prism (wedge)
  7. Crucible (Alchemist): elemental transformation and area denial through toxic pools — the Truncated Cone (the vessel)
  8. Casino (Merchant): risk-reward mechanics and economic warfare strategies — the Hollow Icosphere (geode)
  9. 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

  1. Normal Tier: Basic boss mechanics and standard difficulty
  2. Heroic Tier: Enhanced boss abilities and additional mechanics
  3. Mythic Tier: Maximum challenge with complex multi-phase encounters
  4. 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

  1. Start Simple: Master basic movement and single-character combat before recording
  2. Learn One Arena: Focus on understanding one boss thoroughly before expanding
  3. Record Conservatively: Create safe, reliable recordings rather than ambitious ones
  4. Study Patterns: Observe boss timing carefully before committing to recordings
  5. 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

  1. Movement Mastery: Perfect grid-based positioning and timing
  2. Single-Character Combat: Excel with individual heroes before team coordination
  3. Basic Recording: Create simple, effective 2-minute action sequences
  4. Multi-Character Coordination: Layer multiple recordings for enhanced effectiveness
  5. 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

  1. Single Source of Truth: The Selected marker determines which character receives input
  2. R Key Behavior: "R always operates on whoever is CURRENTLY selected when pressed"
  3. Dynamic Modal Behavior: The modal always reflects the currently selected character
  4. Type Safety: The Single system param guarantees exactly one selected character
  5. 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 RecordingRequest variants
  • 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, this arenic repo (Bevy 0.18.1) is still a 2D placeholder — src/main.rs spawns only a Camera2d under bare DefaultPlugins; the constants, perspective camera, and window override below do not exist in this repo's src/ yet. Treat this as the spec to implement. Every file path captioned arenic_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

ThingValue
1 game world unit1 meter by convention (Bevy doesn't enforce it; glTF mandates meters) = 1 Blender unit
Tile size0.25 units (a 0.25 × 0.25 × 0.25 cube)
Player / characterSphere radius 0.125 → diameter 0.25 = one tile
BossSphere radius 0.5 (0.125 × 4) → diameter 1.0 = 4 tiles across
ProjectileSphere radius 0.0625 → diameter 0.125 = ½ tile
Window resolution1280 × 720 physical px (16:9)
CameraPerspective, 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 × 31 tiles = 16.5 × 7.75 units. There are 9 arenas in a 3 × 3 layout (TOTAL_ARENAS = 9), tiled by ARENA_WIDTH/ARENA_HEIGHT.
  • Grid → world: get_local_tile_space(col, row, z) = Vec3::new(col * 0.25, row * 0.25, z). Note col/row are scaled by TILE_SIZE but z is raw units — callers pass the entity's radius as z so a sphere sits centered one radius above the board (e.g. a character at z = 0.125, a boss at z = 0.5).

Entity sizes (all spheres in the reference)

EntityRadius (units)DiameterIn tiles≈ px @ zoom-in
Character / Player0.125 (= TILE_SIZE/2)0.251 × 1≈ 19 px
Boss0.5 (= 0.125 × 4)1.04 across≈ 75 px
Projectile0.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:

KindWhat it isIn Arenic
Physical pixelsReal pixels in the framebuffer / on the panel1280 × 720 (pinned by new)
Logical pixelsDPI-independent "CSS px"; what UI layout usesphysical / 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 at 720 physical 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 is 640 × 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_factor and 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) and look_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": 24 framed on one arena, 72 to 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

  1. fov is the vertical FOV (Bevy convention), in radians. Horizontal FOV follows from the aspect ratio: hFOV = 2·atan(tan(vFOV/2) · width/height).
  2. The hardcoded aspect_ratio is dead config. Bevy's camera_system overwrites PerspectiveProjection.aspect_ratio every frame with the actual viewport width/height. So the reference's aspect_ratio: 16.0/9.0 literal 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. Tune fov, 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 rounded 9.55 gives 75.39, not 75.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.

ZoomCamera zVisible h (units)Visible w (units)px / unit1 tileFrames
In249.547816.97475.41≈ 19 pxone arena (16.5×7.75 fits, width-bound, ~0.47u margin)
Out7228.64350.9225.14≈ 6.28 pxfull 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.
  • 24 rounds 23.33 up 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 × 24 frames the 3 × 3 tiling cleanly and preserves the same ~2.9% margin. Change fov, 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 — its ui module 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 this arenic workspace 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 coord N maps to local N·0.25 with zero rounding error, and movement (translation += dir * TILE_SIZE, ±0.25/step) never accumulates drift — 65 successive +0.25 steps equal 16.25 exactly. A messy size like 0.3 would de-center entities over time. Characters stay dead-center on tiles.
  • One layout, nine arenas. The same constants and the same get_local_tile_space build 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; read GlobalTransform when you need true world coordinates (e.g. cross-arena distance/targeting, as auto_shot does). 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 Node sized with Val::Px is 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:

  1. The pixel kinds differ — ties back to §2. The ≈19 px/tile is physical framebuffer px; Val::Px is logical px. On a 2× display the same tile is ≈9.5 logical px. Hardcoding either number into UI breaks on the other DPI — so never hardcode "a tile is 19 px" in a Node.
  2. 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::Px with 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.0 multiplies Val::Px a second time (UI-only). If you set it, divide the projected value by UiScale.
  • Don't feed physical inputs into Val::Px (e.g. raw physical_viewport_size or physical cursor coords) without dividing by scale_factor first.
  • world_to_viewport returns Result — 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.rect arena border sized ARENA_WIDTH × ARENA_HEIGHT) lives in the 3D scene and inherits the 19 px/tile scale for free. A screen-space overlay (Node/Text HUD) 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 +ZLies 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:

  1. Fix at spawn (recommended, non-destructive):
    #![allow(unused)]
    fn main() {
    Transform::from_rotation(Quat::from_rotation_x(std::f32::consts::FRAC_PI_2))
    }
  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 (radius 0.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 Bevy SceneRoot).
  • 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:

  1. Size: 2 tiles = 2 × 0.25 = 0.5 units diameter → radius 0.25.
  2. On-screen: 0.5 × 75.4 ≈ 38 physical px at zoom-in.
  3. Blender: model it 0.5 m across, origin centered, Ctrl+A → All Transforms, export assets/models/miniboss.glb with +Y Up.
  4. 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 m by 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/unit is 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 fov is 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 by WindowResolution).
  • 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::new takes u32 physical pixels in 0.18 (new(1280, 720)), not f32 — the reference's new(1280.0, 720.0) won't compile here.
  • No *Bundle types — spawn the camera as components (Camera3d, Projection::Perspective(..), Transform); required components fill the rest.
  • PerspectiveProjection.aspect_ratio is auto-managed by camera_system — don't rely on the hardcoded 16.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 value 8.125 is exactly correct (= midpoint of tile-center X 0…16.25). But the true Y midpoint is 3.75 (tile-center Y 0…7.5), so 3.5 sits 0.25 units (one TILE_SIZE) too low. draw_arena_border partially compensates with +TILE_SIZE/2 = 0.125 (reaching 3.625), still 0.125 short. If you port this, prefer 3.75.
  • Startup arena is GuildHouse (index 1), so the initial camera is offset one ARENA_WIDTH in X from the base — not arena 0.
  • Rows grow in −Y: per-arena offset_y is subtracted, so the 3×3 grid 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.