17 min. read
I’ve recently started development on cmdfx, which is a lightweight, minimal game engine for your terminal.
I got the idea while working on kasciffy, an asciffier for Kotlin Multiplatform. I thought it would be pretty cool if I could make games in the terminal (and I needed an excuse to get back into C), so I began development.
C is a nice, clean choice. I revisited all of the nice things in CMake and got all of my packaging, installers, and everything together. C++ was a close second, but I figured that I wanted it to be a true “developer engine,” or a game engine that only developers are really ever going to use and make for eachother. I could be wrong, but no one really wants to play a game only out of ASCII characters, and most people don’t know what a “terminal” is anyways.
I chose C because I really enjoy memory management when I can control it, especially in an environment like the terminal. I also chose C over C++ because I began disliking the latter’s syntax. I think the whole ::
thing with namespaces is a little weird. However, naming functions like MyName_functionDo
in C also is a little weird, but who really cares. I would take that over CmdFX::Sprite::Builder::setChar
or something like that.
And to be honest, the whole classes idea is starting to wear off on me. Don’t get me wrong, OOP is amazing, but I think I’m moving toward the side of functional languages, and not really having to deal with all that.
The final decision for C was just what language I wanted to practice the most. I’ll need C knowledge for Kotlin/Native cinterop (at least until they finally add support for C++), and using GDNative with Godot whenever I decide to put Kotlin on a pause.
I knew I didn’t want this to be a header-only library, because that looks weird for a large project like this. Yes, header-only is sometimes useful, like what I did for the C Bindings for the *.lvlz
File format. However, I also knew I was going to need to rely on platform implementation, since we were woring with terminal operators.
Speaking of platform: I now realize that POSIX is a little limited when it comes to terminal functions. The Windows API keeps track of everything, and it does have a lot of useful structs to get around. Most of the controlers for the terminal in POSIX are just ANSI functions. For example, printf("\033[?25h")
hides the cursor. I would never have expected you be able to print something and be able to completely customize your own terminal experience. And, I’m surprised I didn’t know about this earlier. I wasn’t always on my Gaming PC.
canvas.h
The first header and implementation I got started with was canvas.h
, which was getting all of the base drawing functions out there, so I could build on that for future implementations (see sprites.h
).
Surprisingly, setting a character in the terminal is as simple as:
#include <stdio.h>
void Canvas_setChar(int x, int y, char c) {
if (x < 1) return;
if (y < 1) return;
int oldX = Canvas_getCursorX();
int oldY = Canvas_getCursorY();
Canvas_setCursor(x, y);
putchar(c);
Canvas_setCursor(oldX, oldY);
}
putchar
is both simple and multiplatform, which came at a pleasant surprise. Glad POSIX and Windows can agree on some standardization.
Setting the cursor position was a little different. The POSIX implementation, as usual, is just a simple ANSI print:
#include <stdio.h>
#include "cmdfx/canvas.h"
void Canvas_setCursor(int x, int y) {
int width = Canvas_getWidth();
int height = Canvas_getHeight();
if (x < 1 || x > width) return;
if (y < 1 || y > height) return;
printf("\033[%d;%dH", y, x);
}
Windows uses a more expected implemtation with a dedicated console controller:
#include <stdlib.h>
#include <windows.h>
#include "cmdfx/canvas.h"
void Canvas_setCursor(int x, int y) {
int width = Canvas_getWidth();
int height = Canvas_getHeight();
if (x < 1 || x > width) return;
if (y < 1 || y > height) return;
COORD coord = {x - 1, y - 1};
SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord);
}
Both of these kinda set the expectation for how implementing both platforms will go. Windows has a lot of different classes for everything, and POSIX just asks you to print something out.
sprites.h
Skipping ahead to sprites.h
was when the real game engine started to come together. I wanted to be able to store a character map, and then be able to move it around dynamically. Then I could have proper Z-Index layering for backdrops, implement collision detection, and maybe even add a built-in physics engine as well.
// Leaving out the (very long) documentation...
typedef struct CmdFX_Sprite {
int x;
int y;
int width;
int height;
char** data;
char*** ansi;
int z;
int id;
} CmdFX_Sprite;
Got a nice struct to keep track of everything. Another reason why I chose C, is that I don’t have to worry about all of the stuff that comes with classes, I can just throw together a bunch of variables and call it a day.
Most of these things would require me to finish events.h
first, which I was putting off because I wanted to figure out what events I wanted. I got the resize event down, and I know I want key and mouse inputs, but I’d need to figure out what I could actually read. Becuase POSIX uses ANSI characters to control everything, you can’t programatically get what’s on the screen, unlike on Windows, which is why Canvas_isCursorVisible()
is controlled by an internal variable on its implementation.
But, back to topic. sprites.c
is entirely within the common
folder, which is nice. It only relies on the canvas.h
implementation for all character control.
#include <string.h>
#include "cmdfx/sprites.h"
void _getSpriteDimensions(char** data, int* width, int* height) {
*width = 0;
*height = 0;
if (data == 0) return;
for (int i = 0; data[i] != 0; i++) {
(*height)++;
int len = strlen(data[i]);
if (len > *width) {
*width = len;
}
}
}
CmdFX_Sprite* Sprite_create(char** data, char*** ansi, int z) {
CmdFX_Sprite* sprite = malloc(sizeof(CmdFX_Sprite));
if (sprite == 0) return 0;
sprite->x = -1;
sprite->y = -1;
sprite->z = z;
sprite->id = 0;
_getSpriteDimensions(data, &sprite->width, &sprite->height);
sprite->data = data;
sprite->ansi = ansi;
return sprite;
}
When I first started declaring the function, I had width
and height
variables declared, along with x
and y
in the create
function. The I realized it’s not being placed when you create it, just thrown into memory. Then I again realized I could calculate the two former bounds with the data
array, so it all got shortened down again.
I started testing it out of the box with drawings, and it worked okay. Then I realized how tedious it is to initialize a char**
(let alone a char***
, which I have yet to test), so I implemented some “builder functions” and added null safety for the data
pointer.
#include <stdlib.h>
#include "cmdfx/canvas.h"
#include "cmdfx/sprites.h"
int Sprite_setChar(CmdFX_Sprite* sprite, int x, int y, char c) {
if (sprite == 0) return 0;
if (sprite->data == 0) return 0;
if (x < 0 || y < 0 || x >= sprite->width || y >= sprite->height) return 0;
sprite->data[y][x] = c;
// Redraw Character if Drawn
if (sprite->id != 0)
Canvas_setChar(sprite->x + x, sprite->y + y, c);
return 1;
}
sprite->id
is initialized when it’s drawn. It’s essentially just an index in an array, but it makes things easier to keep track. You could also just tell if x
and y
are -1
, but this was easier. With this, you could spam Sprite_setChar
methods instead of trying to create a char**
from a char[][]
.
I then realized I actually needed to initialize the data
pointer somehow, so I created a resize builder method. This one’s quite long:
#include <stdlib.h>
#include "cmdfx/canvas.h"
#include "cmdfx/sprites.h"
int Sprite_resize0(CmdFX_Sprite* sprite, int width, int height, char padding) {
if (sprite->data == 0) {
sprite->data = malloc(sizeof(char*) * sprite->height);
if (sprite->data == 0) return 0;
for (int i = 0; i < sprite->height; i++) {
sprite->data[i] = malloc(sizeof(char) * sprite->width);
if (sprite->data[i] == 0) return 0;
memset(sprite->data[i], padding, sprite->width);
}
}
char** newData = malloc(sizeof(char*) * height);
if (newData == 0) return 0;
char*** newAnsi = 0;
if (sprite->ansi != 0) {
newAnsi = malloc(sizeof(char**) * height);
if (newAnsi == 0) {
free(newData);
return 0;
}
}
for (int i = 0; i < height; i++) {
newData[i] = malloc(sizeof(char) * width);
// Fail check
if (newData[i] == 0) {
for (int j = 0; j < i; j++) free(newData[j]);
free(newData);
if (newAnsi != 0) {
for (int j = 0; j < i; j++) free(newAnsi[j]);
free(newAnsi);
}
return 0;
}
memset(newData[i], padding, width);
if (newAnsi != 0) {
newAnsi[i] = malloc(sizeof(char*) * width);
// Fail check
if (newAnsi[i] == 0) {
for (int j = 0; j <= i; j++) {
free(newData[j]);
}
free(newData);
for (int j = 0; j < i; j++) {
free(newAnsi[j]);
newData[i][j] = (sprite->data[i] != 0) ? sprite->data[i][j] : padding;
}
free(newAnsi);
}
for (int j = 0; j < width; j++) newAnsi[i][j] = 0;
}
}
for (int i = 0; i < sprite->height && i < height; i++) {
for (int j = 0; j < sprite->width && j < width; j++) {
newData[i][j] = sprite->data[i][j];
if (newAnsi != 0 && sprite->ansi != 0) {
newAnsi[i][j] = sprite->ansi[i][j];
}
}
}
for (int i = 0; i < sprite->height; i++) {
free(sprite->data[i]);
if (sprite->ansi != 0) {
for (int j = 0; j < sprite->width; j++) {
if (sprite->ansi[i][j] != 0) free(sprite->ansi[i][j]);
}
free(sprite->ansi[i]);
}
}
free(sprite->data);
if (sprite->ansi != 0) free(sprite->ansi);
sprite->data = newData;
sprite->ansi = newAnsi;
sprite->width = width;
sprite->height = height;
}
To be honest, most of the actual nitty-gritty C was borrowed from StackOverflow after getting through a few SEGFAULTs. But I eventually got it to work.
The function is appended with 0
, like most other functions, since the method is also used for Sprite_resizeAndCenter
and Sprite_resizeWithPadding
.
I like to set up my release stuff early, so I don’t have to worry about it when I finish. It’s pretty dumb, because it distracts you from actually finishing it, but I still was curious for a bit.
So, I added a nice big section in my CMakeLists.txt
to throw together a bunch of package installers.
# Packaging
option(PACKAGE_CMDFX "Package ${PROJECT_NAME}" ON)
if (PACKAGE_CMDFX)
set(ARCHITECTURE ${CMAKE_SYSTEM_PROCESSOR})
if (ARCHITECTURE STREQUAL "AMD64")
set(ARCHITECTURE "x64")
endif()
set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}/build/package")
set(CPACK_PACKAGE_NAME ${PROJECT_NAME})
set(CPACK_PACKAGE_INSTALL_DIRECTORY ${PROJECT_NAME})
set(CPACK_PACKAGE_VENDOR "Gregory Mitchell")
set(CPACK_PACKAGE_CONTACT "Gregory Mitchell")
if (WIN32)
set(CPACK_GENERATOR "ZIP;NSIS;TGZ;WIX")
set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-windows-${ARCHITECTURE}")
set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")
set(CPACK_NSIS_DISPLAY_NAME ${PROJECT_NAME})
set(CPACK_NSIS_MUI_ICON "${PROJECT_SOURCE_DIR}/assets/cmdfx.ico")
set(CPACK_NSIS_MUI_UNIICON "${PROJECT_SOURCE_DIR}/assets/cmdfx.ico")
set(CPACK_WIX_VERSION 4)
set(CPACK_WIX_PRODUCT_ICON "${PROJECT_SOURCE_DIR}/assets/cmdfx.ico")
set(CPACK_WIX_LICENSE_RTF "${PROJECT_SOURCE_DIR}/LICENSE.rtf")
set(CPACK_WIX_UPGRADE_GUID "42937a72-0b91-4a57-8f45-60d84df05e66")
elseif(APPLE)
set(CPACK_GENERATOR "TGZ;ZIP;productbuild;Bundle")
set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-macOS-${ARCHITECTURE}")
set(CPACK_PACKAGE_ICON "${PROJECT_SOURCE_DIR}/assets/cmdfx.icns")
set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE.rtf")
set(CPACK_BUNDLE_NAME ${PROJECT_NAME})
set(CPACK_BUNDLE_ICON "${PROJECT_SOURCE_DIR}/assets/cmdfx.icns")
set(CPACK_BUNDLE_PLIST "${PROJECT_SOURCE_DIR}/Info.plist")
elseif(UNIX)
set(CPACK_GENERATOR "TGZ;DEB;RPM")
set(CPACK_PACKAGE_FILE_NAME "${PROJECT_NAME}-${PROJECT_VERSION}-linux-${ARCHITECTURE}")
set(CPACK_PACKAGE_ICON "${PROJECT_SOURCE_DIR}/assets/cmdfx.png")
set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")
endif()
include(CPack)
endif()
I find it weird that CMake can just determine what target you want to use from --target
. I also find it extremely annoying that I have 3 billion *.vcxproj
files everywhere in my test
folder. But CMake is CMake, right? I’ll take what I can get.
Just wish my .gitignore
didn’t look so weird.
.idea
.vscode
build/
Testing/
.cmake/
x64/
Release/
Debug/
CMakeFiles
Makefile
CMakeCache.txt
*.cmake
DartConfiguration.tcl
cmdfx-test-*
CMakeDoxyfile.in
CMakeDoxygenDefaults.cmake
install_manifest.txt
_CPack_Packages/
*.dir/
*.o
*.a
*.so
*.dylib
*.dll
*.vcxproj*
*.sln
*.exp
*.exe
*.lib
It starts off normal, then devolvs into, what?
This was a longer post explaining the beginning of my development on CmdFX, a video game engine for your terminal. I’ll probably write about this again in a few weeks, if I haven’t decide to ditch the project (which I won’t, becuase now I’m invested pretty good). Thanks for reading, mate.