Skip to main content
Reading time: ~35 minutes
ScriptableObjects in CORP are data containers that allow you to define reusable assets and configuration. They provide a Unity-like approach to managing game data separate from your code.

Table of Contents

What are ScriptableObjects?

ScriptableObjects are classes that extend ScriptableObject and define data structures for game assets. They separate data from logic, making it easy to create and modify game content without changing code.

Key Features

  • Data Assets: Store configuration and content as assets
  • Reusability: Create multiple instances with different data
  • Serialization: Automatically serialized and deserialized
  • Type Safety: Full TypeScript type checking
  • Lazy Loading: Load assets on-demand or cache them
  • References: ScriptableObjects can reference other ScriptableObjects

Benefits

  • Designer-Friendly: Non-programmers can create and edit assets
  • Version Control: Assets are separate files, easy to track changes
  • Modularity: Organize game data independently from code
  • Performance: Shared data loaded once and reused

Creating ScriptableObjects

Basic ScriptableObject

import { ScriptableObject } from "CORP/shared/assets/scriptable-object";

export class WeaponData extends ScriptableObject {
    public weaponName: string = "Sword";
    public damage: number = 25;
    public attackSpeed: number = 1.0;
    public range: number = 5;
    public cost: number = 100;
}

ScriptableObject with Complex Data

import { ScriptableObject } from "CORP/shared/assets/scriptable-object";

export class EnemyData extends ScriptableObject {
    // Basic stats
    public enemyName: string = "Goblin";
    public health: number = 100;
    public damage: number = 10;
    public speed: number = 8;
    
    // Behavior configuration
    public aggroRange: number = 50;
    public attackRange: number = 5;
    public retreatHealth: number = 20;
    
    // Loot configuration
    public dropTable: { itemName: string, chance: number }[] = [
        { itemName: "Gold", chance: 1.0 },
        { itemName: "Sword", chance: 0.1 }
    ];
    
    // Visual configuration
    public color: Color3 = new Color3(0, 1, 0);
    public scale: number = 1.0;
}

ScriptableObject with References

ScriptableObjects can reference other ScriptableObjects:
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { WeaponData } from "./WeaponData";

export class CharacterClass extends ScriptableObject {
    public className: string = "Warrior";
    public baseHealth: number = 150;
    public baseSpeed: number = 10;
    
    // Reference to another ScriptableObject
    public startingWeapon!: WeaponData;
    
    // Array of references
    public availableWeapons: WeaponData[] = [];
}

The Data Namespace

The Data namespace provides utilities for working with serialized data in CORP. It defines special data types that are automatically deserialized when loaded, and includes factory methods for creating type-safe references and serialized values.

Available Types

The Data namespace supports these special serialized types:
import { Data } from "CORP/shared/data";

// Object References
Data.ComponentReference       // Reference to a Component
Data.GameObjectReference      // Reference to a GameObject
Data.InstanceReference        // Reference to a Roblox Instance
Data.ScriptableObjectReference // Reference to a ScriptableObject

// Serialized Roblox Types
Data.SerializedVector3        // Vector3 as serializable data
Data.SerializedColor3         // Color3 as serializable data

// Function References
Data.FunctionReference        // Reference to a function on an object

// Network References (for networked data)
Data.Networked.NetworkBehaviorReference // Reference to a NetworkBehavior
Data.Networked.InstanceReference        // Reference to a networked Instance

Factory Methods

Create these special types using the factory:
import { Data } from "CORP/shared/data";

// ScriptableObject references
Data.factory.createScriptableObjectReference("%assets%/Weapons/Sword");

// Serialized Roblox types
Data.factory.createSerializedVector3(0, 10, 0);
Data.factory.createSerializedColor3(1, 0, 0); // Red

// Component/GameObject references
Data.factory.createComponentReference(referenceId);
Data.factory.createGameObjectReference(referenceId);
Data.factory.createInstanceReference(referenceId);

// Function references
Data.factory.createFunctionReference("methodName", object);

// Network references
Data.Networked.factory.createNetworkBehaviorReference(id);
Data.Networked.factory.createInstanceReference(id);

Type Guards

Check if a value is a special data type:
import { Data } from "CORP/shared/data";

if (Data.isScriptableObjectReference(value)) {
    // value is ScriptableObjectReference
    print(value.$path);
}

if (Data.isSerializedVector3(value)) {
    // value is SerializedVector3
    const vec = new Vector3(value.x, value.y, value.z);
}

// Other type guards:
Data.isSpecial(value)                  // Any special type
Data.isComponentReference(value)
Data.isGameObjectReference(value)
Data.isInstanceReference(value)
Data.isFunctionReference(value)
Data.isSerializedColor3(value)
Data.Networked.isNetworkedSpecial(value)
Data.Networked.isNetworkBehaviorReference(value)
Data.Networked.isInstanceReference(value)

Why Use Data.factory?

  • Type Safety: Compile-time validation of structure
  • Autocomplete: IDE suggestions for all properties
  • Automatic Deserialization: Special types are automatically converted when loaded
  • Consistency: Standardized format across all assets
  • Maintainability: Easier to refactor and update
  • Documentation: Self-documenting code
Recommendation: Always use Data.factory methods when creating special references or serialized types in ModuleScript assets for better development experience and fewer runtime errors.

Loading ScriptableObjects

Using fetch()

The fetch() method loads any ScriptableObject by path:
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { WeaponData } from "./data/WeaponData";

// Load a weapon data asset using the VFS path
const sword = ScriptableObject.fetch<WeaponData>(
    "%assets%/Weapons/Sword"
);

print(sword.weaponName); // "Sword"
print(sword.damage);     // 25
VFS Path Convention: Use %assets% to reference the collapsed assets directory. During bootstrap, all Gamepack assets are collected into a centralized location accessible via this path. See Unreal System - Asset Management for more details.

Using fetchThis()

When loading from within a ScriptableObject class:
export class WeaponData extends ScriptableObject {
    public static loadSword(): WeaponData {
        return this.fetchThis<WeaponData>("%assets%/Weapons/Sword");
    }
}

// Usage
const sword = WeaponData.loadSword();

Using makeLoader()

For lazy-loaded, cached access:
import { makeLoader } from "CORP/shared/assets/scriptable-object";
import { WeaponData } from "./data/WeaponData";

// Create a loader function
export const getSwordData = makeLoader<WeaponData>(
    "%assets%/Weapons/Sword"
);

// Use anywhere in your code
// First call loads the asset, subsequent calls return cached instance
const sword = getSwordData();

In Components

import { Behavior } from "CORP/shared/componentization/behavior";
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { WeaponData } from "../data/WeaponData";

export class WeaponController extends Behavior {
    private weaponData!: WeaponData;
    
    public onStart(): void {
        // Load weapon data
        this.weaponData = ScriptableObject.fetch<WeaponData>(
            "%assets%/Weapons/Rifle"
        );
        
        print(`Equipped: ${this.weaponData.weaponName}`);
        print(`Damage: ${this.weaponData.damage}`);
    }
    
    public attack(): void {
        // Use the data
        const damage = this.weaponData.damage;
        const range = this.weaponData.range;
        
        // Perform attack...
    }
    
    protected getSourceScript(): ModuleScript {
        return script as ModuleScript;
    }
    
    public willRemove(): void {}
}

Asset Files

Create asset files as JSON in your assets folder:
{
    "$scriptableObjectClass": "weapon_data",
    "$data": {
        "weaponName": "Magic Sword",
        "damage": 50,
        "attackSpeed": 1.5,
        "range": 6,
        "cost": 500
    }
}
Save as MagicSword.scriptable or MagicSword.json in your assets folder.

ModuleScript Format

Alternatively, use a ModuleScript:
// MagicSword.scriptable.ts
export = {
    $scriptableObjectClass: "weapon_data",
    $data: {
        weaponName: "Magic Sword",
        damage: 50,
        attackSpeed: 1.5,
        range: 6,
        cost: 500
    }
};

Using the Data Namespace

When creating ModuleScript ScriptableObjects, use the Data namespace for type-safe asset creation:
// MagicSword.scriptable.ts
import { Data } from "CORP/shared/data";

export = Data.factory.createScriptableObjectData("weapon_data", {
    weaponName: "Magic Sword",
    damage: 50,
    attackSpeed: 1.5,
    range: 6,
    cost: 500
});
Benefits of using Data.factory.createScriptableObjectData:
  • Type safety and autocomplete
  • Validates the structure at compile time
  • Cleaner, more readable syntax
  • Consistent format across assets

Referencing Other ScriptableObjects in ModuleScripts

Use Data.factory.createScriptableObjectReference for references:
// Warrior.scriptable.ts
import { Data } from "CORP/shared/data";

export = Data.factory.createScriptableObjectData("character_class", {
    className: "Warrior",
    baseHealth: 150,
    startingWeapon: Data.factory.createScriptableObjectReference(
        "%assets%/Weapons/Sword"
    ),
    availableWeapons: [
        Data.factory.createScriptableObjectReference("%assets%/Weapons/Sword"),
        Data.factory.createScriptableObjectReference("%assets%/Weapons/Axe")
    ]
});

Serializing Roblox Types

Use Data.factory methods to serialize Vector3, Color3, and other Roblox types:
// Enemy.scriptable.ts
import { Data } from "CORP/shared/data";

export = Data.factory.createScriptableObjectData("enemy_data", {
    enemyName: "Goblin",
    health: 100,
    damage: 10,
    
    // Serialize Vector3
    spawnPosition: Data.factory.createSerializedVector3(0, 10, 0),
    patrolOffset: Data.factory.createSerializedVector3(10, 0, 10),
    
    // Serialize Color3
    bodyColor: Data.factory.createSerializedColor3(0, 1, 0), // Green
    glowColor: Data.factory.createSerializedColor3(1, 0, 0), // Red
    
    // Complex data structures
    dropTable: [
        { itemName: "Gold", chance: 1.0 },
        { itemName: "Sword", chance: 0.1 }
    ]
});
When loaded, these serialized types are automatically converted back to their Roblox equivalents:
const enemyData = ScriptableObject.fetch<EnemyData>("%assets%/Enemies/Goblin");

// Automatically deserialized to Vector3
print(enemyData.spawnPosition); // Vector3(0, 10, 0)

// Automatically deserialized to Color3
print(enemyData.bodyColor); // Color3(0, 1, 0)
Best Practice: Always use Data.factory methods when creating ModuleScript ScriptableObjects for better type safety and maintainability. The serialization system handles automatic deserialization when assets are loaded.

Configuration Format

For Roblox Studio, use Configuration instances:
MagicSword (Configuration)
├── $scriptableObjectClass (StringValue) = "weapon_data"
└── $data (StringValue) = {"weaponName":"Magic Sword","damage":50,...}

With References

Reference other ScriptableObjects in asset data:
{
    "$scriptableObjectClass": "character_class",
    "$data": {
        "className": "Warrior",
        "baseHealth": 150,
        "startingWeapon": {
            "$type": "scriptableObjectReference",
            "$path": "%assets%/Weapons/Sword"
        },
        "availableWeapons": [
            {
                "$type": "scriptableObjectReference",
                "$path": "%assets%/Weapons/Sword"
            },
            {
                "$type": "scriptableObjectReference",
                "$path": "%assets%/Weapons/Axe"
            }
        ]
    }
}

With Roblox Types

Use serialized formats for Roblox types:
{
    "$scriptableObjectClass": "enemy_data",
    "$data": {
        "enemyName": "Goblin",
        "health": 100,
        "color": {
            "r": 0,
            "g": 1,
            "b": 0
        },
        "spawnPosition": {
            "x": 0,
            "y": 10,
            "z": 0
        }
    }
}
These are automatically converted to Color3 and Vector3 when loaded.

Registration

ScriptableObjects must be registered in your Gamepack’s exports.

Registering in Gamepack

Create exports/scriptableobjects.ts:
import { Config } from "CORP/shared/unreal/gamepacks/config";
import { WeaponData } from "../src/data/WeaponData";
import { EnemyData } from "../src/data/EnemyData";
import { CharacterClass } from "../src/data/CharacterClass";
import { LevelConfig } from "../src/data/LevelConfig";

const scriptableObjects: Config.ScriptableObjects = {
    mappings: {
        // Use snake_case for ScriptableObject names
        "weapon_data": WeaponData,
        "enemy_data": EnemyData,
        "character_class": CharacterClass,
        "level_config": LevelConfig
    }
};

export = scriptableObjects;
Naming Convention: Always use snake_case for ScriptableObject mapping names. This ensures consistency with components and macros.

Common Use Cases

1. Weapon System

// WeaponData.ts
export class WeaponData extends ScriptableObject {
    public weaponName: string = "";
    public weaponType: "melee" | "ranged" = "melee";
    public damage: number = 10;
    public attackSpeed: number = 1.0;
    public range: number = 5;
    public ammoCapacity?: number;
    public reloadTime?: number;
}

// WeaponController.ts
import { Behavior } from "CORP/shared/componentization/behavior";
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { WeaponData } from "../data/WeaponData";

export class WeaponController extends Behavior {
    private currentWeapon!: WeaponData;
    
    public equipWeapon(weaponPath: string): void {
        this.currentWeapon = ScriptableObject.fetch<WeaponData>(weaponPath);
        print(`Equipped ${this.currentWeapon.weaponName}`);
    }
    
    public attack(): void {
        if (this.currentWeapon.weaponType === "ranged") {
            this.fireProjectile();
        } else {
            this.meleeAttack();
        }
    }
    
    private fireProjectile(): void {
        // Use this.currentWeapon.damage, range, etc.
    }
    
    private meleeAttack(): void {
        // Use this.currentWeapon.damage, range, etc.
    }
    
    protected getSourceScript(): ModuleScript {
        return script as ModuleScript;
    }
    
    public onStart(): void {}
    public willRemove(): void {}
}

2. Enemy Configuration

// EnemyData.ts
export class EnemyData extends ScriptableObject {
    public enemyName: string = "";
    public health: number = 100;
    public damage: number = 10;
    public speed: number = 8;
    public aggroRange: number = 50;
    public goldDrop: number = 10;
    public experienceDrop: number = 50;
}

// EnemySpawner.ts
import { Behavior } from "CORP/shared/componentization/behavior";
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { EnemyData } from "../data/EnemyData";
import { Enemy } from "../components/Enemy";

export class EnemySpawner extends Behavior {
    public spawnEnemy(enemyDataPath: string, position: Vector3): void {
        const enemyData = ScriptableObject.fetch<EnemyData>(enemyDataPath);
        
        const enemyObject = new GameObject(enemyData.enemyName);
        enemyObject.setTransform(new CFrame(position));
        
        const enemy = enemyObject.addComponent(Enemy);
        enemy.initializeFromData(enemyData);
    }
    
    protected getSourceScript(): ModuleScript {
        return script as ModuleScript;
    }
    
    public onStart(): void {}
    public willRemove(): void {}
}

3. Level Configuration

// LevelConfig.ts
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";

export class LevelConfig extends ScriptableObject {
    public levelName: string = "";
    public difficulty: number = 1;
    public timeLimit: number = 300;
    public enemySpawnRate: number = 5;
    public maxEnemies: number = 20;
    public requiredKills: number = 50;
    public bossName?: string;
}

// LevelManager.ts
import { Behavior } from "CORP/shared/componentization/behavior";
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { LevelConfig } from "../data/LevelConfig";

export class LevelManager extends Behavior {
    private config!: LevelConfig;
    
    public loadLevel(levelPath: string): void {
        this.config = ScriptableObject.fetch<LevelConfig>(levelPath);
        
        print(`Loading level: ${this.config.levelName}`);
        print(`Difficulty: ${this.config.difficulty}`);
        
        this.startLevel();
    }
    
    private startLevel(): void {
        // Use config to set up level
        // this.config.timeLimit, maxEnemies, etc.
    }
    
    protected getSourceScript(): ModuleScript {
        return script as ModuleScript;
    }
    
    public onStart(): void {}
    public willRemove(): void {}
}

4. Item Database

// ItemData.ts
export class ItemData extends ScriptableObject {
    public itemName: string = "";
    public description: string = "";
    public itemType: "consumable" | "equipment" | "quest" = "consumable";
    public stackable: boolean = true;
    public maxStack: number = 99;
    public value: number = 0;
    public rarity: "common" | "rare" | "epic" | "legendary" = "common";
}

// Inventory.ts
import { Behavior } from "CORP/shared/componentization/behavior";
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { ItemData } from "../data/ItemData";

export class Inventory extends Behavior {
    private items: Map<string, { data: ItemData, count: number }> = new Map();
    
    public addItem(itemPath: string, count: number = 1): void {
        const itemData = ScriptableObject.fetch<ItemData>(itemPath);
        
        if (this.items.has(itemPath)) {
            const existing = this.items.get(itemPath)!;
            if (itemData.stackable) {
                existing.count = math.min(
                    existing.count + count,
                    itemData.maxStack
                );
            }
        } else {
            this.items.set(itemPath, { data: itemData, count });
        }
        
        print(`Added ${count}x ${itemData.itemName}`);
    }
    
    protected getSourceScript(): ModuleScript {
        return script as ModuleScript;
    }
    
    public onStart(): void {}
    public willRemove(): void {}
}

5. Character Presets

// CharacterPreset.ts
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { WeaponData } from "./WeaponData";

export class CharacterPreset extends ScriptableObject {
    public presetName: string = "";
    public health: number = 100;
    public speed: number = 16;
    public jumpPower: number = 50;
    public startingWeapon!: WeaponData;
    public abilities: string[] = [];
}

// CharacterCreator.ts
import { Behavior } from "CORP/shared/componentization/behavior";
import { ScriptableObject } from "CORP/shared/assets/scriptable-object";
import { CharacterPreset } from "../data/CharacterPreset";

export class CharacterCreator extends Behavior {
    public createCharacter(presetPath: string): GameObject {
        const preset = ScriptableObject.fetch<CharacterPreset>(presetPath);
        
        const character = new GameObject(preset.presetName);
        
        // Apply preset values to character components
        const health = character.addComponent(HealthSystem);
        health.maxHealth = preset.health;
        
        const movement = character.addComponent(Movement);
        movement.speed = preset.speed;
        movement.jumpPower = preset.jumpPower;
        
        // weapon from preset.startingWeapon reference
        const weapon = character.addComponent(WeaponController);
        weapon.equipWeapon(preset.startingWeapon);
        
        return character;
    }
    
    protected getSourceScript(): ModuleScript {
        return script as ModuleScript;
    }
    
    public onStart(): void {}
    public willRemove(): void {}
}

Best Practices

1. Use snake_case for Names

// ✓ Good - consistent snake_case
"weapon_data": WeaponData,
"enemy_data": EnemyData,
"character_preset": CharacterPreset

// ✗ Bad - inconsistent casing
"WeaponData": WeaponData,
"enemyData": EnemyData

2. Provide Default Values

export class WeaponData extends ScriptableObject {
    // ✓ Good - all fields have defaults
    public weaponName: string = "Unknown";
    public damage: number = 0;
    public range: number = 1;
    
    // ✗ Bad - no defaults, can cause errors
    // public weaponName: string;
    // public damage: number;
}

3. Use Data.factory for ModuleScript Assets

// ✓ Good - type-safe with Data.factory
import { Data } from "CORP/shared/data";

export = Data.factory.createScriptableObjectData("weapon_data", {
    weaponName: "Sword",
    damage: 25,
    startingWeapon: Data.factory.createScriptableObjectReference("%assets%/Weapons/Default")
});

// ✗ Avoid - manual object creation (error-prone)
export = {
    $scriptableObjectClass: "weapon_data",
    $data: {
        weaponName: "Sword",
        startingWeapon: {
            "$type": "scriptableObjectReference",
            "$path": "%assets%/Weapons/Default"
        }
    }
};

4. Use makeLoader for Frequently Accessed Assets

// constants.ts
import { makeLoader } from "CORP/shared/assets/scriptable-object";

export const getDefaultWeapon = makeLoader("%assets%/Weapons/Default");
export const getPlayerConfig = makeLoader("%assets%/Config/Player");

// Use in components
import { getDefaultWeapon } from "./constants";

const weapon = getDefaultWeapon(); // Cached after first call

5. Organize Assets by Type

assets/
├── Weapons/
│   ├── Sword.scriptable
│   ├── Bow.scriptable
│   └── Staff.scriptable
├── Enemies/
│   ├── Goblin.scriptable
│   ├── Dragon.scriptable
│   └── Skeleton.scriptable
└── Config/
    ├── PlayerConfig.scriptable
    └── GameSettings.scriptable

6. Document ScriptableObject Properties

/**
 * Defines configuration for weapon items.
 */
export class WeaponData extends ScriptableObject {
    /** Display name of the weapon */
    public weaponName: string = "";
    
    /** Base damage per hit */
    public damage: number = 10;
    
    /** Number of attacks per second */
    public attackSpeed: number = 1.0;
    
    /** Maximum attack range in studs */
    public range: number = 5;
}

6. Validate Data on Load

export class WeaponData extends ScriptableObject {
    public weaponName: string = "";
    public damage: number = 10;
    
    public validate(): boolean {
        if (this.weaponName === "") {
            warn("WeaponData: weaponName is empty");
            return false;
        }
        
        if (this.damage < 0) {
            warn("WeaponData: damage cannot be negative");
            return false;
        }
        
        return true;
    }
}

// In component
const weapon = ScriptableObject.fetch<WeaponData>(path);
if (!weapon.validate()) {
    // Handle invalid data
}

Next Steps

  • Macros: Learn about instance transformation
  • Unreal System: Understand Gamepack integration
  • Networking: NetworkedVariables can replicate ScriptableObjects
  • Examples: See complete implementations

Master ScriptableObjects for flexible, designer-friendly game data! 📦