Skip to content

Common Configuration

This page documents how support for a game is written in common/.

There are two kinds of configs here.

Where do configurations go?

Configurations should be written in common/src/config/game-support/GAMENAME.ts.

Once you have written a config, go to common/src/config/config.ts and import it. Mount the game configuration on GAME_CONFIGS, and the GPT configuration on GAME_PT_CONFIGS.

A quick note on Games vs GPTs.

A game config is configuration for a game. Games in Tachi aren't actually the meaty part of support, instead, games form a kind of "group" for their playtypes.

For example, in IIDX there are two kinds of playtypes - Single Play and Double Play. Although these are completely separate kinds of games (it wouldn't make sense to share scores between them), they do share songs.

The game part - in this case iidx - defines things that are shared between all of its playtypes - things like what to call the game and what the song documents look like.

Whereas the game + playtype (typically shortened to GPT) is the part that contains almost all of the actual configuration. This distinction will become more obvious when you see the difference in size between the two configs.

Note

I picked the shorthand GPT before ChatGPT et. al. blew up. Ah well.

Game Configurations

Here is an example configuration - taken from our actual implementation of IIDX.

export const IIDX_CONF = {
    name: "beatmania IIDX",
    playtypes: ["SP", "DP"],
    songData: z.strictObject({
        genre: z.string(),
        displayVersion: z.nullable(z.string()),
    }),
} as const satisfies INTERNAL_GAME_CONFIG;

Note

game in Tachi is a bit of an unfortunate name. While - intuitively - most people will think of the game as the important part, game in Tachi functions more like a "grouping" of playtypes.

Our IIDX game contains two playtypes - SP and DP, these are implemented almost entirely separately -- SP and DP could have entirely different calculations if they wanted!

Things should be grouped together if the songs involved in the game can have multiple charts across-playtypes. Since songs in IIDX can have SP and DP difficulties, they share a game!

name

This is the name for the game that will be displayed to end users. This should be formatted generally how the game formats things.

playtypes

The list of playtypes this game supports. For games that only have one playtype (and likely will never have another), "Single" is typically used.

Example

For SDVX, we use playtypes: ["Single"]. Since SDVX doesn't normally have any sort of separate playtypes.

songData

What game-specific properties should exist on a song?

This is written as a Zod schema, which allows us to simultaneously declare the type of additional data, and how to validate it.

This game-specific information is stored on the data field of a song.

Example

Here's an example IIDX song:

{
    "altTitles": [],
    "artist": "dj nagureo",
    "data": {
        "displayVersion": "1",
        "genre": "PIANO AMBIENT"
    },
    "id": 1,
    "searchTerms": [],
    "title": "5.1.1."
},

Note the data field, which allows for game-specific metadata.

GPT Configurations

This is the real meat-and-potatoes for implementing something for Tachi.

Here is our actual implementation for WACCA's Single playtype.

Note

WACCA only has one playtype - "Single".

export const WACCA_SINGLE_CONF = {
    providedMetrics: {
        score: {
            type: "INTEGER",
            validate: p.isBetween(0, 1_000_000),
            formatter: FmtNum,
            description: "The score value. This is between 0 and 1 million.",
        },
        lamp: {
            type: "ENUM",
            values: ["FAILED", "CLEAR", "MISSLESS", "FULL COMBO", "ALL MARVELOUS"],
            minimumRelevantValue: "CLEAR",
            description: "The type of clear this score was.",
        },
    },

    derivedMetrics: {
        grade: {
            type: "ENUM",
            values: [
                "D",
                "C",
                "B",
                "A",
                "AA",
                "AAA",
                "S",
                "S+",
                "SS",
                "SS+",
                "SSS",
                "SSS+",
                "MASTER",
            ],
            minimumRelevantValue: "S",
            description: "The grade this score was.",
        },
    },

    optionalMetrics: {
        ...FAST_SLOW_MAXCOMBO,
    },

    defaultMetric: "score",
    preferredDefaultEnum: "grade",

    scoreRatingAlgs: {
        rate: {
            description: "Rating as it's implemented in game.",
        },
    },
    profileRatingAlgs: {
        naiveRate: {
            description: "A naive rating algorithm that just sums your 50 best scores.",
        },
        rate: {
            description:
                "Rating as it's implemented in game, taking 15 scores from the latest version and 35 from all old versions.",
        },
    },
    sessionRatingAlgs: {
        rate: { description: "The average of your best 10 ratings this session." },
    },

    defaultScoreRatingAlg: "rate",
    defaultProfileRatingAlg: "naiveRate",
    defaultSessionRatingAlg: "rate",

    difficulties: {
        type: "FIXED",
        order: ["NORMAL", "HARD", "EXPERT", "INFERNO"],
        shorthand: {
            NORMAL: "NRM",
            HARD: "HRD",
            EXPERT: "EXP",
            INFERNO: "INF",
        },
        default: "EXPERT",
    },

    classes: {
        stageUp: {
            type: "PROVIDED",
            values: WaccaStageUps,
        },
        colour: {
            type: "DERIVED",
            values: WaccaColours,
        },
    },

    orderedJudgements: ["marvelous", "great", "good", "miss"],

    versions: {
        reverse: "REVERSE",
    },

    chartData: z.strictObject({
        isHot: z.boolean(),
    }),

    preferences: z.strictObject({}),
    scoreMeta: z.strictObject({ mirror: z.boolean().optional() }),

    supportedMatchTypes: ["songTitle", "tachiSongID"],
} as const satisfies INTERNAL_GAME_PT_CONFIG;

We'll go over each bit of this.

Metrics

See Metrics and Metric Groups.

defaultMetric

What should the default metric for this GPT be? This is used for chart leaderboards, and should ideally be an INTEGER or DECIMAL metric. It is not legal for this to be a GRAPH or NULLABLE_GRAPH metric, as those are not sanely comparable.

preferredDefaultEnum

If the user has no preferences overriding this, what should this GPT default to showing when showing enum graphs?

Example

Here, we see that the site has picked lamps by default to show. This is because the default enum for IIDX is lamp.

Note

Users may override this preference in their settings for this GPT.

Score, Session, Profile Rating Algorithms

These are all the exact same idea, but appear in different parts of Tachi.

All of these define the names of rating algorithms and a description.

Rating algorithms are functions that return a number or null. This can be used to implement things like VOLFORCE (SDVX, USC) or tierlist ratings.

Optionally, a rating algorithm's config may specify a formatter: field, which is a function that transforms the number into a string somehow. If this is not specified, this will default to a function that rounds the number to two decimal places.

Difficulties

There are two kinds of difficulties available in Tachi. "FIXED", which is a more traditional approach defining a fixed set of possible difficulties for your song (NORMAL, HYPER, ANOTHER, etc.), and "DYNAMIC", which allows any arbitrary string as a difficulty name.

Dynamic Difficulties

Dynamic difficulties are intended for use in games where a song may have any number of possible charts.

An example of this would be something like osu!, where a song may have as many difficulties as it wants, with any string as their name.

Fixed Difficulties

For games where songs can only have a certain amount of difficulties (most arcade games) a fixed config likely makes more sense.

With a fixed config, you define the order: in which these difficulties appear in,a default difficulty to redirect to if the song is selected in the search bar and optionally, some shorthand (generally for mobile view).

Example

difficulties: {
    type: "FIXED",
    order: ["NORMAL", "HARD", "EXPERT", "INFERNO"],
    shorthand: {
        NORMAL: "NRM",
        HARD: "HRD",
        EXPERT: "EXP",
        INFERNO: "INF",
    },
    default: "EXPERT",
}

Classes

Classes are enums for profiles. These allow you to store discrete values on a user's profile. What values are supported are controlled by the values: field. You can use the ClassValue helper function to help define values.

There are two types of classes:

"DERIVED"

In which the value of the class is derived from the user's profile metrics.

Example

A common example of this would be things like colour rankings in konami arcade games. These are cutoffs like >1500 jubility = ORANGE, >2000 jubility = GREEN.

"PROVIDED"

In which the value of the class is provided by a score import somehow.

Example

A common example of this would be things like dans. These are not functions of existing state, and must be stated in an import method.

Note

"DERIVED" classes are allowed to go back down - if the deriving implementation says the class is now worse, the class the user has will decrease.

However, "PROVIDED" classes cannot be superceded by worse ones - if the user was 10th dan and then cleared 3rd dan, their dan will not be overwritten. The only way for a "PROVIDED" class to go down is to contact an admin at the moment.

Maybe the in the future, users could have the ability to manually wipe their own classes, in-case they corrupt their profile.

orderedJudgements

An ordered list (best to worst) of strings, representing the name of judgements (hit windows) for this GPT.

Versions

See Versions, as whether your GPT needs this or not requires some assessment.

Chart Data

The chartData field allows you to define GPT-specific fields on chart documents.

For example, in IIDX we need to store the notecount of a chart somewhere (it's used to calculate percent and grade).

The chartData field is a Zod schema indicating the structure of this additional information.

Example

chartData: z.strictObject({
    inGameID: z.number().int().nonnegative(),
    clearTier: z.strictObject({
        value: z.number(),
        text: z.string(),
        individualDifference: z.boolean(),
    }).nullable(),
}),

declares that a chart for this GPT should look like:

{
    "chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
    "data": {
        "inGameID": 1,
        "clearTier": { value: 12.5, text: "12C", individualDifference: false }
    },
    "difficulty": "ADV",
    "isPrimary": true,
    "level": "10",
    "levelNum": 10,
    "playtype": "Single",
    "songID": 1,
    "versions": ["exceed", "konaste"]
},

Note the GPT-specific properties in the data field now.

Preferences

If this GPT should have specific preferences (for BMS, you can set a list of tables you don't want to see, for example), list them here as a Zod schema.

Example

A preferences of

preferences: z.strictObject({ showCustomCharts: z.boolean() })

corresponds to a settings document of:

[
    {
        "userID": 1,
        "game": "iidx",
        "playtype": "SP",
        "preferences": {
            "preferredScoreAlg": null,
            "preferredSessionAlg": null,
            "preferredProfileAlg": null,
            "preferredDefaultEnum": null,
            "defaultTable": null,
            "preferredRanking": null,
            "stats": [],
            "gameSpecific": {
                "showCustomCharts": false
            }
        },
        "rivals": []
    }
]

Note the preferences.gameSpecific part, which holds these game-specific preferences.

Score Metadata

Sometimes, we want to store stuff about a score that isn't really related to a metric.

A common example of this would be something like the mods the player was using - were they using RANDOM, MIRROR, etc?

Note

Score Metadata DOES NOT appear on PBs. This metadata is specific to the score itself, and would never make sense to merge.

If a PB is comprised of a best score using RANDOM, and a best lamp using MIRROR, what should the PB merge to? It doesn't make any sense as an operation.

This, similarly, is a Zod schema, and appears on a score documents scoreMeta field.

Supported Match Types

This is for BATCH MANUAL usage. BATCH MANUAL has to specify a matchType - how should it resolve a given identifier?

Some import methods might want to use in game IDs, hashes of a certain kind, etc.

For a list of all possible match types, see Match Types.

You should list the available matchTypes in this field of the config.