Skip to main content

Overview

Snake is the proof-of-concept game for Game Grammar. It demonstrates how discrete events — movements, collisions, growth, death — can be emitted as a structured sequence that a transformer learns to predict. The implementation runs on a 10×10 grid (configurable) and generates events on every step() call. The game never touches the model directly. Instead, events are tokenized and fed to the transformer, which learns the grammar of Snake gameplay purely from next-token prediction.
Snake tests movement physics, conditional growth, and self-collision — the core primitives needed to validate that event-driven game-agnosticism works.

Game Rules

The Snake game follows standard rules:
  1. Movement: Snake moves one cell per tick in the current direction
  2. Input: Player can change direction, but cannot reverse (e.g., UP→DOWN is ignored)
  3. Food: Eating food increments score, grows the snake by 1, and spawns new food
  4. Death: Game ends when the snake hits a wall or collides with its own body
  5. Legal actions: Any direction except the opposite of current direction

SnakeState

The game state is represented as an immutable dataclass defined in core.py:
@dataclass
class SnakeState:
    head: tuple[int, int]        # Current head position
    body: list[tuple[int, int]]  # All segments including head
    direction: Action            # Current facing direction
    food: tuple[int, int]        # Food position
    score: int                   # Current score
    alive: bool                  # Whether snake is alive
    tick: int                    # Game tick counter
The Action enum defines four directions:
class Action(Enum):
    UP = "UP"
    DOWN = "DOWN"
    LEFT = "LEFT"
    RIGHT = "RIGHT"

SnakeGame Class

The SnakeGame class implements the minimal game protocol required for event generation:

Initialization

class SnakeGame:
    def __init__(self, width=10, height=10, seed=None):
        self.width = width
        self.height = height
        self.rng = _random.Random(seed)
        self._state: SnakeState | None = None

Core Methods

reset() -> SnakeState

Initializes a new game:
  • Spawns snake at center of grid
  • Randomly selects starting direction
  • Places food in empty cell
  • Returns initial state
def reset(self) -> SnakeState:
    cx, cy = self.width // 2, self.height // 2
    direction = self.rng.choice(list(Action))
    head = (cx, cy)
    body = [head]
    food = self._spawn_food(body)
    self._state = SnakeState(
        head=head, body=body, direction=direction,
        food=food, score=0, alive=True, tick=0,
    )
    return self._state

step(action: Action) -> (SnakeState, list[Event], bool)

Executes one game tick and returns:
  1. New state
  2. List of events emitted
  3. done flag (True if game ended)
Returns valid actions for current state (all directions except opposite):
def legal_actions(self, state: SnakeState) -> list[Action]:
    return [a for a in Action if a != OPPOSITE.get(state.direction)]

Event Emission

On every step() call, the game emits a sequence of events with salience levels. Events are defined in core.py:
@dataclass(frozen=True)
class Event:
    type: str              # Event type (MOVE, EAT, DIE_WALL, etc.)
    entity: str            # Entity that triggered it ("player", "food")
    payload: dict          # Event-specific data
    tick: int              # Game tick when it occurred
    salience: Salience     # Importance level (0-4)

class Salience(IntEnum):
    TICK = 0               # Low-priority tick marker
    MOVEMENT = 1           # Position changes
    COLLISION = 2          # Collision events
    RULE_EFFECT = 3        # Growth, scoring, spawning
    PHASE = 4              # High-level state transitions

Event Types

The Snake game emits these event types:
Event TypeEntityTriggerSaliencePayload
INPUT_U/D/L/RplayerPlayer inputMOVEMENT{"action": "UP"}
MOVEplayerSuccessful moveMOVEMENT{"pos": (x, y)}
EATplayerHead lands on foodRULE_EFFECT{"pos": (x, y)}
GROWplayerAfter eatingRULE_EFFECT{"length": int}
FOOD_SPAWNfoodAfter eatingRULE_EFFECT{"pos": (x, y)}
SCOREplayerAfter eatingRULE_EFFECT{"score": int}
DIE_WALLplayerHit boundaryCOLLISION{"pos": (x, y)}
DIE_SELFplayerSelf-collisionCOLLISION{"pos": (x, y)}

Physics Implementation

The step() method implements game physics in this order:

1. Direction Resolution

# Ignore reversals (can't go UP if moving DOWN)
if action != OPPOSITE.get(s.direction):
    direction = action
else:
    direction = s.direction

2. Input Event

events.append(Event(
    type=f"INPUT_{action.value[0]}",
    entity="player",
    payload={"action": action.value},
    tick=tick,
    salience=Salience.MOVEMENT,
))

3. Compute New Head Position

dx, dy = DIR_DELTA[direction]
nx, ny = s.head[0] + dx, s.head[1] + dy

4. Wall Collision Check

if nx < 0 or nx >= self.width or ny < 0 or ny >= self.height:
    events.append(Event(
        type="DIE_WALL", entity="player",
        payload={"pos": (nx, ny)},
        tick=tick, salience=Salience.COLLISION,
    ))
    # Set alive=False, return with done=True

5. Food Collision Check

ate = new_head == s.food
if ate:
    events.append(Event(
        type="EAT", entity="player",
        payload={"pos": new_head},
        tick=tick, salience=Salience.RULE_EFFECT,
    ))

6. Body Update

new_body = [new_head] + list(s.body)
if not ate:
    new_body.pop()  # Retract tail unless we ate

7. Self-Collision Check

if new_head in new_body[1:]:  # Check against body excluding head
    events.append(Event(
        type="DIE_SELF", entity="player",
        payload={"pos": new_head},
        tick=tick, salience=Salience.COLLISION,
    ))

8. Successful Move Event

events.append(Event(
    type="MOVE", entity="player",
    payload={"pos": new_head},
    tick=tick, salience=Salience.MOVEMENT,
))

9. Growth and Food Respawn

if ate:
    new_score += 1
    events.append(Event(
        type="GROW", entity="player",
        payload={"length": len(new_body)},
        tick=tick, salience=Salience.RULE_EFFECT,
    ))
    new_food = self._spawn_food(new_body)
    events.append(Event(
        type="FOOD_SPAWN", entity="food",
        payload={"pos": new_food},
        tick=tick, salience=Salience.RULE_EFFECT,
    ))
    events.append(Event(
        type="SCORE", entity="player",
        payload={"score": new_score},
        tick=tick, salience=Salience.RULE_EFFECT,
    ))
The order matters: the model learns that EAT → GROW → FOOD_SPAWN is a mandatory sequence. This is conditional rule learning — events follow causal chains that the transformer predicts.

Example Event Sequence

A typical gameplay sequence looks like this:
BOS SNAP PLAYER X5 Y5 DIR_R LEN1 FOOD X8 Y6 SCORE V0
  TICK INPUT_R MOVE X6 Y5
  TICK INPUT_R MOVE X7 Y5
  TICK INPUT_D MOVE X7 Y6
  TICK INPUT_R MOVE X8 Y6 EAT GROW LEN2 FOOD_SPAWN X3 Y2 SCORE V1
  TICK INPUT_U MOVE X8 Y5
  ...
Each TICK bundles:
  1. Player input
  2. Movement or death
  3. Optional rule effects (growth, scoring, spawning)
The transformer learns from this sequence that:
  • MOVE is always adjacent to previous position
  • EAT at food position triggers GROW and FOOD_SPAWN
  • DIE_WALL or DIE_SELF ends the episode
  • Score increments by 1 when eating

Training Results

With 31K parameters trained on 200 episodes:
MetricResult
Physical validity95% — moves are adjacent cells, positions in bounds
Rule validity100% — EAT→GROW+FOOD_SPAWN, DIE→EOS
Loss4.47 → 0.25 (random baseline: ln(74) ≈ 4.3)
The model learned:
  • One-cell-per-tick movement
  • Eating triggers growth and food respawn
  • Death ends the episode
  • Positions stay within bounds
The model learned these rules from event sequences alone, without explicit rule specifications. This validates the core premise: game grammar emerges from next-token prediction.

Agents

Three agents generate training data, defined in agents.py:

RandomAgent (40%)

Randomly selects from legal actions:
class RandomAgent:
    def act(self, state: SnakeState, legal: list[Action]) -> Action:
        return self.rng.choice(legal)

GreedyAgent (40%)

Minimizes Manhattan distance to food:
class GreedyAgent:
    def act(self, state: SnakeState, legal: list[Action]) -> Action:
        fx, fy = state.food
        best_actions = [a for a in legal 
                        if manhattan(next_pos(a), food) == min_dist]
        return self.rng.choice(best_actions)

WallFollowerAgent (20%)

Prefers moves that keep a wall adjacent:
class WallFollowerAgent:
    def act(self, state: SnakeState, legal: list[Action]) -> Action:
        wall_moves = [a for a in legal if near_wall(next_pos(a))]
        choices = wall_moves if wall_moves else legal
        return self.rng.choice(choices)
This mix creates diverse gameplay patterns that help the model learn different archetypes.

Next Steps

  • Tier 2 Evaluation: Test conditional rule emergence (does the model predict GROW after EAT?)
  • Tier 3 Evaluation: Detect archetype patterns in generated sequences
  • Visualization: Render sampled sequences as playable replays
  • Ouroboros: Let the model play against itself and train on self-generated data

Build docs developers (and LLMs) love