Static content system (runtime): sparse statics, server collision, client render #89

Closed
opened 2026-07-19 12:06:18 +02:00 by marco · 3 comments
Owner

Part of the Alpha epic (pillar 2). Split from the original #89: this is the runtime static-content system — data model + server collision + client render. The authoring tool is #113; the PvP arena (#109) is content authored on top of this; the large world map is post-alpha (#114).

Build it general and size-agnostic — map size is just a parameter, it must scale to a future 1000x1000 world with no code change (ModernUO-style). Statics are sparse, never a dense per-tile array.

Scope

  • Static model: { x, y, staticId, blocks }staticId names art in the pack static/ namespace; blocks = does it block movement.
  • Sparse storage: statics kept as a list / spatial index keyed by tile, NOT a dense WidthxHeight array (most tiles are empty). Terrain (land) stays the dense grid.
  • Content format: a base terrain layer (extend content/maps/world.map or a compact sibling) + a sparse statics file (x y staticId rows), both committed and copied to server & client output like world.map today.
  • Server collision: a blocking static makes its tile an obstacle in TileMap, so IsWalkable and the existing HasLineOfSight both see it — render and collision derive from the SAME data (a wall that draws must block, incl. for ranged LOS #111).
  • Client render: draw only visible statics (reuse GridRenderer.VisibleObstacles / viewport bounds), real pack art with placeholder fallback, correct iso depth order.
  • Multiple land tile types (grass/dirt/stone floor) selectable per tile, beyond walkable/obstacle.
  • Load validation: bounds, >=1 spawn, every staticId resolves to pack art, coherent flags — fail fast with a clear error, never corrupt the world.

Out of scope

  • The authoring tool (#113).
  • Static height/z-layering, multi-tile statics, animated statics (future).
  • Authoring the 1000x1000 world (#114, post-alpha).

Format decision (stay text for now)

Keep the on-disk authoring format textual and diffable — two committed files (dense terrain grid + sparse statics list), read/written by the editor (#113). Rationale: at friends scale nothing forces binary (1M terrain tiles ~1MB text, gzip-friendly; parse is a one-time ~tens of ms; statics are sparse), and a committed binary would kill PR review and merge of map content — the worst option is a hand-committed opaque blob.
Put the loader behind a swappable seam (TileMap.Load(path) via an IMapSource abstraction) so the format can change in ONE place without touching game logic. Binary is deferred to the large-world scale (#114) and, if adopted, as a source->compiled build step (author-friendly source committed + a just build-map producing the runtime binary), mirroring the existing assets.isoa pipeline — not a hand-committed blob.

Definition of Done

Base DoD applies on top.

  • A content file places statics (e.g. a wall run + pillars) and mixed land tiles; the server loads and validates them.
  • A blocking static rejects movement into its tile AND blocks line-of-sight through it (unit-tested against TileMap/World).
  • The client renders those statics and land types from the pack (placeholder fallback), only within the viewport, at correct iso depth.
  • Size-agnostic: a map far larger than the arena loads with no code change and keeps O(1) walkability lookups (test or note on a large synthetic map).
Part of the Alpha epic (pillar 2). Split from the original #89: this is the **runtime** static-content system — data model + server collision + client render. The authoring tool is #113; the PvP arena (#109) is content authored on top of this; the large world map is post-alpha (#114). > Build it **general and size-agnostic** — map size is just a parameter, it must scale to a future 1000x1000 world with no code change (ModernUO-style). Statics are **sparse**, never a dense per-tile array. ## Scope - Static model: `{ x, y, staticId, blocks }` — `staticId` names art in the pack `static/` namespace; `blocks` = does it block movement. - **Sparse** storage: statics kept as a list / spatial index keyed by tile, NOT a dense WidthxHeight array (most tiles are empty). Terrain (land) stays the dense grid. - Content format: a base terrain layer (extend `content/maps/world.map` or a compact sibling) + a **sparse statics file** (`x y staticId` rows), both committed and copied to server & client output like `world.map` today. - Server collision: a blocking static makes its tile an obstacle in `TileMap`, so `IsWalkable` **and** the existing `HasLineOfSight` both see it — render and collision derive from the SAME data (a wall that draws must block, incl. for ranged LOS #111). - Client render: draw only visible statics (reuse `GridRenderer.VisibleObstacles` / viewport bounds), real pack art with placeholder fallback, correct iso depth order. - Multiple land tile types (grass/dirt/stone floor) selectable per tile, beyond walkable/obstacle. - Load validation: bounds, >=1 spawn, every `staticId` resolves to pack art, coherent flags — fail fast with a clear error, never corrupt the world. ## Out of scope - The authoring tool (#113). - Static height/z-layering, multi-tile statics, animated statics (future). - Authoring the 1000x1000 world (#114, post-alpha). ## Format decision (stay text for now) Keep the on-disk authoring format **textual and diffable** — two committed files (dense terrain grid + sparse statics list), read/written by the editor (#113). Rationale: at friends scale nothing forces binary (1M terrain tiles ~1MB text, gzip-friendly; parse is a one-time ~tens of ms; statics are sparse), and a committed **binary would kill PR review and merge** of map content — the worst option is a hand-committed opaque blob. Put the loader behind a swappable seam (`TileMap.Load(path)` via an `IMapSource` abstraction) so the format can change in ONE place without touching game logic. Binary is deferred to the large-world scale (#114) and, if adopted, as a **source->compiled build step** (author-friendly source committed + a `just build-map` producing the runtime binary), mirroring the existing `assets.isoa` pipeline — not a hand-committed blob. ## Definition of Done _Base DoD applies on top._ - [ ] A content file places statics (e.g. a wall run + pillars) and mixed land tiles; the server loads and validates them. - [ ] A blocking static rejects movement into its tile AND blocks line-of-sight through it (unit-tested against `TileMap`/`World`). - [ ] The client renders those statics and land types from the pack (placeholder fallback), only within the viewport, at correct iso depth. - [ ] Size-agnostic: a map far larger than the arena loads with no code change and keeps O(1) walkability lookups (test or note on a large synthetic map).
marco added this to the Alpha milestone 2026-07-20 18:37:57 +02:00
marco changed title from Static content system: per-tile static art (houses, walls, …) beyond the single tree to Static content system (runtime): sparse statics, server collision, client render 2026-07-20 19:06:26 +02:00
Author
Owner

Technical design (agreed — critical-design-review)

Verdict: go. No wire/persistence/threading change; fits the existing TileMap/GridRenderer seams; size-agnostic. Owner decisions confirmed:

  • blocks via a StaticCatalog (Shared, keyed by staticId) — statics file stays terse x y staticId; a new static kind = one catalog entry + static/<id> art (extend-by-type).
  • Terrain = char grid (reuse world.map): chars → TerrainType via a table (. grass, ~ marsh/water, S spawn, room for dirt/stone); trees stop being a terrain type and become a tree blocking static in world.statics.
  • One static per tile (MVP); z-stacking deferred.

Design

  • Model (Shared): keep the dense TerrainType[,] grid (enum generalized, each terrain {walkable, artName}); add a sparse IReadOnlyDictionary<GridPosition, StaticTile> (StaticTile = (string StaticId, bool Blocks), Blocks from StaticCatalog).
  • Collision: IsWalkable(p) = terrain-walkable AND no blocking static; BlocksSight(p) = terrain-opaque OR blocking static → HasLineOfSight unchanged. OOB = impassable+opaque.
  • Format + seam: two committed textual files in content/maps/ — the terrain grid + world.statics (x y staticId). New TileMap.Load(mapPath, staticsPath) (keep Parse(text) for terrain-only unit tests); replace the two inline File.ReadAllText call sites; add the copy rule to the 3 csprojs (server, client, tests). Load validates: bounds, ≥1 spawn, known terrain chars + known static ids (fail fast).
  • Client render: convention static/<id>→texture dict (placeholder fallback) folded into the depth-sorted _worldSprites (keyed on TileFoot.Y), tree occlusion-fade generalized to tall statics; ground picks land/<terrain> art by type.

Invariants Check (CLAUDE.md ## Design checklist)

  1. Scope ✓ — runtime static system only; #113/#109/#114 separate.
  2. Server-authoritative ✓ — collision from the one server TileMap+statics; statics offline-authored (no client placement, no new spammable intent); move/drop/spawn/LOS reject blocking-static tiles.
  3. GM authorization — N/A — no admin action added.
  4. Identity — N/A — no account/character/JWT path.
  5. Protocol versioned ✓ — NO wire change: statics loaded from committed content on both sides (like world.map), never sent → no ProtocolVersion bump.
  6. String catalog — N/A — no player-facing text (load errors = server logs).
  7. Single-threaded sim ✓ — map+statics built at startup, read-only in ticks, no lock, no tick file I/O.
  8. World.cs HARD GATE ✓ — collision stays in TileMap/MovementSystem; World only holds the injected TileMap.
  9. Screen HARD GATE ✓ — render in GridRenderer/WorldRenderer, not a Screen.
  10. Client engine-independence ✓ — model/format/collision in Shared (unit-tested, no MonoGame); culling in Client.Core; only draws in Client.
  11. Gameplay/Networking separation ✓ — TileMap is gameplay; Networking untouched.
  12. Act on the instance ✓ — statics are immutable authored data; the dict IS the authoritative store, no shadow collection.
  13. Extend by type, not switch ✓ — new static = StaticCatalog entry + convention art; new terrain = enum + land/<terrain>; char→terrain becomes a table, lookup by id not switch.
  14. Server-paced actions — N/A — loads once at startup.
  15. Persistence (GameServer) ✓ — statics are authored CONTENT, not world.sav entities: no blob/version/tick-I/O.
  16. Persistence (Auth) — N/A — no EF/SQLite schema.
  17. Process separation ✓ — server (collision) + client (render) each load their committed copy; no new contract; JWT unaffected.
  18. Typed options ✓ — reuse GameOptions.ContentRoot; statics filename a const declared once; no IConfiguration in services.
  19. Broadcasts/AoI — N/A — statics not broadcast (client-loaded content), no InterestManager/Diff change.
  20. Multi-platform ✓ — pure .NET + textual files + Path.Combine.
  21. Asset fallback ✓ — missing static/<id> → PlaceholderArt; missing statics file → empty; runnable without assets.isoa.
  22. ModernUO ✓ — follow dense-land + sparse-statics + flag catalog; diverge on no-z/textual-format/no-.mul (friends-scale + diffable-content).
  23. Docs & DoD ✓ — same change updates architecture.md + a map-format doc + this check on the issue.

No ✗, no HARD GATE tripped.

Risks (mitigated)

  • #=tree is terrain today → becomes a static: format-breaking for world.map + TileMapTests; migrate the map (terrain-only + world.statics trees) and update tests.
  • HasLineOfSight must consult blocking statics (walls stop arrows #111).
  • Both content files copied to server+client+tests (drift = render≠collision on a client, cosmetic only — collision is server-authoritative).
  • Tall statics need foot-anchor + occlusion-fade (#66).
  • Deferred: z-stacking, per-placement blocks override, binary compiled format (#114).

Implementation to follow on a branch.

# Technical design (agreed — `critical-design-review`) **Verdict: go.** No wire/persistence/threading change; fits the existing `TileMap`/`GridRenderer` seams; size-agnostic. Owner decisions confirmed: - **`blocks` via a `StaticCatalog`** (Shared, keyed by `staticId`) — statics file stays terse `x y staticId`; a new static kind = one catalog entry + `static/<id>` art (extend-by-type). - **Terrain = char grid** (reuse `world.map`): chars → `TerrainType` via a table (`.` grass, `~` marsh/water, `S` spawn, room for dirt/stone); **trees stop being a terrain type** and become a `tree` blocking static in `world.statics`. - **One static per tile** (MVP); z-stacking deferred. ## Design - **Model (Shared):** keep the dense `TerrainType[,]` grid (enum generalized, each terrain `{walkable, artName}`); add a **sparse** `IReadOnlyDictionary<GridPosition, StaticTile>` (`StaticTile = (string StaticId, bool Blocks)`, `Blocks` from `StaticCatalog`). - **Collision:** `IsWalkable(p)` = terrain-walkable AND no blocking static; `BlocksSight(p)` = terrain-opaque OR blocking static → `HasLineOfSight` unchanged. OOB = impassable+opaque. - **Format + seam:** two committed textual files in `content/maps/` — the terrain grid + `world.statics` (`x y staticId`). New `TileMap.Load(mapPath, staticsPath)` (keep `Parse(text)` for terrain-only unit tests); replace the two inline `File.ReadAllText` call sites; add the copy rule to the 3 csprojs (server, client, tests). Load validates: bounds, ≥1 spawn, known terrain chars + known static ids (fail fast). - **Client render:** convention `static/<id>`→texture dict (placeholder fallback) folded into the depth-sorted `_worldSprites` (keyed on `TileFoot.Y`), tree occlusion-fade generalized to tall statics; ground picks `land/<terrain>` art by type. ## Invariants Check (CLAUDE.md `## Design checklist`) 1. Scope ✓ — runtime static system only; #113/#109/#114 separate. 2. Server-authoritative ✓ — collision from the one server `TileMap`+statics; statics offline-authored (no client placement, no new spammable intent); move/drop/spawn/LOS reject blocking-static tiles. 3. GM authorization — N/A — no admin action added. 4. Identity — N/A — no account/character/JWT path. 5. Protocol versioned ✓ — NO wire change: statics loaded from committed content on both sides (like world.map), never sent → no ProtocolVersion bump. 6. String catalog — N/A — no player-facing text (load errors = server logs). 7. Single-threaded sim ✓ — map+statics built at startup, read-only in ticks, no lock, no tick file I/O. 8. World.cs HARD GATE ✓ — collision stays in TileMap/MovementSystem; World only holds the injected TileMap. 9. Screen HARD GATE ✓ — render in GridRenderer/WorldRenderer, not a Screen. 10. Client engine-independence ✓ — model/format/collision in Shared (unit-tested, no MonoGame); culling in Client.Core; only draws in Client. 11. Gameplay/Networking separation ✓ — TileMap is gameplay; Networking untouched. 12. Act on the instance ✓ — statics are immutable authored data; the dict IS the authoritative store, no shadow collection. 13. Extend by type, not switch ✓ — new static = StaticCatalog entry + convention art; new terrain = enum + `land/<terrain>`; char→terrain becomes a table, lookup by id not switch. 14. Server-paced actions — N/A — loads once at startup. 15. Persistence (GameServer) ✓ — statics are authored CONTENT, not world.sav entities: no blob/version/tick-I/O. 16. Persistence (Auth) — N/A — no EF/SQLite schema. 17. Process separation ✓ — server (collision) + client (render) each load their committed copy; no new contract; JWT unaffected. 18. Typed options ✓ — reuse GameOptions.ContentRoot; statics filename a const declared once; no IConfiguration in services. 19. Broadcasts/AoI — N/A — statics not broadcast (client-loaded content), no InterestManager/Diff change. 20. Multi-platform ✓ — pure .NET + textual files + Path.Combine. 21. Asset fallback ✓ — missing `static/<id>` → PlaceholderArt; missing statics file → empty; runnable without assets.isoa. 22. ModernUO ✓ — follow dense-land + sparse-statics + flag catalog; diverge on no-z/textual-format/no-.mul (friends-scale + diffable-content). 23. Docs & DoD ✓ — same change updates architecture.md + a map-format doc + this check on the issue. No ✗, no HARD GATE tripped. ## Risks (mitigated) - `#`=tree is terrain today → becomes a static: **format-breaking** for `world.map` + `TileMapTests`; migrate the map (terrain-only + `world.statics` trees) and update tests. - `HasLineOfSight` must consult blocking statics (walls stop arrows #111). - Both content files copied to server+client+tests (drift = render≠collision on a client, cosmetic only — collision is server-authoritative). - Tall statics need foot-anchor + occlusion-fade (#66). - Deferred: z-stacking, per-placement blocks override, binary compiled format (#114). _Implementation to follow on a branch._
Author
Owner

Implementing now on branch feat/static-object-layer, on top of the biomes foundation (#142) — keep Andrea TerrainInfo/biomes, add the sparse static/object layer and move Tree from terrain to a blocking static. Consolidates #139 (Biomes D fixtures object-layer), closed as dup. Server + client become purely flag-based (walkable? blocks?), statics drawn generically by static/.

Implementing now on branch feat/static-object-layer, **on top of the biomes foundation (#142)** — keep Andrea TerrainInfo/biomes, add the sparse static/object layer and move Tree from terrain to a blocking static. Consolidates #139 (Biomes D fixtures object-layer), closed as dup. Server + client become purely flag-based (walkable? blocks?), statics drawn generically by static/<id>.
Author
Owner

Delivered in #144 (merged to main). The PR closed #139 but not this issue — closing now.

Runtime static-content system is live: dense terrain grid + sparse statics, server collision (IsWalkable and HasLineOfSight derive from a static's blocks), viewport-culled generic client render (static/<id>), size-agnostic. Follow-ups tracked separately: #141 (fold marsh scatter into passable statics) and #143 (express terrain+static defs as a data file).

Delivered in #144 (merged to `main`). The PR closed #139 but not this issue — closing now. Runtime static-content system is live: dense terrain grid + **sparse** statics, server collision (`IsWalkable` **and** `HasLineOfSight` derive from a static's `blocks`), viewport-culled generic client render (`static/<id>`), size-agnostic. Follow-ups tracked separately: #141 (fold marsh scatter into passable statics) and #143 (express terrain+static defs as a data file).
marco closed this issue 2026-07-22 14:48:03 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#89
No description provided.