Goal: Build a tiny 2D engine using value types correctly and observe pitfalls/perf.
Tasks
- Implement a
readonly struct Vector2with fieldsdouble X, Y, constructor,Length,Normalized(), and operators+,-, unary-, scalar*,Dot(in Vector2 a, in Vector2 b), andDeconstruct(out double x, out double y). Ensure no hidden copies and no boxing. - Implement a
struct BoundingBoxwith fieldsVector2 Min, Max(mutable is fine here). AddContains(in Vector2 p)andInflate(double delta). - Implement a
class ParticlewithVector2 Position,Vector2 Velocity, and a read-onlydouble Speed => Velocity.Length. Addvoid Step(double dt, in BoundingBox bounds)that moves the particle and bounces on edges (invert velocity on collision). Because bouncing only flips a sign,Speedstays constant for the life of a particle, which is what makes the distance total in Task 4 easy to check. - Write a
Mainthat creates 100 particles with random velocities inside a 100×100 box and advances them for 1,000 steps, accumulating the total distance traveled. - Pitfall demo: Add a
public Vector2 this[int i] { get; set; }indexer to aParticlescontainer that stores aList<Vector2>of positions. Demonstrate the forgotten write-back: read an element into a local, produce an updated value, and do not assign it back. The stored element is unchanged and nothing warns you. Then fix it by assigning the local back through the indexer. While you are there, try to write the noisier versions of the same mistake and note the compiler errors you get: mutating aforeachvariable and mutating throughlist[0].X++are both rejected outright. C# closes those doors; the silent copy-and-forget is the one it leaves open.
Acceptance Criteria
Vector2isreadonly struct, methods that read only take parameters asin.- No allocation or boxing in the hot loop (avoid
object,ArrayList, LINQ in the inner step). - The simulation runs and prints the total distance traveled and the final average speed.
Vector2overridesToString()(for example$"({X:F2},{Y:F2})"), so printing a vector shows its values rather than the type name.- Name the type
Vector2yourself and do not importSystem.Numerics: the BCLVector2isfloat-based and has noNormalized()instance method, so the two collide.
Checkpoint
- With
new Random(1), 100 particles,dt = 0.016and 1,000 steps, the run printsTotal distance: 31873.62andFinal average speed: 19.92. Those numbers are reproducible: a different total means a different seed, a different bounce rule, or a different particle count, not a different machine. - The final average speed lands near 20, the middle of the 10 to 30 range you sampled from, and it equals the run mean (
totalDistance / (Count * steps * dt)). Print both and compare them: they agree only because bouncing flips a sign and never rescales. If they diverge,Stepis changing speed somewhere. bounds.Contains(p.Position)is true for every particle after every step.- The pitfall demo prints
(1.00,1.00)and then(2.00,2.00). If the first line already reads(2.00,2.00), you wrote the value back and lost the demonstration.
Hints: Vector Math & Simulation (Guided)
Hints - General
General tips
- Keep
Vector2a readonly struct and useinparameters where you read but don’t mutate to avoid copies. - Avoid LINQ, closures, and allocations inside the inner simulation loop. Pre‑allocate collections with capacity.
- Prefer inclusive bounds (
>= Minand<= Max) so particles resting on edges are considered inside.
Task 1: Vector2
Task 1: Vector2
- Length:
Math.Sqrt(X*X + Y*Y). - Normalized(): divide by length when length > 0, otherwise return
(0,0)to avoidNaN. - Operator hints (signatures):
public static Vector2 operator +(in Vector2 a, in Vector2 b)public static Vector2 operator -(in Vector2 a, in Vector2 b)public static Vector2 operator *(in Vector2 a, double k)
Dot(in a, in b)returnsa.X*b.X + a.Y*b.Y.Deconstruct(out double x, out double y)assignsx = X; y = Y;.
Task 2: BoundingBox
Task 2: BoundingBox
Contains(p):p.X >= Min.X && p.X <= Max.X && p.Y >= Min.Y && p.Y <= Max.Y.Inflate(d):Min = (Min.X - d, Min.Y - d)andMax = (Max.X + d, Max.Y + d).- Consider a constructor guard to ensure
Min <= Maxon both axes if inputs might be unsorted.
Task 3: Particle.Step
Task 3: Particle.Step
- Integrate:
next = Position + Velocity * dt. - Bounce X:
- If
next.X < bounds.Min.X: setnext.X = bounds.Min.Xand makevx = +Math.Abs(vx). - If
next.X > bounds.Max.X: setnext.X = bounds.Max.Xand makevx = -Math.Abs(vx).
- If
- Bounce Y similarly with
vy. - Finally assign
Position = next; Velocity = (vx, vy);. - If particles occasionally “tunnel” through edges, reduce
dtor iterate collision resolution per axis.
Task 4: Simulation loop
Task 4: Simulation loop
- Create particles with random positions in
[0,100]and velocities asdir.Normalized() * speed. - Accumulate distance as
total += p.Speed * dteach step. - Use
new List<Particle>(capacity: 100)to avoid resizes; seedRandomfor reproducibility.
Mutable‑struct pitfall demo
Task 5: Mutable‑struct pitfall demo
- In
foreach (var pos in positions),posis a copy. You cannot even try to change it: the compiler rejects the assignment (CS1654 for a mutable struct, CS0191 for areadonly structsuch asVector2). Mutating through the indexer,list[0].X++, is rejected too (CS1612, the return value is not a variable). - So demonstrate the mistake that does compile:
var tmp = list[i]; tmp = new Vector2(tmp.X + 1, tmp.Y + 1);and then forget thelist[i] = tmp;. Every line is legal, the stored element never changes, and no diagnostic fires. - Fix by writing the value back:
list[i] = tmp;. If you want genuinerefaccess to elements, switch to an array orSpan<T>, or useCollectionsMarshal.AsSpan(list);List<T>itself does not exposerefelements through its public API.
Testing & verification ideas
Testing & verification ideas
- Assert
Vector2.Dot(a,b) == Vector2.Dot(b,a)and thatNormalized().Lengthis ~1 (within epsilon). - After each step, assert
bounds.Contains(p.Position); if false, check bounce/clamp math. - Log a few positions/velocities at edges to verify sign inversions.
Performance checks (optional)
- Ensure the hot loop allocates 0 bytes (no boxing/closures). You can spot accidental allocations by avoiding
stringconcatenation inside loops and keepingToString()calls out of the hot path.