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.
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--;
}
}