10 min read

Stats - Warum jedes Game ein StatSystem braucht

Stats - Warum jedes Game ein StatSystem braucht
Photo by Gian-Luca Riner / Unsplash

Stats sind der Grundstein für so ziemlich jedes Videospiel!

Aber eins nach dem anderen. "Stats" ist eine Abkürzung für "Statistics" und beschreibt effektiv alle Zahlenwerte, die im Videospiel-Kontext in einem Spiel vorkommen können.

Beispiele:

  • Bewegungsgeschwindigkeit
  • Stärke, Geschicklichkeit, Intelligenz, ...
  • Lebenspunkte, Mana
  • Level
  • Buff-Effekte wie "+20% Stärke"
  • Highscore

Speziell RPGs legen viel Wert auf tiefgreifende Stats und komplexe Berechnungsformeln (zum Beispiel: Damage = Schaden - Rüstung), aber auch in anderen Genres sind zahlreiche Stats vertreten.

Die Problematik ist nicht die Anlage der Stats im Code, sondern die Zusammenhänge, Auswirkungen und Berechnungen die darüber abgebildet werden sollen

Natürlich ist es einfach zu sagen:

class_name Player
extends CharacterBody3D

@export var health_points: int = 100
@export var mana_points: int = 50
@export var strength: int = 8
@export var dexterity: int = 4
@export var base_damage: int = 10
@export var ...
... # du siehst vermutlich schon, worauf das Ganze hinaus läuft...

Und schon hat man einen Player, der ganz viele verschiedene Stats hat. Super, fertig! Wozu also ein StatSystem?

Naja, schauen wir uns die Stats mal genauer an:

  • Lebenspunkte: wenn diese <= 0 sind, soll der Player sterben und ein Game Over Screen öffnen, ggf. einen Sound Effect abspielen, eine Animation starten und vielleicht noch den Gegner KI State ändern und andere Stats wie bspw. "Anzahl Tode" hochzählen
  • Lebens- und Manapunkte: Beide haben einen Aktuellen und einen Maximalwert. Wenn der Spieler 100 Lebenspunkte hat, 10 Schaden bekommt, dann hat der Spieler 90/100 Lebenspunkte (2 Stats!)
  • Manapunkte: Skills kosten Mana, hier ist es wichtig, dass der Spieler noch genug Mana hat um den Skill zu wirken. Also sowas wie if cost <= current_mana.
  • Stärke: Hat eine Abhängigkeit zum Schadens-Stat - umso mehr Stärke der Spieler hat, umso mehr Schaden soll der Spieler auch verursachen. Sowas in der Art also: damage = base_damage + (strength * 2)

Und so weiter. Wie du siehst, müssen komplexe Systeme ineinandergreifen. Würdest du das jetzt alles in der Player Klasse definieren, wird dein Player-Skript hunderte Zeilen lang sein und die meisten Zeilen drehen sich um die Stat-Verwaltung des Spielers.

# Player.gd
extends Node
class_name Player

## Signals
signal hp_changed(current: int, max_value: int)
signal mp_changed(current: int, max_value: int)
signal stat_changed(stat_name: StringName, new_value: int)
signal died()

## Base Stat Values
@export var max_hp_base: int = 100
@export var max_mp_base: int = 50

@export var str_base: int = 10
@export var dex_base: int = 10
@export var int_base: int = 10

## Current Values
var hp: int
var mp: int

## Modifiers (additive)
var modifiers := {
	&"max_hp": 0,
	&"max_mp": 0,
	&"str": 0,
	&"dex": 0,
	&"int": 0,
}

In diesem Beispiel-Skript mit einer kleinen Menge an Stats haben wir allein für den Variablen-Block bereits 30 Zeilen. Dazu kommen dann natürlich noch Funktionen wie: take_damage(amount: int), get_hp(), get_max_hp(), get_str(), get_max_str(), ..., heal(amount: int), add_modifier, apply_buff, ...

Wenn jetzt noch Gegner und Gegner-Klassen und zerstörbare Gegenstände, Wände o.ä. dazu kommen ...

Jetzt kommt ein Stat-System ins Spiel und verschafft Abhilfe. Wir schaffen einen "Ort" an dem die Stats zentral verwaltet werden können, eine klare Möglichkeit um Stats zu definieren und zu referenzieren und im Bestfall ergänzen wir auch noch Signale um an anderer Stelle auf Änderungen reagieren zu können.

Mit so einem System kann man es schaffen, zentralisiert die Stats zu konfigurieren und zu verwalten. Eine "Entität" (Spieler, Gegner, ...) bekommt dann sozusagen die Möglichkeit, Stats zu haben und diese zu verändern. Der große Vorteil zeigt sich allein schon aus der Programmierer-Brille: Es gibt exakt eine "Source of Truth" und alle greifen darauf zu. Gibt es einen Bug im Stat-System betrifft das alle Entitäten, die Stats haben und im Umkehrschluss wird der Bug auch für alle behoben. Ohne Stat-System muss man die Stats jeder Entität prüfen, was fehleranfällig und aufwändig ist.

Jetzt kann ich ja viel erzählen und euch glauben lassen, dass ich mir das Ganze ausgedacht habe. Aber nein, ich bin definitiv nicht der Erste, der in diese Richtung gedacht hat:

RPG Programming Pitfalls #1: Stat System – Random Potion
How to Make an RPG: Stats
Master crafting RPG character stats. Uncover common JRPG attributes, their impact on gameplay, and build a robust RPG stat system for your game’s combat.

Das Thema ist also ein sprichwörtlicher "alter Hut". Ein TES III: Morrowind ist daher bis heute technisch beeindruckend. Morrowind geht weiter als die allermeisten RPGs und implementiert nicht nur "Schaden" in Form von "Lebenspunkte-Verlust", sondern auch noch "Stat-Schaden". In Morrowind ist es möglich, dass ein Gegner deiner Stärke schadet, was wiederum Auswirkung darauf hat, wie viel du tragen kannst und wie beweglich du in deiner Rüstung ist. "Heilen" kannst du diesen "Stärke-Schaden" indem du bspw. spezielle Tränke trinkst. Sicherlich war es nervig, wenn ein Gegner dafür gesorgt hat, dass du auf einmal bewegungsunfähig bist, weil du 0 Stärke hast. Das ist jedoch wieder eher ein Game Design Problem und weniger ein Problem, welches durch das Stat System verursacht hat.

Bevor es ans Eingemachte geht und ich ein grobes Stat System für Godot skizzieren möchte, möchte ich nochmal darauf eingehen, wann ein Stat System wirklich sinnvoll ist.

"Immer dann, wenn das Spiel Stats hat" wäre etwas zu "platt" und wäre in einigen Fällen viel zu aufwändig. Es gibt zwar keine "magische Zahl", aber ich würde sagen, dass sich ein Stat System immer dann lohnt, wenn man etwas mehr Rechenleistung zur Verfügung hat (Ziel-Plattform PC, Konsole, etc. aber eben kein Retro Handheld wie die kleinen Anbernic Geräte) und eben eine gewisse Menge Stats verwalten muss. In dem Moment in dem es Highscore-Listen geben soll oder ein Level-System o.ä. lohnt es sich schon, intensiver über ein Stat-System nachzudenken. Klassische RPGs sollten immer ein ausgefeiltes Stat-System haben. Abenteuer-Spiele, (Hyper-)Casual-Spiele, Spiele "ohne Gegner" oder aber Hardcore Spiele, bei denen der Spieler ohnehin nur 1 Leben hat (Hotline Miami etc.) können vermutlich darauf verzichten.

Ich persönlich habe mir ein solches Stat-System für mein erstes Spiel - A Void Shaper - gewünscht. Dort habe ich alles noch stark "gefrickelt", ohne viel Sinn und Verstand. Das hat dazu geführt, dass das Balancing schwerer wurde, Bugs musste ich an mehreren Stellen pflegen und es war immer mit Aufwand verbunden, neue Upgrades einzupflegen.

Bei dem ambitionierten Immersive Sim + RPG Projekt - Labyrinthum Arcanum - habe ich meine Learnings direkt angewandt und eine StatsComponent implementiert. Diese Komponente kann ich problemlos jeder beliebigen Entität zuweisen. Dadurch kann ich alle vorherigen Nachteile beseitigen und sogar ein angenehmes Authoring Tool aufsetzen.

Das Eingemachte

In Labyrinthum Arcanum habe ich die StatsComponent in C# geschrieben, der Einfachheit halber möchte ich hier aber ein Beispiel mit GDScript skizzieren. Mit den Generics in C# kann man jedoch "bessere" Ergebnisse erzielen, weil ich mit meinem GDScript Beispiel auf float basierte Stats setzen werde. In C# wäre natürlich ein Stat<T> denkbar und somit optimierter und flexibler.

class_name StatDefinition
extends Resource

## Human-readable name
@export var display_name: String = ""

## The starting value when a stat is first created.
@export var default_value: float = 0.0

## Absolute minimum the stat's calculated value can reach.
@export var min_value: float = 0.0

## Absolute maximum the stat's calculated value can reach.
@export var max_value: float = 999999.0

## If true, the final value is always rounded to the nearest integer.
@export var round_to_integer: bool = false

Warum float? Das Problem ist, dass man in GDScript sonst immer über Variant den Typ umwandeln müsste. Das kostet Performance und macht den Code auch nicht lesbarer. Zumal Modifier-Logiken etc. auch wieder mehrmals implementiert werden müssen. Ich habe mit StatInt, StatFloat, usw. experimentiert und leider keine guten Ergebnisse damit erzielen können. Daher setze ich jetzt auf float inkl. Flag zum runden. Es ist, zugegebenermaßen, etwas "hacky".

Diese StatDefinition kann nun einfach über die Godot Engine angelegt werden, sodass wir Health.tres, Dexterity.tres und Mana.tres etc. definieren können. Als "Identifier" dient hierbei der resource_name, da diese Property ein StringName ist.

Schlussendlich sollen diese Stats dann später in einem StatProfile zusammengeführt werden. Um aber auch Varianten auf Editor/Design-Ebene zu unterstützen (Goblin, Elite-Goblin), habe ich noch eine Abstraktions-Ebene ergänzt:

class_name StatProfileEntry
extends Resource

## The stat definition to include. Drag a .tres StatDefinition here.
@export var definition: StatDefinition

## If enabled, [member override_value] replaces the definition's default.
@export var use_override: bool = false

## The starting value for this stat in this profile.
## Only used when [member use_override] is [code]true[/code].
@export var override_value: float = 0.0

Das ermöglicht mir, für bestimmte Gegner-Typen (idR Variationen) Overrides zu definieren um einem bestimmten Gegner bspw. mehr Leben zu geben.

Neben der StatDefinition gibt es auch noch StatConstraint um wirklich sicherzustellen, dass ein Stat nicht unter oder über einen bestimmten Wert gehen kann. Dadurch kann Health niemals MaxHealth übersteigen.

class_name StatConstraint
extends Resource

## The type of bound this constraint enforces.
enum Type {
	MAX_BOUNDED_BY, ## stat's value ≤ target's value + offset
	MIN_BOUNDED_BY, ## stat's value ≥ target's value + offset
}

## How the constrained value is clamped.
@export var type: Type = Type.MAX_BOUNDED_BY

## The stat being constrained (resource_name of a StatDefinition).
@export var stat_name: String = ""

## The stat providing the bound (resource_name of a StatDefinition).
@export var target_stat_name: String = ""

## Optional offset added to the target's value before clamping.
@export var offset: float = 0.0

Der offset ist vermutlich eher "YAGNI", hier könnte man aber einen Puffer einbauen für Overheals oder sowas.

Schlussendlich läuft das Ganze dann im StatProfile zusammen:

class_name StatProfile
extends Resource

## The stats this entity type has, each with an optional value override.
@export var entries: Array[StatProfileEntry] = []

## Constraints between stats in this profile.
@export var constraints: Array[StatConstraint] = []

Der Designer kann mit diesem Aufbau nun ein StatProfile für jeden Gegner, den Spieler, Items, ... definieren und im Dateisystem speichern. Mithilfe von static func to_json() und static func from_json() wäre es auch möglich, die Daten in einer Datenbank o.ä. zu speichern.

Zur Klarstellung: Bisher befinden wir uns rein auf Editor-Ebene. Zur Laufzeit ist hier noch gar nichts passiert!

Um die Entwicklung eines Authoring Tools zu erleichtern, nutze ich gerne noch eine Hilfsklasse um dort alle StatDefinitions zu speichern, damit ich dort zentral definieren kann, welche Stats es im Spiel geben soll. Das soll schlussendlich auch typos verhindern, weil das Stat-System ja schlussendlich auf einem StringName als Identifier basiert. Das bedeutet inhärent immer die Möglichkeit, sich beim Stat Namen zu vertippen.

class_name StatDatabase
extends Resource

## All stat definitions managed by this database.
@export var definitions: Array[StatDefinition] = []

## Returns all stat names in this database.
func get_all_names() -> PackedStringArray:
	var names: PackedStringArray = []
	for definition: StatDefinition in definitions:
		names.append(definition.resource_name)
	return names

Darüber kann ich dann mithilfe von validate_property ein Dropdown im Godot Inspector darstellen um dort nur aus den Stats auswählen zu können, die auch tatsächlich im Spiel existieren.

Somit ist die komplette Datenebene abgedeckt. Ich habe hier bewusst ein paar Hilfsfunktionen ausgelassen um den Blog-Artikel nicht zu sprengen und möchte hier eher die Architektur skizzieren.

Zur Laufzeit wird das Ganze über eine Node abgebildet. Diese Node gibt dann der Parent-Node sozusagen Stats. Oder andersrum: Die Node Player hat Stats. Und dank dieser "Hat-Beziehung", ist es auch egal, ob die Parent-Node der Player ist, ein Gegner oder aber ein Item oder eine zerstörbare Wand.

class_name StatContainer
extends Node

# Signals
signal stat_value_changed(stat_name: String, old_value: float, new_value: float)
signal stat_base_value_changed(stat_name: String, old_value: float, new_value: float)
signal stat_added(stat_name: String)
signal stat_removed(stat_name: String)
signal modifier_added(stat_name: String, modifier: StatModifier)
signal modifier_removed(stat_name: String, modifier: StatModifier)

# Config
@export var profile: StatProfile
@export var extra_definitions: Array[StatDefinition] = []

# Private
## Maps stat resource_name with StatInstance
var _stats: Dictionary = {}
var _constraints: Array[StatConstraint] = []

Initial werden dann die Signale mit generischen Listenern registriert. Das ermöglicht eine einfache Reaktion auf Stat-Änderungen. Im Labyrinthum Arcanum Projekt habe ich initial den "Fehler" gemacht und die Signale direkt auf StatDefinition-Ebene definiert. Das führte aber dazu, dass ich bei der Nutzung zur Laufzeit erstmal alle Stat-Ressourcen duplizieren musste um die Instanzen, sowie die Signale sauber hinzubekommen. Mit einer Node gibt es das Problem nicht.

Die StatInstance zieht sich dann alles aus der StatDefinition raus und definiert auf der Ebene alles Stat-relevante, was zur Laufzeit benötigt wird. Signale werden dort definiert, die dann nochmal über das stat_value_changed im StatContainer gekapselt werden.

Auf das Modifier System bin ich hierbei noch gar nicht genauer eingegangen. Das würde auch den Beitrag vollständig sprengen. Daher möchte ich final auf die konkretere Nutzung eingehen.

class_name Player
extends CharacterBody3D

@export var stats: StatContainer

func _ready() -> void:
  stats.stat_value_changed.connect(_on_stat_value_changed)

func modify_stat(of: StringName, amount: float) -> void:
  var current: float = stats.get_value(of)
  stats.set_base_value(of, current + amount) # can be + or -

func _on_stat_value_changed(stat_name: String, old_value: float, new_value: float) -> void:
  if stat_name == StatDatabase.HEALTH: # &"health"
    if new_value <= 0: die()

Ein Gegner könnte nun:

class_name Enemy
extends CharacterBody3D

@export var stats: StatContainer

func attack(target: Node) -> void:
  if target is Player:
    var player: Player = target
    player.modify_stat(StatDatabase.HEALTH, stats.get_value(StatDatabase.DAMAGE)

Und schon wird der Damage Stat des Gegners mit den Lebenspunkten des Spielers verrechnet. Und das völlig generisch! Ein NPC könnte jetzt eine func heal(target: Node) implementieren und den Spieler auf diese Weise einfach heilen.

Eine Schwäche dieses System ist derzeit, dass ich die "Reaktionen" (if health <= 0: die()) als Entwickler umsetze. Auch das kann (und sollte) der Designer selbst einstellen können. Natürlich ist es mit dem genannten Beispiel nicht weiter problematisch, weswegen ich das Beispiel auch gewählt habe. Aber es wird in einem Spielprojekt ja viele weitere solcher Reaktionen geben. Beispielsweise eine um zu prüfen, ob Stat-Requirements eines Items erfüllt werden und ggf. muss im UI etwas angepasst werden oder ein Item wird plötzlich ausrüstbar.

Hierzu kann man aber das bestehende System relativ simpel erweitern und eine StatHandlerDefinition ergänzen:

class_name StatHandlerDefinition
extends Resource

@export var stat_name: StringName
@export var operator # GREATER_THAN, LESSER_THAN, EQUALS, NOT EQUALS, ...
@export var value: float
@export var what: Callable

Darüber kann man sich den switch/case Baum im stat_changed listener sparen und im StatContainer ein Array[StatHandlerDefinition] speichern, über den man dann im Listener iteriert und den Handler anwendet.

Darüber schafft man sich als Entwickler die Möglichkeit, in "die Breite" zu entwickeln. Natürlich muss nun der Entwickler konkret das Callable dazu implementieren um den Tod auszulösen, aber der Mehrwert ist hier wieder: das passiert einmalig und auch Gegner etc. können von der selben StatHandlerDefinition profitieren.

Sicherlich kann man das auch noch weiter treiben und das "was passiert" in Form von Daten definieren. Dann hat der Designer wirklich völlige Kontrolle darüber, was wann und wie passiert. Im Falle von die() würde sicherlich auch einfach die Angabe eines Animationsnamens reichen. Innerhalb dieser Animation wird dann ein Soundeffekt und eine Sterbe-Animation abgespielt und final queue_free() aufgerufen. Das wird jedoch nicht alle Anwendungsfälle abdecken und ist sicherlich auch abhängig vom Spiel.

Mit dieser Grundlage lässt sich auch prima ein ModifierSystem implementieren um wirklich (De-)Buffs, Item Effekte (+5 Stärke) oder andere Skills darauf anzuwenden.

Was ist Deine Meinung zu solchen Stat-Systemen? Ist das "over engineeered"? Wenn ja, warum? Wie verwaltest du deine Stats und was für eine Art Spiel entwickelst du? Hast du vielleicht mal so ein System intensiv verwendet und musstest dann lernen, dass es doch eher "im Weg" war und hast anschließend wieder eine einfachere Implementierung genommen?

Das würde mich total interessieren, weil ich mit dem System bisher noch kein Spiel fertig gestellt habe und primär im Rahmen von Prototypen damit gearbeitet habe.