Developing a Modular Snake Game in C++ Using Windows Console API

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();
    }
}

Tags: C++ game development Windows API Console Double Buffering

Posted on Sun, 28 Jun 2026 16:00:29 +0000 by Gighalen