Snake/Snake.cs

Snake game logic for a single snake. Manages the snake body as a ring buffer, buffered turn input, collision checks with walls and self, growth when eating an apple, and exposes helpers like Head, Tail, CopyTo and Reset.

Native Interop
namespace Coilgarden;

/// <summary>
/// The snake's logical body and heading. Pure rules: no engine types, no time, no input
/// devices. Everything here is exact integer maths, which is what lets the whole of the
/// gameplay be tested headlessly and guarantees the presentation layer cannot alter it.
/// </summary>
public sealed class Snake
{
	private readonly Arena arena;

	/// <summary>
	/// The body as a ring buffer, head first. A ring buffer rather than a list because both
	/// operations a tick performs - add a head, drop a tail - are then O(1) and allocate
	/// nothing, however long the snake gets.
	/// </summary>
	private readonly GridPos[] cells;

	/// <summary>
	/// One flag per arena cell, so "am I standing here" is a single array read rather than
	/// a walk of up to several hundred segments. A bool is sufficient because a body with
	/// two segments in one cell is precisely the self-collision this class refuses to let
	/// happen.
	/// </summary>
	private readonly bool[] occupied;

	private readonly Direction[] buffer;

	private int headIndex;
	private int bufferCount;

	public Snake( Arena arena, int startLength, Direction startDirection, int maxBufferedTurns )
	{
		this.arena = arena ?? throw new System.ArgumentNullException( nameof( arena ) );

		cells = new GridPos[arena.CellCount];
		occupied = new bool[arena.CellCount];
		buffer = new Direction[System.Math.Max( 1, maxBufferedTurns )];

		Reset( startLength, startDirection );
	}

	/// <summary>Number of segments, head included.</summary>
	public int Length { get; private set; }

	/// <summary>The heading the last tick actually travelled. Reversal is judged against this.</summary>
	public Direction Direction { get; private set; }

	public GridPos Head => cells[headIndex];

	public GridPos Tail => this[Length - 1];

	/// <summary>Segment by distance from the head; 0 is the head itself.</summary>
	public GridPos this[int index]
	{
		get
		{
			if ( index < 0 || index >= Length ) throw new System.ArgumentOutOfRangeException( nameof( index ) );

			return cells[(headIndex + index) % cells.Length];
		}
	}

	/// <summary>How many turns are waiting to be taken. Exposed for tests and debug display.</summary>
	public int BufferedTurns => bufferCount;

	/// <summary>
	/// The heading the next <see cref="Step"/> will actually travel: the first buffered turn
	/// if one is waiting, otherwise the current heading.
	/// <para>
	/// Read-only, and it changes nothing. It exists because "which way am I about to go" is a
	/// different question from "which way am I going", and both the survivability query and
	/// anything that wants to lean into a turn before it happens need the former.
	/// </para>
	/// </summary>
	public Direction NextDirection => bufferCount > 0 ? buffer[0] : Direction;

	public bool Occupies( GridPos cell ) => arena.Contains( cell ) && occupied[arena.IndexOf( cell )];

	/// <summary>
	/// Would moving into <paramref name="target"/> hit this snake's own body?
	/// <para>
	/// The single authority on the rule, used both by <see cref="Step"/> to resolve a move
	/// and by <see cref="SnakeGame.WouldSurvive"/> to answer the same question without
	/// taking one. It was previously re-derived at three call sites, which is a defect
	/// waiting to happen: three copies of a subtle rule that nothing forces to agree.
	/// </para>
	/// <para>
	/// The subtlety is <paramref name="growing"/>. The tail vacates its cell on the same
	/// tick the head arrives, so moving into it is legal - unless the snake is growing, in
	/// which case the tail stays put and the cell is genuinely occupied. This one condition
	/// is the whole of the classic off-by-one, and getting it wrong makes a snake chasing
	/// its own tail die for no visible reason.
	/// </para>
	/// </summary>
	public bool CollidesWithBody( GridPos target, bool growing )
	{
		if ( !arena.Contains( target ) ) return false;
		if ( !occupied[arena.IndexOf( target )] ) return false;

		var vacating = !growing && Length > 1 ? Tail : (GridPos?)null;

		return target != vacating;
	}

	/// <summary>
	/// Puts the snake back to its starting state without allocating, so a restart cannot
	/// leave a stale flag behind and cannot cause a hitch.
	/// </summary>
	public void Reset( int startLength, Direction startDirection )
	{
		System.Array.Clear( occupied, 0, occupied.Length );

		Direction = startDirection;
		bufferCount = 0;
		headIndex = 0;
		Length = 0;

		var head = arena.Centre;
		var behind = startDirection.Opposite().Delta();

		// A start length that would not fit is clamped rather than throwing: the caller is
		// a config value, and a game that refuses to start is worse than one that starts
		// slightly shorter than asked. Same arithmetic GameRules clamps with, so a run built
		// from clamped rules is never truncated here.
		var wanted = Math.Clamp( startLength, 1, arena.MaxStartLength( startDirection ) );

		for ( var i = 0; i < wanted; i++ )
		{
			var cell = new GridPos( head.X + behind.X * i, head.Y + behind.Y * i );
			if ( !arena.Contains( cell ) ) break;

			// Appending at the tail, which for an empty body means the head as well.
			cells[i] = cell;
			occupied[arena.IndexOf( cell )] = true;
			Length++;
		}
	}

	/// <summary>
	/// Records a turn to be taken on an upcoming tick.
	/// <para>
	/// Buffering is what makes fast play feel fair: a turn pressed between ticks is kept
	/// rather than dropped. The rule that matters is what a new turn is judged against -
	/// the <em>last</em> heading the snake will be travelling once the buffer drains, not
	/// the one it is travelling now. Judging against the current heading is the classic
	/// bug: two quick presses could then queue a right turn and a reversal, and the snake
	/// would fold into itself through no fault of the player.
	/// </para>
	/// </summary>
	/// <returns>False when the turn was refused, either as a reversal or a repeat, or
	/// because the buffer is full.</returns>
	public bool TryTurn( Direction direction )
	{
		var reference = bufferCount > 0 ? buffer[bufferCount - 1] : Direction;

		// A repeat is dropped rather than queued, otherwise holding a key fills the buffer
		// with turns that do nothing and crowds out the one the player actually wants next.
		if ( direction == reference ) return false;
		if ( reference.IsOpposite( direction ) ) return false;
		if ( bufferCount >= buffer.Length ) return false;

		buffer[bufferCount++] = direction;
		return true;
	}

	/// <summary>
	/// Advances one tick. <paramref name="apple"/> is the cell the apple occupies, or null
	/// when there is none.
	/// </summary>
	public StepResult Step( GridPos? apple )
	{
		TakeBufferedTurn();

		var from = Head;
		var to = from + Direction.Delta();

		if ( !arena.Contains( to ) )
		{
			return new StepResult( StepOutcome.HitWall, from, to, Direction, false, 0 );
		}

		var willGrow = apple.HasValue && apple.Value == to;

		if ( CollidesWithBody( to, willGrow ) )
		{
			return new StepResult( StepOutcome.HitSelf, from, to, Direction, false, 0 );
		}

		if ( !willGrow )
		{
			DropTail();
		}

		PushHead( to );

		if ( !willGrow )
		{
			return new StepResult( StepOutcome.Moved, from, to, Direction, false, 0 );
		}

		var outcome = Length >= arena.CellCount ? StepOutcome.FilledArena : StepOutcome.Ate;

		// Scoring is not this class's business; SnakeGame fills the points in. The snake
		// knows about geometry and nothing else.
		return new StepResult( outcome, from, to, Direction, true, 0 );
	}

	/// <summary>
	/// Copies the body into a caller-owned buffer, head first, and reports how many cells
	/// were written. Lets the renderer read the whole body each frame without this class
	/// handing out its internals or allocating an enumerator.
	/// </summary>
	public int CopyTo( GridPos[] destination )
	{
		if ( destination is null ) return 0;

		var count = System.Math.Min( Length, destination.Length );

		for ( var i = 0; i < count; i++ )
		{
			destination[i] = this[i];
		}

		return count;
	}

	private void TakeBufferedTurn()
	{
		if ( bufferCount == 0 ) return;

		Direction = buffer[0];

		for ( var i = 1; i < bufferCount; i++ )
		{
			buffer[i - 1] = buffer[i];
		}

		bufferCount--;
	}

	private void PushHead( GridPos cell )
	{
		headIndex = (headIndex - 1 + cells.Length) % cells.Length;
		cells[headIndex] = cell;
		occupied[arena.IndexOf( cell )] = true;
		Length++;
	}

	private void DropTail()
	{
		occupied[arena.IndexOf( Tail )] = false;
		Length--;
	}
}