Rust Traits in Action – RPG Game Edition

Pasted image 20250510002239.png

Rather than getting tangled in abstract definitions, we’ll approach trait objects from a practical perspective—what they let you do. Specifically, we’ll see how they help you manage different game entities under a common interface, enabling polymorphism without losing the safety and performance Rust is known for.


A Quick Refresher on Polymorphism

Runtime Polymorphism

Runtime polymorphism is a concept from object-oriented programming where the actual method or behavior that gets executed is determined at runtime, rather than at compile time.

In Rust (and other languages like C++ or Java), runtime polymorphism typically involves trait objects or virtual method tables (vtables).

trait Animal {
    fn speak(&self);
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn speak(&self) {
        println!("Woof!");
    }
}

impl Animal for Cat {
    fn speak(&self) {
        println!("Meow!");
    }
}

fn make_it_speak(animal: &dyn Animal) {
    animal.speak(); // the actual method call is resolved at runtime
}

Here, &dyn Animal is a trait object, and calling speak() on it is a runtime-dispatched call — Rust uses a vtable behind the scenes to figure out which speak() method to call (Dog's or Cat's).


What is a Trait Object?

In Rust, trait objects allow for a powerful form of polymorphism—the ability to write code that can operate on different types through a shared interface. Think of them as a way to say: "I don't care exactly what you are, as long as you can do these things."

Trait objects enable dynamic dispatch, which means the method that gets called is determined at runtime, rather than at compile time (as with generics and static dispatch).

This is especially useful when you want to:


Rust’s Relationship to OOP

Rust doesn’t reject object-oriented programming — it reimagines it.

Instead of classes and inheritance, Rust builds on composition, traits, and explicit interfaces. You still get polymorphism, encapsulation, and shared behavior — but without the baggage of inheritance chains or implicit sub-typing.

Traits let you say what something can do, not what it is. This shifts the focus from identity to capability, a more flexible and more composable model of abstraction.

In short, Rust doesn’t do “objects” the traditional way — it does behavior-oriented design, on your terms.

It can be summarized in a simple words as: rust promotes composition over abstraction and inheritance

Different Roots, Different Trade-offs

In traditional OOP (e.g. Java, C++), polymorphism is built on inheritance and subtyping, often leading to deep hierarchies and tight coupling. Rust avoids this by using traits and composition, which keep behavior modular and explicit.

But there are trade-offs:

  • No inheritance means you must be more deliberate with code reuse — Rust favors composition over extension.

  • No implicit down-casting means less flexibility, but far more safety.

  • Trait objects (e.g. Box<dyn Trait>) introduce runtime costs: dynamic dispatch uses a v-table, which adds a layer of indirection and may prevent some compiler optimizations (like inlining).

In short, Rust chooses clarity and performance by default, and only reaches for dynamic polymorphism when you explicitly ask for it.
You get power, but you always pay the cost up front.


Syntax Overview

A trait object is created by using the dyn keyword:

trait Character {
    fn attack(&self);
}

fn perform_attack(c: &dyn Character) {
    c.attack();
}

You can also use:


Trait Objects vs Generics

Rust has two main ways of achieving polymorphism:

Feature Trait Objects (dyn Trait) Generics (impl Trait / <T>)
Dispatch Dynamic Static
Performance Slight runtime cost Faster
Flexibility More flexible More strict
Use in Collections Great for heterogeneous Requires same concrete type

Rule of Thumb:

📌 Use generics when you know the types at compile time and want speed.
📌 Use trait objects when you need to abstract over types at runtime.


Trait Objects are Dynamically Sized

Trait objects are dynamically sized types (DSTs). You can’t use them on their own—they must be behind a pointer like &, Box, or Rc.

// Valid
let hero: Box<dyn Character> = Box::newnew();

// Invalid
let hero: dyn Character = Wizard::new(); // Error! DSTs must be behind a pointer

What Do Trait Objects Enable?

Trait objects unlock powerful design patterns in applications. In game development, especially for RPGs, they’re an ideal solution for managing heterogeneous collections of game entities.

Storing Multiple Types in One Collection

Let’s say your RPG has several character classes:

struct Wizard { /* fields */ }
struct Warrior { /* fields */ }
struct Rogue { /* fields */ }

Each one is implemented as a separate struct. But they all implement a shared trait:

trait Character {
    fn attack(&self);
    fn defend(&self);
}

Now, you want to store all these different characters in a single party:

let party: Vec<Box<dyn Character>> = vec![
    Box::newnew(),
    Box::newnew(),
    Box::newnew(),
];

Real-World Analogy: RPG Party System

Imagine you're building a game where players can choose from different races or classes. Each has unique logic and stats, but your game engine needs to treat them uniformly when it comes to game mechanics like attacking or receiving damage.

Trait objects let you write logic like:

fn run_battle(party: &Vec<Box<dyn Character>>) {
    for character in party {
        character.attack();
    }
}

You don’t care what kind of character it is, you just care that it can attack. This is dynamic dispatch in action.


Creating a Tiny Role-Playing Game

Characters in the game can be one of three races: humans, elves, and dwarves. These are represented by the Human, Elf, and Dwarf variants in the Character enum.

Characters interact with things. Things are represented by the Thing enum, which currently supports two variants: Sword and Trinket. There’s only one form of interaction right now: enchantment. Enchanting a thing means calling character.enchant(&mut thing).

When enchantment is successful, the thing glows brightly. When it fails, the item is transformed into a worthless trinket.

Pasted image 20250510113027.png

Here’s the implementation:

use rand::prelude::*;  
use rand::Rng;  
  
#[derive(Debug)]  
enum Thing {  
    Sword,  
    Trinket,  
}  
  
#[derive(Debug)]  
enum Character {  
    Dwarf,  
    Human,  
    Elf,  
}  
  
trait Enchanter: std::fmt::Debug {  
    fn competency(&self) -> f64;  
  
    fn enchant(&self, thing: &mut Thing)  
    where  
        Self: std::fmt::Debug,  
    {  
        let probability_of_success = self.competency();  
        let mut rng = rand::rng();  
        let spell_is_successful = rng.random_bool(probability_of_success);  
  
        println!("{:?} mutters incoherently.", self);  
        if spell_is_successful {  
            println!("The {:?} glows brightly.", thing);  
        } else {  
            println!("The {:?} fizzes, then turns into a worthless trinket.", thing);  
            *thing = Thing::Trinket;  
        }  
    }  
}  
  
impl Enchanter for Character {  
    fn competency(&self) -> f64 {  
        match self {  
            Character::Dwarf => 0.5,  
            Character::Human => 0.8,  
            Character::Elf => 0.95,  
        }  
    }  
}  
  
fn main() {  
    let mut it = Thing::Sword;  
  
    let party = vec![  
        Character::Dwarf,  
        Character::Human,  
        Character::Elf,  
    ];  
  
    let spell_caster = party.choose(&mut rand::rng()).unwrap();  
    spell_caster.enchant(&mut it);  
}

Let’s break down what’s happening in the code:

trait Enchanter {
    fn competency(&self) -> f64;

    fn enchant(&self, thing: &mut Thing) {
        // ...
    }
}

The Enchanter trait defines a shared behavior: anything that can enchant must have a competency score (represented as a f64), and must implement an enchant() method.

We then implement this trait for the Character enum, with each race getting its own success probability:

impl Enchanter for Character {
    fn competency(&self) -> f64 {
        match self {
            Character::Dwarf => 0.5,
            Character::Human => 0.8,
            Character::Elf => 0.95,
        }
    }
}

When a character attempts to enchant an item, the outcome depends on randomness and their competency. A failed attempt downgrades the item to a Trinket.


Note

While this example doesn't use a trait object explicitly (like &dyn Enchanter), the key trait-based polymorphism is still here. We rely on the trait interface to abstract the enchanting behavior, and we could move to dynamic dispatch if needed.


So... Where Are the Trait Objects?

Right now, everything uses static dispatch. The compiler knows all types at compile time, and method calls are resolved directly. But here’s the powerful part: with only a small change, we can convert this into a fully dynamic system.

Let’s say we split Character into different struct types instead of enum variants:

struct Elf;
struct Human;
struct Dwarf;

Each struct implements the Enchanter trait:

impl Enchanter for Elf {
    fn competency(&self) -> f64 { 0.95 }
}

impl Enchanter for Human {
    fn competency(&self) -> f64 { 0.8 }
}

impl Enchanter for Dwarf {
    fn competency(&self) -> f64 { 0.5 }
}

Now we can create a mixed collection of enchanters — different types with shared behavior:

let party: Vec<Box<dyn Enchanter>> = vec![
    Box::new(Elf),
    Box::new(Human),
    Box::new(Dwarf),
];

This is where trait objects really start to shine.


Tip

You can now call enchant() on any member of the party without needing to know their exact type.
Runtime polymorphism gives you that flexibility — method calls go through a vtable under the hood.


Why This Matters

If you’ve ever tried to model a complex system of characters, abilities, or AI behavior, you’ve probably run into a situation where enums start to get messy. You add new variants, then new match arms, and suddenly your clean architecture is full of brittle boilerplate.

Trait objects offer a better way.

They let you express shared behavior cleanly — and let your code operate on abstractions, not implementations. You can add a new race or a new enchantment style without touching your core game loop or party logic.

Tip

Next time you find yourself creating yet another match statement or trying to wedge a new type into an enum, ask yourself: Would a trait object make this easier?
Odds are, the answer is yes.