Open-World Game Design
Designing an open world is fundamentally a trade-off between density of content and memory/CPU budget. This document collects the patterns the agent uses when generating the open-world city example.
Procedural generation
Grid + jitter
The simplest viable city layout is a grid of blocks where each block holds one or more buildings whose position is jittered slightly to break the symmetry:
for (int x = 0; x < gridX; x++)
for (int z = 0; z < gridZ; z++)
{
Vector3 block = origin + new Vector3(x * blockSpacing, 0f, z * blockSpacing);
SpawnBuilding(block + Random.insideUnitSphere * 4f);
}
The generated CityGenerator.cs follows this pattern. For more variety,
swap the grid for:
- Voronoi street networks (Delaunay triangulation, then prune).
- L-system road growth (recursive rewrite rules -- great for organic cities).
- Wave function collapse -- tile-based, but expensive to author.
Building composition
A building is rarely a single mesh. The generated BuildingGenerator.cs
composes three primitives:
- A wall cube with a Standard material (concrete/glass color).
- A slightly smaller cube with an emissive window material so windows glow at night.
- A
BoxColliderfor collision.
For more variety:
- Vary the footprint (square vs rectangular).
- Add a flat roof cap with a different material.
- Add a parapet along the top.
- Randomly choose between glass-tower, brick-mid-rise, and concrete-low-rise palettes.
Runtime vs baked
The agent generates everything at runtime in Start() because:
- The Unity project opens with an empty scene and the user immediately sees content after pressing Play.
- No need to ship
.unityscenes with hundreds of GameObjects. - Easy to randomize: just change the seed.
The downside is the first-frame hitch on large worlds. Mitigations:
- Spread generation across frames using a coroutine that yields after every N buildings.
- Use
Object.Instantiatefrom a small set of prefabs instead of building primitives from scratch each time. - Use
Graphics.DrawMeshInstancedfor buildings once you do not need per-building colliders.
Level of Detail (LOD)
For an open world you must use LOD. Without it, the GPU processes every window pane on every building no matter how far away it is.
| LOD | Screen size | Poly count |
|---|---|---|
| 0 | 60%+ | Full mesh with windows |
| 1 | 25-60% | Simplified mesh, no windows |
| 2 | 5-25% | Single box per building |
| Culled | <5% | Not rendered at all |
Add a LODGroup to each building and assign LOD meshes. For procedural
buildings you can build the LOD meshes in code by reducing the window
count and merging meshes.
Streaming and chunking
For worlds larger than ~1km x 1km you must chunk:
- Divide the world into a grid of chunks (e.g. 200m x 200m).
- Load chunks near the player; unload chunks far away.
- Use
AddressablesorSceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive)to load chunk scenes.
The generated example does NOT chunk -- it builds everything in one shot, which is fine for an 8x8 block city. Chunking becomes mandatory around 16x16 blocks.
Fog and far plane
Always set RenderSettings.fog for open worlds. It hides:
- Distant popping when LOD changes happen.
- The far clip plane edge.
A linear fog from 100m to 800m is a good default for a city.
Day/Night cycle
The generated DayNightCycle.cs rotates a directional light around the X
axis to simulate the sun:
float sunAngle = (_timeOfDay - 0.25f) * 360f; // 0.25 = noon
sun.transform.rotation = Quaternion.Euler(sunAngle, -30f, 0f);
- Keep the day length short (60-180 s) for demo purposes; 24 min real time per day for actual gameplay.
- Lerp ambient light and skybox color between day/night presets so the world does not suddenly flip black.
- Turn street lights on/off based on a sun-intensity threshold.
Player traversal
Open worlds need fast traversal. Options:
| Method | Speed (m/s) | Use case |
|---|---|---|
| Walking | 3-5 | On-foot exploration |
| Sprinting | 7-10 | Short bursts |
| Car (arcade) | 15-30 | The generated VehicleController.cs |
| Horse / mount | 8-12 | Fantasy open world |
| Fast travel | instant | Pre-unlocked locations |
The generated project ships both a PlayerController (on-foot) and a
VehicleController (car) so the user can switch between them.
NPC traffic
For an open-world city, AI traffic should be lightweight:
- Use
NavMeshAgentwith a lowaccelerationfor cars so they look smooth, not jerky. - Pool the agents -- never
Instantiatea new car every time one drives off-screen. - Despawn agents when they are > 100m from the player.
The generated NPCController.cs covers pedestrians; for cars you would
extend it with lane-following logic.
Save systems
Open worlds need persistent saves. The generated SaveSystem.cs writes a
single JSON file to Application.persistentDataPath:
[Serializable]
public class GameSave
{
public Vector3 playerPosition;
public int score;
public float timeOfDay;
public List<string> completedQuests;
}
For larger worlds, split saves into:
- Player profile (small, frequent).
- World state (large, infrequent -- chunk ownership, building destruction, NPC schedules).
Common pitfalls
- Floating point precision: Unity uses single-precision floats. Beyond ~5km from the origin, physics and rendering jitter. Use a floating origin that re-centres the world on the player every few hundred metres.
- Shadow distance: the default 150m shadow distance is too short for an open world. Raise to 300-500m for desktop; lower for mobile.
- Lighting rebuild: make sure all your static city geometry is marked Navigation Static AND Lightmap Static before baking. For runtime- generated cities you cannot bake -- rely on realtime lighting and the directional sun.
- Collider spam: 50 buildings x 6 faces each = 300 colliders. Mark
small decoration meshes as
isTrigger = falseand use a singleBoxColliderper building.