Rook's Quest

A blitz chess roguelite set in Karachi. Walk Saddar, run a chai dhaba, ride a bicycle through the city, then hold the board against waves in real time.

Status
In development
Engine
Unity, C#
Platform
PC on Steam, tuned for Steam Deck
Studio
Kreed Digital Studio

Trailer

The game

I'm making Rook's Quest in Unity at Kreed Digital Studio, modelling its assets in Blender and bringing them in through my own Blender-to-Unity pipeline. It's coming to PC on Steam, with full controller support.

Karachi, at golden hour

Your hub is a walkable slice of the city: Saddar's yellow arcades, Frere Hall behind its railings, stained old flats, rickshaws, carts, paan and a sky full of wires.

Chai, anda, paratha

Open the dhaba for a two-minute shift: pour, carry, serve and wipe tables for Club Points while the street shouts its orders.

Two wheels, three wheels

Ride a bicycle or the trash collector's cargo tricycle through the city.

Then hold the board

Chess at blitz speed: no turns, every piece on its own clock, and waves coming down the board for nine minutes. Pick a starting buff, deploy pawns bought with gold and upgrade every five waves.

It didn't start in Karachi

Rook's Quest began as Project KickFlipper, a low-poly trickshot prototype set in a suburban park and a neon town. Drag the handle to compare the same spawn point then and now.

After: the spawn point in Karachi Before: the spawn point in the park prototype BeforeAfter

The KickFlipper prototype

Optimisation work

Two changes from the game's development that cut per-frame cost. Switch between the tabs to compare the code.

Rotation: manual coroutine to DOTween

This type of rotation animation is used across multiple objects in the game, from player turns to environmental elements and UI transitions. Because it runs frequently and across many instances, optimizing it early was critical for reducing CPU overhead, ensuring consistent frame times, and keeping GC allocations low, especially on mobile.

Why this is better:
  • Reduced lines of code: From 9 lines to 2—cleaner and easier to maintain.
  • Improved performance: DOTween handles interpolation internally using optimized C# coroutines, avoiding extra GC pressure and CPU usage from per-frame math.
  • Smoother animation: Built-in easing like Ease.OutSine makes transitions feel natural without manual math.
  • GC friendly: No per-frame Quaternion recalculation or struct allocations—DOTween uses pooling and caching behind the scenes.
Quaternion startRotation = player.transform.rotation;
Quaternion endRotation = Quaternion.Euler(0, targetYAngle, 0);
float elapsed = 0f;

while (elapsed < duration)
{
    elapsed += Time.deltaTime;
    player.transform.rotation = Quaternion.Slerp(startRotation, endRotation, elapsed / duration);
    yield return null;
}

Trajectory preview: manual loop to Burst and the Job System

This trajectory preview runs every frame while the shot is charging and for multiple objects. Because it's high-frequency and touches lots of math per frame, pushing it off the main thread with Burst was a priority to reduce CPU spikes and keep frame times stable on mobile.

Why this is better (Burst + Jobs):
  • Main-thread time down: The O(N) integration loop moves to a Burst-compiled job, cutting per-frame CPU on the main thread during charge previews.
  • SIMD & cache-friendly math: Burst vectorizes arithmetic and tight loops, often unrolling and using fast-math where safe—meaning the same math costs fewer cycles.
  • No per-frame GC churn: Positions are written to a pre-allocated buffer (e.g., NativeArray<float3>), then copied to a persistent trajBuffer. No ToArray() or transient allocations.
  • Scales gracefully: Need more preview points or a smaller dt for smoother curves? The heavy work stays off the main thread, so FPS remains stable.
  • Deterministic pathing (optional): Fixed dt + pure math in the job yields consistent results across devices (within float tolerance).
  • Collision kept cheap: A single ray test per segment on the main thread to cut at first impact keeps physics interactions simple and affordable.

Result: Main Player loop Now have 0 GC collection as compared to having 40Bytes per every time we draw trajectory, eliminating charge-time hitches. Which will help when multiple players would be using this Trajectory.

Implementation : I am Marking the job with [BurstCompile], using Unity.Mathematics for math, storing points in a persistent NativeArray<float3> (Allocator.Persistent), Schedule() the job, Complete() before reading, reuse buffers each frame, and Dispose() in OnDestroy().

private void DrawTrajectory(float force)
{
    frameCounter++;
    Vector3 start = ball.position;
    Vector3 targetPos = Target.position;
    Vector3 toTarget = (targetPos - start);
    float toTargetLen = toTarget.magnitude;
    Vector3 toTargetN = (toTargetLen > 1e-5f) ? (toTarget / toTargetLen) : Vector3.forward;
    float curveVal = (curveSlider != null) ? curveSlider.value : 0f;

    bool inputsChanged =
        cachedCharging != isCharging ||
        Mathf.Abs(cachedForce - force) > 0.001f ||
        Mathf.Abs(cachedCurveVal - curveVal) > 0.001f ||
        (cachedStart - start).sqrMagnitude > 1e-4f ||
        (cachedTargetPos - targetPos).sqrMagnitude > 1e-4f;

    if (!inputsChanged && isCharging && (frameCounter % recomputeEveryNFrames != 0))
    {
        trajectoryLine.startColor = startColorCached;
        trajectoryLine.endColor = endColorCached;
        return;
    }

    cachedCharging = isCharging;
    cachedForce = force;
    cachedCurveVal = curveVal;
    cachedStart = start;
    cachedTargetPos = targetPos;
    cachedToTarget = toTargetN;

    if (!isCharging)
    {
        trajCount = 2;
        trajPoints[0] = start;
        trajPoints[1] = targetPos;

        trajectoryLine.positionCount = trajCount;
        trajectoryLine.SetPosition(0, trajPoints[0]);
        trajectoryLine.SetPosition(1, trajPoints[1]);

        trajectoryLine.startColor = startColorCached;
        trajectoryLine.endColor = endColorCached;
        return;
    }

    Vector3 sideDir = Vector3.Cross(Vector3.up, toTargetN).normalized;
    Vector3 lateralAccel = sideDir * (curveVal * curveFactor);

    trajCount = 0;
    Vector3 pos = start;
    Vector3 vel = toTargetN * force;
    float traveled = 0f;

    trajPoints[trajCount++] = pos;

    float dt = trajectoryTimeStep;
    Vector3 acc = Physics.gravity + lateralAccel;
    float maxDist = maxRenderDistance;
    int maxPts = Mathf.Min(maxTrajectoryPoints, trajPoints.Length - 1);

    for (int i = 0; i < maxPts; i++)
    {
        Vector3 nextVel = vel + acc * dt;
        Vector3 nextPos = pos + nextVel * dt;

        Vector3 seg = nextPos - pos;
        float segLen = seg.magnitude;

        if (traveled + segLen > maxDist)
        {
            float remain = maxDist - traveled;
            Vector3 dir = segLen > 1e-6f ? (seg / segLen) : Vector3.forward;
            nextPos = pos + dir * remain;
            trajPoints[trajCount++] = nextPos;
            break;
        }

        int hits = 0;
        float castDist = Mathf.Max(0f, vel.magnitude * dt);
        if (castDist > 1e-6f)
            hits = Physics.RaycastNonAlloc(pos,
			vel.normalized, ray1, castDist, ~0, QueryTriggerInteraction.Ignore);

        if (hits > 0)
        {
            var hit = ray1[0];
            trajPoints[trajCount++] = hit.point;

            vel = Vector3.Reflect(vel, hit.normal) * bounceFactor;
            pos = hit.point + vel.normalized * 0.01f;

            Vector3 prev = trajPoints[trajCount - 2];
            traveled += Vector3.Distance(prev, hit.point);
            continue;
        }

        vel = nextVel;
        pos = nextPos;
        traveled += segLen;

        trajPoints[trajCount++] = pos;
    }

    trajectoryLine.positionCount = trajCount;
    for (int i = 0; i < trajCount; i++)
        trajectoryLine.SetPosition(i, trajPoints[i]);

    trajectoryLine.startColor = startColorCached;
    trajectoryLine.endColor = endColorCached;
}

Coming to Steam

For PC, tuned for Steam Deck, with full controller support. In development at Kreed Digital Studio.