Cocos Creator Mini-Game Project Structure: How to Split Boot, Scenes, and Settlement Pages
At 2 AM, staring at the “scene not found” error on my screen, I realized this mini-game project’s directory structure had spiraled completely out of control.
Three months ago, I started with full confidence: one scene for the menu, another for the main game interface, and a third for the settlement page—sounds reasonable, right? But the result? Player scores mysteriously reset during scene transitions, loading animations stuttered like a slideshow, and worst of all, one level took 5 seconds to load, causing half the users to leave before even seeing the game screen.
That’s when I understood: mini-game architecture isn’t just about throwing a few scene files together. Later, I refactored the entire project to a single-scene + four-layer architecture, cutting loading time from 5 seconds to under 2 seconds, with scene transitions smooth as butter.
Why Single-Scene Architecture is Recommended for Mini-Games
Honestly, when I first started making mini-games, I never thought about “architecture” at all. Menu, game, settlement—each interface got its own scene file. Simple and crude.
Only after running it did I discover a pile of problems:
Switching overhead. Every director.loadScene() call requires the engine to destroy the old scene, create a new one, and reload resources. This process is disastrous for mini-games—53% of mobile users will abandon a page that takes more than 3 seconds to load, and multi-scene switching easily exceeds this threshold.
State loss. Player finishes a level with a score of 1200, switches to settlement page—data gone. Why? Scene transitions destroy all nodes unless you store data in global variables or persistent nodes (I’ll explain how later).
Animation interruption. The menu has a cool logo animation that hasn’t finished playing, user clicks the start button, scene switches—animation gets abruptly cut off, terrible user experience.
The advantages of single-scene architecture are clear: all UI layers exist in the same scene, switching just means changing a Layer’s active property, no destruction and reconstruction overhead, state is naturally preserved, and animations don’t get interrupted. Mini-games typically don’t have many interfaces, so this architecture is perfectly sufficient. Large games are different—with many levels and large resources, multi-scene subpackage loading is indeed necessary. But for mini-games? Single-scene is enough.
Project Directory Structure Template
Alright, now that we know why to use single-scene architecture, let’s look at how to organize the project directory.
Here’s the template I summarized after stumbling through many pitfalls (using Cocos Creator 3.x as an example):
assets/
├── Scenes/
│ └── Main.scene # The only main scene
├── Scripts/
│ ├── managers/
│ │ ├── GameManager.ts # Game state, data storage
│ │ ├── UIManager.ts # Layer switching logic
│ │ └── AudioManager.ts # Audio management
│ ├── layers/
│ │ ├── BootLayer.ts # Startup page logic
│ │ ├── MenuLayer.ts # Main menu logic
│ │ ├── GameLayer.ts # Game main interface
│ │ └── SettlementLayer.ts # Settlement page
│ └── components/
│ ├── PlayerController.ts
│ └── EnemyAI.ts
├── Prefabs/
│ ├── UI/
│ │ ├── BootPanel.prefab # Startup page UI prefab
│ │ ├── MenuPanel.prefab # Menu UI
│ │ ├── SettlementPanel.prefab
│ ├── Game/
│ │ ├── Player.prefab
│ │ ├── Obstacle.prefab
│ └── Effects/
│ └── Explosion.prefab
├── Resources/
│ ├── textures/ # Dynamically loaded images
│ ├── audio/ # Audio files
│ └── fonts/
└── bundles/ # Asset Bundle subpackages (WeChat mini-game optimization)
├── core/ # Core resource bundle
└── levels/ # Level resource bundle
Key points:
Scenes directory contains only one file. Main.scene is the only main scene, containing Canvas, GameManager, and various Layer nodes. Don’t stuff other scenes into this directory.
managers holds management class scripts. GameManager uses game.addPersistRootNode() to set as a persistent node, responsible for storing scores, level progress, and other data; UIManager handles Layer switching logic.
layers directory holds logic scripts for each UI layer. Each Layer corresponds to a Prefab (in Prefabs/UI directory), scripts attach to the Prefab, then drag into Main.scene’s Canvas node.
bundles directory is for WeChat/Douyin mini-games. Mini-games have first package size limits, exceeding 4MB requires Asset Bundle subpackage loading. Core resources go in core bundle, level resources load on demand.
Is your project directory organized this way? If not, you might want to consider adjusting it.
Boot Scene: How to Design the Startup Page
Let me share a data point: Cocos Creator engine itself is about 1.9MB, plus your game resources, first screen loading time is 3-5 seconds by default. For mini-games, this is too long—users click open the game, stare at a black screen or blank page for 3 seconds, many just close it.
Boot Layer exists to solve this problem. Its responsibilities are simple:
Display loading progress. Let users know the game is loading, not frozen. A progress bar or percentage number is enough, don’t make it too fancy—at this point, image resources haven’t finished loading yet.
Preload core resources. Use resources.preload() or Asset Bundle’s loadBundle() to preload images and audio needed next.
Brand display. Put up a logo, make a simple animation (like logo scaling in with fade), and advertise the brand while you’re at it.
The startup flow looks like this:
Engine initialization → Boot Layer display → Preload core resources → Loading complete → Switch to MenuLayer
Code example (BootLayer.ts):
import { _decorator, Component, Node, resources, ProgressBar } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('BootLayer')
export class BootLayer extends Component {
@property(ProgressBar)
progressBar: ProgressBar | null = null;
start() {
// Preload core resources
this.preloadCoreAssets();
}
preloadCoreAssets() {
resources.preloadDir('textures', (err, items) => {
if (err) {
console.error('Preload failed:', err);
return;
}
// Loading complete, switch to menu
this.switchToMenu();
});
}
updateProgress(current: number, total: number) {
if (this.progressBar) {
this.progressBar.progress = current / total;
}
}
switchToMenu() {
// Tell UIManager to switch to MenuLayer
// Code will be shown later
}
}
WeChat mini-game first package optimization: If your game needs to be published on WeChat mini-game platform, the first package limit is 4MB. Anything beyond requires Asset Bundle subpackaging. You can load these bundles in Boot Layer:
assetManager.loadBundle('levels', (err, bundle) => {
if (err) return;
console.log('Level bundle loaded');
});
H5 platform custom startup page: Cocos Creator provides a build-templates mechanism to customize the startup page after H5 publication. Create a build-templates/web-mobile directory in the project root, put a custom index.html, and you can replace the default black startup page—add a loading animation or brand image.
See the official forum tutorial for details: Creator | Custom Startup Page for H5.
With Boot Layer designed, next comes the core of the entire architecture: how the four layers switch.
Four-Layer Architecture Implementation: From Menu to Settlement
This is the core part. The entire main scene structure looks like this:
Main.scene
├── Canvas (UI container)
│ ├── BootLayer → Loading progress, brand display
│ ├── MenuLayer → Start interface, level selection
│ ├── GameLayer → Game main interface
│ └── SettlementLayer → Settlement page (score, time, continue/retry)
├── GameManager (persistent node)
│ └─ Stores: score, level progress, player settings
└── AudioRoot (audio management node)
See, all four Layers are under the same Canvas. When switching, you only change a Layer’s active property, other Layers continue to exist—state doesn’t get lost, animations don’t get interrupted.
The core logic for Layer switching is in UIManager:
// UIManager.ts
import { _decorator, Component, Node, tween, Vec3 } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('UIManager')
export class UIManager extends Component {
private layers: Map<string, Node> = new Map();
private currentLayer: string = 'BootLayer';
onLoad() {
// Collect all Layer nodes
const canvas = this.node.getChildByName('Canvas');
if (!canvas) return;
canvas.children.forEach(child => {
if (child.name.endsWith('Layer')) {
this.layers.set(child.name, child);
child.active = false; // Initially hide all
}
});
// Show BootLayer
this.switchLayer('BootLayer');
}
switchLayer(targetLayer: string) {
// Hide current layer
const current = this.layers.get(this.currentLayer);
if (current) {
current.active = false;
}
// Show target layer
const target = this.layers.get(targetLayer);
if (target) {
target.active = true;
// Add fade-in animation (optional)
this.playFadeIn(target);
}
this.currentLayer = targetLayer;
}
playFadeIn(node: Node) {
// Simple scale fade-in animation
node.setScale(new Vec3(0.9, 0.9, 1));
tween(node)
.to(0.2, { scale: new Vec3(1, 1, 1) })
.start();
}
}
Key point: Use Map to store all Layer references, so you don’t need to getChildByName() every time when switching, much more efficient.
Settlement page data transfer: Player finishes a level, score 1200, time 45 seconds—how does this data get to the settlement page?
Answer: GameManager.
GameManager is a persistent node, its lifecycle spans the entire game. Player finishes a level in GameLayer, stores score and time in GameManager; when switching to SettlementLayer, reads data from GameManager to display.
// GameManager.ts (simplified version)
import { _decorator, Component, game } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('GameManager')
export class GameManager extends Component {
public currentScore: number = 0;
public currentLevel: number = 1;
public playTime: number = 0;
onLoad() {
// Set as persistent node
game.addPersistRootNode(this.node);
}
// GameLayer calls this method to save data
saveResult(score: number, time: number) {
this.currentScore = score;
this.playTime = time;
}
// SettlementLayer calls this method to get data
getResult() {
return {
score: this.currentScore,
time: this.playTime
};
}
}
When SettlementLayer displays, directly read GameManager’s data:
// SettlementLayer.ts
onEnable() {
const gameManager = find('GameManager')?.getComponent(GameManager);
if (!gameManager) return;
const result = gameManager.getResult();
this.scoreLabel.string = `Score: ${result.score}`;
this.timeLabel.string = `Time: ${result.time}s`;
}
Data transfer solved. No need for complex event systems, GameManager handles it all.
Persistent Nodes: Cross-Layer Data Transfer
Actually, I already mentioned persistent nodes above, let me elaborate.
game.addPersistRootNode() is an official Cocos Creator API that makes a node not get destroyed during scene transitions. But we’re using single-scene architecture, scenes don’t switch, why do we need persistent nodes?
Actually, persistent nodes are also useful in single-scene architecture: they can serve as global data centers and event centers.
GameManager responsibilities:
Store player data—scores, level progress, high scores, unlocked level list.
Store settings data—sound effects toggle, music toggle, language selection.
Provide global events—like “level complete” event, each Layer can listen and respond.
Important notes:
Persistent nodes cannot be placed under Canvas. Canvas is a UI container that gets manipulated with Layer’s active state changes. Persistent nodes need to be independent, placed at the scene’s root level.
Persistent nodes must be created manually. In Main.scene, create an empty node, name it “GameManager”, attach GameManager script, then call game.addPersistRootNode(this.node) in the script’s onLoad().
Use persistent nodes judiciously. Don’t stuff everything in there, only store data that truly needs to be shared across interfaces. Each Layer’s own UI state (like whether a button is visible), don’t put in GameManager—let the Layer manage itself.
A complete data structure example:
// GameManager.ts (complete version)
interface PlayerData {
highestScore: number;
unlockedLevels: number[];
currentLevel: number;
}
interface GameSettings {
soundEnabled: boolean;
musicEnabled: boolean;
language: 'zh' | 'en';
}
@ccclass('GameManager')
export class GameManager extends Component {
private playerData: PlayerData = {
highestScore: 0,
unlockedLevels: [1],
currentLevel: 1
};
private settings: GameSettings = {
soundEnabled: true,
musicEnabled: true,
language: 'zh'
};
onLoad() {
game.addPersistRootNode(this.node);
// Load data from local storage
this.loadData();
}
loadData() {
const saved = localStorage.getItem('playerData');
if (saved) {
this.playerData = JSON.parse(saved);
}
}
saveData() {
localStorage.setItem('playerData', JSON.stringify(this.playerData));
}
// getters and setters...
}
I also added localStorage read/write to persist player data locally. Mini-game platforms all support localStorage (WeChat mini-games use wx.setStorageSync, but wrapped localStorage also works).
Summary
After all this, here’s an architecture decision checklist for you to compare with your own project:
If you’re making mini-games:
- Use single-scene architecture (one Main.scene)
- Put all UI layers under Canvas, switch with Layer
- BootLayer handles loading progress and preloading
- GameManager as persistent node for data storage
- Organize directory structure as assets/Scripts/managers/layers/Prefabs
If you’re making large games:
- Multi-scene is necessary (many levels, large resources)
- But UI layers can use single-scene + Layer approach
- Use Asset Bundle for subpackage loading
I’ve used this architecture in several mini-game projects, stumbled through pitfalls, revised it several times—should be fairly mature now. If you have questions, leave a comment, or find an example project on GitHub to compare.
Next article, I plan to talk about Layer switching animation effects—fade in/out, slide transitions, scale zoom, making transitions even smoother.
Build Cocos Creator Mini-Game Single-Scene Architecture
Build a mini-game project architecture with Boot, main scene, and settlement page from scratch
⏱️ Estimated time: 30 min
- 1
Step1: Create project directory structure
Create directories under assets including Scenes, Scripts/managers, Scripts/layers, Scripts/components, Prefabs/UI, Prefabs/Game, Resources, bundles, etc., organizing files by responsibility. - 2
Step2: Create Main.scene and GameManager
Create the only main scene Main.scene, create a GameManager node at root level and attach script, call game.addPersistRootNode(this.node) in onLoad() to set as persistent node. - 3
Step3: Create four Layer prefabs
Create BootLayer, MenuLayer, GameLayer, SettlementLayer four Prefabs, each Prefab has corresponding logic script attached, all placed in Prefabs/UI directory. - 4
Step4: Implement UIManager switching logic
In UIManager.ts, use Map to store all Layer references, implement switchLayer(targetLayer) method, switch interfaces by controlling active property, add fade-in animation to improve experience. - 5
Step5: Implement Boot Layer loading process
In BootLayer.ts, call resources.preloadDir() to preload core resources, update progress bar, after loading completes call UIManager to switch to MenuLayer.
FAQ
Why is single-scene architecture recommended for mini-games?
How to pass data during scene transitions?
Which level should the persistent node be placed at?
What is the role of Boot Layer?
What if WeChat mini-game first package exceeds 4MB?
7 min read · Published on: May 19, 2026 · Modified on: May 19, 2026
Related Posts
Mini-Game State Machine Design: Complete Flow from Home to Battle to Settlement
Mini-Game State Machine Design: Complete Flow from Home to Battle to Settlement
Indie Game Development: Validate Gameplay First, Build Systems Later (MVP Practical Guide)
Indie Game Development: Validate Gameplay First, Build Systems Later (MVP Practical Guide)
Vitest Component Testing in Practice: Browser Mode and Playwright Integration
Comments
Sign in with GitHub to leave a comment