Skip to content

Creating Custom Entities

WatIsDeze edited this page Dec 9, 2025 · 1 revision

Creating Custom Entities Tutorial

This comprehensive tutorial walks you through creating custom entities in Q2RTXPerimental, from basic concepts to advanced features.

Prerequisites

Before starting, ensure you:

  • Have built Q2RTXPerimental successfully
  • Understand C++ basics (classes, inheritance, virtual methods)
  • Have read Entity System Overview
  • Know how to use a map editor (TrenchBroom recommended)

Tutorial Structure

We'll create three entities of increasing complexity:

  1. Simple Decorative Entity - Static model with effects
  2. Interactive Button - Player interaction and signals
  3. Animated Monster - Full AI, animation, combat

Part 1: Simple Decorative Entity

Let's create a glowing crystal that rotates and pulses.

Step 1: Create Header File

Create src/baseq2rtxp/svgame/entities/misc/svg_misc_crystal.h:

#pragma once

#include "svgame/entities/svg_base_edict.h"

/**
 * @brief A decorative glowing crystal
 * 
 * Spawns a rotating crystal model with pulsing light effect.
 * Demonstrates basic entity creation and thinking.
 */
class svg_misc_crystal_t : public svg_base_edict_t {
public:
    // Type registration
    DefineClass(
        "misc_crystal",              // Classname for maps
        svg_misc_crystal_t,          // This class
        svg_base_edict_t,            // Parent class
        EdictTypeInfo::TypeInfoFlag_GameSpawn
    );
    
    // Constructor/Destructor
    svg_misc_crystal_t() = default;
    virtual ~svg_misc_crystal_t() = default;
    
    // Virtual methods
    virtual void Spawn() override;
    virtual void Think() override;
    
private:
    // Custom properties
    float pulseSpeed = 2.0f;     // Cycles per second
    float baseLight = 200.0f;    // Base light intensity
    float pulseAmount = 100.0f;  // Light variation
    
    bool isPulsing = true;       // Enable/disable pulsing
};

// Register with spawn system
DECLARE_EDICT_SPAWN_INFO(misc_crystal, svg_misc_crystal_t);

Step 2: Create Implementation File

Create src/baseq2rtxp/svgame/entities/misc/svg_misc_crystal.cpp:

#include "svg_misc_crystal.h"
#include "svgame/svg_main.h"

// Register spawn function
DEFINE_EDICT_SPAWN_INFO(misc_crystal, svg_misc_crystal_t);

/**
 * @brief Spawn the crystal entity
 */
void svg_misc_crystal_t::Spawn() {
    // Call base class first
    svg_base_edict_t::Spawn();
    
    // Set entity properties
    s.entityType = ET_GENERAL;
    solid = SOLID_BBOX;  // Solid bounding box
    movetype = MOVETYPE_NONE;  // Doesn't move
    
    // Set bounding box (for collision)
    VectorSet(mins, -16, -16, 0);
    VectorSet(maxs, 16, 16, 48);
    
    // Read spawn parameters from entity dictionary
    // These come from key-value pairs in the map editor
    if (entityDictionary.contains("light")) {
        baseLight = std::stof(entityDictionary["light"]);
    }
    if (entityDictionary.contains("speed")) {
        pulseSpeed = std::stof(entityDictionary["speed"]);
    }
    if (entityDictionary.contains("amount")) {
        pulseAmount = std::stof(entityDictionary["amount"]);
    }
    
    // Set model
    const char *modelName = "models/objects/crystal/tris.md2";
    if (entityDictionary.contains("model")) {
        modelName = entityDictionary["model"].c_str();
    }
    gi.SetModel(edict, modelName);
    
    // Set visual effects
    s.effects |= EF_ROTATE;  // Rotate continuously
    s.renderfx |= RF_GLOW;   // Glow effect
    
    // Link into world (makes it solid and visible)
    gi.LinkEntity(edict);
    
    // Set up think callback for pulsing
    if (isPulsing) {
        SetThinkCallback(&svg_misc_crystal_t::Think);
        nextThinkTime = level.time + FRAMETIME;
    }
    
    gi.dprintf("misc_crystal spawned at (%.1f, %.1f, %.1f)\n",
               s.origin[0], s.origin[1], s.origin[2]);
}

/**
 * @brief Update pulsing effect
 */
void svg_misc_crystal_t::Think() {
    if (!isPulsing) {
        return;
    }
    
    // Calculate pulsing light intensity using sine wave
    float time = level.time.count() * pulseSpeed;
    float pulse = std::sin(time * M_PI * 2.0f);
    float currentLight = baseLight + (pulse * pulseAmount);
    
    // Update light_level for client
    light_level = currentLight;
    
    // Optional: Change visual effects based on intensity
    if (currentLight > baseLight) {
        s.renderfx |= RF_GLOW;
    } else {
        s.renderfx &= ~RF_GLOW;
    }
    
    // Schedule next think (every frame for smooth pulsing)
    nextThinkTime = level.time + FRAMETIME;
}

Step 3: Add to Build System

Edit src/baseq2rtxp/svgame/CMakeLists.txt (or appropriate CMake file):

set(SVGAME_SOURCES
    # ... existing files ...
    
    # Add our new files
    entities/misc/svg_misc_crystal.cpp
    entities/misc/svg_misc_crystal.h
    
    # ... rest of files ...
)

Step 4: Build and Test

cd build
cmake --build . --config Release

Step 5: Use in Map

In your map editor (TrenchBroom), create a new entity with:

  • classname: misc_crystal
  • origin: 0 0 0 (or desired position)
  • light: 200 (optional, default is 200)
  • speed: 2.0 (optional, pulses per second)
  • amount: 100 (optional, pulse intensity)
  • model: models/objects/crystal/tris.md2 (optional)

Part 2: Interactive Button Entity

Now let's create a button that players can press, which triggers other entities.

Header File

Create src/baseq2rtxp/svgame/entities/func/svg_func_button.h:

#pragma once

#include "svgame/entities/svg_base_edict.h"

/**
 * @brief Interactive button entity
 * 
 * Players can press the button (USE key) to trigger targets.
 * Demonstrates Use callback, UseTargets, and state management.
 */
class svg_func_button_t : public svg_base_edict_t {
public:
    DefineClass(
        "func_button",
        svg_func_button_t,
        svg_base_edict_t,
        EdictTypeInfo::TypeInfoFlag_GameSpawn
    );
    
    svg_func_button_t() = default;
    virtual ~svg_func_button_t() = default;
    
    // Virtual methods
    virtual void Spawn() override;
    virtual void Use(svg_base_edict_t *other, svg_base_edict_t *activator,
                     entity_usetarget_type_t useType, int32_t useValue) override;
    virtual void Think() override;
    
    // UseTargets support
    virtual UseTargetHint GetUseTargetHint() override {
        if (state == STATE_IDLE) {
            return UseTargetHint::PRESSABLE;  // Shows "Press E" hint
        }
        return UseTargetHint::NONE;
    }
    
    virtual void OnUseTargetPressed(svg_base_edict_t *user) override {
        Use(this, user, USETARGET_TYPE_PRESS, 0);
    }
    
private:
    // Button states
    enum ButtonState {
        STATE_IDLE,      // Waiting to be pressed
        STATE_PRESSED,   // Currently pressed
        STATE_RETURNING  // Returning to idle
    };
    
    ButtonState state = STATE_IDLE;
    
    // Properties
    float wait = 1.0f;         // Time before returning
    vec3_t startOrigin;        // Original position
    vec3_t pressedOrigin;      // Position when pressed
    float pressDistance = 4.0f; // How far button moves
    
    // Methods
    void StartPress();
    void FinishPress();
    void StartReturn();
    void FinishReturn();
};

DECLARE_EDICT_SPAWN_INFO(func_button, svg_func_button_t);

Implementation File

Create src/baseq2rtxp/svgame/entities/func/svg_func_button.cpp:

#include "svg_func_button.h"
#include "svgame/svg_main.h"

DEFINE_EDICT_SPAWN_INFO(func_button, svg_func_button_t);

void svg_func_button_t::Spawn() {
    svg_base_edict_t::Spawn();
    
    // Set up as brush entity
    s.entityType = ET_PUSHER;
    solid = SOLID_BSP;
    movetype = MOVETYPE_PUSH;
    
    // Load brush model from map
    gi.SetModel(edict, entityDictionary->model);
    
    // Read properties
    wait = GetEntityDictValue<float>("wait", 1.0f);
    pressDistance = GetEntityDictValue<float>("lip", 4.0f);
    
    // Store start position
    startOrigin = s.origin;
    
    // Calculate pressed position (moves in -normal direction)
    vec3_t moveDir = {0, 0, -1};  // Default: down
    if (entityDictionary.contains("angle")) {
        float angle = std::stof(entityDictionary["angle"]);
        AngleVectors(vec3_t{0, angle, 0}, moveDir, nullptr, nullptr);
    }
    pressedOrigin = startOrigin + (moveDir * pressDistance);
    
    // Set up sound
    if (entityDictionary.contains("sounds")) {
        int soundType = std::stoi(entityDictionary["sounds"]);
        // Load appropriate sound based on soundType
    }
    
    // Link into world
    gi.LinkEntity(edict);
    
    state = STATE_IDLE;
    
    gi.dprintf("func_button spawned, target='%s'\n",
               entityDictionary.contains("target") ? 
               entityDictionary["target"].c_str() : "(none)");
}

void svg_func_button_t::Use(svg_base_edict_t *other, svg_base_edict_t *activator,
                             entity_usetarget_type_t useType, int32_t useValue) {
    // Can only be pressed when idle
    if (state != STATE_IDLE) {
        return;
    }
    
    gi.dprintf("Button pressed by %s\n", 
               activator->client ? activator->client->pers.netname : activator->classname);
    
    // Start pressing
    StartPress();
    
    // Play activation sound
    gi.sound(edict, CHAN_VOICE, gi.soundindex("switches/butn2.wav"), 
             1.0f, ATTN_STATIC, 0);
    
    // Trigger targets
    UseTargets(activator);
    
    // Emit signal for advanced entity communication
    EmitSignal("OnPressed", this, activator);
}

void svg_func_button_t::StartPress() {
    state = STATE_PRESSED;
    
    // Move button to pressed position
    s.origin = pressedOrigin;
    gi.LinkEntity(edict);
    
    // Set up think to return after wait time
    SetThinkCallback(&svg_func_button_t::FinishPress);
    nextThinkTime = level.time + gametime_t::from_sec(wait);
}

void svg_func_button_t::FinishPress() {
    // Start returning to original position
    StartReturn();
}

void svg_func_button_t::StartReturn() {
    state = STATE_RETURNING;
    
    // Animate return (for smooth movement, use velocity)
    // For simplicity, we'll just snap back
    s.origin = startOrigin;
    gi.LinkEntity(edict);
    
    // Small delay before becoming usable again
    SetThinkCallback(&svg_func_button_t::FinishReturn);
    nextThinkTime = level.time + gametime_t::from_msec(100);
}

void svg_func_button_t::FinishReturn() {
    state = STATE_IDLE;
    // Ready to be pressed again
}

void svg_func_button_t::Think() {
    // Think callback will be set by state transition functions
    // No general think behavior needed
}

Usage in Map

In your map editor:

  • classname: func_button
  • Create a brush and texture it
  • target: door1 (name of entity to trigger)
  • wait: 1.0 (seconds before returning)
  • lip: 4 (movement distance)
  • sounds: 1 (optional, sound type)

Part 3: Simple AI Monster

Finally, let's create a basic monster with AI.

Header File (Simplified)

Create src/baseq2rtxp/svgame/entities/monster/svg_monster_grunt.h:

#pragma once

#include "svgame/entities/svg_base_edict.h"

/**
 * @brief Basic grunt monster
 * 
 * Demonstrates AI, animation, and combat.
 * Patrols, detects enemies, attacks, and can be killed.
 */
class svg_monster_grunt_t : public svg_base_edict_t {
public:
    DefineClass(
        "monster_grunt",
        svg_monster_grunt_t,
        svg_base_edict_t,
        EdictTypeInfo::TypeInfoFlag_GameSpawn
    );
    
    svg_monster_grunt_t() = default;
    virtual ~svg_monster_grunt_t() = default;
    
    // Virtual methods
    virtual void Spawn() override;
    virtual void Think() override;
    virtual void Pain(svg_base_edict_t *attacker, float kick, int damage) override;
    virtual void Die(svg_base_edict_t *inflictor, svg_base_edict_t *attacker,
                     int damage, vec3_t point) override;
    
private:
    // AI states
    enum AIState {
        AI_IDLE,
        AI_PATROL,
        AI_CHASE,
        AI_ATTACK,
        AI_PAIN,
        AI_DEAD
    };
    
    AIState aiState = AI_IDLE;
    
    // Properties
    float sightRange = 1024.0f;
    float attackRange = 512.0f;
    float attackDamage = 10.0f;
    float attackDelay = 1.0f;
    
    gametime_t nextAttackTime;
    
    // AI methods
    void IdleAI();
    void ChaseAI();
    void AttackAI();
    
    bool FindEnemy();
    bool CanSeeEnemy();
    void FaceEnemy();
    void MoveToward(vec3_t target);
    void FireWeapon();
};

DECLARE_EDICT_SPAWN_INFO(monster_grunt, svg_monster_grunt_t);

Implementation (Core Parts)

#include "svg_monster_grunt.h"
#include "svgame/svg_main.h"

DEFINE_EDICT_SPAWN_INFO(monster_grunt, svg_monster_grunt_t);

void svg_monster_grunt_t::Spawn() {
    svg_base_edict_t::Spawn();
    
    // Set up as monster
    s.entityType = ET_MONSTER;
    solid = SOLID_BBOX;
    movetype = MOVETYPE_STEP;  // Can step over obstacles
    
    // Set bounding box
    VectorSet(mins, -16, -16, -24);
    VectorSet(maxs, 16, 16, 32);
    
    // Set health
    health = 100;
    max_health = 100;
    gib_health = -30;
    takedamage = DAMAGE_YES;
    
    // Load model
    gi.SetModel(edict, "models/monsters/grunt/tris.md2");
    
    // Set up AI
    aiState = AI_IDLE;
    
    // Link and start thinking
    gi.LinkEntity(edict);
    SetThinkCallback(&svg_monster_grunt_t::Think);
    nextThinkTime = level.time + FRAMETIME;
}

void svg_monster_grunt_t::Think() {
    // AI state machine
    switch (aiState) {
        case AI_IDLE:
            IdleAI();
            break;
        case AI_CHASE:
            ChaseAI();
            break;
        case AI_ATTACK:
            AttackAI();
            break;
    }
    
    // Continue thinking
    nextThinkTime = level.time + FRAMETIME;
}

void svg_monster_grunt_t::IdleAI() {
    // Look for enemies
    if (FindEnemy()) {
        aiState = AI_CHASE;
        gi.sound(edict, CHAN_VOICE, gi.soundindex("grunt/sight.wav"),
                 1.0f, ATTN_NORM, 0);
    }
}

void svg_monster_grunt_t::ChaseAI() {
    if (!enemy || !CanSeeEnemy()) {
        // Lost enemy
        enemy = nullptr;
        aiState = AI_IDLE;
        return;
    }
    
    // Face enemy
    FaceEnemy();
    
    // Check attack range
    float dist = VectorDistance(s.origin, enemy->s.origin);
    if (dist < attackRange) {
        aiState = AI_ATTACK;
        nextAttackTime = level.time;
    } else {
        // Move toward enemy
        MoveToward(enemy->s.origin);
    }
}

void svg_monster_grunt_t::AttackAI() {
    if (!enemy || !CanSeeEnemy()) {
        enemy = nullptr;
        aiState = AI_IDLE;
        return;
    }
    
    FaceEnemy();
    
    // Check if out of range
    float dist = VectorDistance(s.origin, enemy->s.origin);
    if (dist > attackRange * 1.5f) {
        aiState = AI_CHASE;
        return;
    }
    
    // Attack when delay expires
    if (level.time >= nextAttackTime) {
        FireWeapon();
        nextAttackTime = level.time + gametime_t::from_sec(attackDelay);
    }
}

bool svg_monster_grunt_t::FindEnemy() {
    // Simple enemy detection: find closest player in sight
    svg_base_edict_t *closest = nullptr;
    float closestDist = sightRange;
    
    for (int i = 1; i <= maxclients->value; i++) {
        svg_base_edict_t *player = GetEntityByIndex(i);
        if (!player->inuse || !player->client)
            continue;
            
        float dist = VectorDistance(s.origin, player->s.origin);
        if (dist < closestDist) {
            // Check line of sight
            cm_trace_t tr = SVG_Trace(s.origin, vec3_origin, vec3_origin,
                                       player->s.origin, this, MASK_OPAQUE);
            if (tr.fraction == 1.0f) {
                closest = player;
                closestDist = dist;
            }
        }
    }
    
    if (closest) {
        enemy = closest;
        return true;
    }
    return false;
}

void svg_monster_grunt_t::FireWeapon() {
    // Play attack animation
    s.frame = FRAME_attack01;  // Start attack animation
    
    // Play sound
    gi.sound(edict, CHAN_WEAPON, gi.soundindex("grunt/attack.wav"),
             1.0f, ATTN_NORM, 0);
    
    // Fire bullet
    vec3_t start = s.origin + vec3_t{0, 0, 24};  // Shoot from chest height
    vec3_t dir;
    VectorSubtract(enemy->s.origin, start, dir);
    VectorNormalize(dir);
    
    cm_trace_t tr = SVG_Trace(start, vec3_origin, vec3_origin,
                               start + dir * attackRange,
                               this, MASK_SHOT);
    
    if (tr.fraction < 1.0f && tr.ent == enemy) {
        // Hit enemy!
        SVG_Damage(enemy, this, this, dir, tr.endpos, tr.plane.normal,
                   attackDamage, 0, DAMAGE_BULLET, MOD_BLASTER);
    }
    
    // Spawn muzzle flash
    gi.WriteByte(svc_muzzleflash);
    gi.WriteShort(s.number);
    gi.WriteByte(MZ_BLASTER);
    gi.multicast(s.origin, MULTICAST_PVS);
}

void svg_monster_grunt_t::Pain(svg_base_edict_t *attacker, float kick, int damage) {
    // Play pain sound
    gi.sound(edict, CHAN_VOICE, gi.soundindex("grunt/pain.wav"),
             1.0f, ATTN_NORM, 0);
    
    // If we weren't aware of attacker, target them
    if (!enemy) {
        enemy = attacker;
        aiState = AI_CHASE;
    }
}

void svg_monster_grunt_t::Die(svg_base_edict_t *inflictor, svg_base_edict_t *attacker,
                               int damage, vec3_t point) {
    // Play death sound
    gi.sound(edict, CHAN_VOICE, gi.soundindex("grunt/death.wav"),
             1.0f, ATTN_NORM, 0);
    
    // Check for gibbing
    if (health < gib_health) {
        // Gib!
        ThrowGibs(damage);
        SVG_FreeEntity(this);
        return;
    }
    
    // Death animation
    s.frame = FRAME_death01;
    s.entityType = ET_MONSTER_CORPSE;
    
    // Become non-solid
    solid = SOLID_NOT;
    movetype = MOVETYPE_TOSS;
    takedamage = DAMAGE_NO;
    aiState = AI_DEAD;
    
    // Remove after 30 seconds
    SetThinkCallback(nullptr);
    nextThinkTime = gametime_t::zero();
    
    // Schedule corpse removal
    think_frame = level.frameNumber + (30 * 40);  // 30 seconds at 40 Hz
}

Testing Your Entities

Console Commands

// Spawn entity at player location
spawn misc_crystal
spawn func_button
spawn monster_grunt

// Give yourself items
give all
god

// Debug entity
ent_info <entity_number>

Map Testing

  1. Create a test map in TrenchBroom
  2. Place your entities
  3. Compile the map
  4. Load in game: map yourmap

Common Pitfalls

1. Forgetting to Call Base Class

// BAD
void Spawn() {
    s.entityType = ET_MONSTER;
    // Missing base class call!
}

// GOOD
void Spawn() {
    svg_base_edict_t::Spawn();  // Always call first!
    s.entityType = ET_MONSTER;
}

2. Not Linking Entity

// BAD
void Spawn() {
    s.origin = {100, 200, 50};
    // Entity not linked - won't appear!
}

// GOOD
void Spawn() {
    s.origin = {100, 200, 50};
    gi.LinkEntity(edict);  // Make it solid/visible
}

3. Wrong Movetype/Solid Combo

// BAD: Trigger that blocks movement
solid = SOLID_BBOX;
movetype = MOVETYPE_NONE;

// GOOD: Trigger that doesn't block
solid = SOLID_TRIGGER;
movetype = MOVETYPE_NONE;

4. Think Without NextThink

// BAD: Think never called again
void Think() {
    DoSomething();
    // Missing nextThinkTime!
}

// GOOD
void Think() {
    DoSomething();
    nextThinkTime = level.time + FRAMETIME;
}

Next Steps


Congratulations! You've created your first custom entities. Now experiment and create your own unique gameplay!

Clone this wiki locally