18.4 Interview Prep by Role (HoYoverse, Riot, Epic)

Phase 18 · Portfolio, Career & Interview · Study time: 40–80 h

What each studio and role actually tests — coding, C#/C++ depth, math, engine knowledge, and defending every decision in your projects.

Every game studio's hiring process, from a five-person mobile team to a company the size of HoYoverse, ends up asking the same small set of questions underneath whatever specific wording shows up on the day. This chapter is a direct answer key: the actual pipeline you go through, the math questions that come up in nearly every technical round, the C++ questions engine-track interviewers reach for, the C#/Unity questions gameplay-track interviewers reach for, and the debugging questions that matter more than any of the trivia above them. Every question below gets a worked answer, the same way every code example in this curriculum gets real output — this chapter is meant to be rehearsed out loud, not just read once.

You already know the material being tested here. The math is chapter 2.1 (vectors, dot product, cross product) and chapter 2.3 (rotations, quaternions, slerp). The C++ is chapter 1.3 (RAII, references vs pointers, copy vs move) and chapter 3.1-3.2 (cache lines, memory layout). The C# and Unity material is chapter 1.2 and chapter 4.2 (the MonoBehaviour lifecycle, coroutines, ScriptableObjects). What is new in this chapter is not the underlying knowledge — it is the compressed, interview-shaped form that knowledge needs to come out in under time pressure, plus a plan for the month before you sit down in one of these rooms.

the studio hiring pipeline, start to finish [1] Application resume + portfolio go in (referrals often skip straight to stage 3) | v [2] Recruiter call 15-30 min, screens for fit, salary range, role match -- NOT technical | v [3] Technical test take-home project OR a live coding/math test, usually 2-7 days to do | v [4] Technical 1-4 rounds: code review of your test, whiteboard math, system design, interviews live debugging -- this is where most of this chapter lives | v [5] Team / culture meet the people you would actually sit next to -- mostly confirms a interview fit both sides already suspect is real | v [6] Offer negotiate, compare, decide most candidates get dropped at stage 3 or stage 4 -- a small studio can compress the whole pipeline to 1-2 weeks; a large AAA studio can stretch it to 2-3 months across many panels

1. The hiring pipeline, stage by stage

Stage 1, the application, is mostly a filter you pass or fail before a human ever reads it carefully. A resume gets skimmed for seconds, not minutes — a link to a playable build, a short GitHub with real commit history, or a referral from someone already inside the studio does more work than another paragraph of prose ever will. A referral in particular often skips the queue entirely and lands directly on a recruiter's desk, which is part of why "does anyone I know work there" is worth checking before you apply cold.

Stage 2, the recruiter call, is not technical, and treating it like one wastes the call. A recruiter is screening for basic fit: what role are you actually looking for, does your expected salary range overlap the budget, are you eligible to work in the country the studio hires in, roughly when could you start. The one thing worth rehearsing here is a 60-90 second answer to "tell me about yourself" that ends on why this specific studio, not a generic "I love games" — a recruiter has heard the generic version a thousand times and it tells them nothing.

Stage 3, the technical test, splits into two very different formats. A take-home gives you days, expects production-shaped code, and is covered in full in section 6. A live test — a shared coding environment, a whiteboard, sometimes a timed online judge — is closer to what section 2 through section 4 below rehearse: short, specific questions with a correct or defensible answer, under time pressure, sometimes with someone watching you think.

Stage 4, the technical interviews, is usually more than one round: a review of your take-home where they ask "why did you do it this way" (this is often more revealing than the code itself), a math or algorithms round, sometimes a system-design conversation for more senior roles, and very often a live debugging exercise — read section 5 closely, because "reason through an unfamiliar bug out loud" is one of the highest-signal things a studio can watch you do in an hour.

Stage 5, the team and culture interview, exists mostly to confirm a fit both sides already suspect is real by this point — you rarely get rejected here for a technical reason. It is also the stage where the questions you ask (section 8) matter the most, because this is the room full of people you would actually work with every day.

Stage 6 is the offer: base salary, sometimes a signing or relocation bonus, sometimes equity or a performance bonus tied to a title's success, a leveling decision (which affects both pay band and expectations), and a negotiation window. Negotiating respectfully and with real market information is normal and expected; studios budget for it.

Tip A portfolio link that opens straight into a 30-second playable build beats a portfolio link that opens into a wall of text every time. If a reviewer has to choose between clicking "play" and reading three paragraphs, they click play — build for that reality, not the one where someone reads everything you wrote.

2. The math questions that come up constantly

These six questions show up, in some phrasing, in nearly every technical round for a gameplay or engine role. Each one below gets the compressed, interview-ready answer plus a worked numeric example — the full derivations already live in chapter 2.1 and chapter 2.3; this section is the fast, correct version you should be able to produce from memory, on a whiteboard, without hesitating.

2.1 "What does the sign of a dot product tell you?"

float Dot(Vec3 a, Vec3 b) {
    return a.x * b.x + a.y * b.y + a.z * b.z;
}

Vec3 forward   = { 0.0f, 0.0f, 1.0f };   // where the character is facing
Vec3 toEnemyA  = { 2.0f, 0.0f, 3.0f };   // enemy is ahead and to the side
Vec3 toEnemyB  = { 2.0f, 0.0f, -3.0f };  // enemy is behind and to the side

float dotA = Dot(forward, toEnemyA);   // 0*2 + 0*0 + 1*3  =  3.0
float dotB = Dot(forward, toEnemyB);   // 0*2 + 0*0 + 1*(-3) = -3.0

dotA comes out positive, dotB comes out negative. Dot(a,b) = |a||b|cos(theta), and the SIGN of that expression is entirely decided by cos(theta): positive means the angle between the two vectors is less than 90 degrees (roughly the same direction), negative means it is more than 90 degrees (roughly opposite directions), and exactly zero means the two vectors are perpendicular. Here that reads as: enemy A is somewhere in the character's forward-facing hemisphere, enemy B is somewhere behind it — this is literally how "is this target in front of me" checks, backstab detection, and one-sided visibility tests get written, without ever computing an actual angle.

Worth adding out loud in an interview: if both vectors are normalized (length 1), the dot product does not just share cos(theta)'s sign, it equals cos(theta) exactly, which is what makes a field-of-view check cheap — compare a live dot product against a precomputed cosine threshold instead of computing an inverse-cosine (acos) on every single check.

Tip A full field-of-view check is one dot product plus a distance check, and it is meant to run before an expensive operation, not instead of it: if (toTarget.sqrMagnitude <= range * range && Dot(forward, toTarget.normalized) >= cosHalfFov) then raycast to confirm line of sight. The cheap checks reject most candidates before the expensive raycast ever runs.

2.2 "Which way does a cross product point, and how do you use it to tell left from right?"

Vec3 Cross(Vec3 a, Vec3 b) {
    return {
        a.y * b.z - a.z * b.y,
        a.z * b.x - a.x * b.z,
        a.x * b.y - a.y * b.x
    };
}
float Dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }

Vec3 forward  = { 0.0f, 0.0f, 1.0f };   // character facing +Z
Vec3 up       = { 0.0f, 1.0f, 0.0f };
Vec3 toTarget = { 1.0f, 0.0f, 1.0f };   // target is ahead AND off to the +X side

Vec3 c = Cross(forward, toTarget);
// c.x = 0*1 - 1*0 =  0
// c.y = 1*1 - 0*1 =  1
// c.z = 0*0 - 0*1 =  0
// c = (0, 1, 0)

float side = Dot(c, up);   // 0*0 + 1*1 + 0*0 = 1.0  -- POSITIVE

A raw cross product only answers "give me a vector perpendicular to both of these" — by itself, c = (0, 1, 0) just means "straight up," which is not yet the left/right answer an interviewer is actually asking for. The extra step is what makes it useful: in Unity's left-handed, Y-up coordinate system (+X right, +Y up, +Z forward), Dot(Cross(forward, toTarget), up) > 0 means the target is to the character's right, and a negative result means left. Here side = 1.0, positive, and the target's own +X offset — the "right" direction in this coordinate system — confirms it.

Two more facts worth stating out loud: the cross product's magnitude is |a||b|sin(theta), so it shrinks toward zero as two vectors become nearly parallel or anti-parallel (useless for a side test right at those extremes) — and cross product is anti-commutative: Cross(toTarget, forward) here comes out to (0, -1, 0), the exact negative of c. Swapping the operand order always flips the sign, so which vector comes first is never a free choice.

2.3 "Why do we use quaternions instead of Euler angles?"

The short, interview-ready version: Euler angles (pitch, yaw, roll) suffer gimbal lock — when one rotation axis lines up with another, a whole degree of freedom collapses, and the numbers stop moving smoothly even though the object still can. Quaternions do not have this failure mode, and they interpolate correctly between two orientations (via slerp, section 2.6) along the shortest possible arc, whereas lerping raw Euler numbers can visibly take the long way around, or snap, right at a gimbal-locked pose. Chapter 2.3 walks through the full derivation; here is the compressed, numeric version of what the failure actually looks like.

// orientation A: pitch =  89.9,  yaw =   0.0,  roll =   0.0    (just under straight up)
// orientation B: pitch =  90.1,  yaw = 180.0,  roll = 180.0    (just past straight up)
//
// A and B describe almost the SAME physical orientation -- tipped a fraction of a degree
// past vertical, yaw and roll effectively cancel out that close to the pole -- but every
// single number is wildly different between A and B
//
// if two nearby FRAMES happen to land on opposite sides of this pole, and code naively
// lerps the raw pitch/yaw/roll numbers between them, the object visibly SNAPS or spins
// through a huge angle for what should have been a barely-visible movement

That is the concrete cost of gimbal lock: not just "you lose a degree of freedom" as an abstract fact, but a real, visible pop in a camera or a character's rotation, on exactly the kind of near-vertical look direction a third-person camera crosses all the time. Quaternions sidestep it entirely because they represent orientation as a single point on a 4D unit sphere, with no axis ever able to collapse onto another.

Common mistake Answering "gimbal lock" and stopping there. Interviewers who ask this question are often listening for whether you also know quaternions are still stored as Euler angles in the Inspector for human readability, converted internally, and that Euler angles remain the right tool for reading and setting a rotation by hand — chapter 2.3, section 12 covers exactly when Euler is still fine. Naming both sides of the tradeoff, not just the one that sounds impressive, is the stronger answer.

2.4 "How do you transform a point from local space to world space?"

// object: position (5, 0, 0), rotated 90 degrees around Y, scale 1 (uniform)
// local point: (1, 0, 0) -- one unit along the object's own local +X axis

// step 1: scale (skipped here, scale is 1)
Vec3 scaled = { 1.0f, 0.0f, 0.0f };

// step 2: rotate 90 degrees around Y
//   x' = x*cos(90) + z*sin(90) =  1*0 + 0*1 =  0
//   y' = y                     =  0
//   z' = -x*sin(90) + z*cos(90) = -1*1 + 0*0 = -1
Vec3 rotated = { 0.0f, 0.0f, -1.0f };

// step 3: translate by the object's world position, (5, 0, 0)
Vec3 worldPos = { rotated.x + 5.0f, rotated.y + 0.0f, rotated.z + 0.0f };
// worldPos = (5.0, 0.0, -1.0)

The order is scale, then rotate, then translate — never any other order, and never rotate-then-translate reversed, because rotating a point that has already been shifted away from the origin spins it around the world origin instead of around the object's own center, a classic and very visible bug (an object that should spin in place instead orbits some other point in the scene). Chapter 2.1's combining-transforms section (2.1, sections 6-9) covers why this order falls directly out of matrix multiplication being applied right-to-left: M = T * R * S. Going the other direction — world space back to local space — undoes each step in reverse: subtract the position, apply the inverse rotation (for a pure rotation matrix, that is just its transpose), then divide by scale. In Unity this whole worked example is exactly what transform.TransformPoint(localPos) does internally, and transform.InverseTransformPoint(worldPos) does the reverse.

2.5 "How do you check if a point is inside a sphere? Inside a camera frustum?"

bool IsInsideSphere(Vec3 point, Vec3 center, float radius) {
    Vec3 d = { point.x - center.x, point.y - center.y, point.z - center.z };
    float distSq = d.x * d.x + d.y * d.y + d.z * d.z;   // squared distance, no sqrt needed
    return distSq <= radius * radius;
}

// center = (0,0,0), radius = 5
bool insideA = IsInsideSphere({3, 4, 0}, {0, 0, 0}, 5.0f);  // distSq = 9+16+0 = 25, r*r = 25 -> true
bool insideB = IsInsideSphere({3, 4, 1}, {0, 0, 0}, 5.0f);  // distSq = 9+16+1 = 26, r*r = 25 -> false

The detail interviewers are actually fishing for here: comparing squared distance to squared radius instead of taking a square root. Both sides of the comparison are non-negative, so distSq <= radius*radius is exactly equivalent to dist <= radius without ever calling sqrt — one of the more expensive scalar operations available, and one that has no reason to appear in a check this simple.

struct Plane { Vec3 normal; float d; };   // plane: dot(normal, point) + d == 0, normal points INWARD

float SignedDistance(Plane p, Vec3 point) {
    return p.normal.x * point.x + p.normal.y * point.y + p.normal.z * point.z + p.d;
}

bool IsInsideFrustum(Vec3 point, Plane planes[6]) {
    for (int i = 0; i < 6; i++) {
        if (SignedDistance(planes[i], point) < 0.0f)
            return false;   // outside THIS plane means outside the whole frustum
    }
    return true;
}

// near plane example: plane at z = 2, normal (0,0,1) meaning "inward" is +z, so d = -2
// signedDistance = z - 2
// point (0,0,5): distance = 5 - 2 =  3  ->  inside this plane
// point (0,0,1): distance = 1 - 2 = -1  ->  outside (in front of the near clip plane)

A camera frustum is six planes (near, far, left, right, top, bottom); a point is inside the frustum exactly when it is on the inward side of all six. In real engines this test almost never runs against a single point — it runs against a whole object's bounding sphere or bounding box, reusing exactly the plane math above, because testing one cheap bounding volume per object is far faster than testing every vertex. Unity does this automatically for cameras (GeometryUtility.TestPlanesAABB is the built-in version of the loop above), so in practice you rarely hand-write this — interviewers ask it anyway because it is a clean way to check you actually understand what a plane equation and a dot product are doing together.

2.6 "What's the difference between lerp and slerp, and when do you use each?"

Lerp is a straight line through ordinary flat space — correct and cheap for positions, colors, and plain numbers. A rotation stored as a quaternion is a point on a 4D unit sphere, and a straight line between two points on a sphere cuts through the sphere, landing at a shorter length unless you renormalize afterward. Slerp (spherical linear interpolation) instead follows the sphere's own surface, moving at constant angular speed along the shortest arc — the mathematically correct tool for interpolating rotations. Chapter 2.3 (sections 10-11) covers both in full, including nlerp (plain lerp, then renormalize) as slerp's cheaper approximate cousin. Here is the numeric gap between them, worked by hand:

// q0 = identity = (0, 0, 0, 1)           -- 0 degrees around Y
// q1 = (0, 0.7071, 0, 0.7071)             -- 90 degrees around Y
// dot(q0, q1) = 0.7071 = cos(45 deg)  -- angle between them in quaternion space is 45 degrees

// slerp(q0, q1, t) = [sin((1-t)*45)/sin(45)] * q0 + [sin(t*45)/sin(45)] * q1

// at t = 0.25:
//   slerp = (0.0000, 0.1951, 0.0000, 0.9808)
//   this IS the exact quaternion for 22.5 degrees (0.25 * 90) -- slerp moves at
//   constant angular speed, so 25% of the way through really is 22.5 degrees there

// nlerp (plain lerp, then re-normalize) at t = 0.25:
//   raw lerp   = (0.0000, 0.1768, 0.0000, 0.9268)   -- length 0.9435, NOT unit length
//   normalized = (0.0000, 0.1874, 0.0000, 0.9823)   -- corresponds to about 21.6 degrees

At t = 0.25, slerp lands exactly on the constant-speed answer, 22.5 degrees. Nlerp's straight-line path drifts slightly off that schedule and lands closer to 21.6 degrees — small here, but the gap grows with the angle between the two rotations and with how far t sits from the endpoints. For a single frame-to-frame update where the current and target rotation are already close together, that gap is invisible, and most engines use nlerp everywhere for exactly that reason — it needs no trigonometry, just adds, scales, and one normalize. For one large, deliberate rotation across a wide angle — a camera doing one big orbit, a 90-degree turn animated over a full second — the gap is visible enough that slerp is worth the extra cost. Rule of thumb: many small per-frame steps, use nlerp; one big rotation, use slerp; and never plain-lerp a quaternion without renormalizing afterward.

3. C++ questions for engine roles

Engine-track interviews reach for these six almost every time. All six build directly on chapter 1.3 (C++ basics) and chapter 3.1-3.2 (memory and cache); this section is the compressed, whiteboard-ready version of each.

3.1 "What's the difference between a pointer and a reference?"

#include <iostream>

void AddOnePointer(int* p) {
    *p = *p + 1;      // must dereference to reach the value
}

void AddOneReference(int& r) {
    r = r + 1;         // r IS the variable, no dereference needed
}

int main() {
    int x = 10;

    AddOnePointer(&x);        // must pass the ADDRESS explicitly
    std::cout << x << "\n";   // 11

    AddOneReference(x);       // just pass x, the reference binds to it directly
    std::cout << x << "\n";   // 12

    int* p = nullptr;         // a pointer CAN be null
    // int& r;                 // ERROR: a reference must be bound the moment it's declared
}

This prints 11, then 12. A pointer is an ordinary variable that happens to hold a memory address: it can be null, it can be reassigned to point somewhere else later, and reaching the value it points to needs an explicit *. A reference is an alias for an already-existing variable: it must be bound at the moment it is declared, it can never be null in well-defined code, it can never be reseated to refer to something else afterward, and using it needs no special syntax — r just is x under a different name. The practical rule: use a reference for "this parameter is guaranteed to exist and I am not going to rebind it" (most function parameters), and reach for a pointer specifically when you need "this might not exist," "I need to change what I'm pointing at," or you're working with arrays or dynamically-owned memory.

3.2 "Explain virtual functions and vtables."

#include <iostream>

class Enemy {
public:
    virtual void TakeDamage(int amount) {
        std::cout << "Enemy takes " << amount << " damage\n";
    }
    virtual ~Enemy() {}   // virtual destructor -- section 3.5 explains why this matters
};

class Boss : public Enemy {
public:
    void TakeDamage(int amount) override {
        std::cout << "Boss takes " << amount << " damage, and roars\n";
    }
};

int main() {
    Enemy* e = new Boss();   // base-class pointer, but a DERIVED object underneath
    e->TakeDamage(10);       // which version actually runs?
    delete e;
}

This prints Boss takes 10 damage, and roars — not the Enemy version, even though e is declared as an Enemy*. Marking TakeDamage virtual makes the call resolve at runtime based on the object's actual type, a mechanism called dynamic dispatch, and here is how it actually works under the hood:

one Boss object in memory, and its vtable Boss object Boss's vtable (ONE shared copy per class, +----------------+ not one per object) | vptr |--> +--------------------------------------+ +----------------+ | slot 0: TakeDamage --> Boss::TakeDamage | | other Boss data | | slot 1: destructor --> Boss::~Boss | +----------------+ +--------------------------------------+ e->TakeDamage(10), where e is an Enemy* that actually points at a Boss: 1. follow e's hidden vptr -- it points at the BOSS vtable, decided by the object's REAL type, not the pointer's declared type 2. look up the TakeDamage slot in that table 3. call whatever function pointer sits there: Boss::TakeDamage, not Enemy::TakeDamage

Every object of a class with at least one virtual function carries one hidden pointer, the vptr, pointing at its class's vtable — a table of function pointers, one slot per virtual function, built once per class rather than once per object. Calling a virtual function means: follow the object's vptr, look up the right slot, call whatever function pointer is sitting there. That one extra pointer indirection, and the small memory cost of the vptr itself, is the actual "cost" of virtual functions — and it is exactly what makes dynamic dispatch possible in the first place.

3.3 "What is RAII, and why does C++ lean on it so heavily?"

#include <iostream>

class LockGuard {
public:
    LockGuard(bool& lockedFlag) : locked(lockedFlag) {
        locked = true;
        std::cout << "lock acquired\n";
    }
    ~LockGuard() {
        locked = false;
        std::cout << "lock released\n";
    }
private:
    bool& locked;
};

bool isLocked = false;

void DoWork() {
    LockGuard guard(isLocked);           // acquires the lock
    std::cout << "isLocked = " << isLocked << "\n";
    // ... work happens here, including a possible early return or exception ...
}   // guard's destructor runs HERE, automatically -- lock released no matter how we leave

int main() {
    DoWork();
    std::cout << "isLocked = " << isLocked << "\n";
}

Output: lock acquired, isLocked = 1, lock released, isLocked = 0. RAII stands for Resource Acquisition Is Initialization — an awkward name for a simple idea, already covered in chapter 1.3, section 2: tie a resource's lifetime directly to an object's lifetime. Acquire the resource in the constructor, release it in the destructor, and let the language's own guarantee that destructors always run — even on an early return, even when an exception unwinds the stack — do the cleanup work for you. The interview-ready answer names the acronym, gives one concrete real-world example (std::unique_ptr for heap memory, std::lock_guard for a mutex — chapter 1.3, section 3 already covers smart pointers this way), and contrasts it with manual acquire/release, which is one forgotten cleanup call away from a leak or a permanently-held lock.

3.4 "Explain move semantics."

#include <iostream>
#include <utility>

class Buffer {
public:
    Buffer(int n) : size(n), data(new int[n]) {
        std::cout << "constructed, size " << size << "\n";
    }
    Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
        std::cout << "COPIED, size " << size << "\n";     // expensive: new allocation + copy
    }
    Buffer(Buffer&& other) noexcept : size(other.size), data(other.data) {
        other.data = nullptr;
        other.size = 0;
        std::cout << "MOVED, size " << size << "\n";       // cheap: just copied a pointer
    }
    ~Buffer() { delete[] data; }
private:
    int size;
    int* data;
};

Buffer MakeBuffer() {
    Buffer b(1000);
    return b;                  // the compiler moves this out instead of copying it
}

int main() {
    Buffer a = MakeBuffer();          // move (or elided entirely by the compiler)
    Buffer c = std::move(a);          // explicit move: "a" is about to be discarded
}

This prints constructed, size 1000, then MOVED, size 1000 — the COPIED line never has to run at all. Buffer&& other is an rvalue reference, a reference type that only binds to values about to be discarded (a temporary, or anything wrapped in std::move). std::move does not move anything by itself — it is just a cast that tells the compiler "treat this as an rvalue," which makes the move-constructor overload the one that gets picked instead of the copy constructor. The win is concrete: the move constructor steals the existing heap pointer (a handful of bytes copied) instead of allocating a brand-new array and copying every element into it (chapter 1.3, section 6 already introduces copy vs move — this is the same idea, with the actual mechanics spelled out). Returning a local by value gets a further compiler optimization called RVO that can skip the move entirely, but the underlying principle — transfer ownership, don't duplicate — is what move semantics generalizes everywhere else.

3.5 "What actually happens when you call new, and when you call delete?"

#include <iostream>

class Player {
public:
    Player() { std::cout << "Player constructed\n"; }
    ~Player() { std::cout << "Player destroyed\n"; }
};

int main() {
    Player* p = new Player();   // step 1: operator new gets raw memory
                                  // step 2: the constructor runs IN that memory
    std::cout << "using player\n";

    delete p;                    // step 1: the destructor runs
                                  // step 2: operator delete frees the memory
    // using p after this line reads freed memory -- undefined behavior
}

Output: Player constructed, using player, Player destroyed. new is two steps folded into one keyword: allocate raw memory (operator new, conceptually similar to the malloc from chapter 1.1's C fundamentals), then run the constructor inside that memory. delete reverses both steps in the opposite order: run the destructor, then free the memory (operator delete). Three bugs to name if asked "what goes wrong here": forgetting delete entirely leaks the memory forever; calling delete twice on the same pointer is a double-free, undefined behavior and often an immediate crash; using a pointer after it has been deleted reads freed memory, a dangling-pointer / use-after-free bug — chapter 1.1's undefined-behavior material and chapter 1.3, section 10's sanitizer coverage both apply directly here. One more pairing rule worth stating: memory from new[] must be freed with delete[], never plain delete — mismatching them is undefined behavior too, which is exactly why smart pointers (chapter 1.3, section 3) exist: they turn "remember to call delete correctly" into "the destructor does it automatically," the same RAII idea as section 3.3 above.

3.6 "What does cache-friendly data layout mean, and how would you restructure this?"

// Array of Structs (AoS) -- the usual first instinct
struct Particle {
    float x, y, z;        // position
    float vx, vy, vz;     // velocity
    float life;
    int   id;              // rarely touched by the hot update loop
};
Particle particles[10000];   // one Particle is 8 floats/ints = 32 bytes

// Struct of Arrays (SoA) -- reorganized for the hot loop
struct ParticleSystem {
    float x[10000], y[10000], z[10000];
    float vx[10000], vy[10000], vz[10000];
    float life[10000];
    int   id[10000];
};

void UpdatePositions(ParticleSystem& ps, float dt, int count) {
    for (int i = 0; i < count; i++) {
        ps.x[i] += ps.vx[i] * dt;
        ps.y[i] += ps.vy[i] * dt;
        ps.z[i] += ps.vz[i] * dt;
    }
}

Chapter 3.2 already walked through this exact reorganization for a particle system in depth; the interview only wants the fast, correct version of it, on demand. Modern CPUs pull memory in fixed-size chunks called cache lines (64 bytes on most desktop and mobile hardware, chapter 3.1) — not one variable at a time. In the AoS layout, one Particle is 32 bytes, so a single cache line load pulls in two whole particles, but UpdatePositions only ever reads six of each particle's eight fields — life and id ride along unused, wasting a meaningful fraction of every cache line the loop touches. In the SoA layout, the x array is packed edge-to-edge with nothing but x values, so one 64-byte cache line load delivers sixteen consecutive values the loop will actually use, with nothing wasted. This is why hot per-frame loops over thousands of small objects — particles, physics bodies, boids, anything data-oriented — often get reorganized this way even though keeping related fields together in one struct is the normal OOP instinct.

Tip You do not need to have every STL container's exact complexity memorized to do well in a C++ round — what interviewers actually watch for is whether you reach for vector by default (chapter 1.4), whether you can explain why a linked list loses to a contiguous array on modern hardware even when its Big-O looks fine on paper (chapter 1.4, section 2's cache argument), and whether you can talk about memory layout at all without being prompted. Depth on two or three of these topics beats a shallow list of twenty.

4. C# and Unity questions

Gameplay-track interviews reach for these five constantly. All five build on chapter 4.2 (the MonoBehaviour lifecycle, coroutines, ScriptableObjects) and chapter 1.2 (C# basics, value vs reference types).

4.1 "What's the difference between Update and FixedUpdate?"

using UnityEngine;

public class UpdateVsFixed : MonoBehaviour
{
    public float moveSpeed = 5f;
    public float forceAmount = 10f;

    void Update()
    {
        // once per RENDERED frame -- Time.deltaTime varies frame to frame
        // good for: input, camera movement, anything tied to what's on screen
        transform.Translate(Vector3.forward * moveSpeed * Time.deltaTime);
    }

    void FixedUpdate()
    {
        // runs on a FIXED clock (default: every 0.02s = 50 times/second),
        // independent of the render frame rate -- good for physics forces
        GetComponent<Rigidbody>().AddForce(Vector3.forward * forceAmount);
    }
}

Update runs once per rendered frame, so it runs as often as the game is rendering — 200 times a second on a fast machine, 20 times a second on a struggling one — and Time.deltaTime stretches or shrinks to match. FixedUpdate runs on its own fixed clock (chapter 4.2, sections 4-5; chapter 6.1's fixed-vs-variable-timestep material covers why physics needs this), independent of the render rate: on a machine holding 200fps, FixedUpdate still fires exactly 50 times a second, no more; on a machine struggling at 20fps, a single rendered frame can contain zero, one, or several FixedUpdate calls in a row while Unity's scheduler tries to catch the physics clock back up to real time. Physics forces belong in FixedUpdate specifically because physics integration needs a consistent timestep to stay numerically stable — reading Time.deltaTime instead of Time.fixedDeltaTime inside physics code is a common, subtle bug that only shows up as instability on bad frames.

4.2 "Explain what a coroutine actually is."

using UnityEngine;
using System.Collections;

public class PoisonEffect : MonoBehaviour
{
    public void ApplyPoison(int totalDamage, int ticks, float tickInterval)
    {
        StartCoroutine(PoisonRoutine(totalDamage, ticks, tickInterval));
    }

    IEnumerator PoisonRoutine(int totalDamage, int ticks, float tickInterval)
    {
        int perTick = totalDamage / ticks;
        for (int i = 0; i < ticks; i++)
        {
            yield return new WaitForSeconds(tickInterval);
            TakeDamage(perTick);
            Debug.Log("tick " + (i + 1) + " of " + ticks + ", dealt " + perTick);
        }
    }

    void TakeDamage(int amount) { /* subtract from health here */ }
}

ApplyPoison(30, 3, 1f) computes perTick = 10, then logs tick 1 of 3, dealt 10, waits a second, logs tick 2 of 3, dealt 10, waits another second, logs tick 3 of 3, dealt 10 — three separate moments spread a second apart, not all at once. A coroutine is a function that can pause at a yield statement and resume later, entirely driven by Unity's single main thread, once per frame, checking whether each paused coroutine's resume condition (here, "has tickInterval seconds passed") is satisfied yet. This is worth saying explicitly, because it is the single most common misconception: a coroutine is not a thread (chapter 3.3 covers real threads and the data races that come with them) — nothing runs in parallel, it is cooperative pausing and resuming on the same thread the rest of your game logic runs on. Two practical gotchas: a coroutine started on a GameObject that gets disabled or destroyed stops silently with no warning, and a coroutine has no return value — communicate a result back out through a callback or a shared field instead.

4.3 "What causes garbage allocation in C#, and how do you avoid it in a hot path?"

using UnityEngine;
using UnityEngine.UI;

public class AllocatesEveryFrame : MonoBehaviour
{
    public Text hpLabel;
    int currentHp = 50;
    int maxHp = 100;

    void Update()
    {
        // builds a brand-new string on the heap every single frame,
        // even on the frames where HP hasn't changed at all
        hpLabel.text = "HP: " + currentHp + " / " + maxHp;
    }
}

Unity's Profiler has a "GC Alloc" column per frame; this version shows a nonzero number on every single frame, 60 or more times a second, whether or not the HP number ever actually changed. Each small allocation is eventually reclaimed by the garbage collector, and a collection pause is exactly the kind of hitch a profiling pass hunts down — this comes back directly in section 5's debugging scenario. Here is the fixed version:

using UnityEngine;
using UnityEngine.UI;
using System.Text;

public class NoPerFrameAlloc : MonoBehaviour
{
    public Text hpLabel;
    int currentHp = 50;
    int maxHp = 100;
    int lastShownHp = -1;
    StringBuilder sb = new StringBuilder(32);   // built ONCE, reused every frame

    void Update()
    {
        if (currentHp == lastShownHp) return;   // nothing changed, skip the work entirely

        sb.Clear();
        sb.Append("HP: ").Append(currentHp).Append(" / ").Append(maxHp);
        hpLabel.text = sb.ToString();   // still allocates one string, but only when HP changed
        lastShownHp = currentHp;
    }
}

Two separate fixes stacked together: a dirty-flag check that skips all the work entirely on the (usually large) majority of frames where nothing changed, and a reused StringBuilder instead of chained + concatenation, which under the hood built and threw away several intermediate strings for every single line. It still allocates one final string via ToString(), but only on the frames HP actually changes, not sixty times a second regardless. Other allocation sources worth naming from memory, even without a code example for each: boxing a value type into an object-typed API, a lambda closure capturing a local variable, foreach over certain collection types, LINQ methods like Where and Select, and — a favorite trick question — Camera.main, which silently performs a GameObject.Find-style search under the hood every time it is called, so calling it inside Update is both a hidden allocation risk and a hidden search cost. All of these are fine inside Awake or Start, which run once; the discipline is specifically about anything that runs every frame.

4.4 "When would you reach for a ScriptableObject instead of a plain class or a MonoBehaviour?"

using UnityEngine;

[CreateAssetMenu(menuName = "Data/EnemyStats")]
public class EnemyStats : ScriptableObject
{
    public string enemyName;
    public int maxHp;
    public float moveSpeed;
}

public class Enemy : MonoBehaviour
{
    public EnemyStats stats;   // many Enemy instances can point at the SAME asset
    int currentHp;

    void Awake()
    {
        currentHp = stats.maxHp;   // read shared config, keep per-instance state separate
    }
}

A ScriptableObject is a data container asset that lives on disk in the project, not attached to any GameObject or tied to any one scene's lifetime — which makes it naturally shared. Every Enemy of the same type can point at the exact same EnemyStats asset instead of each one carrying its own private copy, exactly as chapter 4.2 (section 13) and chapter 6.4's item-definition system already use them. Reach for one for shared, read-mostly configuration data, for values a designer should be able to tune without touching code or reloading a scene, and for lightweight event channels (chapter 6.7's observer-pattern section) that decouple a raiser from its listeners with no hard reference between them.

Common mistake Storing per-instance runtime state directly on a ScriptableObject and expecting it to reset cleanly between play sessions. Field edits made to a ScriptableObject's data while playing in the Editor persist onto the actual asset file's default values once you stop, because you are editing the one shared asset, not a private copy — a classic beginner trap. Per-instance state that needs to reset belongs on the MonoBehaviour (or a plain class it owns), reset in Awake; the ScriptableObject stays read-mostly shared configuration.

4.5 "How would you approach optimizing a scene that's running slow?"

The short version, in order: profile first, on the target device, before changing anything; find whether the cost is CPU-bound or GPU-bound; then apply the fix that matches. Section 5 below walks the full method end to end on a concrete scenario — the checklist here is the Unity-specific toolbox that method reaches for once it knows where the time is going:

The one rule that matters more than any single item on that list: never apply a fix without a profiler measurement before and after it. An "obvious" culprit — a big texture, a complicated shader — sometimes is not the actual cost, and a change made on a guess proves nothing either way. Section 5 shows exactly what that measured, iterative process looks like in practice.

5. Debugging and scenario questions: process beats trivia

A studio would rather watch you reason through an unfamiliar problem with a repeatable method than hear you recite a fact you memorized. "The game runs at 20fps on mobile, what do you check first?" is not really a performance question — it is a question about whether you have a systematic process at all, or just a list of things you've heard help sometimes.

20fps on mobile -- where is the time actually going? (profile ON THE DEVICE, not the editor) total frame time = 50ms (target: 33.3ms for 30fps, or 16.6ms for 60fps) | ----------------------------------------- | | | CPU main thread CPU render thread GPU time (scripts, physics, (batching, culling, (vertex/fragment GC, Update loops) submitting draw calls) shaders, overdraw) | | | v v v check: GC Alloc check: draw call check: Frame Debugger spikes? heavy count, SetPass for overdraw, shader Update() work? calls, batching cost, resolution | | | v v v object pooling, batching, atlasing, simplify shaders, cut allocations, GPU instancing, LOD, texture cache GetComponent occlusion culling compression

Before opening the Profiler in detail, there is a fast, low-effort split worth doing first: temporarily lower the render resolution — if the frame rate improves, the game is at least partly GPU-bound (fill rate, overdraw, shader cost). Temporarily lower the enemy/object count instead — if that improves it, the game is at least partly CPU-bound (scripts, draw call submission, physics). This one cheap experiment, doable in under a minute, already tells you which half of the diagram above to dig into first.

Here is a model answer, structured as an actual worked trace rather than a list of tips — this is what "systematic" looks like with real numbers attached:

// a model trace of the debugging session, start to finish

// baseline, profiled on a mid-range Android device (NOT the editor):
//   total frame time: 50ms (20 fps)   CPU main: 38ms   CPU render: 6ms   GPU: 6ms
//   CPU main is by far the largest slice -> this is CPU-bound, so shader or
//   texture changes would not have helped here -- ignore the GPU side for now

// step 1: check the GC Alloc track -> spiking about 4KB every frame, traced to
//         a UI label rebuilding its string every Update even when HP hasn't changed
// fix: dirty-flag the label (section 4.3's fixed version), only rebuild on change
//   total frame time: 41ms (24 fps)   CPU main: 29ms   -- improved, not solved, keep going

// step 2: re-profile -> CPU main still dominant. Deep Profile shows 200 calls to
//         GetComponent per frame, inside a loop that runs in Update
// fix: cache the reference once in Awake (chapter 4.2, section 9) instead of
//      calling GetComponent every single frame
//   total frame time: 24ms (41 fps)   CPU main: 12ms   -- CPU is no longer the bottleneck

// step 3: re-profile again -> GPU time is now the largest slice (9ms of 24ms).
//         the Frame Debugger shows heavy overdraw from three stacked transparent
//         UI panels
// fix: this becomes the NEXT thing to investigate -- the bottleneck MOVED, it
//      did not disappear, and reporting that out loud is the correct, expected answer

Notice the shape of that answer: measure on the real target first, form exactly one hypothesis, change exactly one thing, re-measure against the previous number rather than a feeling, and expect the bottleneck to move rather than vanish. That last point matters more than it sounds — a candidate who says "I fixed it" after step 1 and stops has not actually verified anything; a candidate who keeps re-measuring after every change, and calls out that the GPU is now the limiting factor, is demonstrating exactly the process a studio wants to see repeated on a real bug six months into the job.

A short list of other scenario questions worth having a one-line method for, even without a full worked trace for each: "it works in the editor but crashes only in the build" (check platform-specific #if blocks, an asset missing from the build, or code stripped by IL2CPP that was only reachable through reflection); "it stutters every few seconds, not constantly" (a periodic pattern like this points toward garbage collection pauses rather than a steady per-frame cost — exercise 3 at the end of this chapter works through exactly this one); "it works solo but breaks in multiplayer" (look for client-only assumptions and non-deterministic ordering, chapter 14.2, section 11).

Tip Narrate your thinking out loud, even the wrong turns. An interviewer watching you say "I'd check X first because Y — if that's not it, next I'd look at Z" learns far more about how you'd behave on a real production bug than watching you silently arrive at the right answer. Silence reads as "I don't have a process," even when you do.

6. The take-home test: how to treat it

A take-home is not judged primarily on whether the feature works — most submissions do. It is judged on whether the code reads like something a team could safely build on top of next week. Treat it exactly like a small, real production pull request rather than a coding-challenge flex: solve precisely the scope that was asked for, keep it clean and readable over clever, and explain your decisions instead of leaving a reviewer to guess at them.

Resist the urge to add features nobody asked for. It is tempting to demonstrate range by bolting on a save system, a settings menu, or an extra mechanic the brief never mentioned — but studios read unrequested scope as a signal about how you would behave on a real deadline, not as extra credit. Knowing when to stop is a skill in itself, and a bloated submission usually reads as a candidate who does not yet have it.

README.md

## What this does
One or two sentences. What the take-home asked for, in your own words.

## How to run it
Exact commands. Assume the reviewer has 2 minutes, not 20.

## Decisions and tradeoffs
- Chose X over Y because [reason]. This is the part interviewers actually read.
- Left Z out of scope because the brief didn't ask for it -- happy to add it,
  wanted to keep this focused instead of guessing what else you wanted.

## What I'd do with more time
- One or two concrete next steps, not a wishlist.

## Known limitations
- Anything you know is incomplete or fragile. Say it yourself, first.

A README shaped like this is worth more than any single clever line of code in the submission, because it is the part that shows judgment rather than just execution — naming your own tradeoffs, before a reviewer has to go find them, is exactly what a senior teammate does in a real pull request description. Keep the commit history readable too: a small number of meaningful, well-scoped commits reads better than one giant commit that hides the whole thought process, and better than fifty tiny "wip" commits that hide it a different way. If you were handed an existing codebase to extend rather than a blank repository, match its existing style even where you would have made a different choice yourself — consistency with the surrounding code is itself part of what is being graded.

Common mistake Spending triple the suggested time on a take-home that says "should take about 3 hours." Going far over the stated budget does not read as extra dedication — it reads as poor scoping, which is precisely one of the skills a take-home is trying to measure. If the time box feels too tight for what is being asked, note that honestly in the README rather than silently blowing past it.

7. Talking about your own projects: the STAR method

STAR is a simple structure for answering "tell me about a time you..." questions without rambling: Situation (one or two sentences of context), Task (specifically what you were responsible for), Action (what you actually did — this is where most of the answer's words should go, and where "I" belongs more than "we," since the interviewer is trying to isolate your specific contribution), and Result (the outcome, ideally with a real number attached to it).

Here is a full worked example, for the prompt "tell me about a technical challenge you solved in one of your projects":

Situation: "I was building a small third-person platformer in Unity as a solo project, targeting a low-end Android tablet as my minimum spec." Task: "Partway through, the frame rate dropped to about 20fps once I had roughly thirty enemies on screen at once, and I needed to get it back to a stable 30fps without cutting the enemy count, since crowd density was part of the level design." Action: "I profiled on the actual tablet rather than the editor and found the frame was CPU-bound, with a large GC Alloc spike traced to each enemy's AI script calling GetComponent every frame and rebuilding a target list every Update. I cached the component references once in Awake, replaced the per-frame list rebuild with a plain reused list, and pooled the enemies' hit-effect particle systems instead of instantiating and destroying them constantly." Result: "Frame time dropped from about 50ms to 18ms on that tablet, the game held a stable 30fps with the full thirty-enemy crowd, and I came away with a profiling-first habit that I now apply to every performance problem, not just that one."

Notice this answer is not inventing anything new — it walks the exact debugging method from section 5, applied to a specific, concrete project, with real before-and-after numbers. That overlap is not an accident: an interviewer who hears this answer gets both "does this person have a real project" and "does this person actually know how to debug performance" confirmed in the same sixty seconds.

Tip Aim for roughly 60-90 seconds spoken out loud (around 150-220 words) and lead with the result if you have a strong number — "I got frame time from 50ms down to 18ms" grabs attention and makes the interviewer want to ask a follow-up, where a long situation setup before you ever mention what happened tends to lose them first. Prepare one ready story per common bucket ahead of time — a technical challenge, a disagreement with a teammate or a design decision, a time you had to learn something unfamiliar fast, a mistake and what you would do differently — rather than trying to improvise a good STAR structure cold, live, under pressure.

8. Questions you should ask them

An interview is evaluating the studio just as much as the studio is evaluating you, and the questions you ask are themselves part of what is being evaluated — a good question shows genuine engineering curiosity, not just interest in getting an offer.

Two things to avoid: leading with compensation and benefits at this stage (that is the recruiter and offer stage's job, sections 1 and 6 of the pipeline, not the technical or team interview), and asking anything a thirty-second look at the studio's own website would have answered — it signals you did not prepare.

9. How studios differ

The same six math questions and the same debugging method apply everywhere, but where an interviewer spends the bulk of the hour shifts hard depending on what kind of studio is hiring. Reading this correctly before you walk in changes what you review the night before.

roughly how a technical interview's time gets spent, by studio type (relative emphasis, not exact percentages -- every studio is different) Mobile live-service (HoYoverse-like) C#/Unity depth ***** Optimization/mem ***** Math *** Systems/design ** C++/engine * Networking * Console / PC AAA (engine-heavy) C++/engine ***** Systems/design **** Math *** Optimization/mem *** C#/Unity depth ** Networking * Competitive multiplayer Networking ***** Math *** C++/engine *** C#/Unity depth *** Systems/design *** Optimization/mem ** legend: * = touched on lightly ***** = a major focus, expect several questions

A mobile live-service studio — the HoYoverse shape of studio — probes deep Unity knowledge (ScriptableObjects, addressables and asset streaming, UI performance, coroutines, chapter 4.2 and chapter 12.1's territory), and probes optimization and memory hard, because the game has to run acceptably on years-old low-end Android and iOS hardware while updating constantly without breaking a live production system (chapter 8.5's mobile-performance checklist, chapter 14.4's live-ops and gacha material). Expect the take-home and the live debugging round to both lean toward "make this Unity scene faster" over "implement this algorithm from scratch."

A console or PC AAA studio — especially anything engine-heavy or shipping on an in-house engine — probes C++ hard (all of section 3 above, plus templates and the STL from chapter 1.3), engine internals (whether in-house or Unreal, chapter 5.x), cache-aware memory layout (chapter 3.1-3.2), and sometimes rendering-pipeline knowledge (chapter 7.1) for anything graphics-adjacent. Expect whiteboard questions that ask you to reason about memory and ownership more than questions that ask you to wire up gameplay quickly.

A competitive multiplayer studio puts networking at the center of the interview: client-server architecture, UDP versus TCP, tick rate (chapter 14.1), client-side prediction, server reconciliation, and lag compensation (chapter 14.2) — plus a strong bias toward "explain the tradeoff" questions rather than "give the one correct answer" questions, because problems like "I died behind the wall" (chapter 14.2, section 8) genuinely have no clean fix, only tradeoffs, and an interviewer wants to see that you know the tradeoff exists rather than confidently proposing a magic solution that does not.

10. A one-month study plan before you apply

one month before you start applying week 1 week 2 week 3 week 4 --------- ---------- ---------- ---------- refresh math, polish ONE portfolio mock interviews, research studios, C++, C# basics piece: playable in rehearse STAR tailor resume, from section 2-4 under 2 min, README stories out loud, apply in a batch without notes from section 6 timed practice test (section 9)

Week 1: redo every question in section 2 and section 3 from memory, with no notes, out loud — not just recognizing the right answer when you see it, but producing it cold, the way an interview actually demands. Be honest about which topic felt shaky and spend the rest of the week specifically there, not evenly across everything.

Week 2: pick exactly one portfolio project and polish it rather than spreading effort across several. Make it playable or buildable by a stranger in under two minutes, write its README using section 6's skeleton, and — for online applications where a live demo is not possible — record a 60-90 second walkthrough video as a backup.

Week 3: run at least two or three mock technical interviews, with a friend, a mentor, or by recording yourself answering section 5's debugging scenario out loud on a timer. Rehearse your STAR stories from section 7 until each one lands reliably under ninety seconds without rambling. Do one timed take-home-style exercise under real conditions, including writing the README, not just the code.

Week 4: research five to ten target studios through the lens of section 9 — what does each one's interview probe hardest, and does your prep actually match. Reorder your portfolio and resume to lead with whatever is most relevant per studio. Prepare three or four questions from section 8 tailored to each one. Apply in a batch rather than trickling applications out one at a time over months — a batch gives you comparable timelines and, eventually, comparable offers to weigh against each other.

11. Glossary

12. Exercises

Exercise 1 A character faces forward = (0, 0, 1). A target sits in the direction toTarget = (-2, 0, 1) relative to the character. By hand: (a) compute Dot(forward, toTarget) and say whether the target is in front of or behind the character; (b) compute Cross(forward, toTarget), then Dot(Cross(forward, toTarget), up) where up = (0, 1, 0), and say whether the target is to the character's left or right.
Show answer
(a) Dot(forward, toTarget) = 0*(-2) + 0*0 + 1*1 = 1
    Positive -> the target is in FRONT of the character (less than 90 degrees off forward).

(b) Cross(forward, toTarget):
      x = fy*tz - fz*ty = 0*1 - 1*0   =  0
      y = fz*tx - fx*tz = 1*(-2) - 0*1 = -2
      z = fx*ty - fy*tx = 0*0 - 0*(-2) =  0
    Cross(forward, toTarget) = (0, -2, 0)

    Dot((0, -2, 0), (0, 1, 0)) = 0*0 + (-2)*1 + 0*0 = -2
    Negative -> the target is to the character's LEFT.

Both answers match the numbers directly: toTarget has a positive z-component (mostly ahead) and a negative x-component (off to the character's own left, given +X is right in this coordinate system) — the dot product test confirms "ahead," and the cross-then-dot test confirms "left," exactly matching what the raw vector already suggests by inspection, which is a good way to sanity-check your own worked answer under interview pressure.

Exercise 2 The following code compiles and runs. Predict its exact output, explain why it does not print what a naive reading suggests, and then state the one-word fix.
#include <iostream>

class Base {
public:
    Base() { std::cout << "Base ctor\n"; }
    ~Base() { std::cout << "Base dtor\n"; }   // NOT virtual
};

class Derived : public Base {
public:
    Derived() { data = new int[100]; std::cout << "Derived ctor\n"; }
    ~Derived() { delete[] data; std::cout << "Derived dtor\n"; }
private:
    int* data;
};

int main() {
    Base* b = new Derived();
    delete b;   // what actually runs here?
}
Show answer
Output:
Base ctor
Derived ctor
Base dtor

Derived dtor never prints. Because ~Base() is not marked virtual, it gets no slot in a vtable, and delete b resolves purely by b's declared static type (Base*) rather than the object's real type — exactly the opposite of section 3.2's TakeDamage example, where marking the function virtual is precisely what made runtime dispatch happen. Only ~Base() runs, ~Derived() is skipped entirely, and the int[100] allocated in Derived's constructor is never freed — a real memory leak, on top of skipping whatever other derived-specific cleanup might have existed.

The fix is one word: mark the base destructor virtual ~Base() { ... }. A virtual destructor gets a vtable slot exactly like TakeDamage did in section 3.2, so delete b now correctly dispatches to Derived's destructor first, which runs its own cleanup and then automatically chains up to Base's destructor. Fixed output: Base ctor, Derived ctor, Derived dtor, Base dtor. The general rule, worth stating from memory: any class meant to be deleted through a base-class pointer needs a virtual destructor, full stop — chapter 1.3, section 9's Rule of 0/5 material covers this same rule from the constructor side.

Exercise 3 A PC game normally holds a steady 12ms frame time, but every 5-10 seconds the frame time spikes to about 180ms for a single frame, then returns to normal. Using the method from section 5, name what you would check first and why, and describe roughly what a fix would look like.
Show answer

The pattern itself is the clue, before any profiler is even opened: this is periodic, not constant — the average frame time is fine, but something spikes far above it every few seconds. A steady CPU or GPU bottleneck shows up as a constantly elevated frame time, not sharp isolated spikes on top of an otherwise healthy average. That specific shape — long stretches of normal, punctuated by short severe spikes — is the classic signature of a garbage collection pause: allocations accumulate quietly across many frames without costing much individually, until the collector's threshold is crossed and it has to stop the world briefly to reclaim memory.

Following section 5's method: open the Profiler's GC Alloc track first and confirm it visually lines up with the spike frames — if a spike in "GC Alloc" or a "GC.Collect" marker appears in exactly the frames where frame time jumps, that confirms the hypothesis before touching any code. Next, find what is actually accumulating — a Deep Profile capture around one of the spikes typically points at something creating small temporary objects every frame that only get cleaned up once memory pressure crosses a threshold: a list rebuilt with LINQ inside an enemy AI's per-frame update, a particle system allocating a new array per emission, or a UI element rebuilding a string every frame exactly like section 4.3's example. The fix follows section 4.3 and section 4.5 directly: pool the repeatedly-created objects, cache and reuse collections instead of rebuilding them, and remove the unnecessary per-frame allocation at its source. Finally, re-measure over a long enough window (at least the 5-10 second gap between the original spikes) to confirm the spikes are actually gone, not just less frequent — a partial fix that thins out the spikes without removing them is a real, worth-reporting outcome, not a silent "fixed."

Every question in this chapter reduces to the same underlying test: can you produce a correct, compressed answer to something you already know, under time pressure, and can you reason out loud through something you do not immediately know, with a repeatable method instead of a guess. The math, the C++, the C#, and the debugging scenario are all just different surfaces for testing that same pair of skills — study plan, portfolio, and rehearsed STAR stories are how you make sure the interview actually gets to see them.

← Back to all chapters