Data, Scenes, and Systems: Separation of Concerns in Briarthorn

Written by, Funnan on April 22, 2026

devlogGodotupdatebriarthorn

When you’re building a game on top of Godot, the engine hands you four tools that look similar from a distance but solve very different problems: resources, scenes, nodes, and scripts. It’s tempting to reach for whichever one is closest and keep going. For a while that works. Then you want to add a second weapon type, and the decisions you didn’t make start making themselves.

Briarthorn’s most recent merge — make firing systems consume weapon capability data — is a good excuse to write down the architecture that’s been taking shape, because that one pull request touched almost every layer of it and landed cleanly because of how the layers are split.


The Layers, Top to Bottom

The rule I’ve been holding myself to is: each layer owns exactly one kind of concern.

None of those layers are allowed to reach sideways into another layer’s job. A scene doesn’t define damage values. A system doesn’t know how to draw anything. A command doesn’t run per-frame. When a layer starts growing a second responsibility, that’s the signal to split it.


Resources: Data Definitions Live in .tres

Before this last merge, weapons in Briarthorn were partly data and partly scripts — a missile.gd, a plasma.gd, hand-rolled. That’s fine for one or two of them. It doesn’t scale.

The fix was to define a small set of typed resource scripts that describe what a thing is, and then let designers fill those in as .tres files in the editor:

# database/types/type_entity.gd
class_name TypeEntity extends Resource

@export var category: TypeEnums.Category = TypeEnums.Category.UNKNOWN
@export var side: TypeEnums.Side = TypeEnums.Side.UNKNOWN
@export var scene: PackedScene = null
@export var weapon: TypeWeapon = null
@export var stats: TypeStats = null
@export var engine: TypeEngine = null
# database/types/type_weapon.gd
class_name TypeWeapon extends Resource

enum TypeDamage { UNKNOWN, IMPACT, BLAST, FIRE }

@export var damage: float = 0.0
@export var damage_radius: float = 0.0
@export var damage_type: TypeDamage = TypeDamage.UNKNOWN
@export var range_max: float = 0.0
@export var fuze_range: float = 0.0
@export var timeout: float = 0.0

A TypeEntity composes smaller resources — TypeStats, TypeEngine, TypeWeapon — instead of being one monolithic blob of fields. Each sub-resource is meaningful on its own: a weapon without an engine or stats is perfectly valid, and so is a civilian aircraft without a weapon.

Then the actual game data lives as plain .tres files:

# database/entities/Missile.tres (abridged)
[sub_resource type="Resource" id="stats"]
kinetic = 10
maneuver = 8
durable = 4
compute = 2
sensor = 6

[sub_resource type="Resource" id="weapon"]
damage = 8.0
damage_radius = 30.0
damage_type = 2          # BLAST
range_max = 20000.0
fuze_range = 150.0
timeout = 60.0

[resource]
category = 12             # WEAPON_AIR_MISSILE
scene = ExtResource("Missile.tscn")
weapon = SubResource("weapon")
stats = SubResource("stats")

The screenshot at the top of this post is the Godot inspector pointed at Missile.tres. It’s not code. It’s data. A .tres file has no behavior, no update loop, no references to a scene tree — it is a pure value that the rest of the game reads from.

That’s the separation working. If I want a faster missile with a bigger blast radius, I edit one file in the inspector and I’m done. I don’t touch scripts. I don’t recompile anything. I don’t risk a regression in flight logic because I tweaked a number.


The Index: DatabaseTypes

One flat lookup table sits in front of those resources so the rest of the game never has to know about file paths:

# database/database_types.gd
class_name DatabaseTypes extends Resource

const _ENTITIES: Dictionary[TypeEnums.Category, TypeEntity] = {
    TypeEnums.Category.UNKNOWN:             preload("res://database/entities/Unknown.tres"),
    TypeEnums.Category.VEHICLE_AIR_UNKNOWN: preload("res://database/entities/Air.tres"),
    TypeEnums.Category.VEHICLE_AIR_FIGHTER: preload("res://database/entities/Fighter.tres"),
    TypeEnums.Category.WEAPON_AIR_MISSILE:  preload("res://database/entities/Missile.tres"),
    TypeEnums.Category.WEAPON_ENERGY_PLASMA: preload("res://database/entities/Plasma.tres"),
}

func get_entity(category: int) -> TypeEntity:
    if _ENTITIES.has(category):
        return _ENTITIES[category]
    return _ENTITIES[TypeEnums.Category.UNKNOWN]

Any system that needs to know “what is category 12?” asks DatabaseTypes. It gets back a TypeEntity resource and reads whatever it needs. If a category isn’t found, the lookup silently falls back to Unknown.tres — no branching error-handling at every call site.


Scenes: Presentation, Nothing Else

A scene in Briarthorn is allowed to render things, play animations, and respond to visual signals. That’s it.

Enemy.tscn doesn’t know what an enemy’s damage is. Missile.tscn doesn’t know its own blast radius. The reason is simple: those values belong to the data (Enemy.tres, Missile.tres), and duplicating them into the scene means two sources of truth that will drift apart. Instead, the scene exposes enough for a script to wire it up at spawn time — position, heading, a sprite — and the data layer provides the rest.

This is also why TypeEntity carries a PackedScene as a field. The resource says “when you need to show one of me, instantiate this scene” — but the scene is a dumb visualization of what the resource already knows. The data owns the identity; the scene owns the pixels.


Models: Who Exists Right Now

Resources are immutable archetypes. Models are the live, mutable world state.

# src/models/model_entities.gd
class_name ModelEntities extends Resource

var _data: Dictionary[int, DataEntity] = {}

func create_entity(id: int) -> DataEntity:
    var entity := DataEntity.new()
    entity.id = id
    _data[id] = entity
    return entity

func get_entity(id: int) -> DataEntity:
    return _data.get(id)
# src/models/model_tracks.gd
func create_track(
    id_observer: int, id_target: int, category: TypeEnums.Category, side: TypeEnums.Side
) -> DataTrack:
    var track := get_track(id_observer, id_target)
    if track:
        return track
    # ... create, store, emit signal ...
    track_added.emit(id_observer, track)
    return track

Models are plain resource-backed containers. No _process, no _physics_process, no scene-tree awareness. They emit signals when their contents change so that presentation can react, but they don’t know who’s listening. Anything that needs to iterate entities — a motion system, a sensor system, a UI — goes through the model and doesn’t touch the scene tree directly.

The payoff shows up in tests and in tooling. A model can be constructed, populated, and inspected without Godot ever rendering a frame.


Systems: Logic, Once Per Tick

Systems are the only layer that runs every frame. They read from models, they write to models, and they do one thing each.

# src/systems/system_track_motion.gd
class_name SystemTrackMotion extends Node

func _physics_process(_delta: float) -> void:
    for entity: DataEntity in Briarthorn.model_entities.get_entities():
        _update_tracks(entity)

func _update_tracks(entity: DataEntity) -> void:
    for track: DataTrack in Briarthorn.model_tracks.get_tracks(entity.id):
        var target: DataEntity = Briarthorn.model_entities.get_entity(track.id_target)
        if not target or not target.kinematics:
            continue

        var dx := target.kinematics.position.x - entity.kinematics.position.x
        var dy := target.kinematics.position.y - entity.kinematics.position.y

        track.track_range = sqrt(dx * dx + dy * dy)
        track.azimuth = Heading.new(rad_to_deg(atan2(dx, -dy)))
        track.heading = Heading.new(target.kinematics.heading.value)

SystemTrackMotion updates track geometry. It doesn’t spawn things, doesn’t render anything, doesn’t know about weapons or sensors. If a different system needs to update something else, it’s a different file. A sibling system, SystemSensorDetection, decides whether a track exists. SystemCursorMotion moves the cursor. Each one is small enough to hold in your head.


Commands: Intent Over Implementation

Commands bridge the gap between “the player pressed a button” and “the world changed.” They encapsulate a single user-level intention and know how to turn it into model mutations.

CmdCreateEntity is the straightforward case — spawn a new entity with a given type and position:

# src/commands/cmd_create_entity.gd
class_name CmdCreateEntity extends Command

var id: int = 0
var type: TypeEntity = null
var position: Vector2 = Vector2.ZERO
var heading: float = 0.0

func execute(bt: Briarthorn) -> void:
    if type == null:
        return

    if id == 0:
        id = bt.counter()

    var entity := bt.model_entities.create_entity(id)
    entity.side = type.side
    entity.category = type.category
    entity.kinematics.position = position
    entity.kinematics.heading = Heading.new(heading)

    if type.stats != null:
        entity.stats.kinetic  = type.stats.kinetic
        entity.stats.maneuver = type.stats.maneuver
        entity.stats.durable  = type.stats.durable
        entity.stats.compute  = type.stats.compute
        entity.stats.sensor   = type.stats.sensor
        entity.stats.stealth  = type.stats.stealth

    if type.engine != null:
        entity.engine.throttle = type.engine.throttle
        entity.engine.brake    = type.engine.brake

The command takes a TypeEntity — that is, the pure data from a .tres resource — and copies the relevant pieces into live model state. It has no idea whether that entity will be a fighter, a missile, or something new we add next week. It just reads what’s on the resource and populates the runtime.

CmdFireWeapon is where this merge actually paid off. Before, firing a weapon was hardcoded: the shooter’s position was copied over, a fixed throttle was assigned, and that was roughly it. There was no real notion of what kind of weapon was being fired. The new version asks the database:

# src/commands/cmd_fire_weapon.gd
class_name CmdFireWeapon extends Command

var id_shooter: int = 0
var id_target: int = 0
var category: TypeEnums.Category = TypeEnums.Category.UNKNOWN

func execute(bt: Briarthorn) -> void:
    var type: TypeEntity = bt.types.get_entity(category)
    if type == null: return

    var type_weapon := type.weapon
    if type_weapon == null: return

    var shooter := bt.model_entities.get_entity(id_shooter)
    if shooter == null: return

    var entity := bt.model_entities.create_entity(bt.counter())
    entity.side = type.side
    entity.category = type.category
    entity.kinematics.position = shooter.kinematics.position
    entity.kinematics.heading  = Heading.new(shooter.kinematics.heading.value)
    entity.engine.throttle = 1.0
    # ... copy stats from type.stats ...

It doesn’t matter whether the category is WEAPON_AIR_MISSILE or WEAPON_ENERGY_PLASMA or something that doesn’t exist yet. The command is identical. The database returns a different TypeEntity, the stats are different, the downstream motion and damage values are different — and the firing code never changed.


Why the Merge Was Small

The pull request that kicked this post off (“make firing systems consume weapon capability data”) changed around fifty files, but the conceptual change was narrow: weapons used to carry data inside bespoke scripts, and now they carry it inside TypeWeapon resources that CmdFireWeapon reads through the database.

That change was possible in one merge because each layer already had a single responsibility. The resource layer grew a new type (TypeWeapon). The data layer grew new .tres files (Missile.tres, Plasma.tres). The command layer got a new command (CmdFireWeapon) that consumes the new type. The systems didn’t change — they still iterate entities from the model, oblivious to what those entities represent.

If weapon data had been scattered across scenes, models, and systems, this would have been a multi-week refactor. Because the boundaries were clean, it was a feature.


What’s Next

Adding a new weapon in Briarthorn now looks like:

  1. Create a new .tres under database/entities/.
  2. Add an enum value to TypeEnums.Category.
  3. Register it in DatabaseTypes._ENTITIES.

That’s the entire checklist. No new commands. No new systems. No new scenes unless you want custom visuals. The architecture is finally paying rent.

The next stretch of work moves up the stack: damage resolution, target selection UI, and getting the sensor model honest enough that the tactical situation display reflects what the game engine actually knows. Separation of concerns gives me a lot of confidence that those features will slot in the same way weapons did — as new leaves on layers that already exist, not as rewrites of the trunk.