1 - Fill out Quest metadata form

In order for us to set up a Quest for a game within the Treasure Platform, the following details will need to be collected (see here for a Google Sheets template to provide quests in bulk).

  1. Quest Name
  2. Name of the associated game
  3. Quest Description: A non-technical definition of what a player needs to do to achieve this quest (73 character limit is preferred).
  4. Start and End Dates: Provide a UTC date + timestamp (e.g., 2024-06-07T16:00:00Z).
  5. CTA Link: The URL that the player will need to navigate to to work on said quest
  6. Badge (optional): to accompany this specific quest. See the Badge Design Guide for more details.
  7. Quest Mechanics : To help us normalize rewards please provide approximate scores in the following categories:
    • Friction Score: On a scale of 1-10 with 10 being most friction, how much friction does a player need to go through in order to complete this quest?
    • Difficulty Score: On a scale of 1-10 with 10 being most difficult, how much skill does a player need to complete this quest?
    • Time Required: On average, how much time (in hrs) will a player need to commit to complete this quest?
  8. Quest Event Schema: Tell us the schema for your event payload. See Step (2).
  9. Quest Denominator (integer): This is the denominator field described in the technical criteria step.
  10. Quest Numerator (query): Tell us the SQL query to use to compute the numerator for this quest. The quest is complete when numerator >= denominator. See the “Create a Quest query” for details on the syntax (Treasure team can assist you with formulating this query if needed).

2 - Prepare your payload

The required fields are

  • cartridge_tag
  • name
  • time_server (a string representation of an integer)
  • smart_account (or user_id)
  • id (for the event)
  • properties (a JSON object containing game-specific event data)

Recommended and optional fields are indicated in the schema below.

The other fields you add within properties can be JSON scalar, array, or object types.

Your Event Payload schema should have the following format.

A TypeScript version of your schema.

{
    // Game partner / time / player info.
    cartridge_tag: string; // Required. Value is assigned to you by Treasure.
    name: string; // Required. Name of this type of event. You decide this value.
    time_server: string; // Required. UNIX milliseconds.
    time_local?: string; // Optional. UNIX milliseconds of event at originating device.
    smart_account: string; // Required. Ethereum wallet address for player. Lowercase.
    user_id?: string; // Required if smart_account is undefined. Can be player ID or email, etc.

    // Database upsert info.
    id: string; // Required. Unique identifier for this event.
                // Used as a database upsert key to filter out duplicate updates.
                // Can be omitted if a unique identifier can be found elsewhere in
                // the payload.

    op?: string; // Optional. A field indicating whether to upsert/delete the
                 // database entry specified in id. Valid values:
                 //   "upsert" / "u"
                 //   "delete" / "d"
                 // In the absence of an 'op', default behavior is to upsert.

    // Event-specific properties. Up to 500 KB.
    properties: {
        // ...
        // ...
    },

    // Other metadata. Device and app telemetry.
    session_id?: string; // Optional. Unique Session ID. Helps w/ analytics.
    chain_id?: number; // Optional. Chain ID.
    device: {
        device_unique_id?: string; // Recommended. Unique identifier for device.
        device_name?: string; // Optional. Device name.
        device_model?: string; // Optional. Device model.
        device_os?: string; // Optional. Device OS.
    },
    app: {
        app_identifier: string; // Recommended. Name of backend sending this ("my_game_backend").
        app_environment: number; // Recommended. 0 for dev, 1 for prod.
        app_version: string; // Recommended. Build version for the backend server sending this event.
                             // For example, '0.1' or '1.2.1-alpha'.
    }
}

Examples payloads

3 - Push game events to Treasure

From your backend, push your event payloads to our infra. You can choose from the following methods: Kafka, HTTP POST, or AWS SNS.

There is no need for you to set up your own Kafka cluster with this approach. Just send messages to a dedicated Redpanda Kafka topic that Treasure manages for you.

  1. Receive Kafka client credentials (username, password, bootstrap URL) from Treasure.

  2. Send your JSON payloads using a Kafka producer client. Kafka client libraries are available in a number of languages. The following example is in TypeScript (assumes you have already installed kafkajs@2.2.4):

import { Kafka as KafkaJs, logLevel } from 'kafkajs';

// Create a Kafka producer client.
const kafka = new KafkaJs({
    clientId: 'mygame-client-id', // You choose this.
    brokers: ['KAFKA_BOOTSTRAP_URL'], // Assigned by Treasure.
    logLevel: logLevel.WARN,
    ssl: true,
    sasl: {
        mechanism: 'scram-sha-256',
        username: 'KAFKA_USERNAME', // Assigned by Treasure.
        password: 'KAFKA_PASSWORD', // Assigned by Treasure.
    },
});
const producer = kafka.producer();
await producer.connect();

// Prepare event key, payload, optional headers.
const eventKey = 'match-id-81ac4712'; // You determine this.
const eventPayload = {
    cartridge_tag: 'mygame',
    name: 'completed-match',
    time_server: '1717526677354',
    smart_account: '0xabcde123456789012345678901234567890abcde',
    id: 'match-id-81ac4712',

    properties: {
        completed: true,
        score: '513',
        result: 'victory',
        ants_defeated: '8',
        mosquitos_defeated: '5',
        health_max: '100',
        health_remaining: '90',
        team_members: ['mantis', 'dragonfly', 'antlion'],
        player_level: '3'
    },

    chain_id: 978657,
    device: {
        device_unique_id: '0AF433c8927-9178-C2'
    },
    app: {
        app_identifier: 'mygame-super-server',
        app_environment: 1,
        app_version: '0.1.2'
    }
};
// Headers are optional, but can inform us what to do with entries
// that share the same (upsert) key.
const eventHeaders: String<string, string | string[] | undefined. = {
    op: 'u', // Valid values are "u" / "upsert" / "d" / "delete".
};

// Send message to Treasure Kafka.
await producer.send({
    topic: 'KAFKA_TOPIC', // Assigned by Treasure.
    messages: [
        {
            key: eventKey,
            value: JSON.stringify(eventPayload),
            headers: eventHeaders,
        },
    ],
});

4 - Create a Quest query

Aside from the usual Quest metadata (name, description, optional image, start/end dates), you’ll need to tell us

  1. What cartridge_tag + name to filter for.
  2. The value for the quest’s denominator.
  3. How to count/sum/aggregate the quest’s numerator from the set of filtered events.

A quest is complete when numerator >= denominator. You can express the numerator computation as an aggregate query over the DB.

We currently support PostgreSQL syntax. The following special variables will be subbed into your SQL query:

  • {{START_DATE_MILLIS}} (the start date of the quest in UNIX milliseconds)
  • {{END_DATE_MILLIS}} (the end date of the quest in UNIX milliseconds)
  • {{START_DATE_SECONDS}} (the start date of the quest in UNIX seconds)
  • {{END_DATE_SECONDS}} (the end date of the quest in UNIX seconds)
  • {{USER_ADDRESS}} (the player’s wallet address)

Appendix

Quest query examples

We provide a few simple example queries here.

Combining on-chain + off-chain quest criteria

Using Goldsky’s Mirror pipelines, Darkmatter is able to ingest on-chain events and transactions, allow us to make queries that combine web3 and web2 data.

If you’d like us to ingest your on-chain data to use for Quests, here are the steps.

1

Provide event signature

Give Treasure the event signature that you want us to index. Example: Stamp(uint256 indexed stampId, address indexed caller, uint256 nonce)

If your event is defined in a widely adopted standard (like ERC20, ERC721, etc), then please tell us which chain and contract_address to listen for as well.

2

Table setup

Treasure will set up a table containing those events. In our example, that table would be contract_events.stamp. Note that event_params JSONB captures the event parameters for an emitted event Stamp(13, 0xabcde123456789012345678901234567890abcde, 69).

{
  "chain": "ruby",
  "address": "0x5167e9a422aced95c2d0b62bf05a7847a9a942b2",
  "block_timestamp": 1717526700,
  "block_number": 640297,
  "transaction_index": 1,
  "log_index": 0,
  "transaction_hash": "0x53cad22fdb2283e9c35415e73a638efb3731712dd3b27ca4ed73631b8bd099ea",
  "event_name": "Stamp",
  "event_signature_indexed": "Stamp(uint256 indexed,address indexed,uint256)",
  "event_signature": "Stamp(uint256,address,uint256)",
  "event_params": {
    "stampId": "13",
    "caller": "0xabcde123456789012345678901234567890abcde",
    "nonce": "69"
  }
}
3

Construct SQL query

Construct a SQL query to compute the numerator for you quest. Example for a quest whose goal is to “win at least 1 match and produce an on-chain Stamp with nonce 69 or 420”:

WITH victory_count AS (
    SELECT
        COUNT(*) AS count
    FROM
        events.mygame_completed_match
    WHERE
        time_server BETWEEN {{START_DATE_MILLIS}} AND {{END_DATE_MILLIS}}
        AND smart_account = '{{USER_ADDRESS}}'
        AND result = 'victory'
),
stamp_count AS (
    SELECT
        COUNT(*) AS count
    FROM
        contract_events.stamp
    WHERE
        block_timestamp BETWEEN {{START_DATE_SECONDS}} AND {{END_DATE_SECONDS}}
        AND event_params->>'caller' = '{{USER_ADDRESS}}'
        AND (event_params->>'nonce')::numeric IN (69, 420)
)
SELECT
    (COALESCE(victory_count.count, 0) > 0)::int
    + (COALESCE(stamp_count.count, 0) > 0)::int AS numerator
FROM
    victory_count FULL JOIN stamp_count ON TRUE