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:
-
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.
-
Player data: Actors can store and manage player-specific data, such as inventory, progress, or achievements.
-
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:
-
Create a new TypeScript file in your module's directory with a name representing your actor.
-
Define your actor class by extending the
ActorBase
class:
- Add your actor to the module's configuration file (
module.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.
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.
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.
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.
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.
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.
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.
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.
Check if an Actor Instance Exists
Use the exists
method to check if an actor instance exists.
Destroy an Actor Instance
Use the destroy
method to destroy an actor instance.
Storage
Actors have access to two types of storage:
-
State: The actor's internal state, which is automatically persisted and can be accessed via
this.state
. -
KV Storage: A key-value storage system provided by the
StorageDriver
. It allows storing and retrieving data using string keys.
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:
-
Encapsulate state: Ensure that actor state is only accessed and modified within the actor itself. Avoid exposing the internal state directly to external components.
-
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.
-
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.
-
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.
-
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.
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.
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.
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:
- In your module's configuration file (
module.json
), add thestorageAlias
property to the actor's configuration:
- 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.