Standardized YAML frontmatter on all 10 docs/architecture/ files with title, description, type, status, ticket, decision_refs, and author fields. Enables context-aware document loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
47 KiB
title, description, type, status, ticket, decision_refs, author, created, updated
| title | description | type | status | ticket | decision_refs | author | created | updated | |
|---|---|---|---|---|---|---|---|---|---|
| Z-Layer Rendering Pipeline — Gap Analysis | Gap analysis for multi-floor z-layer rendering — y-sort occlusion contract, airborne rendering, lower floor compositing, fog interaction, and performance budgets | architecture | active |
|
Tyre | YYYY-MM-DD | YYYY-MM-DD |
Z-Layer Rendering Pipeline — Gap Analysis
Author: Tyre (architecture) | Sprint: 6 | Decision: D-049 amendment
Adjustments Incorporated
-
Fog moves to z:900 — FogOverlay sits OUTSIDE FogGroup (the CanvasGroup), as a sibling under the World node. FogGroup draws at z:0 (default), FogOverlay draws at z:900. The CanvasGroup composites layers 0-300+ into a single texture, then FogOverlay draws the fog shader quad over that. Currently z_index=10 in scene tree — needs update to 900.
-
Modal at CanvasLayer 30 — New CanvasLayer node in main.tscn at
layer = 30. Pause menu, inventory modal, death screen. Empty for Sprint 6. -
Y-sort occlusion contract — Formalized below.
-
Airborne + upper floor rendering — Proposed below.
Y-Sort Occlusion Contract (Formal)
Most critical architectural constraint in the rendering pipeline. Getting this wrong breaks the entire top-down visual.
The rule: In Godot 4, within a y_sort_enabled parent, z_index is the PRIMARY sort key and y-position is SECONDARY. Confirmed via Godot issue #62715 and fix #62837.
The contract:
- All y-sorted content MUST share z_index = 0 within YSortGroup. Entities, furniture, wall faces — everything that participates in positional occlusion gets z:0.
- Nested y_sort_enabled nodes flatten their children into the parent's sort pool. Entities (y_sort=true, z:0) has its individual entity sprites participate directly in YSortGroup's y-sort alongside furniture.
- D-044 entity-wins-ties: Entities node added AFTER Furniture node in scene tree order. Same y-position means later sibling wins.
- Items on surfaces (cup on table): parent-child node relationship. Child draws after parent. No z_index needed.
- Wall faces (when added): in YSortGroup at z:0. Wall at y=5 occludes entity at y=4 but not y=6. Side/back walls use Overhead (z:300).
CRITICAL BUG IN CURRENT SCENE TREE: Entities currently has z_index = 3. Entity sprites will ALWAYS draw after furniture regardless of y-position. When furniture sprites are added, they'll be permanently hidden behind entities. Must change to z_index = 0.
Airborne, Overhead, and Upper Content
Everything stays inside FogGroup — fog applies uniformly regardless of altitude.
Fixed Top-Down Camera Constraint (D-019)
The camera is fixed at ~15-20° from vertical. The player never tilts the camera upward. This has major implications for vertical rendering:
- You never "look up." The camera is above everything. What you see is always the TOP of things.
- Upper floors occlude lower floors. If you're on floor 1 of a 3-story building, the camera sees the roof of floor 3, NOT your floor 1 — unless the game selectively hides/transparentifies upper floors (like Rimworld, Prison Architect).
- The Overhead layer (z:300) handles everything above the player on the current floor. Ceiling edges, overhead pipes, upper floor platforms — all rendered as semi-transparent occlusion from directly above.
- Upper floor entities (z:400+) are only relevant when visible through horizontal offset or floor transparency. A catwalk extending to the side (you see it because it's offset from your position, not because you looked up) or content on a floor above you where that floor has been made transparent by the game. This is a niche case, not the primary use.
This significantly simplifies the upper range compared to the original proposal.
Revised Upper Range
- z:400 UpperContent — Single range for any upper-floor content visible through horizontal offset or floor transparency. One y-sort group, not per-floor stacking.
- z:500-899 — Reserved but likely unused. The fixed camera means you rarely see more than one floor above. Reserved for edge cases (multi-level atrium with transparent floors, specific story moments).
The elaborate per-floor stacking (z:400, z:500, z:600...) from the original proposal is over-engineered for a fixed top-down camera. Simplified to a single UpperContent range.
Flying Objects: Scale INCREASES with Altitude
With a fixed top-down camera, objects at higher altitude are CLOSER to the camera. This inverts the intuition from the looking-down case:
- Things below you: further from camera → scale < 1.0 (shrink)
- Things at your level: normal → scale 1.0
- Things above you (flying): closer to camera → scale > 1.0 (grow)
A drone at high altitude appears LARGE (close to camera). As it descends to ground level, it shrinks toward normal sprite size. A ship approaching from orbit starts as a large shadow, shrinking as it descends. This is physically correct for a top-down perspective.
Scale formula for airborne objects:
# Altitude in floors above current floor
var altitude_floors := (entity.z_step - current_floor_z_step) / 5.0
# 3% size increase per floor of altitude
var scale_factor := 1.0 + altitude_floors * 0.03
airborne_sprite.scale = Vector2(scale_factor, scale_factor)
Scale by altitude:
| Altitude | Floors above | Scale | Visual effect |
|---|---|---|---|
| Ground level | 0 | 1.00 | Normal entity size |
| Low airborne | 0.5-1 | 1.02-1.03 | Barely noticeable |
| Mid airborne | 2-3 | 1.06-1.09 | Clearly larger, floating above |
| High airborne | 5 | 1.15 | Prominent, casting shadow below |
| Near ceiling | 8-9 | 1.24-1.27 | Large, approaching render ceiling |
| Render ceiling | 10 | 1.30 | Maximum, beyond this: not rendered |
Rendering Ceiling
There must be a maximum altitude above which objects stop rendering as sprites. A ship in orbit, a satellite at 500m — these are not visible in the top-down game view.
Contract: rendering ceiling = 10 floors above current floor = z_step + 50 = 25 meters.
In the world z-coordinate system:
- z_step is an integer, 0.5m per step
- floor_index = z_step / 5, floor height = 2.5m (5 sub-levels * 0.5m)
- Rendering ceiling: current z_step + 50 (10 floors, 25m)
Above the rendering ceiling:
- Objects do NOT render as sprites
- They MAY cast ground shadows (shadow sprite at z:10, FloorObjects level — a dark ellipse on the ground)
- They MAY produce environmental effects (engine noise, wind particles, lighting changes)
- They MAY appear as insert overlay indicators (radar blip, direction arrow) in the Insert scope
This aligns with both visual limits (at 1.30 scale, a sprite is already uncomfortably large) and gameplay logic (your character's perception doesn't extend 25m straight up in meaningful detail).
Rendering floor (looking down): Symmetric — 10 floors below = z_step - 50 = 25m down. Already enforced by the +/- 2 visible floor window for DETAILED rendering. Beyond +/- 2, content could render as simplified dots/shadows rather than full sprites, up to +/- 10. Beyond +/- 10, not rendered at all.
Revised Cases
Projectiles / low airborne (arrow mid-flight, thrown grenade, hovering drone at room height):
- z:200, scale ~1.0-1.03
- Visually above ground entities, below overhead
- NOT y-sorted with ground — always on top of ground content
- Most common airborne case
High airborne (drone at altitude, descending shuttle, flying creature):
- z:200, scale 1.03-1.30 (driven by altitude z_step)
- Still inside Airborne node, differentiated by per-sprite scale
- Objects above overhead height (z_step > current + 15, i.e., 3+ floors): render at z:350 (above Overhead at z:300) — they're above the ceiling, so they draw over it
- Casts ground shadow at z:10 (FloorObjects)
Overhead structure (ceiling edges, catwalk beams, overhead pipes):
- z:300 unchanged
- Semi-transparent partial occlusion
- NOT scaled — this is structural, not altitude-variable
Upper floor content (entities on a catwalk visible through horizontal offset or floor transparency):
- z:400
- Normal scale (they're ON a surface, not flying)
- Own y-sort group for interleaving with each other
- Rare case with fixed top-down camera
Proposed World Z Layout (Revised)
FogGroup (CanvasGroup, z:0)
FloorTiles z:0 ground plane
FloorObjects z:10 cosmetic detail + ground shadows from high-altitude objects
YSortGroup z:100 ground entities/furniture/walls, y-sorted
Airborne z:200 projectiles, low-flying objects (scale ~1.0)
Overhead z:300 ceiling, upper structure, semi-transparent
HighAirborne z:350 flying objects above ceiling height (scale 1.03-1.30)
UpperContent z:400 upper-floor entities via horizontal offset/transparency
[z:500-899 reserved] unlikely to be needed with fixed camera
Design Contract (Revised)
- z:0-100 Ground: Floor surfaces + y-sorted content on player's current floor.
- z:200 Airborne: Low-altitude flying objects (0-3 floors above). Scale 1.0-1.09. Above ground entities, below overhead.
- z:300 Overhead: Structural elements above player. Semi-transparent. No scale.
- z:350 HighAirborne: Flying objects above ceiling height (3-10 floors above). Scale 1.09-1.30. Above overhead. Casts ground shadow at z:10.
- z:400 UpperContent: Entities on visible upper floors (horizontal offset or transparency). Normal scale. Own y-sort. Rare with fixed camera.
- z:500-899 reserved: Unlikely to be needed. Available for edge cases.
- Rendering ceiling: 10 floors (25m, z_step + 50). Above this: no sprite rendering. Ground shadows and environmental effects only.
- Fog uniform across altitude: Shader reads visibility per tile, doesn't distinguish z_index. Intentional — perception determines what you see, not altitude.
Sprint 6 impact: None. Airborne/HighAirborne/UpperContent don't exist in scene tree yet. Contract reserves the ranges.
Complete Adjusted Pipeline
World scope (default canvas, z:-200 to z:900)
World (Node2D) [world_renderer.gd]
FogGroup (CanvasGroup, z:0)
LowerFloor2 (Node2D) z:-200 [future, dynamic, scale 0.94, modulate dim]
LowerFloor1 (Node2D) z:-100 [future, dynamic, scale 0.97, modulate dim]
FloorTiles (TileMapLayer) z:0
FloorObjects (Node2D) z:10 also: ground shadows from high-altitude objects
YSortGroup (Node2D, y_sort) z:100
Furniture (Node2D) z:0 [future]
Entities (Node2D, y_sort) z:0 FIX from current z:3
WallFaces (Node2D) z:0 [future]
Airborne (Node2D) z:200 [future] low-flying, scale ~1.0
Overhead (Node2D) z:300
HighAirborne (Node2D) z:350 [future] above-ceiling flying, scale 1.03-1.30
UpperContent (Node2D, y_sort) z:400 [future] rare with fixed camera
FogOverlay (Node2D) z:900 OUTSIDE FogGroup, fog shader
Insert scope (CanvasLayer 10)
InsertOverlay (CanvasLayer, layer=10)
InteractionPrompt v0.1 fallback
InteractionList D-057 verb list
RadialMenu [future] D-058
PerceptionMarkers [future] entity labels
Minimap [recommend: move from UI, diegetic per D-013]
UI scope (CanvasLayer 20)
UILayer (CanvasLayer, layer=20)
HUD health/stamina
Minimap [current, may move to Insert]
MonologueDisplay internal monologue text
CursorRenderer geometric cursor, topmost in UI
Modal scope (CanvasLayer 30)
ModalLayer (CanvasLayer, layer=30) NEW, empty for Sprint 6
[future] PauseMenu, InventoryModal, DeathScreen
Gap Checks
CanvasGroup + PointLight2D -- NO GAP
Vision cone PointLight2D (D-046) sits inside FogGroup/YSortGroup/Entities on the player entity. CanvasGroup composites its children WITH lighting into the output texture. Fog overlay then draws over the composited result. Intended behavior: "vision cone lights the world, fog covers what you don't know." These are two separate systems sharing LOS data (as documented in fog-shader-spec.md).
Camera2D + CanvasLayer -- NO GAP
Camera2D transforms the default canvas (World + FogOverlay). CanvasLayers (Insert, UI, Modal) are NOT affected by camera transform. Insert overlay elements that need world-anchoring (interaction labels near entities) must convert world-to-screen coordinates in their scripts. Standard Godot pattern. interaction_list.gd currently doesn't do this — it's a fixed overlay. Entity-anchored positioning per D-057 is a script change, not architecture.
Multi-floor transitions -- NO GAP
floor_index = z_step / 5, sub_level = z_step % 5. When the player changes floors: server sends new visible_entities for the new floor, entity renderer removes old and creates new, fog textures update for new floor's visibility. No rendering layer changes needed. Multi-floor visibility (stairwells) would add per-floor y-sort groups at z:400+ — that's v0.2+, no architectural debt now.
Fog shader coordinate mapping -- NO GAP
FogOverlay at z:900 outside FogGroup. Changing z:10 to z:900 doesn't affect shader behavior — z_index is draw order only, not UV math. fog_shader.gd positions its ColorRect via camera offset, confirmed by reading the source.
Insert overlay sub-layering -- NO GAP
Within a CanvasLayer, child z_index values are relative to that CanvasLayer's space, not the world space. InsertOverlay children can use z_index 0, 1, 2... for internal ordering without conflicting with world z values. The 1000+ numbering scheme in constants.gd is for documentation/reference. Implementation uses scene tree order or small z_index values.
z_index range -- NO GAP
Godot 4 z_index valid range: -4096 to 4096. Our highest world z value is 900. Well within range.
Relative z_index accumulation -- NO GAP
Godot defaults to z_as_relative = true. Within FogGroup: FloorTiles effective z = FogGroup(0) + 0 = 0. FloorObjects effective z = 0 + 10 = 10. YSortGroup effective z = 0 + 100 = 100. Within YSortGroup: Entities z = 0 (relative to YSortGroup). In y-sort mode, this z:0 is used as the sort key within the y-sort pool — not accumulated with parent's z:100. The z:100 on YSortGroup determines when the entire group draws relative to FloorObjects (z:10) and Overhead (z:300). Correct behavior.
Fog uniformity across altitude -- NO GAP
Fog shader reads visibility_texture per tile position. Doesn't distinguish by z_index. A drone at z:200 and an entity at z:100 on the same tile get the same fog treatment. Intentional — your perception determines what you see, not altitude.
Scene Tree Changes Needed
| Node | Current z_index | Target z_index | Notes |
|---|---|---|---|
| FloorTiles | 0 | 0 | Correct, no change |
| FloorObjects | 1 | 10 | Gap for future floor layers |
| YSortGroup | 2 | 100 | Gap for wall base, rubble |
| Entities | 3 | 0 | CRITICAL: fixes y-sort bug |
| Overhead | 4 | 300 | Gap for upper walls |
| FogOverlay | 10 | 900 | Top of world scope |
| ModalLayer | -- | NEW | CanvasLayer layer=30 |
Constants.gd: full rewrite of the Z_ block to three-scope numbering (World 0-899, Insert CanvasLayer 10, UI CanvasLayer 20, Modal CanvasLayer 30).
Looking Down: Lower Floor Rendering
The Problem
The pipeline above handles looking UP (overhead z:300, upper floors z:400+) and anchors the current floor at z:0-100. But what renders BELOW z:0? When the player stands on a balcony, catwalk, or upper floor, they may see floors below them.
Key Insight: Looking Down != Being On That Floor
When you look DOWN at floor N from above, you see a fundamentally different view than when you're ON floor N:
- On floor N: Full interior — floor surface, furniture, entities at eye level, walls, overhead
- Looking down at floor N: Top-down view — floor surface (or rooftops), furniture tops, entity heads/shoulders. No wall interiors, no overhead (you're above the overhead)
This means lower floor rendering is SIMPLER than same-floor rendering. Each lower floor needs:
- Ground surface (the floor or rooftop you see looking down)
- Floor objects (furniture tops, decoration — simplified)
- Entities (people below, seen from above, y-sorted with each other)
- Optionally airborne on that floor (rare — a drone flying below you)
Range Allocation: Negative Z
Godot supports z_index -4096 to 4096. Lower floors use negative z ranges, mirroring the upper floor +100 pattern:
z:-200 to z:-101 Floor-2 below (max visible depth)
z:-100 to z:-1 Floor-1 below (one floor down)
z:0 Current floor ground (anchor point)
z:10 Current floor cosmetic
z:100 Current floor y-sort
z:200 Current floor airborne
z:300 Current floor overhead
z:400 Upper floor +1
z:500 Upper floor +2 (max visible height)
z:500-899 Reserved
z:900 FogOverlay (outside FogGroup)
Within each lower floor's 100-range block:
z:X+0 LowerGround — floor surface / rooftop seen from above
z:X+10 LowerObjects — furniture tops, decorations from above
z:X+50 LowerYSort — entities on that floor, y-sorted with each other
z:X+80 LowerAirborne — flying things on that floor (rare)
Concrete example — floor one below (z:-100 to z:-1):
z:-100 Ground surface (floor tiles or rooftop of floor below)
z:-90 Floor objects (furniture tops from above)
z:-50 Entities (y-sort group — NPCs on the floor below)
z:-20 Airborne (drones flying on that floor, if any)
Floor two below (z:-200 to z:-101):
z:-200 Ground surface
z:-190 Floor objects
z:-150 Entities (y-sort group)
z:-120 Airborne
Visible Floor Window
Contract: current floor +/- 2 (5 floors rendered simultaneously max).
Rationale:
- Beyond 2 floors of vertical distance, entities are too small/distant to be meaningful in a top-down view
- 5 simultaneous floors is a generous budget for gameplay scenarios (2-story buildings, catwalks, atrium balconies, station concourses)
- For extreme cases (50-floor skyscraper), clamp to +/- 2 visible. Floors beyond the window render as deep fog color. This is both a performance win and a gameplay statement: your perception doesn't extend that far vertically
Total z range used: -200 to 900. Well within Godot's -4096 to 4096.
CanvasGroup Interaction: Confirmed Clean
Negative z_index values inside a CanvasGroup work correctly. The CanvasGroup collects ALL children, sorts them by z_index (negative values first), renders them in order into its compositing buffer, and outputs a single texture. Lower floor content at z:-100 draws first (underneath), current floor at z:0-300 draws over it, upper floor at z:400+ draws on top. The fog shader then draws over the entire composited result. No special handling needed.
Fog Interaction: Per-Floor Visibility
This is the most subtle part. The fog shader reads a visibility texture that maps tile positions to visibility states. Currently, this texture covers only the current floor. For multi-floor rendering:
The architectural constraint: The fog shader maps screen pixels to tile XY coordinates, ignoring floor. A tile at (5, 10) on floor 0 and tile (5, 10) on floor 1 map to the same visibility pixel. If the current floor tile is visible, so is anything below/above at the same XY — which is wrong when there's a solid floor between them.
Three solutions (design now, implement later):
-
Per-floor visibility textures. Server sends separate visibility data per visible floor. FogState maintains one visibility texture per floor. The fog shader samples the correct texture based on which floor's content is being drawn. This requires the shader to know the floor of each pixel — achievable by encoding floor index in the alpha channel of each floor's content, or by rendering floors in separate passes.
-
Modulate-based dimming (simplest, recommended for v0.1). Lower floor content gets visual treatment BEFORE compositing, not via the fog shader. Apply
modulateon the lower floor parent nodes:- Floor-1:
modulate = Color(0.4, 0.4, 0.5, 0.7)— dim, desaturated, semi-transparent - Floor-2:
modulate = Color(0.25, 0.25, 0.35, 0.5)— very dim, ghostly - The fog shader still applies current-floor fog over the composited result
- Tiles outside current-floor LOS get full fog treatment, hiding lower floor content beneath them
- Tiles inside LOS show the dimmed lower floor content (if there's a gap/hole in the current floor)
- Floor-1:
-
Combined visibility texture with floor offset. Single texture with per-floor visibility encoded in separate channels (R = current, G = floor-1, B = floor-2). Shader samples the right channel. Limited to 3-4 floors but compact.
Recommendation: Option 2 for initial implementation. It's simple, visually effective (dim = distant floor), and requires zero shader changes. Option 1 for the full implementation when multi-floor rendering is built properly. The z-range allocation works identically either way.
When there's no hole: The current floor's ground tiles at z:0 naturally occlude lower floor content at z:-100 to z:-1 (lower z draws first, ground tiles draw over them). Lower floor content is only visible where the current floor has gaps — open railings, missing floor tiles, glass floors, etc. This is automatic from the z-ordering. No special "can see below" flag needed.
Performance Budget
Per additional visible floor:
| Component | Cost | Notes |
|---|---|---|
| Ground tiles | ~0.1ms | 100-400 tiles, may use simplified tile set |
| Entity sprites | ~0.05ms | 0-15 sprites per floor |
| Y-sort computation | ~0.01ms | Godot internal, trivial |
| Modulate overhead | ~0ms | Single property per parent node |
| Scale transform | ~0ms | GPU matrix multiply, no CPU cost |
| Position update | ~0ms | One Vector2 multiply per frame per floor |
| Per floor total | ~0.16ms | Scale + parallax add no measurable cost |
With +/- 2 window (4 additional floors max): ~0.64ms additional. Total rendering stays well under budget.
Optimization for implementation: lower floors seen from above don't need full tile detail. A simplified representation (fewer tile variants, possibly lower-res) reduces both rendering cost and art production cost. This is an implementation optimization, not an architecture concern.
Visual Treatment
Floors below seen from above look different from floors at eye level. This is an art direction question, but the architecture must support it:
Interior floors (through gap/railing): See actual floor surface and entity heads. Dim, desaturated (modulate). Entities use standard sprites — a top-down game already shows characters from above.
Through transparent/grid floors: Same as above but with grid pattern from the current floor overlaid. The current floor's FloorTiles at z:0 handle this (grid-pattern tile that's partially transparent).
Rooftops (from exterior): Flat roof surface sprite, not interior layout. The server/snapshot would include a "viewed from above" flag or the renderer detects floor offset and selects rooftop sprites. This is a different tile set per building type.
Atriums / open vertical spaces: Multiple floors visible simultaneously. Each floor at its depth level, progressively dimmer. Entities on each floor y-sorted within their floor group. The +/- 2 window handles 5-floor atriums cleanly.
Distance Scaling for Depth Illusion
Should lower floors render at progressively smaller scale to fake perspective? E.g., floor-1 at 97%, floor-2 at 94%. This would sell vertical distance visually and create a parallax effect during camera movement.
Scale + Y-Sort: Composes Cleanly
In Godot 4, y_sort uses the child's global_position.y after all transforms. If a parent Node2D is scaled, its children's positions are transformed accordingly, but their relative y-ordering is preserved (all children are equally affected by the same parent scale). Cross-floor sorting is handled by z_index (floors don't interleave), so scale on one floor group cannot disrupt another floor's y-sort.
Confirmed: Scaling a floor group composes cleanly with y-sort within that group.
Scale Center: Player Position
Scaling a Node2D scales around its origin. For a floor group with world-space children, scaling around (0,0) would shift all content toward the origin — wrong. We need to scale around the player/camera position so lower floors appear to recede directly below the player.
Implementation pattern (per frame):
# Scale around player position — creates parallax depth effect
var scale_factor := 0.97 # floor-1
lower_floor.scale = Vector2(scale_factor, scale_factor)
lower_floor.position = player_world_pos * (1.0 - scale_factor)
This makes lower floor content "lag behind" camera movement at a reduced rate — a natural parallax depth cue that sells vertical distance effectively.
Parallax Effect
Because the camera tracks the player, and lower floors are scaled around the player position, camera movement produces a subtle parallax: lower floor content shifts less than the current floor. This is the most compelling depth cue in a top-down perspective — pure color treatment (dimming/desaturation) cannot produce this spatial effect.
The parallax is proportional to the scale difference:
- Floor-1 at 97% scale: content moves at 97% of camera speed — subtle but perceptible
- Floor-2 at 94% scale: content moves at 94% — clearly different depth plane
Performance: Trivial
Scaling a Node2D group is a transform matrix multiplication, applied by the GPU automatically. Cost per floor:
- Position update per frame: one Vector2 multiply + assign (~0ns, CPU)
- GPU transform: included in existing draw call pipeline, no additional cost
- No texture re-rendering, no additional draw calls
Total additional cost for scale: effectively zero.
Fog Shader Interaction: No Conflict
The fog shader operates on the COMPOSITED output of the CanvasGroup. The CanvasGroup composites all children (including scaled lower floors) into one texture. The fog shader sees final composited pixels — it doesn't know or care that some content was scaled.
Since we use modulate-based dimming for lower floors (not per-floor fog textures), the slight position offset from scaling doesn't cause fog misalignment. The lower floor's visual treatment is applied before compositing, and the fog shader applies current-floor fog over the composited result.
If per-floor visibility textures are implemented later (Option 1), the UV mapping for lower floor fog would need to account for the scale transform. This is a coordinate correction in the per-floor fog pass — straightforward but worth noting.
Readability Cutoff
At the proposed scale values:
- Floor-1 at 97%: fully readable, barely noticeable size difference. Depth is perceived via parallax + dimming.
- Floor-2 at 94%: readable but clearly smaller. Combined with heavy dimming (modulate 0.25 alpha), content becomes atmospheric rather than informational.
This reinforces the +/- 2 visible floor window. Beyond 2 floors, scale would drop below 90%, making content too small for a top-down view. The visibility window and the readability window align naturally.
Recommendation: Both Treatments, Layered
Use desaturation/darkening as the PRIMARY depth cue (via modulate — simple, effective, no transform complexity):
- Floor-1:
modulate = Color(0.4, 0.4, 0.5, 0.7)— dim, cool shift, semi-transparent - Floor-2:
modulate = Color(0.25, 0.25, 0.35, 0.5)— very dim, ghostly
Use scale as SECONDARY depth cue (via transform — adds parallax, sells depth spatially):
- Floor-1:
scale = Vector2(0.97, 0.97)— subtle, most of the parallax effect - Floor-2:
scale = Vector2(0.94, 0.94)— more pronounced, atmospheric
Both are applied on the lower floor parent nodes. Combined, they produce a convincing depth illusion:
- Color treatment tells you "this is distant/below" (instant read)
- Scale/parallax tells you "this is a different depth plane" (perceived during camera movement)
- Occlusion by current floor ground tiles tells you "this is underneath" (spatial relationship)
For upper floors seen from below: scale is less important (catwalks above you don't create the same parallax expectation). Use modulate only for upper floor dimming, no scale. This also avoids the visual oddity of overhead content being slightly larger than ground content.
Scene Tree Update
The lower floor parent nodes gain scale and position updates:
LowerFloor1 (Node2D) z:-100
modulate = Color(0.4, 0.4, 0.5, 0.7)
scale = Vector2(0.97, 0.97)
position = player_pos * 0.03 [updated per frame]
...children...
LowerFloor2 (Node2D) z:-200
modulate = Color(0.25, 0.25, 0.35, 0.5)
scale = Vector2(0.94, 0.94)
position = player_pos * 0.06 [updated per frame]
...children...
The per-frame position update is handled by the world renderer (or a dedicated multi-floor manager) alongside the existing camera tracking logic.
Scene Tree (Multi-Floor, Future)
When multi-floor rendering is implemented, the FogGroup tree extends:
FogGroup (CanvasGroup, z:0)
LowerFloor2 (Node2D) z:-200 [dynamic, scale 0.94, modulate dim]
Ground (TileMapLayer) z:0 relative
Objects (Node2D) z:10 relative
Entities (Node2D, y_sort) z:50 relative
LowerFloor1 (Node2D) z:-100 [dynamic, scale 0.97, modulate dim]
Ground (TileMapLayer) z:0 relative
Objects (Node2D) z:10 relative
Entities (Node2D, y_sort) z:50 relative
FloorTiles z:0 [current floor, always present]
FloorObjects z:10 also: ground shadows from HighAirborne
YSortGroup z:100
Airborne z:200 low flyers, per-sprite scale by altitude
Overhead z:300
HighAirborne z:350 above-ceiling flyers, per-sprite scale 1.03-1.30
UpperContent (Node2D, y_sort) z:400 [dynamic, rare with fixed camera]
Lower floor nodes are created dynamically when the player enters a location with vertical visibility (balcony, catwalk, atrium). Destroyed when they leave. The parent node's z_index places the entire floor at the correct depth. Child z_index values are relative, so the same sub-structure (ground, objects, entities) works at any depth.
Airborne sprites within the Airborne (z:200) and HighAirborne (z:350) nodes have per-sprite scale set by the entity renderer based on the entity's altitude z_step. High-altitude objects also spawn a ground shadow sprite as a child of FloorObjects (z:10).
Sprint 6 Impact
None. Lower floor rendering is not implemented in Sprint 6. The architecture reserves z:-200 to z:-1 and defines the contract so future implementation has a clean home. No scene tree nodes needed now.
Server Requirements (Future)
When multi-floor rendering is implemented, the server needs to provide:
- Per-floor visible entities: ObserverSnapshot includes entities from visible adjacent floors, tagged with floor_index.
- Per-floor visible tiles: Tile data includes floor_index. The renderer groups tiles by floor and assigns to the correct lower/upper floor node.
- Floor gap information: Which tiles on the current floor are transparent/open (allowing view below). This could be a tile property (type: "open_floor", "railing") rather than a separate data field.
- Per-floor visibility (for Option 1 fog): Separate visibility data per floor. The server's LOS algorithm would need to project visibility downward through open floors.
None of these require protocol changes — they're extensions to existing ObserverSnapshot fields. The current protocol already includes floor-aware entity positions (x, y, z fields).
Round 2 Corrections
Items A-D: Confirmed Defer-Safe
A. Floor transitions (player moves between floors): Entity renderer swaps content per floor. No z-layer changes. The LowerFloor/UpperContent nodes are created/destroyed dynamically. Nothing in the z architecture blocks this — it's a script problem (entity renderer grouping by floor_index).
B. Cursor on modals: Cursor lives in UILayer (CanvasLayer 20). Modals live in ModalLayer (CanvasLayer 30). When a modal opens, cursor either (a) moves to ModalLayer temporarily, (b) ModalLayer spawns its own cursor, or (c) cursor CanvasLayer number bumps above modal. All are script-level solutions. The z-layer architecture accommodates any of these — CanvasLayers are independent.
C. Half-wall occlusion: Walls in YSortGroup at z:0, y-sort handles front/back occlusion naturally. Half-height walls use semi-transparent sprites. Entities behind half-walls: upper body occluded, lower body visible — achieved via sprite masking or split sprites (top half at wall z, bottom half visible). This is an art/shader problem. Nothing in the z architecture blocks it.
D. Glass floors / forcefields: Semi-transparent tiles at z:0 (ground plane). Lower floor content at z:-100 is visible through them. The z-ordering naturally handles this — lower floor renders first, semi-transparent ground renders over it, entities render on top. Forcefields could be FloorObjects (z:10) or YSortGroup members (z:100) depending on whether they're walked-over or blocking. Standard transparency, no architecture changes.
Confirmation: nothing in the current z-layer architecture blocks any of A-D. All are script, shader, or art problems that work within the established ranges and contracts.
Item E: Airborne Ascending Alpha Fade
Lead's proposal: ascending objects get bigger (scale > 1.0, closer to camera) AND more transparent (fading out of active layer). A drone taking off fades as it ascends, eventually becoming just a ground shadow.
Combined formula:
var altitude_floors := (entity.z_step - current_floor_z_step) / 5.0
var scale_factor := 1.0 + altitude_floors * 0.03 # 1.0 → 1.30
var alpha := clampf(1.0 - (altitude_floors / 10.0), 0.0, 1.0) # 1.0 → 0.0
airborne_sprite.scale = Vector2(scale_factor, scale_factor)
airborne_sprite.modulate.a = alpha
Visual progression:
| Altitude (floors) | Scale | Alpha | Visual |
|---|---|---|---|
| 0 (ground) | 1.00 | 1.00 | Normal entity, on ground |
| 1 | 1.03 | 0.90 | Slightly larger, barely fading |
| 3 | 1.09 | 0.70 | Noticeably larger, starting to ghost |
| 5 | 1.15 | 0.50 | Large and semi-transparent |
| 8 | 1.24 | 0.20 | Very large, ghostly, barely visible |
| 10 (ceiling) | 1.30 | 0.00 | Invisible — only ground shadow remains |
This is elegant. The object visually "passes through" the camera plane — growing as it approaches, fading as it passes. The ground shadow (at z:10, FloorObjects) persists as the last trace.
CanvasGroup composition: clean. Per-sprite modulate.a works correctly inside a CanvasGroup. The CanvasGroup renders children in z-order into its compositing buffer with proper alpha blending. A sprite at modulate.a = 0.3 composites as semi-transparent in the output texture. Multiple semi-transparent airborne sprites that overlap would blend correctly (rendered in z-order within the Airborne/HighAirborne nodes).
No conflict with lower floor modulate. Lower floor depth treatment uses modulate on the PARENT node (LowerFloor1, LowerFloor2). Airborne alpha uses modulate on INDIVIDUAL sprites within Airborne/HighAirborne nodes. These are completely separate node hierarchies — the per-sprite alpha doesn't interact with the per-floor-group modulate. In Godot 4, modulate multiplies down the tree: a sprite with modulate.a = 0.5 under a parent with modulate.a = 0.7 renders at effective alpha 0.35. But airborne sprites are NOT children of lower floor groups — they're children of Airborne (z:200) or HighAirborne (z:350), whose parent is FogGroup (modulate = default 1.0). No unintended multiplication.
Descending objects (landing): The inverse — object descends from high altitude, scale shrinks from 1.30 toward 1.0, alpha increases from 0.0 toward 1.0. At ground level: normal size, fully opaque. Smooth transition. The same formula works bidirectionally.
Contract update: Airborne sprites have two per-sprite properties driven by altitude:
scale— increases with altitude (1.0 to 1.30)modulate.a— decreases with altitude (1.0 to 0.0)- Ground shadow sprite (child of FloorObjects z:10) — opacity inversely proportional to airborne alpha (shadow gets stronger as object gets higher/more transparent)
Item F: Cross-Floor Visual Effects (Smoke, Water, Gas)
Smoke rising through floor grates, water dripping between levels, gas leaks spreading across floors. These are CROSS-FLOOR effects that visually span multiple floor groups.
Key insight: the FogGroup CanvasGroup composites EVERYTHING in z-order. Cross-floor effects don't need to be children of any specific floor group. They can be standalone nodes at intermediate z values within the FogGroup. The per-floor render group structure does NOT block cross-floor effects — it actually enables them.
How it works:
A smoke plume rising from floor 0 through the ceiling:
- Smoke origin particles at z:150 (between YSortGroup z:100 and Airborne z:200) — visible above ground entities, below flying objects
- Smoke mid-section at z:250 (between Airborne z:200 and Overhead z:300) — rising through air space
- Smoke passing through ceiling at z:325 (between Overhead z:300 and HighAirborne z:350) — partially occluded by ceiling semi-transparency
The effect spans multiple z ranges within the same FogGroup compositing pass. From the CanvasGroup's perspective, it's just more children at various z values — composited in order with everything else.
Water dripping between levels:
- Water source on current floor at z:100 (YSortGroup — a pipe, a ceiling leak target)
- Water particles falling downward: these would be in the lower floor range (z:-50 to z:-1) — visible through floor gaps, just like lower floor entities
- Water splash on lower floor at z:-50 (LowerFloor1's entity range)
Gas spreading across floors:
- Gas origin on one floor
- Gas particles at intermediate z values (z:150, z:250) for current floor
- Gas seeping downward through floor gaps: particles in z:-50 to z:-1 range
- Gas rising upward: particles in z:250 to z:325 range
Reserved VFX z-ranges:
The existing pipeline has natural gaps between functional ranges. These gaps are the VFX home:
z:-75 to z:-51 Lower floor VFX (effects between lower floor entities and current ground)
z:150 to z:199 Ground-level VFX (smoke starting, gas pooling, sparks from ground)
z:250 to z:299 Mid-air VFX (rising smoke, floating particles, air effects)
z:325 to z:349 Ceiling-level VFX (smoke passing through ceiling, overhead effects)
These ranges are already available — they're gaps between the established functional z values. No architecture changes needed. Just a reservation in the contract so future implementers know where VFX nodes belong.
CanvasGroup consideration: All VFX nodes are inside FogGroup. Particle effects with transparency composite correctly in the CanvasGroup buffer. Semi-transparent smoke at z:250 blends with the Airborne content at z:200 and Overhead at z:300 naturally. The fog shader then applies fog over the entire composited result — fogged areas hide VFX just like they hide entities.
Performance: Particle effects are GPU-driven in Godot 4 (GPUParticles2D). A few hundred particles across 3-4 VFX emitters: ~0.1-0.2ms. Well within budget.
Per-floor modulate interaction: VFX nodes at intermediate z values are NOT children of floor groups. They're siblings in the FogGroup. Their modulate is independent. A smoke plume at z:250 is fully opaque regardless of LowerFloor1's modulate at z:-100. This is correct — you see the smoke at full intensity, even if the floor below is dimmed.
Confirmation: nothing in the current z-layer architecture blocks cross-floor VFX. The per-floor group structure and intermediate z gaps actually make it clean — effects slot into the gaps between functional ranges, composited by the CanvasGroup alongside everything else. The only reservation needed is documenting the VFX z-ranges in the contract.
Updated Complete Pipeline (Full Range)
z:-200 to z:-101 Floor-2 below [future, scale 0.94, modulate dim]
z:-100 to z:-76 Floor-1 below [future, scale 0.97, modulate dim]
z:-75 to z:-51 Lower floor VFX [future] effects between lower floor + current ground
z:-50 to z:-1 Floor-1 entities/air [future] lower floor y-sort content
z:0 FloorTiles current floor ground
z:10 FloorObjects cosmetic + ground shadows from high flyers
z:100 YSortGroup current floor y-sort (entities/furniture/walls)
z:150 to z:199 Ground VFX [future] smoke origins, gas pools, ground sparks
z:200 Airborne low-flying objects, scale+alpha by altitude
z:250 to z:299 Mid-air VFX [future] rising smoke, floating particles
z:300 Overhead ceiling, upper structure, semi-transparent
z:325 to z:349 Ceiling VFX [future] smoke through ceiling, overhead effects
z:350 HighAirborne above-ceiling flying, scale+alpha by altitude
z:400 UpperContent upper-floor entities (rare, fixed camera)
z:500-899 Reserved edge cases
z:900 FogOverlay OUTSIDE FogGroup, fog shader
CanvasLayer 10 InsertOverlay interaction UI, perception markers
CanvasLayer 20 UILayer HUD, monologue, cursor
CanvasLayer 30 ModalLayer full-screen modals, pause menu
Total z range: -200 to 900 (of -4096 to 4096 available). Visible floor window (looking down): current - 2 (detailed rendering with scale + parallax). Rendering ceiling (looking up): current + 10 floors (25m). Above: no sprites, ground shadows only. Airborne treatment: scale increases + alpha decreases with altitude. Ground shadow at z:10. VFX ranges: Reserved at z gaps between functional layers. Cross-floor effects via intermediate z values.
Quick Note: Liquid Depth and Z-Layers
Liquid depth at 0.5m sub-levels (wading → struggling → swimming) composes cleanly with the z-layer architecture. No changes needed — it fits in existing and reserved ranges.
Where Liquid Renders
Water has TWO render layers, not one:
-
Water bed at z:10 (FloorObjects) — tinted/darkened floor beneath water. Always present when water exists. Shows underwater color, murk, submerged objects.
-
Water surface occlusion at z:110 (NEW range, between YSortGroup z:100 and Ground VFX z:150) — semi-transparent layer that partially covers entities. Opacity scales with depth:
- Sub 1 (0.5m, wading): alpha ~0.15 — faint shimmer, entity legs slightly obscured
- Sub 2 (1.0m, struggling): alpha ~0.45 — entity lower body submerged, water clearly visible
- Sub 3 (1.5m, swimming): alpha ~0.75 — entity mostly submerged, only head/shoulders visible
-
Water VFX (ripples, splashes, shimmer) at z:150-199 (existing Ground VFX reservation) — particle effects on the water surface.
Y-Sort Interaction
The water surface at z:110 sits ABOVE the entire YSortGroup (z:100). It covers all entities on that tile uniformly regardless of y-position. This is correct — water depth is uniform across a tile, not positional. An entity at y=3 and an entity at y=8 on the same flooded tile are equally submerged.
Entities do NOT y-sort with water. Water is a surface, not an object with a y-position. The z-layer separation handles this naturally: entities y-sort with each other (z:100 group), then water surface draws over all of them (z:110).
Rising Water Transition
Rising water doesn't move through z-ranges. It stays at the same z values — only the OPACITY of the z:110 surface layer changes:
Dry → Sub 1: z:10 appears (water bed color), z:110 appears (alpha 0.15)
Sub 1 → Sub 2: z:10 darkens, z:110 alpha increases to 0.45
Sub 2 → Sub 3: z:10 deeper color, z:110 alpha increases to 0.75
Sub 3 → full: z:110 alpha 0.95, entities nearly invisible, only swimming animation above
The transition is a smooth alpha tween on the surface layer, not a z-range change.
Entity Sprite Consideration
At sub 2-3, entities should ideally show partial submersion (legs hidden, body partially in water). Two approaches:
A. Sprite masking (shader): Entity sprite shader clips pixels below a water-line y-offset. The water surface at z:110 then covers the clipped area. Clean but requires per-entity shader.
B. Opacity-only (simpler): Don't clip entity sprites. The semi-transparent water surface at z:110 just tints/obscures the lower portion naturally. Less precise but simpler. At 75% water alpha (sub 3), entity legs are heavily obscured without needing a clip mask.
Recommendation: Option B for v0.1, Option A as polish. The z-layer architecture supports both — it's an entity shader question, not a z-layer question.
Pipeline Impact
One new reserved z-range:
z:100 YSortGroup entities/furniture/walls
z:110 LiquidSurface [future] water/liquid occlusion, alpha by depth
z:150-199 Ground VFX smoke, ripples, splashes
No architecture changes. z:110 is available in the existing gap. Cross-floor liquid effects (water dripping down) already covered by lower floor VFX ranges (z:-75 to z:-51).
Verdict (Updated — Round 2)
No remaining architectural gaps. The full rendering pipeline is now specified:
- Fixed top-down camera (D-019) simplifies upper range: no "looking up," upper floors only via horizontal offset or transparency
- Negative z (-200 to -1) for lower floors with scale < 1.0 + parallax (depth illusion)
- Positive z (0-900) for current floor, airborne, overhead, high-altitude flying, upper content
- Flying objects INCREASE in scale AND DECREASE in alpha with altitude — smooth "passing through camera" effect
- Airborne split: z:200 (low, below ceiling) and z:350 (high, above ceiling) — different draw order relative to Overhead
- Rendering ceiling: 10 floors / 25m / z_step+50 — above this, no sprites, ground shadows + environmental effects only
- Per-sprite modulate.a on airborne objects composes cleanly with CanvasGroup (no conflict with per-floor modulate)
- Cross-floor VFX (smoke, water, gas) slot into reserved z gaps between functional ranges — architecture enables, not blocks
- VFX ranges reserved: z:-75 to -51, z:150-199, z:250-299, z:325-349
- Items A-D (floor transitions, cursor on modals, half-wall occlusion, glass/forcefields) confirmed defer-safe — no architecture blockers
- CanvasGroup composites all floors + VFX correctly (negative z first, positive z after)
- Fog applies via modulate per floor (v0.1) or per-floor visibility textures (v0.2+)
- Current floor occlusion is automatic from z-ordering (ground at z:0 covers z:-100 content)
- Visible floor window (down): current - 2 (detailed, scale + parallax, ~0.64ms budget)
- Y-sort contract preserved: each floor has its own y-sort group, no cross-floor interleaving
- Dual depth cues for lower floors: modulate (color) + scale (parallax), composes cleanly with y-sort
- Three CanvasLayer scopes defined (Insert 10, UI 20, Modal 30)
- Critical bug: Entities z:3 must become z:0
Ready to implement Sprint 6 changes (scene tree z_index corrections + constants.gd rewrite) on lead's go.