using System;
using System.Collections.Generic;
public readonly struct Vector2
{
public readonly double X;
public readonly double Y;
public Vector2(double x, double y) { X = x; Y = y; }
public double Length => Math.Sqrt(X * X + Y * Y);
public Vector2 Normalized()
{
double len = Length;
return len > 0 ? new Vector2(X / len, Y / len) : new Vector2(0, 0);
}
public static Vector2 operator +(in Vector2 a, in Vector2 b) => new(a.X + b.X, a.Y + b.Y);
public static Vector2 operator -(in Vector2 a, in Vector2 b) => new(a.X - b.X, a.Y - b.Y);
public static Vector2 operator -(in Vector2 a) => new(-a.X, -a.Y);
public static Vector2 operator *(in Vector2 a, double k) => new(a.X * k, a.Y * k);
public static double Dot(in Vector2 a, in Vector2 b) => a.X * b.X + a.Y * b.Y;
public void Deconstruct(out double x, out double y) { x = X; y = Y; }
// Without this override the inline comments below would be wrong: the default
// ValueType.ToString() prints the type name, not the values.
public override string ToString() => $"({X:F2},{Y:F2})";
}
public struct BoundingBox
{
public Vector2 Min;
public Vector2 Max;
public BoundingBox(in Vector2 min, in Vector2 max)
{
Min = new Vector2(Math.Min(min.X, max.X), Math.Min(min.Y, max.Y));
Max = new Vector2(Math.Max(min.X, max.X), Math.Max(min.Y, max.Y));
}
public readonly bool Contains(in Vector2 p) =>
p.X >= Min.X && p.X <= Max.X && p.Y >= Min.Y && p.Y <= Max.Y;
public void Inflate(double d)
{
Min = new Vector2(Min.X - d, Min.Y - d);
Max = new Vector2(Max.X + d, Max.Y + d);
}
}
public class Particle
{
public Vector2 Position;
public Vector2 Velocity;
public Particle(in Vector2 position, in Vector2 velocity)
{
Position = position;
Velocity = velocity;
}
// Speed is the magnitude of Velocity. Bouncing only flips signs, so it stays constant.
public double Speed => Velocity.Length;
public void Step(double dt, in BoundingBox bounds)
{
Vector2 next = Position + Velocity * dt;
double vx = Velocity.X, vy = Velocity.Y;
double nx = next.X, ny = next.Y;
if (nx < bounds.Min.X) { nx = bounds.Min.X; vx = +Math.Abs(vx); }
else if (nx > bounds.Max.X) { nx = bounds.Max.X; vx = -Math.Abs(vx); }
if (ny < bounds.Min.Y) { ny = bounds.Min.Y; vy = +Math.Abs(vy); }
else if (ny > bounds.Max.Y) { ny = bounds.Max.Y; vy = -Math.Abs(vy); }
Position = new Vector2(nx, ny);
Velocity = new Vector2(vx, vy);
}
}
public class Particles
{
private readonly List<Vector2> _positions = new();
public int Count => _positions.Count;
public Vector2 this[int i]
{
get => _positions[i];
set => _positions[i] = value;
}
public void Add(in Vector2 p) => _positions.Add(p); // List<T> stores values inline (no boxing)
}
public class Program
{
public static void Main()
{
var rnd = new Random(1);
var bounds = new BoundingBox(new Vector2(0, 0), new Vector2(100, 100));
var particles = new List<Particle>(capacity: 100);
for (int i = 0; i < 100; i++)
{
var pos = new Vector2(rnd.NextDouble() * 100, rnd.NextDouble() * 100);
var dir = new Vector2(rnd.NextDouble() - 0.5, rnd.NextDouble() - 0.5).Normalized();
var speed = 10 + rnd.NextDouble() * 20; // 10..30 units/sec
particles.Add(new Particle(pos, dir * speed));
}
double dt = 0.016; // ~60 FPS
double totalDistance = 0;
for (int step = 0; step < 1000; step++)
{
foreach (var p in particles)
{
totalDistance += p.Speed * dt;
p.Step(dt, in bounds);
}
}
Console.WriteLine($"Total distance: {totalDistance:F2}");
// The acceptance criteria ask for the FINAL average speed, so measure it
// from the particles as they now are rather than deriving it from the
// distance total.
double finalAverageSpeed = 0;
foreach (var p in particles) finalAverageSpeed += p.Speed;
finalAverageSpeed /= particles.Count;
Console.WriteLine($"Final average speed: {finalAverageSpeed:F2}");
// Mean speed over the whole run is a different quantity, and here it comes
// out identical. That agreement is the check on the bounce logic: it only
// flips signs, so no particle gained or lost speed along the way.
Console.WriteLine($"Mean speed over the run: {totalDistance / (particles.Count * 1000 * dt):F2}");
// Pitfall demonstration: the forgotten write-back.
var store = new Particles();
store.Add(new Vector2(1, 1));
var tmp = store[0]; // a COPY of the stored element
tmp = new Vector2(tmp.X + 1, tmp.Y + 1); // updates the copy only
// store[0] = tmp; // forgotten
Console.WriteLine(store[0]); // (1.00,1.00): the update went nowhere
// Correct: put the copy back.
tmp = store[0];
tmp = new Vector2(tmp.X + 1, tmp.Y + 1);
store[0] = tmp;
Console.WriteLine(store[0]); // (2.00,2.00)
// The louder versions of this trap do not compile at all, which is why the
// silent one above is the version worth practising. Verified error codes:
// foreach (var v in listOfVector2) { v.X++; } // CS0191, X is a readonly field
// foreach (var m in listOfMutable) { m.X++; } // CS1654, foreach iteration variable
// listOfMutable[0].X++; // CS1612, return value is not a variable
// C# closes all three doors. The one it leaves open is the forgotten write-back,
// because every line of it is individually legal.
}
}
Output (the Random is seeded, so these numbers are reproducible on .NET 10):
Total distance: 31873.62 Final average speed: 19.92 Mean speed over the run: 19.92 (1.00,1.00) (2.00,2.00)
Notes on the solution
-
Vector2here is this exercise’s own type, notSystem.Numerics.Vector2. Do not addusing System.Numerics;: the BCL type isfloat-based, has noNormalized()instance method, and importing it turns everydoubleliteral in this file into a compile error. -
Vector2is areadonly struct, so reading its members creates no defensive copies, and operators takein Vector2so nothing is copied on the way in either. -
ToString()is overridden. Without it the last two lines would print the type name rather than the values, because that is whatValueType.ToString()does. -
SpeedisVelocity.Length. Bouncing only inverts a sign, so a particle’s speed is constant and the average comes back at the middle of the 10..30 range. -
Two average-speed lines, on purpose. The acceptance criteria ask for the final average speed, which is a property of the particles at the end of the run, so the solution computes it that way. The run mean, total distance divided by particle count and elapsed time, is a different quantity that happens to agree here. Print both once and the agreement becomes a free assertion on
Step: if the bounce did anything other than flip a sign (clamping the position and rescaling, say, or resolving both axes twice) the two numbers would separate. -
The hot loop is value-type math only: no boxing, no closures, no LINQ, and the lists are pre-sized.
-
The pitfall demo is the forgotten write-back, and that is deliberate. The version people usually reach for, mutating the
foreachvariable, does not compile: the compiler rejects it outright. So does mutating through the indexer. The one shape C# cannot catch is copy, modify the copy, and never store it back, because each of those three lines is legal on its own. That is the bug you will actually meet.