Project Architecture & Folder Structure
Design the organizational backbone of our infinite runner. Folder conventions, namespaces, assembly definitions, ScriptableObjects, the Singleton pattern, and SOLID principles adapted for game development.
This chapter is about setting up the structure that will keep our project maintainable as it grows from a few scripts to dozens. We're making decisions now that will prevent pain later. None of this code does anything visible in the game yet, but it's the foundation everything else is built on.
Why Architecture Matters
Let's look at two versions of the same project to understand why spending time on architecture pays off.
The "Tutorial Code" Project (What We're Avoiding)
Assets/
GameManager.cs (800 lines - handles state, scoring, UI, audio)
PlayerMove.cs (400 lines - movement, input, collision, animation)
Spawner.cs (300 lines - obstacles, coins, power-ups, all mixed)
UIScript.cs (200 lines - every UI screen in one file)
Coin.prefab
Obstacle1.prefab
player model.fbx
background_music.mp3
SampleScene.unity
coin_sound (1).wav
final_final_v2.unity
This is what happens when you don't plan. Everything is in one folder. Scripts are massive monoliths that handle multiple unrelated concerns. File names are inconsistent. There's no way to find anything quickly. Changing the scoring system means editing the GameManager, which also controls audio and UI, so any change might break something unrelated.
The Production Project (What We're Building)
Assets/
_Project/
Scenes/
GameScene.unity
MenuScene.unity
Scripts/
Core/
GameManager.cs (manages game state only)
EventBus.cs (central event system)
GameEvents.cs (event definitions)
Core.asmdef
Player/
PlayerController.cs (lane switching, jump, slide)
PlayerAnimator.cs (animation control)
PlayerCollision.cs (collision handling)
Player.asmdef
World/
ChunkManager.cs (chunk lifecycle)
WorldGenerator.cs (procedural generation)
ObjectPool.cs (object recycling)
OriginShifter.cs (floating point fix)
World.asmdef
Obstacles/
ObstacleBase.cs (base class for all obstacles)
BarrierObstacle.cs (specific obstacle type)
Obstacles.asmdef
Collectibles/
Coin.cs
PowerUp.cs
Collectibles.asmdef
UI/
HUDController.cs
MenuController.cs
GameOverController.cs
UI.asmdef
Audio/
AudioManager.cs
Audio.asmdef
Camera/
CameraController.cs
CameraShake.cs
Camera.asmdef
Data/
GameConfig.cs (ScriptableObject)
DifficultyConfig.cs (ScriptableObject)
Data.asmdef
Prefabs/
Player/
Obstacles/
Collectibles/
UI/
Materials/
Audio/
Music/
SFX/
Art/
Models/
Textures/
Animations/
Settings/
GameConfig.asset
DifficultyConfig.asset
Every script has a clear home. Every file type has its own folder. Each system is isolated in its own folder with its own assembly definition. Finding a file takes seconds. Changing the scoring system means editing Scripts/Core/ files only — nothing else is touched.
We put all our custom files inside a folder called _Project. The underscore makes it sort to the top of the Assets folder, keeping it visually separated from Unity's auto-generated folders (like Settings, Packages, TextMesh Pro). This is a common convention in professional Unity projects. You can name it _Game or _App instead — the important thing is consistency.
Creating the Folder Structure
Let's create this folder structure now. Open your InfiniteRunner project in Unity and follow these steps:
- In the Project window, right-click on Assets and choose Create → Folder. Name it
_Project. - Inside
_Project, create these folders:ScenesScriptsPrefabsMaterialsAudioArtSettings
- Inside
Scripts, create these subfolders:CorePlayerWorldObstaclesCollectiblesUIAudioCameraData
- Inside
Prefabs, create subfolders:Player,Obstacles,Collectibles,UI. - Inside
Audio, create subfolders:Music,SFX. - Inside
Art, create subfolders:Models,Textures,Animations. - Move your existing
GameScene.unityfrom wherever it is into_Project/Scenes/. (Drag it in the Project window.)
Always move files by dragging them within the Project window. Never use Windows Explorer or macOS Finder to move Unity files. Unity tracks files using .meta files, and moving files externally breaks those references, causing missing asset errors.
Version Control with Git
Before we write a single line of game code, we need to set up version control. This is non-negotiable in production development. Version control tracks every change you make to your project, lets you undo mistakes, and keeps a history of your entire project's evolution.
Imagine you spend 3 hours building the player controller. It works great. Then you make a "small change" that breaks everything, and you can't remember what you changed. Without version control, you're stuck. With version control, you type one command and you're back to the working version. Every professional game studio uses version control. No exceptions.
What is Git?
Git is the most widely used version control system in the world. It tracks changes to your files over time. Think of it like an unlimited "undo" system, but much more powerful — you can see exactly what changed, when, and why. You can jump back to any previous state of your project at any time.
Key concepts you need to know:
- Repository (repo) — Your project folder, tracked by Git. All history is stored here.
- Commit — A snapshot of your project at a point in time. Like pressing "save" in a video game. Each commit has a message describing what changed.
- Staging — Choosing which files to include in your next commit. You might have changed 10 files, but only want to commit 3 of them.
- Branch — A parallel version of your project. You can experiment on a branch without affecting your main code. We'll learn this later.
Step 1: Install Git
- Windows: Download Git from
git-scm.com. Run the installer. Accept all default options. When asked about the default editor, choose Visual Studio Code if you installed it. - Mac: Open Terminal and type
git --version. If Git isn't installed, macOS will prompt you to install it via Xcode Command Line Tools. Click Install. - Linux: Open a terminal and run
sudo apt install git(Ubuntu/Debian) orsudo dnf install git(Fedora). - Verify installation: Open a terminal (Command Prompt on Windows, Terminal on Mac/Linux) and type:
TerminalShell
git --versionYou should see something like
git version 2.43.0. The exact version doesn't matter. - Set your identity (one-time setup). Git attaches your name and email to every commit:
TerminalShell
git config --global user.name "Your Name" git config --global user.email "your.email@example.com"
Step 2: Create the .gitignore File
Unity generates a LOT of temporary files — compiled code, cached textures, editor layouts, build outputs. We do not want to track these in Git. They can be regenerated automatically, and they change constantly, cluttering your history.
A .gitignore file tells Git which files and folders to ignore. Create this file in the root of your Unity project (the folder that contains Assets/, Packages/, and ProjectSettings/):
- Open your code editor (VS Code or Visual Studio).
- Create a new file in the root of your Unity project.
- Name it exactly
.gitignore(with the dot at the start, no file extension). - Paste the following content into it:
# Unity generated folders
[Ll]ibrary/
[Tt]emp/
[Oo]bj/
[Bb]uild/
[Bb]uilds/
[Ll]ogs/
[Uu]ser[Ss]ettings/
# MemoryCaptures
[Mm]emoryCaptures/
# Recordings
[Rr]ecordings/
# Asset meta data should only be ignored when the corresponding asset is also ignored
!/[Aa]ssets/**/*.meta
# Autogenerated Crash Report
sysinfo.txt
# Builds
*.apk
*.aab
*.unitypackage
*.app
# Compiled code
*.dll
*.exe
*.pdb
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.idea/
.vs/
.vscode/
*.csproj
*.sln
*.suo
*.user
*.pidb
*.booproj
*.svd
*.pdb
*.mdb
*.opendb
*.VC.db
# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
*.mdb.meta
# Gradle (Android builds)
ExportedObj/
.gradle/
build/
*.unitypackage
# Plastic SCM
ignore.conf
*.private.0
*.private
If you commit the Library folder and then add the .gitignore later, Git will continue tracking those files. The .gitignore only affects untracked files. Always create it first. If you make this mistake, you'll need to run git rm -r --cached Library/ to untrack it — but let's avoid that by doing it in the right order.
Step 3: Initialize the Repository
Now let's turn your Unity project into a Git repository. Open a terminal and navigate to your project's root folder (the one containing Assets/):
# Navigate to your project folder
cd "C:/Users/YourName/Unity Projects/InfiniteRunner"
# Initialize Git repository
git init
You should see: Initialized empty Git repository in .../InfiniteRunner/.git/
Step 4: Your First Commit
Let's save the current state of your project. This is the workflow you'll follow every time:
# 1. See what files Git sees (check that Library/ etc. are NOT listed)
git status
# 2. Stage ALL current files for commit
git add .
# 3. Create your first commit with a descriptive message
git commit -m "Initial project setup with folder structure"
Let's break down those three commands — you'll use them hundreds of times:
| Command | What It Does | Analogy |
|---|---|---|
git status |
Shows which files have changed since the last commit | Checking what's in your shopping cart before checkout |
git add . |
Stages all changed files for the next commit. The . means "everything" |
Putting items in the shopping cart |
git commit -m "..." |
Creates a snapshot with a message describing what changed | Pressing "confirm purchase" — the save is permanent |
Your commit message should describe what you did, not what files changed. Git already tracks which files changed. Good messages:
"Add player controller with lane switching""Fix jump height being too low""Implement object pooling for obstacles"
Bad messages:
"updated stuff""fixed bug""asdfasdf"
Your future self will thank you when you're searching through history trying to find when something changed.
Step 5: Verify It Worked
# See your commit history
git log --oneline
You should see something like:
a1b2c3d Initial project setup with folder structure
That's your first commit. Your project is now safely tracked. If anything ever goes wrong, you can always come back to this state.
The Daily Git Workflow
From now on, at the end of every chapter, we'll tell you to save your progress. The workflow is always the same three commands:
# 1. Check what changed
git status
# 2. Stage the changes
git add .
# 3. Commit with a message
git commit -m "Your description of what you did"
Commit whenever you finish a logical piece of work that compiles and doesn't break anything. At minimum, commit after completing each chapter. In professional development, you'd commit multiple times per day — after adding a feature, fixing a bug, or reaching any stable milestone. Think of it like quicksaving in a game: do it before anything risky.
Useful Git Commands Reference
You only need the three commands above for now. We'll introduce more throughout the course, but here's a quick reference for when you're ready:
| Command | What It Does |
|---|---|
git status |
See what's changed |
git add . |
Stage all changes |
git add filename |
Stage a specific file only |
git commit -m "msg" |
Save a snapshot |
git log --oneline |
See commit history (compact) |
git diff |
See exactly what lines changed |
git checkout -- filename |
Undo changes to a file (revert to last commit) |
We'll introduce branches (for safely experimenting), GitHub (for backup and sharing), and git diff (for reviewing changes) in later chapters as they become relevant. For now, master the three-command workflow: status, add, commit.
Namespaces
A namespace is a way to organize your code into logical groups and prevent naming conflicts. Without namespaces, if you create a class called GameManager and a Unity asset store package also has a class called GameManager, they'll conflict and cause errors. With namespaces, your InfiniteRunner.Core.GameManager is completely separate from any other GameManager.
using UnityEngine;
namespace InfiniteRunner.Core
{
public class GameManager : MonoBehaviour
{
// All code for this class goes inside the namespace block
void Start()
{
Debug.Log("GameManager initialized");
}
}
}
using UnityEngine;
using InfiniteRunner.Core; // Import the Core namespace to use GameManager
namespace InfiniteRunner.Player
{
public class PlayerController : MonoBehaviour
{
void Start()
{
Debug.Log("PlayerController initialized");
}
}
}
Our namespace convention mirrors our folder structure:
| Folder | Namespace |
|---|---|
| Scripts/Core/ | InfiniteRunner.Core |
| Scripts/Player/ | InfiniteRunner.Player |
| Scripts/World/ | InfiniteRunner.World |
| Scripts/Obstacles/ | InfiniteRunner.Obstacles |
| Scripts/Collectibles/ | InfiniteRunner.Collectibles |
| Scripts/UI/ | InfiniteRunner.UI |
| Scripts/Audio/ | InfiniteRunner.Audio |
| Scripts/Camera/ | InfiniteRunner.GameCamera |
| Scripts/Data/ | InfiniteRunner.Data |
Unity already has a class called Camera in the UnityEngine namespace. If we named our namespace InfiniteRunner.Camera, the C# compiler could get confused between our namespace and Unity's Camera class. By using GameCamera, we avoid any ambiguity. This kind of naming conflict is exactly what namespaces are designed to prevent, but sometimes the namespace name itself can cause issues.
Assembly Definitions
Assembly Definitions (asmdef files) tell Unity how to organize your code into separate compilation units. Without them, Unity compiles all of your scripts into a single assembly every time you change any script. With them, Unity only recompiles the assembly that changed.
Why This Matters
Imagine you have 50 scripts. You change one line in Coin.cs. Without assembly definitions, Unity recompiles all 50 scripts. With assembly definitions, Unity only recompiles the Collectibles assembly (maybe 3 scripts) and anything that depends on it. On large projects, this can cut compile times from 10 seconds down to 1–2 seconds. For a small project like ours, the difference is minor, but learning this now sets you up for good habits.
Creating an Assembly Definition
- In the Project window, navigate to
_Project/Scripts/Core/. - Right-click and choose Create → Assembly Definition.
- Name it
InfiniteRunner.Core. - Click on the new
.asmdeffile. In the Inspector, you'll see its settings. - Under "Root Namespace", type
InfiniteRunner.Core. This tells your code editor to use this namespace by default for scripts in this folder.
Repeat this process for each script folder. Here's the dependency chain — some assemblies need to reference others:
InfiniteRunner.Core → (no dependencies - it's the foundation)
InfiniteRunner.Data → references Core
InfiniteRunner.Player → references Core, Data
InfiniteRunner.World → references Core, Data
InfiniteRunner.Obstacles → references Core, Data, World
InfiniteRunner.Collectibles → references Core, Data
InfiniteRunner.UI → references Core, Data
InfiniteRunner.Audio → references Core
InfiniteRunner.GameCamera → references Core, Player
To add a reference: click on an assembly definition file, find the "Assembly Definition References" section in the Inspector, click "+", and drag the referenced assembly definition into the slot.
Once you create an assembly definition in a folder, scripts in that folder can only see scripts in assemblies they explicitly reference. If your PlayerController needs to use GameManager from the Core assembly, the Player assembly definition must list Core as a reference. If you forget this, you'll get "type not found" compile errors. The error message will tell you exactly which type is missing, so you'll know which reference to add.
Assembly definitions are an advanced organizational tool. If they feel overwhelming right now, you can skip them and come back later. Your project will work perfectly without them — compile times will just be slightly slower. We include them here because they're part of production-grade architecture, and it's easier to set them up at the start than to retrofit them later.
ScriptableObjects: Data That Lives as Assets
A ScriptableObject is a special type of class in Unity that stores data as an asset file in your Project window. Instead of hardcoding values like float moveSpeed = 10f; directly in your scripts, you create a ScriptableObject that holds those values. Then you can edit them in the Inspector without touching any code.
Why ScriptableObjects Are Powerful
- Designers can tweak values without opening code. Your game designer (even if that's just you) can adjust speed, jump height, spawn rates, and difficulty curves in the Inspector.
- Multiple configurations from one class. You can create a "Easy Difficulty" asset and a "Hard Difficulty" asset from the same ScriptableObject class, then swap between them.
- Data is shared, not copied. If 100 obstacles reference the same ScriptableObject, they all read the same data. Change it once and all 100 update instantly.
- No scene dependency. ScriptableObjects live in the Project as assets, not in any scene. They persist between scene loads and aren't destroyed when you load a new scene.
Creating a ScriptableObject
Let's create a configuration file for our game's core settings:
using UnityEngine;
namespace InfiniteRunner.Data
{
// The CreateAssetMenu attribute adds an entry to Unity's
// right-click Create menu, letting you create instances
// of this ScriptableObject as asset files.
[CreateAssetMenu(
fileName = "GameConfig",
menuName = "Infinite Runner/Game Config",
order = 0
)]
public class GameConfig : ScriptableObject
{
[Header("Player Settings")]
[Tooltip("Base forward speed of the player")]
[SerializeField] private float baseSpeed = 10f;
[Tooltip("Horizontal distance between lane centers")]
[SerializeField] private float laneWidth = 2.5f;
[Tooltip("How fast the player switches lanes")]
[SerializeField] private float laneSwitchSpeed = 8f;
[Tooltip("Force applied when jumping")]
[SerializeField] private float jumpForce = 10f;
[Header("World Settings")]
[Tooltip("Length of each world chunk in units")]
[SerializeField] private float chunkLength = 40f;
[Tooltip("Number of chunks to keep ahead of the player")]
[SerializeField] private int chunksAhead = 3;
[Tooltip("Number of chunks to keep behind the player")]
[SerializeField] private int chunksBehind = 1;
[Header("Difficulty")]
[Tooltip("How much speed increases per second")]
[SerializeField] private float speedIncreaseRate = 0.1f;
[Tooltip("Maximum speed the game can reach")]
[SerializeField] private float maxSpeed = 30f;
// Public read-only properties: other scripts can READ
// these values but cannot CHANGE them.
public float BaseSpeed => baseSpeed;
public float LaneWidth => laneWidth;
public float LaneSwitchSpeed => laneSwitchSpeed;
public float JumpForce => jumpForce;
public float ChunkLength => chunkLength;
public int ChunksAhead => chunksAhead;
public int ChunksBehind => chunksBehind;
public float SpeedIncreaseRate => speedIncreaseRate;
public float MaxSpeed => maxSpeed;
}
}
Now create an instance of this ScriptableObject in Unity:
- Save the script and switch back to Unity. Wait for it to compile.
- In the Project window, navigate to
_Project/Settings/. - Right-click and choose Create → Infinite Runner → Game Config. (This menu entry was created by our
[CreateAssetMenu]attribute.) - Name the asset
GameConfig. - Click on it. In the Inspector, you'll see all the settings with their default values, neatly organized under headers. You can edit any value here.
Notice the [Header("Player Settings")] and [Tooltip("...")] attributes on our fields. [Header] adds a bold section label in the Inspector, keeping related values grouped together visually. [Tooltip] shows explanatory text when you hover over a field in the Inspector. These small touches make your ScriptableObjects much more user-friendly, especially when you come back to the project after a break and can't remember what each value does.
Using a ScriptableObject in a Script
Any MonoBehaviour script can reference a ScriptableObject. You drag the asset into a serialized field in the Inspector:
using UnityEngine;
using InfiniteRunner.Data;
namespace InfiniteRunner.Player
{
public class PlayerController : MonoBehaviour
{
// Drag the GameConfig asset into this field in the Inspector
[SerializeField] private GameConfig config;
private float currentSpeed;
void Start()
{
// Read values from the config asset
currentSpeed = config.BaseSpeed;
Debug.Log("Starting speed: " + currentSpeed);
Debug.Log("Lane width: " + config.LaneWidth);
}
void Update()
{
// Use config values for movement
transform.Translate(
Vector3.forward * currentSpeed * Time.deltaTime
);
}
}
}
How Our Systems Connect
Here's a conceptual overview of how our systems communicate. The key principle is that systems talk through events, not direct references. This keeps them decoupled — you can change or remove any system without breaking others.
┌─────────────────┐
│ Game Config │ (ScriptableObject)
│ (shared data) │
└────────┬────────┘
│ read by all systems
┌────────────────┼────────────────┐
│ │ │
┌───────▼───────┐ ┌─────▼─────┐ ┌────────▼────────┐
│ Game Manager │ │ Player │ │ World Generator │
│ (state) │ │Controller │ │ (chunks, pool) │
└───────┬───────┘ └─────┬─────┘ └────────┬────────┘
│ │ │
│ ┌─────▼─────┐ │
│ │ Input │ │
│ │ System │ │
│ └───────────┘ │
│ │
┌───────▼────────────────────────────────▼───┐
│ EVENT BUS │
│ (GameStarted, PlayerDied, ScoreChanged, │
│ CoinCollected, StateChanged, etc.) │
└───┬──────────┬──────────┬──────────┬───────┘
│ │ │ │
┌────▼───┐ ┌───▼────┐ ┌──▼───┐ ┌───▼──────┐
│ UI │ │ Audio │ │Camera│ │ Scoring │
│ System │ │Manager │ │System│ │ System │
└────────┘ └────────┘ └──────┘ └──────────┘
Notice that the UI System doesn't know the Player Controller exists. It doesn't need to. It listens for a PlayerDied event and shows the game over screen. If we replaced the player with a spaceship, the UI would work exactly the same — it only cares about events, not where they came from.
The Singleton Pattern
A Singleton is a design pattern that ensures only one instance of a class exists, and provides a global access point to it. In Unity, this is commonly used for manager objects like GameManager and AudioManager — systems where having two copies would cause problems.
using UnityEngine;
namespace InfiniteRunner.Core
{
/// <summary>
/// Generic Singleton base class for MonoBehaviours.
/// Inherit from this to make any manager a Singleton.
/// Usage: public class GameManager : Singleton<GameManager>
/// </summary>
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
private static T instance;
public static T Instance
{
get
{
if (instance == null)
{
// Try to find an existing instance in the scene
instance = FindFirstObjectByType<T>();
if (instance == null)
{
Debug.LogError(
typeof(T).Name + " Singleton not found in scene! "
+ "Make sure a GameObject with this component exists."
);
}
}
return instance;
}
}
protected virtual void Awake()
{
// If an instance already exists and it's not this one, destroy this one
if (instance != null && instance != this)
{
Debug.LogWarning(
"Duplicate " + typeof(T).Name + " found. Destroying duplicate."
);
Destroy(gameObject);
return;
}
instance = this as T;
// Optional: survive scene loads
// DontDestroyOnLoad(gameObject);
}
protected virtual void OnDestroy()
{
if (instance == this)
{
instance = null;
}
}
}
}
Using the Singleton base class is simple. Any manager that should be a Singleton just inherits from it:
using UnityEngine;
namespace InfiniteRunner.Core
{
public class GameManager : Singleton<GameManager>
{
// Awake is called in the base Singleton class,
// so if we override it, we must call base.Awake()
protected override void Awake()
{
base.Awake(); // IMPORTANT: let Singleton do its setup
Debug.Log("GameManager is ready");
}
public void StartGame()
{
Debug.Log("Game started!");
}
}
// Any script can now access it globally:
// GameManager.Instance.StartGame();
}
When to Use Singletons
- DO use Singletons for: GameManager, AudioManager, and other systems where exactly one instance must exist and many systems need to access it.
- Do NOT use Singletons for: Player controllers, enemies, UI elements, or anything that could have multiple instances. If you find yourself wanting more than one of something, it shouldn't be a Singleton.
Many experienced developers dislike Singletons because they create hidden global state, make testing harder, and can lead to tight coupling. These criticisms are valid. However, for a game of this scope, Singletons for core managers are a pragmatic choice. Our event-driven architecture mitigates most of the downsides — other systems communicate through events, not by directly calling GameManager.Instance.DoSomething(). We'll keep Singletons to a minimum: only GameManager, AudioManager, and EventBus.
SOLID Principles for Game Development
SOLID is a set of five design principles from software engineering. They sound academic, but they solve very real problems. Here's each one explained in practical game dev terms:
S — Single Responsibility Principle
"Every class should have one reason to change."
Don't put player movement, scoring, and UI logic in the same script. If you need to change how scoring works, you shouldn't have to open the player movement file. Each script does one thing and does it well.
PlayerController.cshandles movement only.PlayerCollision.cshandles collisions only.PlayerAnimator.cshandles animations only.- They all sit on the same GameObject, but each has a single responsibility.
O — Open/Closed Principle
"Open for extension, closed for modification."
When you add a new obstacle type, you shouldn't have to modify existing obstacle code. Instead, you create a new class that extends the base obstacle. In our project:
ObstacleBase.csdefines the common behavior.BarrierObstacle.csextends it with barrier-specific behavior.- Adding
SpikeObstacle.csdoesn't require changingObstacleBaseorBarrierObstacleat all.
L — Liskov Substitution Principle
"Child classes should work anywhere the parent class is expected."
If your world generator works with ObstacleBase, it should be able to use any subclass (BarrierObstacle, SpikeObstacle) without knowing or caring which specific type it is.
I — Interface Segregation Principle
"Don't force classes to implement methods they don't use."
If you have an ICollectable interface, it should only require methods related to collecting (Collect(), GetValue()). Don't add Move() or Animate() to the same interface — not all collectibles need those.
D — Dependency Inversion Principle
"Depend on abstractions, not concrete implementations."
Our UI system listens for events (an abstraction), not for specific methods on the GameManager (a concrete implementation). If we replaced GameManager with a completely different state system, the UI would still work because it depends on events, not on GameManager directly.
SOLID principles are guidelines, not laws. In game development, pragmatism wins over purity. If following a principle makes your code harder to understand for no practical benefit, skip it. For our project, we'll apply these principles where they clearly help (like using a base class for obstacles) and keep things simple where they don't (like not creating an interface for something only one class implements). The goal is readable, maintainable code — not textbook-perfect architecture.
Putting It All Together
Let's verify our setup by creating the core files we'll build on in the next chapters. Create these three scripts in the specified locations:
namespace InfiniteRunner.Core
{
/// <summary>
/// Defines all game events as structs.
/// Structs are lightweight, no-allocation event payloads.
/// We'll populate this as we build each system.
/// </summary>
public struct GameStartedEvent { }
public struct GameOverEvent
{
public int FinalScore;
public float DistanceTraveled;
}
public struct GamePausedEvent
{
public bool IsPaused;
}
public struct ScoreChangedEvent
{
public int NewScore;
}
}
This file defines our event types as lightweight structs. We'll add more events as we build each system. The key insight is that events are just data — they describe what happened, not how to respond to it. The response is up to whoever is listening.
Chapter Summary
We've laid the architectural foundation for our entire project. Here's what we accomplished:
- Folder structure — Organized assets into
_Project/with subfolders for Scripts, Prefabs, Materials, Audio, Art, Scenes, and Settings. - Version control — Installed Git, created a Unity
.gitignore, initialized a repository, and made our first commit. - Script folders — Separated code by system: Core, Player, World, Obstacles, Collectibles, UI, Audio, Camera, Data.
- Namespaces — Each folder has its own namespace (e.g.,
InfiniteRunner.Core) to prevent naming conflicts and improve organization. - Assembly Definitions — Each script folder gets an
.asmdeffile for faster compile times and explicit dependency management. - ScriptableObjects — Created
GameConfigfor data-driven configuration that designers can edit without touching code. - Singleton pattern — Built a reusable
Singleton<T>base class for our manager objects. - SOLID principles — Understood the five principles and how they apply pragmatically to game development.
- Event architecture — Defined initial event structs for decoupled system communication.
This might feel like we haven't built "the game" yet. That's true — and that's intentional. This architecture is the skeleton. Starting in the next chapter, we'll flesh it out rapidly. The Game Manager comes first, followed by the Event System, and from there, each chapter adds a complete, working system. Because we invested in architecture now, adding each system will be clean and straightforward.
You've finished the architecture chapter. Let's commit everything we've set up. Open your terminal, navigate to your project folder, and run:
git status
git add .
git commit -m "Set up project architecture, folder structure, and core scripts"
Run git log --oneline to see your commits so far. You should see two entries now: your initial setup commit and this one. Get used to this workflow — you'll do it at the end of every chapter from here on out.
Let's build the Game Manager.