Snake: Gridbreaker - What changed when player occupied twenty cells!

Notes from more than 1,600 commits on Snake: Gridbreaker by Arkadiusz Włodarczyk, solo developer at GEM Games.

By Arkadiusz WłodarczykGuest|Last updated: August 8, 2026|25 minutes read
community spotlightindie
Snake: Gridbreaker - What changed when player occupied twenty cells!
A position is one of the first concepts we learn when programming a game.
  • The player is at (10, 12).
  • An enemy is at (4, 8).
  • A portal moves the player from one position to another.
Classic Snake already breaks this simple model. The head has a position, but so does every segment behind it. Together, those positions form the player. I understood that before starting Snake: Gridbreaker. What I missed was how many systems would eventually depend on the exact meaning of the word “player.”
  • Sometimes the player meant the head. Collision checks often cared about the next head position.
  • Sometimes it meant the complete body. Rendering, self-collision, area transitions and several build mechanics needed every segment.
  • In a few cases, the player was closer to a process than to an object. The head started an action, while the tail completed it several movement ticks later.
That distinction became important after the prototype grew into an action roguelite with enemies, hazards, classes, shops, mutations, secret rooms and bosses. The initial version proved that the idea could work. The following 1,600 commits taught me what the original prototype had left unanswered.

The array was fine at first

My first representation of the snake body was an array ordered from head to tail. Movement looked roughly like this:
func move_snake(target: Vector2i, grow: bool) -> void: if not grow: snake_body.pop_back() snake_body.insert(0, target)
The code is easy to read:
  1. Remove the final element.
  2. Insert a new head at index zero.
For a short prototype, I would probably make the same choice again. The problem appeared gradually. insert(0, value) has to move the existing elements in the array. Other systems also used checks such as:
if position in snake_body: # The cell is occupied.
Those operations became part of normal movement. They also became more common as the body gained more responsibilities. The snake was no longer only a visual trail. Its length affected builds. Hazards could interact with specific segments. Systems asked:
  • whether a position belonged to the body
  • where the tail was
  • how the body should be restored after loading
I eventually replaced the direct array with circular deque storage. The current structure keeps a backing array, an index pointing to the head and the current length:
var _snake_segments: Array[Vector2i] = [] var _snake_head_index: int = 0 var _snake_length: int = 0 var _snake_occupancy: Dictionary[Vector2i, int] = {}
A normal movement step changes the head index instead of shifting the whole body:
func move_snake_to(target: Vector2i, grow: bool) -> void: if not grow: var old_tail := get_tail_position() _decrement_snake_occupancy(old_tail) _snake_length -= 1 _ensure_snake_capacity(_snake_length + 1) _snake_head_index = _wrap_snake_index(_snake_head_index - 1) _snake_segments[_snake_head_index] = target _snake_length += 1 _increment_snake_occupancy(target) _mark_snake_body_snapshot_dirty()
The occupancy dictionary answers membership questions without scanning the body. I still needed compatibility with systems that expected an ordered array. Rewriting all of them at once would have made the change much riskier, so get_snake_body() creates an ordered snapshot only when something requests it after the body has changed:
func get_snake_body() -> Array[Vector2i]: if _snake_body_snapshot_dirty: _snake_body_snapshot.clear() for i in range(_snake_length): var index := _wrap_snake_index(_snake_head_index + i) _snake_body_snapshot.append(_snake_segments[index]) _snake_body_snapshot_dirty = false return _snake_body_snapshot
This gave the storage a faster movement path while preserving the old interface during migration. I would not start every Snake prototype with this structure. For a snake containing five segments and a game that is still proving its basic idea, the simple array is easier to inspect and modify. The deque became worthwhile after movement became frequent, the body grew longer and many systems depended on it. The useful question was not whether a circular buffer was theoretically better. I needed to know whether the current representation was creating work in the hottest part of the game. By then, it was.

Rendering faster did not solve the first problem

Performance problems appeared as the board gained more content. My first instinct was to improve the renderer. I looked at how cells and body lines were drawn, how many visual nodes existed and how often geometry was rebuilt. Some of those areas did need work. Per-cell nodes were later replaced with MultiMesh renderers, trails were pooled and several visual systems became incremental. The earlier mistake was broader. The game was asking the renderer to update objects that had not changed. The first architecture used one main notification:
signal board_changed(board)
After movement, the game emitted it:
board.move_snake_to(target_position, grow) emit_signal("board_changed", board)
The view received a new board state and performed a broad synchronization. That is a sensible design for a prototype. One signal means fewer update paths and fewer chances for the visual state to fall behind the logical state. It becomes expensive when the most common event is a snake moving by one cell. During a clean movement step, the walls remain where they were. Food usually stays in place. Hazards, items and the exit do not need to be reconstructed. The body loses a tail cell and gains a head cell. I introduced a separate signal for that path:
signal board_changed(board) signal snake_changed(body)
The movement code could then decide what had actually happened:
if board_visual_changed: emit_signal("board_changed", board) elif snake_moved: emit_signal("snake_changed", board.get_snake_body())
This was the start rather than the final design. As the project continued, the broad notification split into more specific signals for terrain, pickups, hazards, enemies and interactions. I do not have a reliable before-and-after FPS chart for the original change. The game was evolving quickly, and several rendering changes happened around the same period. Presenting one clean percentage would imply a controlled benchmark that I did not perform. What I do have is a set of testable invariants:
  • A clean movement should not emit board_changed.
  • It should emit snake_changed once.
  • A non-growing step should logically touch at most the old tail cell and the new head cell.
  • A growing step only needs to add the new head.
Those guarantees are less exciting than an impressive FPS number, but they protect the architecture from slowly returning to full-board work. Here is the order in which the optimization actually developed:
  1. Broad board refresh
  2. Separate clean snake movement
  3. Update individual board layers
  4. Replace expensive per-cell presentation
I originally started near the bottom of this sequence. Reducing the amount of work at the top produced a more useful change.

A portal became a multi-step operation

Secret rooms introduced a problem that I had not encountered with normal player characters. Consider a snake moving into a breach:
Source board Destination board [T][B][B][H] -> | BREACH | [ entry cells ]
H is the head, B is a body segment and T is the tail. The head reaches the breach first. Several movement ticks may pass before the tail arrives. My first mental model was an ordinary teleport. Commit the destination board, place the player on it and hide the swap behind an animation. That model produced an obvious visual problem. The breach could close while most of the body was still meant to be passing through it. Moving the complete body instantly also looked wrong. The head had entered naturally, but the tail vanished from the previous board. The transition eventually became a release process. After the board handoff, only part of the snake was shown as fully emerged:
Source board Destination board [T][B] -> | BREACH | -> [H][B] still open
Each movement revealed another segment. The game tracked whether a portal release was active, how many solid segments had emerged and the emergence direction. A simplified version of the state looks like this:
portal_release_active = true portal_release_solid_count = 1 portal_release_direction = exit_direction
Then movement advances the release:
func advance_portal_release() -> void: portal_release_solid_count += 1 if portal_release_solid_count >= get_snake_length(): portal_release_active = false seal_breach_visual()
The final state is ordinary gameplay again:
Source board Destination board | SEALED | [H][B][B][T]
The actual implementation also had to survive saving, loading and transitions back to the main board. Collision state could be authoritative before the visual aperture closed, so logical safety and visual continuity were handled separately. The terminology helped here:
  • “Hide the board swap” described a presentation trick.
  • “Portal handoff” described a state change.
  • “Portal release” described the body emerging over time.
Once those operations had separate names, it became easier to decide which system owned each part. This design is still more complicated than an instant teleport. Instant teleportation would be a valid solution if I wanted a fast flash and were willing to accept the complete body moving at once. I kept the gradual emergence because preserving the physical continuity of the snake mattered to the feel of this particular transition. The state machine follows from that design decision. It is not a requirement for every segmented character.

Four directions still needed an input resolver

The first controller implementation checked movement actions one after another:
if Input.is_action_just_pressed("move_up"): queue_direction(Vector2i.UP) if Input.is_action_just_pressed("move_down"): queue_direction(Vector2i.DOWN) if Input.is_action_just_pressed("move_left"): queue_direction(Vector2i.LEFT) if Input.is_action_just_pressed("move_right"): queue_direction(Vector2i.RIGHT)
This worked with a keyboard. It became less predictable when several controller sources were active. A player could hold Up on the D-pad and move the analog stick sideways. Diagonal stick input could also make more than one action cross its threshold. Sequential checks allowed several directions to compete during the same input update. Snake is unforgiving here. A direction selected for a fraction of a second may place the head into the body or into a wall. The input system now resolves all controller data into one vector before adding anything to the movement queue. The current implementation gives the D-pad priority while any D-pad button is held. Analog input uses different thresholds for activation and release:
const STICK_ACTIVATION_THRESHOLD := 0.50 const STICK_RELEASE_THRESHOLD := 0.35 const STICK_AXIS_DOMINANCE_RATIO := 1.25 const STICK_AXIS_NEAR_EQUAL_GAP := 0.05
The gap between 0.50 and 0.35 creates hysteresis. Hysteresis means that the boundaries for entering a state and leaving it are different. A stick must move far enough to select a direction. Once selected, a small drop in strength does not immediately release it. Without that gap, noisy input near one threshold can repeatedly switch between active and inactive. The resolver is roughly:
func resolve_direction() -> Vector2i: if any_dpad_button_is_held(): return resolve_dpad() var stick := read_stick_vector() if stick_is_below_release_threshold(stick): return Vector2i.ZERO if stick_is_near_diagonal(stick): return previous_resolved_direction return dominant_axis_direction(stick)
The exact threshold values came from this game and its movement feel. They are not universal controller constants. The important change was resolving one intended direction before touching the queue. Input buffering solved another issue. At high speed, a player may press Up and then Left between two movement ticks. A short queue preserves both turns, while validation rejects reversals and duplicate inputs. Controller work later spread into menus, focus restoration, overlays, disconnected devices and versioned binding migrations. Those are separate problems, although they share one rule: the game needs an explicit owner for the current input state. A keyboard-only prototype does not need all of this. Even a digital-only controller setup can stay much simpler. The resolver became necessary when D-pad input, analog input and a strict four-direction movement model had to coexist.

Multi-frame effects need an escape route

Portals are one example of an operation that lasts across several frames. Animations, pauses and temporary effects create the same type of risk. A code review found a subtle problem in a death effect. The effect was shrinking a sprite by interpolating from its current scale every frame:
sprite.scale = sprite.scale.lerp(end_scale, progress)
The current scale already contained the result of the previous frame. Reusing it as the starting point caused the values to compound. The final size depended on how often the update ran. The correction stored the initial scale once:
sprite.scale = initial_scale * lerpf(1.0, END_SCALE, progress)
The same sequence temporarily changed the global time scale. Under normal completion, it restored the previous value. There was still an interruption path. If the scene was removed while the sequence was waiting for an animation, the cleanup code on the normal completion path might never execute. The next scene could inherit a time scale of 0 or 0.25. The fix stored the previous value on the sequence and restored it from _exit_tree() as well:
func _exit_tree() -> void: Engine.time_scale = previous_time_scale
This changed the way I review multi-frame code. I look at the normal timeline first, then I interrupt it mentally:
  • What if the player quits?
  • What if the scene changes?
  • What if an awaited object is deleted?
  • What if a save is loaded in the middle?
A system that has a start and an expected finish also needs a path for cases where it never reaches that finish.

Giving AI instructions created another source of stale state

AI coding tools have helped with implementation, tests, reviews and refactors throughout the project. They work better when the repository explains itself. I added files describing architecture, conventions, verification commands and project-specific constraints. AGENTS.md, for example, now contains rules about commits, type annotations, deployment and the need to expose architectural compromises instead of hiding them. While reviewing the repository for this article, I found a useful failure in that approach. An older guidance file still said that the project had no test framework. It also described game.gd as the owner of the authoritative grid and documented the snake as a normal array. Those statements were accurate for an earlier version. They had become instructions pointing toward an architecture that the project had already replaced. An agent following them carefully could make a worse change than an agent receiving no architecture description at all. The wrong guidance would make the mistake look intentional. I have spent plenty of time explaining that documentation helps AI understand a codebase. The missing half of that advice is maintenance. Agent instructions behave like code-adjacent state:
  • They can become stale.
  • They can disagree with the implementation.
  • They deserve review after a large refactor.
This is now part of the same checklist I use for runtime systems. When ownership changes, I search for every place where the old ownership model may still exist. That includes tests, comments, plans and AI guidance files. The repository should describe the current project rather than preserve a confident explanation of its past.

Teaching without stopping the game

The technical problems were only half of the difficulty. Snake: Gridbreaker also moves quickly, and that changes how much information the player can reasonably process. One of the hardest design questions was how to present objectives without asking the player to stop and read. A level still needs to communicate what matters, but a long explanation competes directly with movement, combat and the risk of colliding with the snake’s own body. Removing a traditional tutorial did not remove the need to teach. It moved teaching into progression, animation, level structure and feedback. The first version of a system often feels clear to its developer because the developer already knows why it exists. A new player sees several unknowns at once: what the objective means, which objects are dangerous, what an enemy is preparing to do and which part of the snake is responsible for an effect. This became more difficult as the number of possible builds increased. Each class has its own mastery progression. Runs can also introduce abilities, mutations, items and systems that change how movement, the head, the tail, shields, power-ups or the economy behave. Showing all of that immediately would make the early game broader, but not necessarily deeper. The player would be learning terminology instead of learning through decisions. The Anomaly System became one answer to that problem. It acts as metaprogression, but it also controls the order in which complexity enters the game. New abilities and mechanics appear gradually, after the player has already formed a basic model of movement and survival. That changed how I think about unlock systems. They are not only rewards. They can also be a form of information budgeting. A new mechanic has a cost beyond implementation and balance. It consumes part of the player’s attention. Introducing several mechanics together may make each one less visible, even if every individual mechanic is explained correctly. The same principle applies inside a level. Enemy attacks need to be understandable while the player is moving. Telegraphing cannot rely only on a tooltip or an icon. The shape, timing, animation and sound of an attack have to communicate enough information before the attack resolves. At low speed, a slightly unclear effect may only cause hesitation. At high speed, it can cause an immediate collision. This means readability is not separate from difficulty. It is part of difficulty. I want the game to be challenging, but I do not want every death to feel arbitrary. The useful question after a failed run is not only whether the player lost. It is whether the player can form a better plan for the next attempt. A readable failure can still be severe:
  • The snake may move too quickly.
  • A wall may remove the last safe route.
  • An enemy may force the head toward the body.
  • A build may become powerful but difficult to control.
The game does not need to protect the player from those outcomes, but it should make the chain of cause and effect visible enough to learn from. That balancing act is still ongoing. If every threat waits too long, the game loses pressure. If every signal is subtle, the player may understand the rules only after dying several times for reasons that feel unrelated to their decisions. I am aiming for a state in which the player can say, “I see why that happened,” even when the correct response would have been difficult. Variation created another design problem. A run should not feel different merely because rooms and rewards appear in another order. I wanted some discoveries to change the player’s priorities. Secret rooms and risk rooms help with that because entering them is not only a content change. It is a decision about whether the current build can afford another danger. That kind of variation is more useful to me than randomness by itself. A secret room can offer an opportunity that changes the direction of a run. A risk room can turn a strong build into a dangerous gamble. The important part is that the player chooses to engage with the possibility. The broader lesson is that fast games still need space for thought, but that space does not always have to be a pause screen. It can appear in:
  • the order of unlocks
  • the shape of an arena
  • a short warning animation
  • the decision to enter an optional room
  • the moment after death, when the result is clear enough to suggest a different choice next time
The goal is not to remove complexity. The goal is to schedule it, signal it and connect it to decisions the player can understand.

Where I would keep the simpler version

Several solutions in this article would have been unnecessary during the first weeks of development. I would still:
  • begin a small Snake prototype with an array
  • consider one broad board update signal before creating six specialized paths
  • use direct digital input checks if the game only supported a keyboard
  • teleport the full body immediately if preserving movement through the portal did not matter visually
The later systems cost more to understand and maintain. They earn that cost by solving specific problems:
  • Movement repeatedly shifts or scans a growing body.
  • Broad notifications rebuild unchanged layers.
  • The body must visibly cross a boundary over time.
  • Several controller sources can produce conflicting directions.
  • Long-running effects can be interrupted.
This matters because a postmortem can make every final decision look inevitable. It was not. The original implementations were useful because they allowed the game to exist quickly. I replaced them after the cost became visible in the profiler, in tests or through bugs I could reproduce. Starting with the final architecture would have delayed the prototype and required me to predict problems before I understood the game.

The questions left by those bugs

My current checklist is fairly small.
  1. What does this system mean by “the player”?
  2. Who owns the state it wants to change?
  3. Which parts of the game actually need an update?
  4. Can the operation be interrupted before its expected end?
  5. Is an old save, test or documentation file still describing the previous model?
Each question came from a real problem in the repository:
  • The circular deque came from treating the body as a constantly rebuilt array.
  • Granular signals came from treating every movement as a new board.
  • The portal release came from treating the snake as one teleportable position.
  • The input resolver came from treating four actions as four independent intentions.
  • The cleanup paths came from assuming that every sequence reaches its final line.
Snake movement is still easy to explain: the head moves and the body follows. The difficult part was deciding what “follows” meant inside every other system:
  • Sometimes the body followed within the same function.
  • Sometimes it followed on the next movement tick.
  • At the portal, the tail completed an operation that the head had started several ticks earlier.
That is the most useful change in how I think about the project now. The player is represented by positions, but its behaviour often unfolds as a process.

About the author

Arkadiusz Włodarczyk is a programming instructor and solo game developer from Poland. He develops Snake: Gridbreaker under GEM Games and has taught programming to more than 350,000 students.

Share this article

Frequently asked questions

Newsletter

Stay in the Loop.

Subscribe to our newsletter to receive the latest news, updates, and special offers directly in your inbox. Don't miss out!