__MANTINE_STYLES__Vikfro's Hideout
Back

The Science of Spinning! Osu's spinner implementation

2023-09-07
Gameplay
Tutorial
Programming
Recently I have been working on a very stupid-but-fun game idea which requires players to frantically spin their mouse (or finger on mobile).
It's akin to drawing a circle and computing some sort of quality of that circle drawing, so if you need to recognize a circle gesture this should also be useful to you.
You can check out the code of this little demo over at this GitHub repo.

First naive implementation

I first prototyped this behaviour with a simple first algorithm. It compares the vector of the mouse position to the one from the previous frame. If the point is further ahead in the way the circle is being drawn, then we are spinning correctly. he problem is this comparison depends on the current position on the circle. So I had to divide the circle in four parts, and switch some position comparisons depending on which quarter the positions are in.
Here is the implementation in GDScript. The hardcoded value 156 corresponds to the radius in pixels of the circle I used.
So if extremity.y >= 156 correponds to "if the mouse is in the lower half of the circle".
func _process(delta: float) -> void:
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
var mouse_position = get_local_mouse_position()
var extremity : Vector2 = to_local($Spinner/Spinnee/Extremity.global_position)
if extremity.x >= 0:
if extremity.y >= 156:
if mouse_position.x < extremity.x:
_accelerate_spin_speed(delta)
else:
_decelerate_spin_speed(delta)
elif extremity.x >= 156:
if mouse_position.y > extremity.y:
_accelerate_spin_speed(delta)
else:
_decelerate_spin_speed(delta)
elif mouse_position.x > extremity.x:
_accelerate_spin_speed(delta)
else:
_decelerate_spin_speed(delta)
elif extremity.x <= 0:
if extremity.y <= -156:
// ...
The part about rotating the sprite & handling speed was as follows:
func _process(delta: float) -> void:
# ... above code
spin_speed = maxf(0, spin_speed)
func _accelerate_spin_speed(delta: float) -> void:
spin_speed += spin_accel * delta
spin_speed = minf(spin_speed, max_spin_speed)
func _decelerate_spin_speed(delta: float) -> void:
spin_speed -= spin_decel * delta
spin_speed = maxf(spin_speed, 0)
And it was working pretty well! In some cases it seemed like the deceleration was not working properly however. It also did not handle spinning in the other direction. I had some ideas to make it work better, but then I thought that Osu had basically what I wanted with its spinners.

Osu's implementation

Since that game is open-source, I looked for how it handles its spinners! Here is the relevant bit of code:
// osu/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs:55
protected override void Update()
{
base.Update();
float thisAngle = -MathUtils.RadiansToDegrees(
MathF.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2)
);
float delta = thisAngle - lastAngle;
if (Tracking)
AddRotation(delta);
lastAngle = thisAngle;
IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f;
Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed));
}
I had to dig a bit deeper to understand what the "DrawSize" vector exactly is. I assumed it was either the center position of the spinner, from where it's being drawn, or its width and height. The answer lies in the class Drawable, which is from another repository, osu-framework:
// osu-framework/osu.Framework/Graphics/Drawable.cs
/// <summary>
/// Absolute size of this Drawable in the <see cref="Parent"/>'s coordinate system.
/// </summary>
public Vector2 DrawSize => drawSizeBacking.IsValid ?
drawSizeBacking : drawSizeBacking.Value = ApplyRelativeAxes(RelativeSizeAxes, Size, FillMode);
Every frame it calculates an angle between the mouse position in X and Y axis, after removing the radius of the circle from it. It then computes the difference delta of that angle with the one from the previous frame and adds that delta to the current rotation value of the spinner.
In short it boils down to rotating the spinner to align it with the new position of the mouse, as if we rotated it by the same angle as the mouse is currently projecting from the center of the spinner.
This means that to maximize the rotation, you should try to get the maximum difference in angle from one mouse position to the other. Intuitively, this means moving your pointer in a way that you created the biggest angle since last frame's position.Which means drawing a perfect, as-small-as-possible circle! Good Osu players approach the spinners by keeping their pointer as close to the center as possible and making the tiniest circles possible to maximize their score.
AddRotation(delta) then simply adds this angle delta to the current rotation value of the circle. There is a bit more code around it to take care of special cases when the angle is above 180 or below -180 degrees and to adjust the value accordingly to varying framerates.
public void AddRotation(float angle)
{
// ...
if (angle > 180)
{
lastAngle += 360;
angle -= 360;
}
else if (-angle > 180)
{
lastAngle -= 360;
angle += 360;
}
currentRotation += angle;
// rate has to be applied each frame, because it's not guaranteed to be constant throughout playback
// (see: ModTimeRamp)
drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate));
I implemented this in Godot but I wanted to keep it spinning instead of just following the mouse. So using a RigidBody2D I add the angle delta as a torque force. And it worked really well right out of the bat!
You can check out that code over at this GitHub repo.
There is one thing I modified in my version however. I am not sure the substraction the constant DrawSize is of any use. Or maybe I misunderstood what it is or how it is used? But it seems to me that it does not affect the result in anyway to just calculate the atan2 with the mouse X and mouse Y.

Get updates & share your comments and suggestions!

You can support my activities through Ko-fi!
You can also hire me for different services: software & game dev, design, music composition, guitar recording. Please contact me through one of the above social media platforms.