Core game logic for a single run of Snake. It manages the arena, snake, apple spawner, score, ticks, and stepping the simulation one logical tick at a time, plus restarting and turn input.
namespace Coilgarden;
/// <summary>
/// One run, as pure rules: the arena, the snake, the apple and the score, with a single
/// <see cref="Step"/> that advances all of them by one logical tick.
/// <para>
/// This is the whole game as far as correctness is concerned. It has no engine dependency
/// at all, which is what lets the rules be exercised thousands of times in a headless test
/// run, and it is the seam that stops the feel layer from ever becoming load-bearing:
/// <see cref="GameSession"/> decides <em>when</em> to step, and the views decide how it
/// looks, but neither can change what happens.
/// </para>
/// </summary>
public sealed class SnakeGame
{
private readonly AppleSpawner spawner;
public SnakeGame( GameRules rules, int seed )
{
// Clamped here rather than trusted, so there is no way to construct a run whose
// rules cannot be played - whatever the caller read them from.
Rules = rules.Clamped();
Arena = new Arena( Rules.Width, Rules.Height );
Snake = new Snake( Arena, Rules.StartLength, Rules.StartDirection, Rules.MaxBufferedTurns );
spawner = new AppleSpawner( Arena, seed );
Restart();
}
/// <summary>Convenience for the common case and for tests that do not vary the rules.</summary>
public SnakeGame( int seed ) : this( GameRules.Default, seed )
{
}
/// <summary>The rules this run is being played under. Fixed for its lifetime.</summary>
public GameRules Rules { get; }
public Arena Arena { get; }
public Snake Snake { get; }
/// <summary>Where the apple is, or null when the arena had no room for one.</summary>
public GridPos? Apple { get; private set; }
public int Score { get; private set; }
public int ApplesEaten { get; private set; }
/// <summary>Ticks survived this run. The difficulty curve and the HUD both read it.</summary>
public int Ticks { get; private set; }
/// <summary>Set once the run has ended, and the reason why.</summary>
public StepOutcome? EndedBy { get; private set; }
public bool IsOver => EndedBy.HasValue;
/// <summary>
/// Returns every field to its starting value. Written as a single method that touches
/// all of them so a field added later has one obvious place to be reset, rather than
/// being forgotten in one of several branches.
/// <para>
/// The apple RNG is deliberately <em>not</em> reseeded: consecutive runs in one sitting
/// should not open with the same apple in the same place.
/// </para>
/// </summary>
public void Restart()
{
Snake.Reset( Rules.StartLength, Rules.StartDirection );
Score = 0;
ApplesEaten = 0;
Ticks = 0;
EndedBy = null;
Apple = spawner.Pick( Snake );
}
/// <summary>Records a turn for an upcoming tick. Ignored once the run is over.</summary>
public bool TryTurn( Direction direction ) => !IsOver && Snake.TryTurn( direction );
/// <summary>
/// Would heading this way on the next tick keep the run alive?
/// <para>
/// Answers from the same rule <see cref="Step"/> resolves with rather than a second copy
/// of it, so the two cannot drift apart. Used by the test and self-test bots to play the
/// game through the public API, and it is the query a hint or accessibility cue would
/// be built on.
/// </para>
/// <para>
/// Note this asks about a <em>heading</em>, not about whether the turn is legal -
/// <see cref="TryTurn"/> answers that.
/// </para>
/// </summary>
public bool WouldSurvive( Direction direction )
{
if ( IsOver ) return false;
var target = Snake.Head + direction.Delta();
if ( !Arena.Contains( target ) ) return false;
var growing = Apple.HasValue && Apple.Value == target;
return !Snake.CollidesWithBody( target, growing );
}
/// <summary>
/// Advances one logical tick. Does nothing once the run has ended, so a session that
/// keeps ticking through a death animation cannot accidentally step past it.
/// </summary>
public StepResult Step()
{
if ( IsOver )
{
return new StepResult( EndedBy.Value, Snake.Head, Snake.Head, Snake.Direction, false, 0 );
}
var result = Snake.Step( Apple );
Ticks++;
if ( result.Grew )
{
ApplesEaten++;
var points = ScoreRules.ApplePoints( ApplesEaten, Rules );
Score += points;
result = result with { ScoreGained = points };
// A null here means the snake now covers every cell, which the step already
// reported as FilledArena. Leaving Apple null is correct: there is nowhere to
// put one, and the run is over anyway.
Apple = spawner.Pick( Snake );
}
if ( result.IsTerminal )
{
EndedBy = result.Outcome;
}
return result;
}
}