Skip to content

Scoring

Dean Herbert edited this page May 21, 2024 · 19 revisions

Total score

Score is divided into three portions: accuracy, combo, and bonus. The calculation follows a similar algorithm to Score V2 (from stable). Please check the ScoreProcessor implementation for specifics (ApplyResultInternal should be a good place to start).

Hit results

The HitResult class determines which portion(s) of the total score a judgement contributes to, and the amount of health gained or lost. This is defined on a per-hitobject/per-judgement level.

Score

Type Score Accuracy portion? Combo portion?¹ Bonus portion? Examples MinResult²
Miss 0 HitCircle, Hit, Fruit, Note, TailNote -
Meh 50 HitCircle, Hit, Note, TailNote Miss
Ok 100 HitCircle, Hit, Note, TailNote Miss
Good 200 Note, TailNote Miss
Great 300 HitCircle, Hit, Fruit, Note, TailNote Miss
Perfect 300⁴ Note, TailNote Miss
SmallTickMiss 0 SpinnerTick, DrumRollTick, TinyDroplet -
SmallTickHit 10 SpinnerTick, DrumRollTick, TinyDroplet SmallTickMiss
LargeTickMiss 0 SliderTick, Droplet -
LargeTickHit 30 SliderTick, Droplet, SliderTail⁵ LargeTickMiss
SmallBonus 10 StrongHit, Note, TailNote IgnoreMiss
LargeBonus 50 SpinnerBonus, Banana IgnoreMiss
IgnoreMiss³ 0 Slider, SliderTail, SpinnerBonus, StrongHit, SwellTick, Banana -
IgnoreHit³ 0 Slider, SwellTick IgnoreMiss
ComboBreak 0 HoldNoteBody -
SliderTailHit 150 SliderTail IgnoreMiss
LegacyComboIncrease 0 Not usable (legacy only) -

¹ Contribution to the combo portion also implies the combo is increased for a hit, or reset on a miss.
² The minimum result is provided for reference to be used in later sections that describe hit result application.
³ All hitobjects must provide a hit result, but "Ignore" results are to be provided when the score should not be affected.
⁴ Objects using the perfect judgement which should provide additional score / accuracy above Great should do so via the use of nested objects with tick / bonus judgements.
⁵ In this particular case, the MinResult is an IgnoreMiss, which means that missing the slider end does not break combo.

Health

The amount of health increase or decrease is defined relative to a "Great" hit result. By default, a "Great" hit result increases HP by 5%.

Type Relative addition
IgnoreMiss 0%
IgnoreHit 0%
Miss -200%
Meh 5%
Ok 50%
Good 75%
Great 100%
Perfect 105%
SmallTickMiss -50%
SmallTickHit 50%
LargeTickMiss -100%
LargeTickHit 100%
SmallBonus 50%
LargeBonus 100%

These values can be adjusted by deriving Judgement:

public class MyJudgement : Judgement
{
    protected override double HealthIncreaseFor(HitResult result)
    {
        switch (result)
        {
            case HitResult.Good:
                // Make Goods not reduce HP.
                return DEFAULT_MAX_HEALTH_INCREASE * 0.1;

            default:
                return base.HealthIncreaseFor(result);
        }
    }
}

Hitobjects

By default, all HitObject types affect the accuracy and combo portions of the score. When judged, they accept any hit result that is not a tick, bonus, or ignore result.

To change this, derive HitObject and Judgement to set the appropriate Judgement.MaxResult:

public class MySliderTick : HitObject
{
    public override Judgement CreateJudgement() => new MySliderTickJudgement();
}

public class MySliderTickJudgement : Judgement
{
    public override HitResult MaxResult => HitResult.SmallTickHit;
}

This will in-turn change the value of Judgement.MinResult as described by the table above.

Judging

To judge a DrawableHitObject, invoke DrawableHitObject.ApplyResult(Action<JudgementResult> application) and set the appropriate result within the range of Judgement.MinResult and Judgement.MaxResult for the hitobject:

MaxResult Accepted judgement result types
IgnoreHit IgnoreHit, IgnoreMiss, ComboBreak
Meh Meh, Miss
Ok Ok, Meh, Miss
Good Good, Ok, Meh, Miss
Great Great, Good, Ok, Meh, Miss
Perfect Perfect, Great, Good, Ok, Meh, Miss
SmallTickHit SmallTickHit, SmallTickMiss
LargeTickHit LargeTickHit, LargeTickMiss
SmallBonus SmallBonus, IgnoreMiss
LargeBonus LargeBonus, IgnoreMiss
public class MyDrawableHitObject : DrawableHitObject<MyHitObject>
{
    public MyDrawableHitObject(MyHitObject hitObject)
        : base(hitObject)
    {
    }

    protected override void CheckForResult(bool userTriggered, double timeOffset)
    {
        if (!userTriggered)
        {
            if (timeOffset > HitObject.StartTime)
                ApplyResult(r => r.Type = r.Judgement.MinResult);
        }
        else
            ApplyResult(r => r.Type = r.Judgement.MaxResult);
    }

    protected override bool OnClick(ClickEvent e)
    {
        UpdateResult(true);
        return true;
    }
}

Weighting hitobjects

Nested hitobjects can be used to increase the weighting of hitobjects that are more important than others and should contribute more towards the score:

public class MyHitObject : HitObject
{
    protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
    {
        base.CreateNestedHitObjects(cancellationToken);

        // When applying a result, the same result will also be applied to the padding object (see below).
        // This results in a 2x weighting for the "MyHitObject" type.
        AddNested(new ScorePaddingObject
        {
            // Remember to set a correct start time for nested hitobjects, otherwise their judgements won't be reset correctly!
            // Judgements are only reset once time is rewound past the hitobject's start time.
            StartTime = StartTime
        });

        // For a 3x weighting, add another one!
        // AddNested(new ScorePaddingObject { StartTime = StartTime });
    }
}

public class ScorePaddingObject : HitObject
{
}

public class MyDrawableHitObject : DrawableHitObject<MyHitObject>
{
    private readonly Container<DrawableScorePaddingObject> paddingObjects;

    public MyDrawableHitObject(MyHitObject hitObject)
        : base(hitObject)
    {
        AddInternal(paddingObjects = new Container<DrawableScorePaddingObject>());
    }

    protected override void ClearNestedHitObjects()
    {
        base.ClearNestedHitObjects();
        paddingObjects.Clear();
    }

    protected override void AddNestedHitObject(DrawableHitObject hitObject)
    {
        base.AddNestedHitObject(hitObject);

        if (hitObject is DrawableScorePaddingObject pad)
            paddingObjects.Add(pad);
    }

    protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
    {
        switch (hitObject)
        {
            case ScorePaddingObject pad:
                return new DrawableScorePaddingObject(pad);

            default:
                return base.CreateNestedHitObject(hitObject);
        }
    }

    protected override void CheckForResult(bool userTriggered, double timeOffset)
    {
        if (!userTriggered)
        {
            if (timeOffset > HitObject.StartTime)
                applyResult(false);
        }
        else
            applyResult(true);
    }

    private void applyResult(bool hit)
    {
        ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
        foreach (var nested in paddingObjects)
            nested.ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
    }

    protected override bool OnClick(ClickEvent e)
    {
        UpdateResult(true);
        return true;
    }
}

public class DrawableScorePaddingObject : DrawableHitObject<ScorePaddingObject>
{
    public DrawableScorePaddingObject(ScorePaddingObject hitObject)
        : base(hitObject)
    {
    }

    public new void ApplyResult(Action<JudgementResult> application) => base.ApplyResult(application);
}