Dojo SDK: Build onchain faster
The Dojo SDK provides a powerful, intuitive interface for interacting with onchain state. It streamlines data fetching and subscriptions, supporting both simple and complex queries.
Table of Contents
Key Features
- Type Safety: Leverage TypeScript for robust, error-resistant code.
- Intuitive query syntax: Write queries that feel natural, similar to popular OrMs.
- Flexible subscriptions: Easily subscribe to specific state changes in your Dojo world.
- Signed messages: Sign off-chain state and send to torii.
- Automatic Zustand Support: Drop in zustand state management
- Optimistic Client Rendering: Set state before a transaction has resolved to improve user experiences
Understand Entities and Models
- Entities are uniquely identified by Keys defined in associated models
- Entities can have multiple models, representing complex game states
- When a subscription or query returns data - it returns the updated Entity and changed models.
Torii - a ECS query system powerhouse
Before going further be sure to read this documentation of Torii queries Torii provides a grpc server that wraps all required tools to query your entities in a performant manner.
Example: Subscribing to Specific Model States
Here's a concise example demonstrating how to subscribe to the item
model in the world
namespace, specifically filtering for items with a durability of 2:
const subscription = await sdk.subscribeEntityQuery(
world: {
item: {
$: {
where: { durability: { $is: 2 } },
},
},
},
(response) => {
if (response.data) {
// return data - pipe into any state management!
console.log("Updated entities:", response.data);
} else if (response.error) {
// return error
console.error("Subscription error:", response.error);
}
}
);
Usage
🚀 Installation
npm install @dojoengine/sdk
Generate TypeScript types directly from your world schema:
To take advantage of this type safety (You will need dojo installed):
-
Generate the TypeScript types for your world:
sozo build --typescript
-
You will import these and pass into the sdk init function to give your app type. You can see all the options for sozo, like setting the output directory, with the following command:
sozo build --help
This approach ensures that your code remains in sync with your Dojo world definition, catching potential issues early in the development process.
Initializing the SDK
import { init } from "@dojoengine/sdk";
import {
SchemaType,
schema,
} from "../where-you-generated-your-models/models.gen";
// Initialize the SDK
const db = await init<SchemaType>(
{
client: {
rpcUrl: "your-rpc-url",
toriiUrl: "your-torii-url",
relayUrl: "your-relay-url",
worldAddress: "your-world-address",
},
domain: {
name: "Example",
version: "1.0",
chainId: "your-chain-id",
revision: "1",
},
},
schema
);
Use experimental sdk
With this version, you can use ClauseBuilder which is an improved version of QueryBuilderimport { init } from "@dojoengine/sdk/experimental";
import {
SchemaType,
schema,
} from "../where-you-generated-your-models/models.gen";
// Initialize the SDK
const db = await init<SchemaType>({
client: {
rpcUrl: "your-rpc-url",
toriiUrl: "your-torii-url",
relayUrl: "your-relay-url",
worldAddress: "your-world-address",
},
domain: {
name: "Example",
version: "1.0",
chainId: "your-chain-id",
revision: "1",
},
});
Understanding Queries
Here you can find a detailed explanation over how Torii gRPC works
The SDK utilizes two primary types of queries to interact with the Dojo Engine:
SubscriptionQueryType
: Used for real-time subscriptions to entity and event updates.QueryType
: Used for fetching entities and event messages with more flexible filtering options.
Both query types enable filtering based on entityIds
and specific model properties. The key difference lies in the operators supported within the where
clause:
SubscriptionQueryType
:- Supports only the
$is
operator for exact matches.
- Supports only the
QueryType
:- Supports a variety of operators for more advanced filtering:
- You combine queries with 'And' with 'Or' from deep queries. See Advanced Usage.
Querying Entities
This example fetches player
entities from the world
namespace where id
is "1" and name
is "Alice", demonstrating multiple conditions in a query.
Note: $eq
is for exact matching. Other operators ($gt
, $lt
, etc.) are available for complex queries.
// This is how a raw Torii query would look like.
{
"Composite": {
"operator": "And",
"clauses": [
{
"Member": {
"model": "world-Player",
"member": "id",
"operator": "Eq",
"value": {
"Primitive": {
"U32": 1
}
}
}
},
{
"Member": {
"model": "world-Player",
"member": "name",
"operator": "Eq",
"value": "Alice",
}
},
]
}
}
// ClauseBuilder version
const entities = await sdk.getEntities(
new ToriiQueryBuilder().withClause(
new ClauseBuilder().compose().and(
new ClauseBuilder().where("world-Player", 'id', 'Eq', 1),
new ClauseBuilder().where("world-Player", 'name', 'Eq', "Alice"),
).build();
).build()
);
// Or
const entities = await sdk.getEntities(
new ToriiQueryBuilder().withClause(
AndComposeClause([
MemberClause("world-Player", 'id', 'Eq', 1),
MemberClause("world-Player", 'name', 'Eq', "Alice"),
]).build();
).build()
);
// Simple query version
const entities = await sdk.getEntities({
query: {
// this is namespace to query models in
world: {
// this is model name
player: {
$: { where: { id: { $eq: "1" }, name: { $eq: "Alice" } } },
},
},
},
callback: (response) => {
if (response.data) {
console.log("Fetched entities:", response.data);
} else if (response.error) {
console.error("Fetch error:", response.error);
}
}
});
// Query builder version
const entities = await sdk.getEntities({
query: new QueryBuilder()
.namespace("world", (n) =>
n.entity("player", (e) => e.eq("id", "1").eq("name", "Alice"))
)
.build(),
callback: (response) => {
if (response.data) {
console.log("Fetched entities:", response.data);
} else if (response.error) {
console.error("Fetch error:", response.error);
}
}
});
Subscribing To Entity Changes
Subscribing to entity changes is a bit more trivial. Because Torii subscribe functions only works with KeysClause
or HashedKeysClause
you need first to query your model and then subscribe to those using entityIds.
This example subscribes to item
model updates in the world
namespace, filtering for swords with durability 5. The callback triggers on matching item changes.
Key points:
- Namespace:
world
, Model:item
- Conditions: type "sword", durability 5
- Uses
$is
for exact matching
const entities = await sdk.getEntities({
query: {
// this is namespace to query models in
world: {
// this is model name
item: {
$: {
where: {
type: { $is: "sword" },
durability: { $is: 5 },
},
},
},
},
},
callback: () => {},
});
const subscription = await sdk.subscribeEntityQuery({
query: entities.map((e) => e.entityId),
callback: (response) => {
if (response.data) {
console.log("Updated entities:", response.data);
} else if (response.error) {
console.error("Subscription error:", response.error);
}
},
});
Experimental version
Using experimental, you can automatically achieve the same at the cost of including hashed keys in your query:
const [initialEntities, subscription] = await sdk.subscribeEntities(
new ToriiQueryBuilder()
.withClause(
new ClauseBuilder()
.compose()
.and([
new ClauseBuilder().where(
"world-item",
"type",
"Eq",
"sword"
),
new ClauseBuilder().where(
"world-item",
"durability",
"Eq",
5
),
])
.build()
)
.includeHashedKeys()
.build(),
(response) => {
if (response.data) {
console.log("Updated entities:", response.data);
} else if (response.error) {
console.error("Subscription error:", response.error);
}
}
);
sdk first query entities and then use entityIds for subscription so you only have to care about what you original query was.
Sending Signed Messages
NOTE: If you want messages to be actually sent and broadcasted to all of your torii client instance,
you'll have to properly set relayUrl
in the init
function.
relayUrl
is a multiaddr format which looks like something like this when deployed on slot: /dns4/api.cartridge.gg/tcp/443/x-parity-wss/%2Fx%2Fyour-slot-deployment-name%2Ftorii%2Fwss
// onchain_dash-Message is a composition of the ${namespace}-${Model} type you want to sign.
// Here we take example of a chat because we don't want to load up those messages onchain
// But keep in mind this could be any models defined in your cairo code
const msg = sdk.generateTypedData("onchain_dash-Message", {
identity: account?.address,
content: toValidAscii(data.message),
timestamp: Date.now(),
});
try {
const signature = await account.signMessage(msg);
try {
await db.client.publishMessage(
JSON.stringify(msg),
signature as string[]
);
reset();
} catch (error) {
console.error("failed to publish message:", error);
}
} catch (error) {
console.error("failed to sign message:", error);
}
Using With Zustand
This module takes the Schema
and outputs a typed store you can use around your app. See example here.
- Import the module
import { createDojoStore } from "@dojoengine/sdk/react";
// import this outside of components
export const useDojoStore = createDojoStore<Schema>();
...
// Using in your app
const state = useDojoStore((state) => state);
const entities = useDojoStore((state) => state.entities);
...
// Adding to a callback
const subscription = await sdk.subscribeEntityQuery(
{
world: {
item: {
$: {
where: {
type: { $is: "sword" },
durability: { $is: 5 },
},
},
},
},
},
(response) => {
if (response.error) {
console.error("Error setting up entity sync:", response.error);
} else if (response.data && response.data[0].entityId !== "0x0") {
// You just need to do this!
state.setEntities(response.data);
}
}
);
Zustand vanilla
You can also use a vanilla store which can be imported :
import { createDojoStore } from "@dojoengine/sdk/state";
const store = createDojoStore<Schema>();
// Contrary to react, zustand vanilla store is not wrapped into a bound store.
// to use your store :
const state = store.getState();
const entities = state.getEntities();
Optimistic Client Rendering
We use immer for efficient optimistic rendering. This allows instant client-side entity state updates while awaiting blockchain confirmation.
The process:
- Update entity state optimistically.
- Wait for condition (e.g., a specific state change).
- Resolve update, providing immediate user feedback.
This ensures a responsive user experience while maintaining blockchain data integrity.
See our example project for a real-world implementation.
Note: You will need to have a subscription running in order for the update to resolve.
export const useSystemCalls = () => {
const state = useDojoStore((state) => state);
const {
setup: { client },
account: { account },
} = useDojo();
const generateEntityId = () => {
return getEntityIdFromKeys([BigInt(account?.address)]);
};
const spawn = async () => {
// Generate a unique entity ID
const entityId = generateEntityId();
// Generate a unique transaction ID
const transactionId = uuidv4();
// The value to update
const remainingMoves = 100;
// Apply an optimistic update to the state
// this uses immer drafts to update the state
state.applyOptimisticUpdate(
transactionId,
(draft) =>
(draft.entities[entityId].models.dojo_starter.Moves!.remaining =
remainingMoves)
);
try {
// Execute the spawn action
await client.actions.spawn({ account });
// Wait for the entity to be updated with the new state
await state.waitForEntityChange(entityId, (entity) => {
return (
entity?.models?.dojo_starter?.Moves?.remaining ===
remainingMoves
);
});
} catch (error) {
// Revert the optimistic update if an error occurs
state.revertOptimisticUpdate(transactionId);
console.error("Error executing spawn:", error);
throw error;
} finally {
// Confirm the transaction if successful
state.confirmTransaction(transactionId);
}
};
return {
spawn,
};
};
Advanced Usage
Create complex 'And' with 'Or' statements to narrow in on what you want to fetch.
Complex Queries
const entities = await sdk.getEntities(
{
world: {
player: {
$: {
where: {
And: [
{ score: { $gt: 100 } },
{
Or: [
{ name: { $eq: "Alice" } },
{ name: { $eq: "Bob" } },
],
},
],
},
},
},
item: {
$: {
where: {
And: [{ durability: { $lt: 50 } }],
},
},
},
},
},
(response) => {
if (response.data) {
console.log("Fetched entities:", response.data);
} else if (response.error) {
console.error("Fetch error:", response.error);
}
}
);
Experimental version
const entities = await sdk.getEntities(
new ToriiQueryBuilder()
.withClause(
new ClauseBuilder()
.compose()
.and([
new ClauseBuilder()
.compose()
.and([
new ClauseBuilder().where(
"world-player",
"score",
"Gt",
100
),
new ClauseBuilder()
.compose()
.or([
new ClauseBuilder().where(
"world-player",
"name",
"Eq",
"Bob"
),
new ClauseBuilder().where(
"world-player",
"name",
"Eq",
"Alice"
),
]),
]),
new ClauseBuilder().where(
"world-item",
"durability",
"Lt",
50
),
])
.build()
)
.build(),
(response) => {
if (response.data) {
console.log("Fetched entities:", response.data);
} else if (response.error) {
console.error("Fetch error:", response.error);
}
}
);
// OR (but only if you're a lazy ninja)
const entities = await sdk.getEntities(
new ToriiQueryBuilder()
.withClause(
AndComposeClause([
AndComposeClause([
MemberClause("world-player", "score", "Gt", 100),
OrComposeClause([
MemberClause("world-player", "name", "Eq", "Bob"),
MemberClause("world-player", "name", "Eq", "Alice"),
]),
]),
MemberClause("world-item", "durability", "Lt", 50),
]).build()
)
.build(),
(response) => {
if (response.data) {
console.log("Fetched entities:", response.data);
} else if (response.error) {
console.error("Fetch error:", response.error);
}
}
);