System Architecture and Global State
Creating a robust console-based Snake game requiers efficient state management and a rendering system that avoids the flickering associated with standard system("cls") calls. The following implementation utilizes the Windows API for double buffering and direct console buffer manipulation.
We begin by defining the core constants and global state variables to manage the game world, snake position, and scoring.
#ifndef GAME_CONFIG_H
#define GAME_CONFIG_H
#include <windows.h>
#include <vector>
#include <string>
#define MAP_HEIGHT 20
#define MAP_WIDTH 70
// Grid values: 0 = Empty, 1 = Snake, 2 = Boundary, -1 = Target
static int logicalMap[MAP_HEIGHT][MAP_WIDTH];
static char displayBuffer[MAP_HEIGHT + 10][MAP_WIDTH + 10];
static HANDLE primaryBuffer, secondaryBuffer, uiBuffer;
static COORD drawCursor = { 0, 0 };
static DWORD bytesProcessed = 0;
static bool useSecondary = false;
static int currentDirection = 1; // 1: Right, 2: Down, 3: Left, 4: Up
static int posX[MAP_HEIGHT * MAP_WIDTH], posY[MAP_HEIGHT * MAP_WIDTH];
static int snakeSize = 3;
static int targetX, targetY;
static bool isGameOver = false;
static int currentScore = 0;
static int topScores[11];
static unsigned long frameSleep = 200;
static int wallPassEnabled = 0;
static int selfPassEnabled = 0;
#endif
High-Performance Rendering with Double Buffering
To achieve smooth animation in the Windows console, we initialize multiple screen buffers. By swapping between two buffers, we can prepare the next frame in the background and display it instantly, eliminating the "blinking" effect.
void InitializeBuffers() {
primaryBuffer = CreateConsoleScreenBuffer(GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CONSOLE_TEXTMODE_BUFFER, NULL);
secondaryBuffer = CreateConsoleScreenBuffer(GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CONSOLE_TEXTMODE_BUFFER, NULL);
uiBuffer = CreateConsoleScreenBuffer(GENERIC_WRITE, FILE_SHARE_WRITE, NULL, CONSOLE_TEXTMODE_BUFFER, NULL);
CONSOLE_CURSOR_INFO cursorInfo;
cursorInfo.bVisible = FALSE;
cursorInfo.dwSize = 1;
SetConsoleCursorInfo(primaryBuffer, &cursorInfo);
SetConsoleCursorInfo(secondaryBuffer, &cursorInfo);
SetConsoleCursorInfo(uiBuffer, &cursorInfo);
}
void RenderFrame() {
if (isGameOver) {
TriggerGameOverSequence();
return;
}
UpdateLogicalMap();
HANDLE targetBuffer = useSecondary ? secondaryBuffer : primaryBuffer;
useSecondary = !useSecondary;
for (int i = 0; i < MAP_HEIGHT; i++) {
drawCursor.Y = i; drawCursor.X = 0;
WriteConsoleOutputCharacterA(targetBuffer, displayBuffer[i], MAP_WIDTH, drawCursor, &bytesProcessed);
}
// Render Metadata
drawCursor.Y = MAP_HEIGHT; drawCursor.X = 0;
std::string scoreTxt = "Current Score: " + std::to_string(currentScore);
WriteConsoleOutputCharacterA(targetBuffer, scoreTxt.c_str(), scoreTxt.length(), drawCursor, &bytesProcessed);
SetConsoleActiveScreenBuffer(targetBuffer);
}
Core Game Logic and Collision Physics
The movement logic calculates the snake's next head position based on the current direction. If the wallPassEnabled flag is set, the snake wraps around the screen; otherwise, hitting a boundary triggers a game-over state.
void ExecuteMovement() {
int prevTargetX = targetX, prevTargetY = targetY;
// Remove tail from logical map unless eating food
if (posX[0] != targetX || posY[0] != targetY) {
logicalMap[posX[snakeSize - 1]][posY[snakeSize - 1]] = 0;
} else {
snakeSize++;
currentScore += 10;
logicalMap[targetX][targetY] = 1;
SpawnTarget();
}
// Shift body segments
for (int i = snakeSize - 1; i > 0; i--) {
posX[i] = posX[i - 1];
posY[i] = posY[i - 1];
}
// Calculate new head position
switch (currentDirection) {
case 1: posY[0]++; break; // Right
case 2: posX[0]++; break; // Down
case 3: posY[0]--; break; // Left
case 4: posX[0]--; break; // Up
}
// Boundary Logic
if (posY[0] >= MAP_WIDTH - 1 || posY[0] <= 0 || posX[0] >= MAP_HEIGHT - 1 || posX[0] <= 0) {
if (wallPassEnabled) {
if (posY[0] >= MAP_WIDTH - 1) posY[0] = 1;
else if (posY[0] <= 0) posY[0] = MAP_WIDTH - 2;
else if (posX[0] >= MAP_HEIGHT - 1) posX[0] = 1;
else if (posX[0] <= 0) posX[0] = MAP_HEIGHT - 2;
} else {
isGameOver = true;
}
}
// Self-collision
if (logicalMap[posX[0]][posY[0]] == 1 && !selfPassEnabled) {
isGameOver = true;
}
logicalMap[posX[0]][posY[0]] = 1;
}
Input Handling and Target Spawning
To ensure responsive controls, the game polls the keyboard buffer using _kbhit(). Target spawning uses a randomized search within the empty cells of the grid.
#include <conio.h>
#include <ctime>
void ProcessInput() {
if (_kbhit()) {
char key = _getch();
switch (key) {
case 'w': case 'W': if (currentDirection != 2) currentDirection = 4; break;
case 's': case 'S': if (currentDirection != 4) currentDirection = 2; break;
case 'a': case 'A': if (currentDirection != 1) currentDirection = 3; break;
case 'd': case 'D': if (currentDirection != 3) currentDirection = 1; break;
case ' ': // Turbo boost while space is held
ExecuteMovement();
RenderFrame();
break;
}
}
}
void SpawnTarget() {
srand((unsigned int)time(NULL));
do {
targetX = rand() % (MAP_HEIGHT - 2) + 1;
targetY = rand() % (MAP_WIDTH - 2) + 1;
} while (logicalMap[targetX][targetY] != 0);
logicalMap[targetX][targetY] = -1;
}
Persistent Storage for Rankings
Game scores are persisted to a local text file. Upon game completion, the system compares the current score with the existing leaderboard and updates the file if a new record is achieved.
#include <fstream>
#include <algorithm>
void SyncLeaderboard() {
std::ifstream inFile("scores.dat");
for (int i = 0; i < 10; i++) inFile >> topScores[i];
inFile.close();
if (currentScore > topScores[9]) {
topScores[9] = currentScore;
std::sort(topScores, topScores + 10, std::greater<int>());
std::ofstream outFile("scores.dat");
for (int i = 0; i < 10; i++) outFile << topScores[i] << "\n";
outFile.close();
}
}