Actors

Actors are a core concept in Rivet modules, providing a way to encapsulate state and behavior within a module. They are designed to handle concurrent requests and maintain their own internal state.


What is an Actor?

An actor is a standalone unit that can receive requests, process them, and maintain its own state. Actors are created on-demand and can be accessed via a unique identifier. Each actor runs in isolation, meaning it does not share state with other actors or parts of the system.

Example Uses of Actors

Actors are particularly useful in game development scenarios, such as:

  1. Managing game lobbies: An actor can represent a game lobby, maintaining the state of the lobby, handling player joins/leaves, and processing game-related requests.

  2. Player data: Actors can store and manage player-specific data, such as inventory, progress, or achievements.

  3. Game world entities: In a game world, actors can represent individual entities like NPCs, items, or interactive objects, encapsulating their state and behavior.


Creating an Actor

To create an actor:

  1. Create a new TypeScript file in your module's directory with a name representing your actor.

  2. Define your actor class by extending the ActorBase class:

import { ActorBase } from "./module.gen.ts";

export class MyActor extends ActorBase<MyActorInput, MyActorState> {
  // Actor implementation
}
TypeScript
  1. Add your actor to the module's configuration file (module.json):
{
  "actors": {
    "my_actor": {}
  }
}
JSON

Input and Initialize Function

When an actor is created, it receives an input object and an initialization function is called. The input object is used to provide initial data to the actor, while the initialize function is responsible for setting up the actor's initial state.

import { ActorBase, ActorContext } from "./module.gen.ts";

interface MyActorInput {
  // Input properties
}

interface MyActorState {
  // State properties
}

export class MyActor extends ActorBase<MyActorInput, MyActorState> {
  public async initialize(
    ctx: ActorContext<any>,
    input: MyActorInput
  ): Promise<MyActorState> {
    // Initialize actor state based on input
    return {
      // Initial state properties
    };
  }
}
TypeScript

State

Each actor maintains its own internal state, which can be accessed and modified within the actor's methods. The state is automatically persisted and can be retrieved across multiple requests.

export class MyActor extends ActorBase<MyActorInput, MyActorState> {
  public async someMethod(ctx: ActorContext<any>) {
    // Access and modify state
    this.state.someProperty = "new value";
  }
}
TypeScript

Structuring State

Optimize State for Persistence

When designing your actor's state, think of it as a JSON file that will be persisted to disk. Keep in mind that the format of the state may change over time as your module evolves.

Instead of reading from and writing to the state directly, it's recommended to create getter and setter methods that encapsulate the state manipulation logic. This allows you to handle state changes in a controlled manner and adapt to format changes more easily.

export class MyActor extends ActorBase<MyActorInput, MyActorState> {
  private getProperty(): string {
    return this.state.someProperty;
  }

  private setProperty(value: string): void {
    this.state.someProperty = value;
  }

  public async someMethod(ctx: ActorContext<any>) {
    const value = this.getProperty();
    // Perform some operations
    this.setProperty(newValue);
  }
}
TypeScript

Versioning State for Complex Use Cases

For more complex use cases where the state structure may undergo significant changes, consider implementing a versioning system for your actor state. This allows you to handle state migrations and ensure backward compatibility.

interface MyActorStateV1 {
  // State properties for version 1
}

interface MyActorStateV2 {
  // State properties for version 2
}

export class MyActor extends ActorBase<MyActorInput, MyActorStateV2> {
  public async initialize(ctx: ActorContext<any>, input: MyActorInput): Promise<MyActorStateV2> {
    const state = await this.storage.get<MyActorStateV1 | MyActorStateV2>("state");

    if (!state) {
      // Initialize state with version 2 structure
      return {
        // Initial state properties for version 2
      };
    }

    if (!("newProperty" in state)) {
      // Migrate state from version 1 to version 2
      return {
        ...state,
        newProperty: "default value",
      };
    }

    return state;
  }
}
TypeScript

Remote Procedure Calls (RPC)

Adding RPC Functions To Actors

Actors can define RPC (Remote Procedure Call) functions that can be invoked externally. These functions are defined as methods within the actor class.

export class MyActor extends ActorBase<MyActorInput, MyActorState> {
  public async myRpcFunction(
    ctx: ActorContext<any>,
    request: MyRpcRequest
  ): Promise<MyRpcResponse> {
    // RPC function implementation
  }
}
TypeScript

Here's the updated section on "Calling RPC Functions From Scripts" with subheaders for the different ways to call RPC functions:

Call an RPC Function

Use the call method to invoke an RPC function on an existing actor instance.

const response = await ctx.actor.myActor.call<MyRpcRequest, MyRpcResponse>(
  "actor_instance_id",
  "myRpcFunction",
  request
);
TypeScript

Create an Actor Instance and Call an RPC Function

Use the create method to create a new actor instance, and then use the call method to invoke an RPC function on the newly created instance.

const response = await ctx.actors.myActor.call<MyRpcRequest, MyRpcResponse>(
  "actor_instance_id",
  "myRpcFunction",
  request
);
TypeScript

Get or Create an Actor Instance and Call an RPC Function

Use the getOrCreateAndCall method to either get an existing actor instance or create a new one if it doesn't exist, and then invoke an RPC function on the instance.

const response = await ctx.actors.myActor.getOrCreateAndCall<MyActorInput, MyRpcRequest, MyRpcResponse>(
  "actor_instance_id",
  input,
  "myRpcFunction",
  request
);
TypeScript

Check if an Actor Instance Exists

Use the exists method to check if an actor instance exists.

const actorExists = await ctx.actors.myActor.exists("actor_instance_id");
TypeScript

Destroy an Actor Instance

Use the destroy method to destroy an actor instance.

await ctx.actors.myActor.destroy("actor_instance_id");
TypeScript

Storage

Actors have access to two types of storage:

  1. State: The actor's internal state, which is automatically persisted and can be accessed via this.state.

  2. KV Storage: A key-value storage system provided by the StorageDriver. It allows storing and retrieving data using string keys.

// Accessing state
this.state.someProperty = "value";

// Using KV storage
await this.storage.set("my_key", "my_value");
const value = await this.storage.get("my_key");
TypeScript

When to Use Actors for Storage

When deciding how to store data in your Rivet module, you have a few options: using actor state, using a database, or a hybrid approach. Here are some guidelines on when to use each:

Actor State

Use actor state when:

  • The data is specific to a single actor instance and doesn't need to be shared across instances.
  • The data is relatively small in size and can be efficiently stored in memory.
  • You need fast read and write access to the data within the actor.

Actor state is ideal for storing temporary or session-specific data that is closely tied to the actor's behavior.

Database

Use a database when:

  • The data needs to be persisted long-term and survive actor restarts or failures.
  • The data is large in size or requires complex querying capabilities.
  • The data needs to be shared across multiple actor instances or modules.

Databases provide durability, scalability, and powerful querying features for managing large datasets.

Hybrid Approach

In some cases, a hybrid approach combining actor state and a database can be beneficial:

  • Use actor state for caching frequently accessed data from the database to improve performance.
  • Store a subset of the data in actor state for fast access, while persisting the complete dataset in the database.
  • Use actor state for temporary or intermediate data, and periodically sync it with the database for persistence.

The hybrid approach allows you to leverage the strengths of both actor state and databases, optimizing for performance and data consistency.

Best Practices

When working with actors and storage, consider the following best practices:

  1. Encapsulate state: Ensure that actor state is only accessed and modified within the actor itself. Avoid exposing the internal state directly to external components.

  2. Manage concurrency: If multiple actors or external components need to access shared data, use appropriate concurrency control mechanisms like locks or transactions to prevent data inconsistencies.

  3. Handle failures: Implement proper error handling and recovery mechanisms to handle actor failures gracefully. Use techniques like event sourcing or state snapshotting to recover actor state if needed.

  4. Optimize performance: Be mindful of performance when designing your storage strategy. Avoid excessive database queries or large data transfers between actors and databases. Use caching and lazy loading techniques when appropriate.

  5. Test thoroughly: Write comprehensive tests to verify the correctness of your actor storage implementation. Test various scenarios, including concurrent access, failure cases, and data consistency.

By carefully considering your storage requirements and following best practices, you can design an efficient and reliable storage architecture for your Rivet modules using actors, databases, or a combination of both.


Schedule

Actors can schedule functions to be executed at a later time using the ScheduleDriver. This is useful for implementing delayed tasks or recurring events.

// Schedule a function to run after 5 seconds
this.schedule.runAfter(ctx, 5000, async () => {
  // Scheduled function implementation
});
TypeScript

Actor Gotchas

Use schedule instead of setTimeout

Inside an actor, you should use the ScheduleDriver (this.schedule) to schedule delayed tasks instead of using setTimeout directly.

Call forceSaveState when necessary

If you have long-running tasks or background jobs that modify the actor's state, make sure to call this.forceSaveState() to persist the state changes.

Be mindful of CPU usage

Actors should not perform CPU-intensive tasks that block execution for long periods. If you have computationally heavy operations, consider breaking them down into smaller chunks or using background jobs.


Performance Tips

Minimize Work on the Actor

To optimize performance, aim to perform as little work as possible within the actor itself. Expensive operations like database queries or heavy computations should be done in the request handlers or scripts before sending a message to the actor.

Scripts can scale horizontally, while actors can become a performance bottleneck if misused. By offloading work to the request handlers, you can ensure better scalability and responsiveness of your module.

In-Memory Indexes for Complex State

In some cases, you may need to maintain in-memory indexes or derived data that is not part of the persisted state. You can initialize these indexes in the actor's constructor and update them as needed.

export class MyActor extends ActorBase<MyActorInput, MyActorState> {
  private index: Map<string, string>;

  constructor(
    instanceDriver: ActorInstanceDriver,
    storage: StorageDriver,
    schedule: ScheduleDriver,
  ) {
    super(instanceDriver, storage, schedule);
    this.index = new Map();
  }

  public async someMethod(ctx: ActorContext<any>) {
    // Update in-memory index
    this.index.set("key", "value");

    // Persist state changes
    this.state.someProperty = "new value";
    await this.forceSaveState();
  }
}
TypeScript

Remember to write back the necessary data to the state when you need it to be persisted.

Distribute Load Across Multiple Actors

If you have a high-traffic use case or need to perform resource-intensive tasks, consider splitting the work across multiple actors. You can create an index or a load balancing mechanism to distribute the load evenly.

const actorCount = 8;

function getActorInstanceName(key: string): string {
  const actorIndex = hashFunction(key) % actorCount;
  return `actor-${actorIndex}`;
}

// In your request handler
const actorInstanceName = getActorInstanceName(key);
const response = await ctx.actors.myActor.call(actorInstanceName, "someMethod", request);
TypeScript

By distributing the load across multiple actor instances, you can achieve better performance and scalability in handling high-traffic scenarios.

These additional sections provide guidance on structuring actor state, handling state migrations, and optimizing actor performance. By following these best practices, you can build robust and efficient actors in your Rivet module.


Advanced

Storage Alias

Here's an updated section on Storage Alias in the Rivet Actors documentation:

Storage Alias

Storage Alias is a configuration option that allows you to rename actors while keeping the underlying storage consistent. It is useful when you need to change the name of an actor but want to preserve the existing data associated with it.

To use Storage Alias:

  1. In your module's configuration file (module.json), add the storageAlias property to the actor's configuration:
{
  "actors": {
    "my_actor": {
      "storageAlias": "old_actor_name"
    }
  }
}
JSON
  1. The storageAlias value should be set to the previous name of the actor.

IMPORTANT: Changing the storageAlias will effectively unlink all data stored for this actor. If you change it back to the old value, the data will be restored.

When an actor is initialized, Rivet uses the storageAlias (if provided) instead of the actor's current name to determine the storage key. This ensures that the actor's data remains associated with the same storage key even if the actor's name is changed.