Whenever someone mentions writing a game from scratch, I often see recommendations to use a game engine. But for a small 2D grid game, skipping the engine can be the better choice.
Not because game engines are bad, but because they can introduce solutions to problems you don't have while hiding decisions you might want to make yourself.
To explore this idea, I read Moss Circuit, an 839-line Pascal game that renders directly through the Windows API's GDI.
No game engine. No asset pipeline. No game-loop abstraction.
Just Windows, Messages, SysUtils, Math, and Types in the uses clause.
Here's what I learned from reading all 839 lines.
1. Why Write a Game This Way?
The entire program lives in a single unit containing 41 procedures and functions.
Some of them handle palette colors:
RGBColor, ThemeColor, PageColor, PanelColor, BorderColor, TextColor,
MutedColor, AccentColor, SnakeColor, FoodColor, CanvasColor, GridColor
Others handle drawing primitives:
DrawTextRect, FillRounded, FillCircle, DrawPetal
The remaining code handles game logic and Win32 integration.
This organization is worth learning from, even if you never write Pascal. Separating color helpers, drawing primitives, game logic, and platform-specific code makes a small project easier to navigate without introducing a complicated architecture.
2. Board Geometry Is Recomputed on Resize
This is one of my favorite parts of the implementation.
Whenever the window is resized, CalculateBoard derives the board geometry from the client area's dimensions.
procedure CalculateBoard(ClientWidth, ClientHeight: Integer);
var
Padding: Integer;
begin
Padding := 48;
BoardSize := Min(ClientWidth - Padding * 2, ClientHeight - 286);
BoardSize := Max(240, BoardSize);
BoardLeft := (ClientWidth - BoardSize) div 2;
BoardTop := 190;
CellSize := BoardSize div BoardCells;
BoardSize := CellSize * BoardCells;
BoardLeft := (ClientWidth - BoardSize) div 2;
end;
There are four details here that are easy to miss.
One constant defines the board dimensions.
BoardCells = 24means the grid is always 24 × 24 cells.Integer division can leave unused pixels.
CellSize := BoardSize div BoardCellstruncates the result. The next assignment,BoardSize := CellSize * BoardCells, snaps the board size to an exact multiple of the cell size.The minimum size prevents the board from shrinking indefinitely.
Max(240, ...)establishes a lower bound of 240 pixels.The board is centered again after snapping. Recalculating
BoardLeftensures the final board remains centered rather than using the position calculated from the original dimensions.
That small sequence makes the board geometry predictable without needing a layout engine.
3. Difficulty Is Calculated from the Score
Many Snake clones use a lookup table to define difficulty levels. Moss Circuit uses a formula instead.
function CurrentSpeed: UINT;
begin
Result := Max(MinimumSpeed, StartSpeed - Score * 3);
end;
With StartSpeed = 145 and MinimumSpeed = 66, the timer interval starts at 145 milliseconds and decreases by 3 milliseconds for every point, until it reaches the minimum.
The interval reaches its 66 ms floor at a score of 27.
Applying the calculated speed to the timer takes just one line:
SetTimer(MainWindow, TimerId, CurrentSpeed, nil);
The WM_TIMER message drives the game loop. There is no while true loop anywhere.
This is a good example of using the operating system's event mechanism instead of building a separate game-loop abstraction.
4. The Anti-Reverse Check Defines the Game Feel
A surprisingly large part of making Snake feel right comes down to handling direction changes correctly.
Here's the relevant procedure:
procedure RequestDirection(NewX, NewY: Integer);
begin
if State = gsReady then
BeginGame;
if (State <> gsPlaying) or TurnQueued then
Exit;
if (NewX = -DirectionX) and (NewY = -DirectionY) then
Exit;
NextDirectionX := NewX;
NextDirectionY := NewY;
TurnQueued := True;
end;
Three things happen here.
- Reverse input is rejected. If the snake is moving right, pressing left cannot immediately reverse its direction.
-
Only one turn is queued per tick. Without
TurnQueued, a player could press right and then up within a few milliseconds, potentially causing an unintended turn before the next movement update. - The first direction input starts the game. There's no need to press a separate start button.
The queue pattern is useful beyond Snake. Any grid-based game that updates movement in discrete ticks can encounter similar input-handling bugs.
The important idea is to separate the direction requested by the player from the direction currently being applied by the game simulation.
5. Preferences Persist to %APPDATA%
Moss Circuit stores its settings in a directory under the user's application data folder.
DataDirectory := GetEnvironmentVariable('APPDATA');
if DataDirectory = '' then
DataDirectory := ExtractFilePath(ParamStr(0));
DataDirectory :=
IncludeTrailingPathDelimiter(DataDirectory) + 'MossCircuit';
ForceDirectories(DataDirectory);
The theme preference and high score are stored under %APPDATA%\MossCircuit\ on a typical Windows installation.
If APPDATA is unavailable, the program falls back to the executable's directory.
The file-writing code also handles I/O errors without immediately terminating the game:
procedure SaveSetting(const FileName, Value: string);
begin
AssignFile(FileHandle, DataFile(FileName));
{$I-}
Rewrite(FileHandle);
if IOResult = 0 then
begin
WriteLn(FileHandle, Value);
CloseFile(FileHandle);
end;
{$I+}
end;
The {$I-} directive disables automatic I/O error checking, allowing the program to inspect the result through IOResult.
If opening the file fails, the game avoids a crash caused by that operation. The trade-off is that the setting may not be saved.
There is one important nuance: this fallback does not make the game fully cross-platform. The code still depends on Windows-specific APIs, and the executable directory may not be writable.
6. A Simple but Effective Theming System
The game supports two color themes, and palette values are routed through ThemeColor rather than being hardcoded throughout the drawing code.
Changing the theme flag updates the colors used by the palette helpers.
This is a lightweight theming system: one flag and a set of color functions.
There's no need for a CSS-like system, separate theme files, or a complex inheritance hierarchy for a game this small.
The broader lesson is that architecture should match the scale of the project. A small game can benefit from clear separation of concerns without needing a full framework.
7. What the Game Doesn't Have
Moss Circuit has no audio, particle effects, save slots, multiplayer, or level editor.
For an 839-line game, those omissions make sense. Every additional feature adds implementation and maintenance costs.
The gsComplete state and CompleteGarden procedure also suggest that the game has a defined completion state, with a flower that grows as the board fills.
Rather than adding features just because other games have them, the implementation focuses on the core experience.
Who Should Read This Code?
If you want to understand what a complete small game can look like without the overhead of a game engine, Moss Circuit is worth exploring.
The WinAPI-specific parts have a learning curve, but the game logic is relatively straightforward. If you're already familiar with GDI, you'll recognize many of the patterns quickly.
Even if you normally use a game engine, reading a project like this can help you understand what the engine is doing behind the scenes.
Source code: Moss Circuit on GitHub
For the Indonesian version, including the code map and screenshots, see my blog:
Top comments (0)