# 4. Level Design with Terrain - Landmarks and Sightlines Part of [[Terrain]]. Previous: [[3. Trees Details and the Flyweight]]. Next: [[5. Runtime Terrain Queries and Deformation]]. > [!note] Verification status > `Terrain.SampleHeight` (returns height relative to the terrain's position), `Terrain.activeTerrains`, `TerrainData.GetInterpolatedNormal` checked on the 6000.5 Scripting Reference, 2026-11-10. Level Up! concepts are cited through the vault's [[Level Up! (Review)]]; the review itself flags the Disney term "weenie" as *verify in the book*. **Code not compiled in Unity.** ## 4.1 Level Up! ideas, translated to terrain From the level design chapter (Level 9) as summarized in [[Level Up! (Review)]]: | Idea | On open terrain | Tool in this part | |---|---|---| | **Show the destination early** | The landmark must be visible from spawn and from the first decision point | Sightline checker, coverage % | | **Landmarks guide** (the theme-park "weenie" - term flagged for verification in the review) | One tall, unique silhouette per region; lighting and VFX on it ([[VFX Graph]]) | Peak in part 1, beacon effect | | **Sightlines and reveals** | Ridges hide, then crest reveals. Design the reveal deliberately | Vantage points + blocked distance | | **Pacing** | Alternate open/closed, steep/flat, forest/clearing | Clearings in part 3 | | **Hide the backstage** | Steep slopes and dense forests as natural boundaries, not invisible walls | Splat rules (rock = unclimbable) | | **Metrics first** (Level 5) | Slope limits, jump heights decide what terrain is traversable | [[Character Controllers]], NavMesh Agent Type | ## 4.2 Script: landmark sightline checker Put it on the landmark. Add vantage points (empty GameObjects) where the player stands: spawn, first fork, after the river crossing. The gizmos draw green (visible) or red (blocked, with the blocking point marked). An optional coverage grid shows *from how much of the map* the landmark is visible - a number you can track as you sculpt and plant. The test marches along the eye-to-landmark segment and compares it against `Terrain.SampleHeight`. It optionally also linecasts against colliders (buildings, rocks, trees if tree colliders are enabled). ```csharp // Assets/Scripts/Terrain/LandmarkSightlines.cs using UnityEngine; [ExecuteAlways] public class LandmarkSightlines : MonoBehaviour { [Tooltip("Point on the landmark the player should see, relative to this transform")] public Vector3 visibleTopOffset = new Vector3(0f, 40f, 0f); public Transform[] vantagePoints; [Tooltip("Player eye height above ground, metres (VR: use the player's real standing height)")] public float eyeHeight = 1.7f; [Tooltip("March step along the sightline, metres. Smaller = slower, more accurate")] public float stepMeters = 2f; [Header("Also test colliders (buildings, rocks)")] public bool testColliders = false; public LayerMask occluderMask = ~0; [Header("Coverage grid")] public bool drawCoverage = true; public float gridSpacing = 25f; public float gridRadius = 400f; public Vector3 Target => transform.position + visibleTopOffset; /// Returns true if the target is visible from 'groundPoint' (eye height added). public bool IsVisibleFrom(Vector3 groundPoint, out Vector3 blockPoint) { blockPoint = Vector3.zero; Terrain terrain = TerrainAt(groundPoint); if (terrain == null) return false; Vector3 eye = groundPoint; eye.y = SampleWorldHeight(terrain, groundPoint) + eyeHeight; Vector3 to = Target; float length = Vector3.Distance(eye, to); int steps = Mathf.Max(2, Mathf.CeilToInt(length / Mathf.Max(0.25f, stepMeters))); for (int i = 1; i < steps; i++) { Vector3 p = Vector3.Lerp(eye, to, i / (float)steps); Terrain t = TerrainAt(p); if (t == null) continue; // off the map: nothing to block if (SampleWorldHeight(t, p) > p.y) { blockPoint = p; return false; } } if (testColliders && Physics.Linecast(eye, to, out RaycastHit hit, occluderMask, QueryTriggerInteraction.Ignore)) { // Ignore hits on the landmark itself. if (!hit.transform.IsChildOf(transform)) { blockPoint = hit.point; return false; } } return true; } public static float SampleWorldHeight(Terrain t, Vector3 world) => t.SampleHeight(world) + t.GetPosition().y; public static Terrain TerrainAt(Vector3 world) { foreach (Terrain t in Terrain.activeTerrains) { Vector3 o = t.GetPosition(); Vector3 s = t.terrainData.size; if (world.x >= o.x && world.x <= o.x + s.x && world.z >= o.z && world.z <= o.z + s.z) return t; } return null; } [ContextMenu("Log Coverage")] public void LogCoverage() { int total = 0, visible = 0; ForEachGridPoint(p => { total++; if (IsVisibleFrom(p, out _)) visible++; }); Debug.Log(quot;{name}: visible from {visible}/{total} grid points ({(total > 0 ? 100f * visible / total : 0f):F1}%)."); } void ForEachGridPoint(System.Action<Vector3> action) { Vector3 c = transform.position; for (float x = -gridRadius; x <= gridRadius; x += gridSpacing) for (float z = -gridRadius; z <= gridRadius; z += gridSpacing) { if (x * x + z * z > gridRadius * gridRadius) continue; Vector3 p = new Vector3(c.x + x, 0f, c.z + z); if (TerrainAt(p) != null) action(p); } } void OnDrawGizmosSelected() { Gizmos.color = Color.yellow; Gizmos.DrawWireSphere(Target, 2f); if (vantagePoints != null) { foreach (Transform v in vantagePoints) { if (v == null) continue; Terrain t = TerrainAt(v.position); if (t == null) continue; Vector3 eye = v.position; eye.y = SampleWorldHeight(t, eye) + eyeHeight; bool ok = IsVisibleFrom(v.position, out Vector3 block); Gizmos.color = ok ? Color.green : Color.red; Gizmos.DrawLine(eye, ok ? Target : block); if (!ok) Gizmos.DrawSphere(block, 1.5f); } } if (drawCoverage) { ForEachGridPoint(p => { Terrain t = TerrainAt(p); Vector3 g = new Vector3(p.x, SampleWorldHeight(t, p) + 0.5f, p.z); Gizmos.color = IsVisibleFrom(p, out _) ? new Color(0f, 1f, 0f, 0.6f) : new Color(1f, 0f, 0f, 0.35f); Gizmos.DrawCube(g, Vector3.one * 2f); }); } } } ``` **How to use it in class** 1. Put the landmark (a tower, a dead tree, a glowing crystal) on the peak from part 1. Add `LandmarkSightlines`, set `visibleTopOffset` to its top. 2. Add vantage points at spawn and at each route decision. Select the landmark: green/red lines appear. 3. Turn on **Draw Coverage**. Sculpt a ridge in front of the spawn and watch the grid go red behind it. That ridge is now a **reveal**: put the crest on the main route so the player sees the landmark at the moment you choose. 4. Context menu > **Log Coverage** after each iteration. Paste the % into your level design doc next to the beat chart. **Why this design** - Gizmos only run when selected (`OnDrawGizmosSelected`), so a 1,000-point grid doesn't slow the whole editor. - `SampleHeight` returns height **relative to the terrain**; adding `GetPosition().y` gives world height. Forgetting this is the classic bug when terrains aren't at y = 0. - `TerrainAt` handles multiple tiles; sightlines often cross tile borders. - Trees on terrain have no colliders unless the prototype has a collider *and* Terrain Collider "Enable Tree Colliders" is on - so by default this test ignores trees. That is deliberate: foliage *partially* hides a landmark, which is usually what you want. If dense forest should block, use the collider option and a dedicated occluder layer. ## 4.3 Editor tool: snap and align props to terrain Every landmark, rock and building needs to sit on the ground. Bind it to a shortcut. ```csharp // Assets/Editor/SnapToTerrainMenu.cs using UnityEditor; using UnityEngine; public static class SnapToTerrainMenu { [MenuItem("Tools/Terrain/Snap Selection To Terrain %#t")] // Ctrl/Cmd+Shift+T static void Snap() => Apply(align: false); [MenuItem("Tools/Terrain/Snap And Align Selection To Terrain")] static void SnapAlign() => Apply(align: true); static void Apply(bool align) { foreach (Transform t in Selection.transforms) { Terrain terrain = FindTerrainUnder(t.position); if (terrain == null) continue; Undo.RecordObject(t, "Snap To Terrain"); Vector3 p = t.position; p.y = terrain.SampleHeight(p) + terrain.GetPosition().y; t.position = p; if (align) { TerrainData d = terrain.terrainData; Vector3 local = p - terrain.GetPosition(); Vector3 n = d.GetInterpolatedNormal(local.x / d.size.x, local.z / d.size.z); t.rotation = Quaternion.FromToRotation(t.up, n) * t.rotation; } } } static Terrain FindTerrainUnder(Vector3 p) { foreach (Terrain t in Terrain.activeTerrains) { Vector3 o = t.GetPosition(); Vector3 s = t.terrainData.size; if (p.x >= o.x && p.x <= o.x + s.x && p.z >= o.z && p.z <= o.z + s.z) return t; } return null; } } ``` Align for rocks and debris; **don't** align buildings or landmarks - a vertical tower reads as intentional, a leaning one reads as a bug. ## 4.4 Terrain level design checklist - [ ] Landmark visible from spawn (green line) and from every route decision point. - [ ] At least one **reveal**: a crest where the landmark appears after being hidden. - [ ] Every boundary is a readable slope/cliff/water edge with a rock texture, not an invisible wall. - [ ] Walkable slopes match the controller metrics and NavMesh Agent Max Slope. - [ ] Every dead end has a reward (Level Up! - treasure, view, lore). - [ ] Alternate open and enclosed spaces along the main route (pacing). - [ ] Clearings exist where combat happens (trees break camera and AI sensing; see [[Organic Enemy Sensing]]). - [ ] VR only: no forced slopes or drops while the player is moving with smooth locomotion; prefer gentle grades and teleport anchors. ## 4.5 Exercise Design a 3-beat route: spawn (landmark visible) -> valley (landmark hidden, forest, tension) -> ridge crest (reveal, landmark close). Prove each beat with vantage points and record coverage before and after planting trees. ## Sources - `Terrain.SampleHeight`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Terrain.SampleHeight.html - `Terrain` (activeTerrains, GetPosition): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Terrain.html - `TerrainData` (GetInterpolatedNormal): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/TerrainData.html - Level Up! The Guide to Great Video Game Design (2nd ed.), Scott Rogers - via [[Level Up! (Review)]]