godot-genre-sandbox

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Genre: Sandbox

游戏类型:沙盒

Physical simulation, emergent play, and player creativity define this genre.
物理模拟、涌现式玩法和玩家创意是这类游戏的核心特征。

Available Scripts

可用脚本

voxel_chunk_manager.gd

voxel_chunk_manager.gd

Expert chunked rendering using
MultiMeshInstance3D
for thousands of voxels. Includes greedy meshing pattern and performance notes.
基于
MultiMeshInstance3D
实现的专家级区块化渲染方案,可支持数千个体素。包含贪心网格合并模式和性能优化说明。

Core Loop

核心循环

  1. Explore: Player discovers world rules and materials
  2. Experiment: Player tests interactions (fire burns wood)
  3. Build: Player constructs structures or machines
  4. Simulate: Game runs physics/logic systems
  5. Share: Player saves/shares creation
  6. Emergence: Unintended complex behaviors from simple rules
  1. 探索:玩家探索世界规则和各类材质
  2. 实验:玩家测试元素交互(比如火焰燃烧木材)
  3. 建造:玩家搭建建筑或机械装置
  4. 模拟:游戏运行物理/逻辑系统
  5. 分享:玩家保存或分享自己的创作
  6. 涌现:简单规则衍生出的复杂意外行为

NEVER Do in Sandbox Games

沙盒开发绝对禁忌

  • NEVER simulate the entire world every frame — Only update "dirty" chunks with recent changes. Sleeping chunks waste 90%+ of CPU. Use spatial hashing to track active regions.
  • NEVER use individual
    RigidBody
    nodes for voxels
    — 1000+ physics bodies = instant crash. Use cellular automata for fluids/sand, static collision for solid blocks, and only dynamic bodies for player-placed objects.
  • NEVER save absolute transforms for every block — A 256×256 world = 65,536 blocks. Use chunk-based RLE (Run-Length Encoding):
    {type:AIR, count:50000}
    compresses massive empty spaces.
  • NEVER update
    MultiMesh
    instance transforms every frame
    — This forces GPU buffer updates. Batch changes, rebuild chunks when changed, not every tick.
  • NEVER hardcode element interactions (
    if wood + fire: burn()
    ) — Use property-based systems:
    if temperature > ignition_point and flammable > 0
    . This enables emergent combinations players discover.
  • NEVER use
    Node
    for every grid cell
    — Nodes have 200+ bytes overhead. A million-block world would need 200MB+ just for node metadata. Use typed
    Dictionary
    or
    PackedInt32Array
    indexed by
    position.x + position.y * width
    .
  • NEVER raycast against all voxels for tool placement — Use grid quantization:
    floor(mouse_pos / block_size)
    to directly calculate target cell. Raycasts are O(n) with voxel count.
  • 绝对不要每帧模拟整个世界 — 仅更新有近期变更的“脏”区块。处于休眠状态的区块会浪费90%以上的CPU资源。使用空间哈希来追踪活跃区域。
  • 绝对不要为每个体素单独使用
    RigidBody
    节点
    — 1000+个物理体会直接导致崩溃。流体/沙子使用元胞自动机,固体方块使用静态碰撞,仅玩家放置的物体使用动态物理体。
  • 绝对不要保存每个方块的绝对变换信息 — 一个256×256的世界包含65536个方块。使用基于区块的行程编码(RLE):
    {type:AIR, count:50000}
    可大幅压缩大面积空白区域。
  • 绝对不要每帧更新
    MultiMesh
    实例的变换信息
    — 这会强制GPU更新缓冲区。批量处理变更,仅在区块修改时重建,而非每帧更新。
  • 绝对不要硬编码元素交互逻辑(如
    if wood + fire: burn()
    )— 使用基于属性的系统:
    if temperature > ignition_point and flammable > 0
    。这样能让玩家发现更多涌现式的元素组合。
  • 绝对不要为每个网格单元使用
    Node
    节点
    — 每个节点有200多字节的开销。百万方块的世界仅节点元数据就需要200MB以上的内存。使用类型化
    Dictionary
    PackedInt32Array
    ,通过
    position.x + position.y * width
    进行索引。
  • 绝对不要为工具放置操作对所有体素进行射线检测 — 使用网格量化:
    floor(mouse_pos / block_size)
    直接计算目标单元格。射线检测的时间复杂度是O(n),与体素数量正相关。

Architecture Patterns

架构模式

1. Element System (Property-Based Emergence)

1. 元素系统(基于属性的涌现式设计)

Model material properties, not behaviors. Interactions emerge from overlapping properties.
gdscript
undefined
为材质建模属性而非行为。交互效果由重叠的属性自然涌现。
gdscript
undefined

element_data.gd

element_data.gd

class_name ElementData extends Resource
enum Type { SOLID, LIQUID, GAS, POWDER } @export var id: String = "air" @export var type: Type = Type.GAS @export var density: float = 0.0 # For liquid flow direction @export var flammable: float = 0.0 # 0-1: Chance to ignite @export var ignition_temp: float = 400.0 @export var conductivity: float = 0.0 # For electricity/heat @export var hardness: float = 1.0 # Mining time multiplier
class_name ElementData extends Resource
enum Type { SOLID, LIQUID, GAS, POWDER } @export var id: String = "air" @export var type: Type = Type.GAS @export var density: float = 0.0 # For liquid flow direction @export var flammable: float = 0.0 # 0-1: Chance to ignite @export var ignition_temp: float = 400.0 @export var conductivity: float = 0.0 # For electricity/heat @export var hardness: float = 1.0 # Mining time multiplier

EDGE CASE: What if two elements have same density but different types?

EDGE CASE: What if two elements have same density but different types?

SOLUTION: Use secondary sort (type enum priority: SOLID > LIQUID > POWDER > GAS)

SOLUTION: Use secondary sort (type enum priority: SOLID > LIQUID > POWDER > GAS)

func should_swap_with(other: ElementData) -> bool: if density == other.density: return type > other.type # Enum comparison: SOLID(0) > GAS(3) return density > other.density
undefined
func should_swap_with(other: ElementData) -> bool: if density == other.density: return type > other.type # Enum comparison: SOLID(0) > GAS(3) return density > other.density
undefined

2. Cellular Automata Grid (Falling Sand Simulation)

2. 元胞自动机网格(落沙模拟)

Update order matters. Top-down prevents "teleporting" godot-particles.
gdscript
undefined
更新顺序至关重要。从上到下更新可避免Godot粒子“瞬移”问题。
gdscript
undefined

world_grid.gd

world_grid.gd

var grid: Dictionary = {} # Vector2i -> ElementData var dirty_cells: Array[Vector2i] = []
func _physics_process(_delta: float) -> void: # CRITICAL: Sort top-to-bottom to prevent double-moves dirty_cells.sort_custom(func(a, b): return a.y < b.y)
for pos in dirty_cells:
    simulate_cell(pos)
dirty_cells.clear()
func simulate_cell(pos: Vector2i) -> void: var cell = grid.get(pos) if not cell: return
match cell.type:
    ElementData.Type.LIQUID, ElementData.Type.POWDER:
        # Try down, then down-left, then down-right
        var targets = [pos + Vector2i.DOWN, 
                       pos + Vector2i(- 1, 1), 
                       pos + Vector2i(1, 1)]
        for target in targets:
            var neighbor = grid.get(target)
            if neighbor and cell.should_swap_with(neighbor):
                swap_cells(pos, target)
                mark_dirty(target)
                return
    
    ElementData.Type.GAS:
        # Gases rise (inverse of liquids)
        var targets = [pos + Vector2i.UP,
                       pos + Vector2i(-1, -1),
                       pos + Vector2i(1, -1)]
        # Same swap logic...
var grid: Dictionary = {} # Vector2i -> ElementData var dirty_cells: Array[Vector2i] = []
func _physics_process(_delta: float) -> void: # CRITICAL: Sort top-to-bottom to prevent double-moves dirty_cells.sort_custom(func(a, b): return a.y < b.y)
for pos in dirty_cells:
    simulate_cell(pos)
dirty_cells.clear()
func simulate_cell(pos: Vector2i) -> void: var cell = grid.get(pos) if not cell: return
match cell.type:
    ElementData.Type.LIQUID, ElementData.Type.POWDER:
        # Try down, then down-left, then down-right
        var targets = [pos + Vector2i.DOWN, 
                       pos + Vector2i(- 1, 1), 
                       pos + Vector2i(1, 1)]
        for target in targets:
            var neighbor = grid.get(target)
            if neighbor and cell.should_swap_with(neighbor):
                swap_cells(pos, target)
                mark_dirty(target)
                return
    
    ElementData.Type.GAS:
        # Gases rise (inverse of liquids)
        var targets = [pos + Vector2i.UP,
                       pos + Vector2i(-1, -1),
                       pos + Vector2i(1, -1)]
        # Same swap logic...

EDGE CASE: What if multiple godot-particles want to move into same cell?

EDGE CASE: What if multiple godot-particles want to move into same cell?

SOLUTION: Only mark target dirty, don't double-swap. Next frame resolves conflicts.

SOLUTION: Only mark target dirty, don't double-swap. Next frame resolves conflicts.

undefined
undefined

3. Tool System (Strategy Pattern)

3. 工具系统(策略模式)

Decouple input from world modification.
gdscript
undefined
将输入逻辑与世界修改逻辑解耦。
gdscript
undefined

tool_base.gd

tool_base.gd

class_name Tool extends Resource func use(world_pos: Vector2, world: WorldGrid) -> void: pass
class_name Tool extends Resource func use(world_pos: Vector2, world: WorldGrid) -> void: pass

tool_brush.gd

tool_brush.gd

extends Tool @export var element: ElementData @export var radius: int = 1
func use(world_pos: Vector2, world: WorldGrid) -> void: var grid_pos = Vector2i(floor(world_pos.x), floor(world_pos.y))
# Circle brush pattern
for x in range(-radius, radius + 1):
    for y in range(-radius, radius + 1):
        if x*x + y*y <= radius*radius:  # Circle boundary
            var target = grid_pos + Vector2i(x, y)
            world.set_cell(target, element)
extends Tool @export var element: ElementData @export var radius: int = 1
func use(world_pos: Vector2, world: WorldGrid) -> void: var grid_pos = Vector2i(floor(world_pos.x), floor(world_pos.y))
# Circle brush pattern
for x in range(-radius, radius + 1):
    for y in range(-radius, radius + 1):
        if x*x + y*y <= radius*radius:  # Circle boundary
            var target = grid_pos + Vector2i(x, y)
            world.set_cell(target, element)

FALLBACK: If element placement fails (e.g., occupied by indestructible block)?

FALLBACK: If element placement fails (e.g., occupied by indestructible block)?

Check world.can_place(target) before set_cell(), show visual feedback.

Check world.can_place(target) before set_cell(), show visual feedback.

undefined
undefined

4. Chunk-Based Rendering (3D Voxels)

4. 基于区块的渲染(3D体素)

Only render visible faces. Use greedy meshing to merge adjacent blocks.
gdscript
undefined
仅渲染可见面。使用贪心网格合并相邻方块。
gdscript
undefined

See scripts/voxel_chunk_manager.gd for full implementation

See scripts/voxel_chunk_manager.gd for full implementation

EXPERT DECISION TREE:

EXPERT DECISION TREE:

- Small worlds (<100k blocks): Single MeshInstance with SurfaceTool

- Small worlds (<100k blocks): Single MeshInstance with SurfaceTool

- Medium worlds (100k-1M blocks): Chunked MultiMesh (see script)

- Medium worlds (100k-1M blocks): Chunked MultiMesh (see script)

- Large worlds (>1M blocks): Chunked + greedy meshing + LOD

- Large worlds (>1M blocks): Chunked + greedy meshing + LOD

undefined
undefined

Save System for Sandbox Worlds

沙盒世界的存档系统

gdscript
undefined
gdscript
undefined

chunk_save_data.gd

chunk_save_data.gd

class_name ChunkSaveData extends Resource
@export var chunk_coord: Vector2i @export var rle_data: PackedInt32Array # [type_id, count, type_id, count...]
class_name ChunkSaveData extends Resource
@export var chunk_coord: Vector2i @export var rle_data: PackedInt32Array # [type_id, count, type_id, count...]

EXPERT TECHNIQUE: Run-Length Encoding

EXPERT TECHNIQUE: Run-Length Encoding

static func encode_chunk(grid: Dictionary, chunk_pos: Vector2i, chunk_size: int) -> ChunkSaveData: var data = ChunkSaveData.new() data.chunk_coord = chunk_pos
var run_type: int = -1
var run_count: int = 0

for y in range(chunk_size):
    for x in range(chunk_size):
        var world_pos = chunk_pos * chunk_size + Vector2i(x, y)
        var cell = grid.get(world_pos)
        var type_id = cell.id if cell else 0  # 0 = air
        
        if type_id == run_type:
            run_count += 1
        else:
            if run_count > 0:
                data.rle_data.append(run_type)
                data.rle_data.append(run_count)
            run_type = type_id
            run_count = 1

# Flush final run
if run_count > 0:
    data.rle_data.append(run_type)
    data.rle_data.append(run_count)

return data
static func encode_chunk(grid: Dictionary, chunk_pos: Vector2i, chunk_size: int) -> ChunkSaveData: var data = ChunkSaveData.new() data.chunk_coord = chunk_pos
var run_type: int = -1
var run_count: int = 0

for y in range(chunk_size):
    for x in range(chunk_size):
        var world_pos = chunk_pos * chunk_size + Vector2i(x, y)
        var cell = grid.get(world_pos)
        var type_id = cell.id if cell else 0  # 0 = air
        
        if type_id == run_type:
            run_count += 1
        else:
            if run_count > 0:
                data.rle_data.append(run_type)
                data.rle_data.append(run_count)
            run_type = type_id
            run_count = 1

# Flush final run
if run_count > 0:
    data.rle_data.append(run_type)
    data.rle_data.append(run_count)

return data

COMPRESSION RESULT: Empty chunk (16×16 = 256 blocks of air)

COMPRESSION RESULT: Empty chunk (16×16 = 256 blocks of air)

Without RLE: 256 integers = 1024 bytes

Without RLE: 256 integers = 1024 bytes

With RLE: [0, 256] = 8 bytes (128x compression!)

With RLE: [0, 256] = 8 bytes (128x compression!)

undefined
undefined

Physics Joints for Player Creations

玩家创作的物理关节

gdscript
undefined
gdscript
undefined

joint_tool.gd

joint_tool.gd

func create_hinge(body_a: RigidBody2D, body_b: RigidBody2D, anchor: Vector2) -> void: var joint = PinJoint2D.new() joint.global_position = anchor joint.node_a = body_a.get_path() joint.node_b = body_b.get_path() joint.softness = 0.5 # Allows slight flex add_child(joint)
# EDGE CASE: What if bodies are deleted while joint exists?
# Joint will auto-break in Godot 4.x, but orphaned Node leaks memory.
func create_hinge(body_a: RigidBody2D, body_b: RigidBody2D, anchor: Vector2) -> void: var joint = PinJoint2D.new() joint.global_position = anchor joint.node_a = body_a.get_path() joint.node_b = body_b.get_path() joint.softness = 0.5 # Allows slight flex add_child(joint)
# EDGE CASE: What if bodies are deleted while joint exists?
# Joint will auto-break in Godot 4.x, but orphaned Node leaks memory.

SOLUTION:

SOLUTION:

body_a.tree_exiting.connect(func(): joint.queue_free()) body_b.tree_exiting.connect(func(): joint.queue_free())
body_a.tree_exiting.connect(func(): joint.queue_free()) body_b.tree_exiting.connect(func(): joint.queue_free())

FALLBACK: Player attaches joint to static geometry?

FALLBACK: Player attaches joint to static geometry?

Check
body.freeze == false
before creating joint.

Check
body.freeze == false
before creating joint.

undefined
undefined

Godot-Specific Expert Notes

Godot专属专家提示

  • MultiMeshInstance3D.multimesh.instance_count
    : MUST be set before buffer allocation. Cannot dynamically grow — requires recreation.
  • RigidBody2D.sleeping
    : Bodies auto-sleep after 2 seconds of no movement. Use
    apply_central_impulse(Vector2.ZERO)
    to force wake without adding force.
  • GridMap
    vs
    MultiMesh
    : GridMap uses MeshLibrary (great for variety), MultiMesh uses single mesh (great for speed). Combine: GridMap for structures, MultiMesh for terrain.
  • Continuous CD:
    continuous_cd
    requires convex collision shapes. Use
    CapsuleShape2D
    for projectiles, NOT
    RectangleShape2D
    .
  • MultiMeshInstance3D.multimesh.instance_count
    : 必须在缓冲区分配前设置。无法动态扩容 — 需要重新创建实例。
  • RigidBody2D.sleeping
    : 物体在静止2秒后会自动进入休眠状态。使用
    apply_central_impulse(Vector2.ZERO)
    可强制唤醒物体而不施加力。
  • GridMap
    vs
    MultiMesh
    : GridMap使用MeshLibrary(适合多样的模型),MultiMesh使用单一网格(适合高性能场景)。可结合使用:GridMap用于建筑结构,MultiMesh用于地形。
  • 连续碰撞检测(Continuous CD):
    continuous_cd
    需要凸碰撞形状。对抛射物使用
    CapsuleShape2D
    ,而非
    RectangleShape2D

Reference

参考

  • Master Skill: godot-master
  • 核心技能:godot-master