DEV Community

unity source code
unity source code

Posted on

Building a Tap-Timing Hyper-Casual Game in Unity: The Engineering Behind 'One Perfect Tap'

There's a category of mobile game built on a single input: one tap, at one moment, graded on how close you got. Stack towers. Stop the spinning arrow. Slice the moving fruit. Stamp the object as it aligns.

From the outside, these look like the easiest games in the world to build. A timer, a tap, a comparison. Two hundred lines, done.

I've built and debugged a few of them now, and the honest version is this: the mechanic takes an afternoon, and making it feel good takes three weeks. The gap between "functionally correct timing game" and "timing game people actually replay" is almost entirely made of things that don't show up in a design doc — input latency, frame-rate independence, hit-stop, audio pitch curves, and grading windows tuned to human perception rather than to round numbers.

This post is about that gap. Code included.

The core loop, and why the naive version is wrong
Let's define the mechanic concretely. An object oscillates or rotates. The player taps. We measure how close the object was to a target state at the moment of the tap, and grade it.

Here's the version most people write first:

// Don't ship this
public class BadStampController : MonoBehaviour
{
public Transform target;
public float speed = 2f;
private float timer;

void Update()
{
    timer += speed * Time.deltaTime;
    transform.position = new Vector3(Mathf.Sin(timer) * 3f, 0, 0);

    if (Input.GetMouseButtonDown(0))
    {
        float distance = Vector3.Distance(transform.position, target.position);
        if (distance < 0.1f) Debug.Log("Perfect!");
        else if (distance < 0.5f) Debug.Log("Good");
        else Debug.Log("Miss");
    }
}
Enter fullscreen mode Exit fullscreen mode

}
This works. It is also wrong in at least five ways that will each cost you retention. Let's go through them.

Problem 1: You're measuring position, but the player is judging time
Distance thresholds feel inconsistent because the object isn't moving at a constant speed. With Mathf.Sin, velocity is highest at the centre and approaches zero at the extremes. A 0.1-unit tolerance near the turnaround point represents a huge time window; the same tolerance at full speed represents a few milliseconds.

Players don't perceive "distance." They perceive "was I early or late." So grade on time, not space.

public class StampTiming : MonoBehaviour
{
[SerializeField] private float cycleDuration = 1.6f; // seconds per full cycle
[SerializeField] private float perfectWindow = 0.055f;
[SerializeField] private float greatWindow = 0.110f;
[SerializeField] private float goodWindow = 0.200f;

private float elapsed;

void Update()
{
    elapsed += Time.deltaTime;
}

/// <summary>Signed seconds from the nearest ideal moment. Negative = early.</summary>
public float GetTimingError()
{
    float phase = elapsed % cycleDuration;
    float half  = cycleDuration * 0.5f;
    // Ideal moments sit at phase 0 and phase = half
    float errorA = phase;                         // late relative to 0
    float errorB = phase - half;                  // relative to midpoint
    float errorC = phase - cycleDuration;         // early relative to next 0

    float best = errorA;
    if (Mathf.Abs(errorB) < Mathf.Abs(best)) best = errorB;
    if (Mathf.Abs(errorC) < Mathf.Abs(best)) best = errorC;
    return best;
}

public StampGrade Grade(out float error)
{
    error = GetTimingError();
    float abs = Mathf.Abs(error);
    if (abs <= perfectWindow) return StampGrade.Perfect;
    if (abs <= greatWindow)   return StampGrade.Great;
    if (abs <= goodWindow)    return StampGrade.Good;
    return StampGrade.Miss;
}
Enter fullscreen mode Exit fullscreen mode

}

public enum StampGrade { Perfect, Great, Good, Miss }
Now your windows mean something. A 55ms perfect window is roughly three frames at 60fps — tight, but achievable. That number isn't arbitrary: rhythm games have converged on 40–60ms for their top tier because it sits right at the edge of reliable human motor precision.

Keeping the signed error is important. "You were 80ms early" is coachable feedback. "You missed" is not.

Problem 2: Frame-rate dependence you didn't notice
Time.deltaTime accumulation looks frame-independent, and mostly is. But a subtle killer lives in Update(): your input is only sampled once per frame.

At 60fps, a tap can be reported up to 16.7ms after it physically happened. At 30fps on a budget Android device, that's 33ms — over half of your entire perfect window. Your game will feel randomly unfair on low-end hardware, and you'll never reproduce it on your dev phone.

Two mitigations.

Lock the frame rate deliberately. Hyper-casual games should target 60fps and actually enforce it:

void Awake()
{
Application.targetFrameRate = 60;
QualitySettings.vSyncCount = 0;
Screen.sleepTimeout = SleepTimeout.NeverSleep;
}
Compensate for the sampling delay. If you're using the new Input System, touch events carry a real timestamp. Use it:

using UnityEngine.InputSystem;
using UnityEngine.InputSystem.EnhancedTouch;

void OnEnable()
{
EnhancedTouchSupport.Enable();
Touch.onFingerDown += HandleFingerDown;
}

void OnDisable()
{
Touch.onFingerDown -= HandleFingerDown;
EnhancedTouchSupport.Disable();
}

private void HandleFingerDown(Finger finger)
{
// startTime is the OS-level event time, not the frame time
double eventTime = finger.currentTouch.startTime;
float lag = (float)(Time.realtimeSinceStartupAsDouble - eventTime);
lag = Mathf.Clamp(lag, 0f, 0.05f); // guard against bad clock data

RegisterTap(lag);
Enter fullscreen mode Exit fullscreen mode

}

private void RegisterTap(float inputLag)
{
// Rewind the simulation clock by the measured lag before grading
float correctedElapsed = elapsed - inputLag;
// ... grade using correctedElapsed
}
This single correction is one of the highest-leverage changes you can make. It's the difference between a game that feels responsive on a flagship and one that feels responsive everywhere.

Problem 3: No hit-stop, so nothing lands
This is the one that separates amateur from professional feel, and it costs about fifteen lines.

Hit-stop (or "freeze frame") is a micro-pause on a successful action. Fighting games invented it; every good action game since has used it. The brain reads the pause as impact.

public class HitStop : MonoBehaviour
{
public static HitStop Instance { get; private set; }
private Coroutine running;

void Awake() => Instance = this;

public void Freeze(float duration)
{
    if (running != null) StopCoroutine(running);
    running = StartCoroutine(FreezeRoutine(duration));
}

private IEnumerator FreezeRoutine(float duration)
{
    Time.timeScale = 0f;
    // MUST be unscaled — WaitForSeconds would never resume
    yield return new WaitForSecondsRealtime(duration);
    Time.timeScale = 1f;
    running = null;
}
Enter fullscreen mode Exit fullscreen mode

}
Duration by grade, roughly:

Grade Hit-stop Notes
Perfect 90–120ms Long enough to register as an event
Great 50–70ms Noticeable but not celebratory
Good 25–35ms Barely perceptible
Miss 0ms Never reward a failure with weight
Two gotchas. Anything using Time.deltaTime freezes automatically — that's the point. Anything that shouldn't freeze (UI animations, particle systems you want to keep playing) needs Time.unscaledDeltaTime or ParticleSystem.useUnscaledTime = true. And if you use Time.timeScale elsewhere, wrap it in this single class so two systems never fight over the value.

Problem 4: Static audio makes a combo meaningless
A combo system with the same sound effect every time isn't a combo system. Pitch-shifting on a musical scale is the cheapest way to make repetition feel like escalation:

[SerializeField] private AudioSource sfx;
[SerializeField] private AudioClip stampClip;

private static readonly float[] PentatonicSemitones = { 0, 2, 4, 7, 9, 12, 14, 16, 19, 21, 24 };

public void PlayStampSound(int comboIndex)
{
int step = Mathf.Min(comboIndex, PentatonicSemitones.Length - 1);
float semitones = PentatonicSemitones[step];
sfx.pitch = Mathf.Pow(2f, semitones / 12f); // equal temperament
sfx.PlayOneShot(stampClip);
}
A pentatonic scale is used deliberately — every note in it is consonant with every other, so no combo length can produce a sour interval. It's the same trick behind the satisfying ascending sounds in dozens of casual hits.

Reset comboIndex to 0 on any Miss, and pair it with haptics:

if UNITY_IOS || UNITY_ANDROID

Handheld.Vibrate(); // blunt; consider a haptics plugin for graded feedback

endif

Problem 5: Difficulty that ramps by the wrong variable
The obvious lever is speed — shorten cycleDuration every level. The problem is that speed scales difficulty and reduces the absolute size of your timing window at the same time, so difficulty ramps quadratically and players hit a wall around level 12.

Better: separate the two, and ramp them independently.

public float GetCycleDuration(int level)
{
// Asymptotic ramp — approaches a floor instead of racing to zero
float floor = 0.55f;
float start = 1.8f;
return floor + (start - floor) * Mathf.Exp(-level * 0.06f);
}

public float GetPerfectWindow(int level)
{
// Tighten slowly, and never below human capability
return Mathf.Max(0.040f, 0.070f - level * 0.0008f);
}
An exponential decay toward a floor gives you a curve that feels like it's always getting harder without ever becoming impossible. Add variation on top — occasional levels with two objects, or a reversed direction — rather than pushing the base speed further.

The stuff around the mechanic
Once the core feels right, the remaining work is mostly infrastructure. Briefly, because these are well-trodden:

Pool everything. Stamped objects, particles, floating score text. In a game where a session is 90 seconds of rapid spawning, GC spikes are visible as stutter, and stutter in a timing game is a lost tap.

// Unity's built-in pool, 2021+
private ObjectPool pool;

void Awake()
{
pool = new ObjectPool(
createFunc: () => Instantiate(prefab),
actionOnGet: o => o.gameObject.SetActive(true),
actionOnRelease: o => o.gameObject.SetActive(false),
actionOnDestroy: o => Destroy(o.gameObject),
defaultCapacity: 20,
maxSize: 60
);
}
Watch your build size. Hyper-casual lives and dies on CPI, and install conversion drops measurably as APK size grows. Target under 40MB. Use ASTC compression, strip unused packages, enable IL2CPP with managed stripping on High, and check the Editor log's build report for what's actually eating space. It's almost always uncompressed audio.

Instrument from day one. The events that matter: level_start, level_complete with grade distribution, level_fail with attempt count, and timing_error_ms bucketed. That last one tells you whether your windows are tuned correctly — if 70% of taps land in Perfect, your game is too easy; if under 15% do, players are quitting out of frustration.

Ad cadence, specifically. Interstitials between levels, with a hard floor of 45–60 seconds between impressions and nothing at all in the first session. Rewarded video for continue-after-fail is the highest-converting placement in this genre because the offer arrives at the exact moment of frustration. Never show an ad within two seconds of a Perfect — you'll take the best feeling in your game and attach an interruption to it.

On starting from a template
I'll be upfront: I work on Unity game templates, so treat this section accordingly.

Everything above is buildable from scratch, and building it once is genuinely good for your understanding. But there's a real argument for starting from a working precision-tap project — the parts I've described are the interesting 20%, and the other 80% is menu flow, save serialisation, AdMob and IAP wiring, settings screens, and store compliance. If you want a reference implementation of the tap-timing loop with ads and IAP already integrated, the Stamp It hyper-casual Unity template is structured around exactly this mechanic, and a broader hyper-casual puzzle game engine covers the same infrastructure across multiple puzzle loops.

The reason I mention it at all is scope discipline, which is the actual killer of indie projects. I made a similar argument in a longer piece on what it actually takes to build a survival crafting game in Unity — the systems you don't write are often what determines whether you ship. Hyper-casual is the inverse case: the scope is small enough that a solo dev genuinely can finish, which is exactly why the bar for polish is so high.

The short version
If you take four things from this:

Grade on time, not distance. Keep the error signed.
Compensate for input latency using real event timestamps, and lock your frame rate.
Add hit-stop. Fifteen lines, disproportionate impact.
Ramp speed and window size independently, with an asymptotic curve.
The mechanic is a comparison between two floats. The game is everything you wrap around that comparison. Most developers spend 90% of their time on the float and wonder why it doesn't feel like the games they were copying.

If you've shipped something in this genre, I'd like to hear what your Perfect window ended up at and how you landed on it — that number seems to vary more between good games than I'd have expected.

Top comments (0)