Coding Adventure - Word Snake
Lately I’ve been playing word games like Boggle Party (Netflix), Wordshake, and even a special ‘Word Snake’ round at my local pub quiz. The premise of these games is quite simple: connect adjacent letters in a grid to form a word, or many words! I thought for fun I could try mocking up a simple recreation in C++.
Building the Grid
First, let’s define a data structure for our Tiles. It needs to know it’s grid coordinates, hold a letter, and record if it is currently selected. Using a vector here makes for easy iteration over all tiles.
struct Tile {
int gridX = 0, gridY = 0;
char letter = '!';
bool selected = false;
};
std::vector<Tile> tiles;
When the program starts, we can initialise the grid, taking in X and Y dimensions.
void InitTiles(int gridDimensionX, int gridDimensionY) {
tiles.resize(gridDimensionX * gridDimensionY);
for (int y = 0; y < gridDimensionY; y++) {
for (int x = 0; x < gridDimensionX; x++) {
int index = x + y * gridDimensionY;
tiles[index].gridX = x;
tiles[index].gridY = y;
tiles[index].selected = false;
}
}
}
Using SDL3s built in renderer, we can draw rectangles on the screen for each tile, using their X and Y coordinate, and drawing them brighter if selected.
SDL_FRect rect;
for (int i = 0; i < tiles.size(); i++) {
if (tiles[i].selected) {
SDL_SetRenderDrawColor(renderer, 127, 127, 127, SDL_ALPHA_OPAQUE);
}
else {
SDL_SetRenderDrawColor(renderer, 60, 60, 60, SDL_ALPHA_OPAQUE);
}
rect.x = windowWidth / gridDimensionX * tiles[i].gridX;
rect.y = (windowHeight - 50) / gridDimensionY * tiles[i].gridY + 50;
rect.w = windowWidth / gridDimensionX - 2;
rect.h = (windowHeight - 50) / gridDimensionY - 2;
SDL_RenderFillRect(renderer, &rect);
SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE);
SDL_RenderRect(renderer, &rect);
SDL_SetRenderScale(renderer, 4.0f, 4.0f);
SDL_RenderDebugText(renderer, (rect.x + rect.w / 2) / 4, (rect.y + rect.h / 2) / 4, std::string(1, tiles[i].letter).c_str());
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
}
I use the width and height of the window here for easily fitting the tiles to the screen; alternatively this could be cleaner if the screen isn’t expected to resize on the fly. Also note there is a 50px gap at the top of the screen that I will use later!
Using the same dimensions, we can check if the mouse is over a tile and return that tiles index. Including some padding is helpful for when the player tries to connect diagonal tiles.
int GetTileAtMouse(int padding) {
for (int i = 0; i < tiles.size(); i++) {
if (mouse.x >= windowWidth / gridDimensionX * tiles[i].gridX + padding && mouse.x <= windowWidth / gridDimensionX * tiles[i].gridX + windowWidth / gridDimensionX - 2 - padding &&
mouse.y >= (windowHeight - 50) / gridDimensionY * tiles[i].gridY + padding + 50 && mouse.y <= (windowHeight - 50) / gridDimensionY * tiles[i].gridY + (windowHeight - 50) / gridDimensionY - 2 - padding + 50) {
return i;
}
}
return -1;
}
… which we use when checking for mouse input.
const SDL_MouseButtonFlags mouseState = SDL_GetMouseState(&mouse.x, &mouse.y);
if (mouseState & SDL_BUTTON_LMASK) {
mouse.leftButtonDown = !mouse.leftButton;
mouse.leftButton = true;
}
else {
mouse.leftButtonUp = mouse.leftButton;
mouse.leftButton = false;
}
if (mouse.leftButton) {
int index = GetTileAtMouse(20);
if (index != -1) {
SelectTile(index);
}
}
Selection & Fill Logic
By storing a vector of Tile references, we can check if the given tile is able to be selected. This also allows for backtracking the selection. When a tile is selected, we add to the vector and also to a string keeping track of the current word.
void SelectTile(int index) {
//First letter, add to selection
if (SelectedTiles.empty()) {
tiles[index].selected = true;
SelectedTiles.push_back(&tiles[index]);
CurrentWord += tiles[index].letter;
return;
}
Tile* lastTile = SelectedTiles.back();
Tile* penultimateTile = SelectedTiles.size() > 1 ? SelectedTiles[SelectedTiles.size() - 2] : nullptr;
//If backtracking, undo selection
if (&tiles[index] == penultimateTile) {
SelectedTiles.back()->selected = false;
SelectedTiles.pop_back();
CurrentWord.pop_back();
return;
}
//If adjacent, add to selection
if ((abs(tiles[index].gridY - lastTile->gridY) <= 1) &&
(abs(tiles[index].gridX - lastTile->gridX) <= 1)) {
if (!tiles[index].selected) {
tiles[index].selected = true;
SelectedTiles.push_back(&tiles[index]);
CurrentWord += tiles[index].letter;
}
return;
}
//Otherwise, reset
for (int i = 0; i < SelectedTiles.size(); i++) {
SelectedTiles[i]->selected = false;
}
CurrentWord = "";
SelectedTiles.clear();
}
Then, when a word is submitted, we compare the string against a dictionary. But I’m getting ahead of myself! We need some letters.
Filling the tiles with a random letter sometimes makes a decent result, but it’s more fun when it’s guaranteed to have a decently long word somewhere to find. Given a target word, we can walk through the grid placing letters as we go, until we successfully place the word. Some particular paths might hit a dead end, so if that happens, we try again.
bool FillTilesWithTarget(std::string TargetWord) {
int tileCount = tiles.size();
int wordLength = TargetWord.length();
if (wordLength > tileCount) {
return false;
}
//Choose a random starting tile
int currentIndex = SDL_rand(tileCount);
for (int i = 0; i < wordLength; i++) {
//Fill with a letter
tiles[currentIndex].letter = TargetWord.back();
TargetWord.pop_back();
//Return if word is complete
if (TargetWord.empty()) {
return true;
}
std::vector<int> neighbours = GetEmptyNeighbours(currentIndex);
if (neighbours.size() == 0) {
return false;
}
//Move to an adjacent tile
currentIndex = neighbours[SDL_rand(neighbours.size())];
}
}
After filling that long word in, we can assign the rest of the empty tiles a random letter A-Z for a good result. Alternatively, it could be interesting to choose a few shorter target words and fill those in similarly to the above.
void NewRound() {
PlayedWords.clear();
std::string TargetWord;
do {
TargetWord = newTargetWord();
} while (TargetWord.length() < 7 || TargetWord.length() > tiles.size());
while (!FillTilesWithTarget(TargetWord)) {
ClearTiles();
}
FillEmptyTiles();
Timer = 90.0f;
}
Dictionary
I found a list of English words on GitHub, with original credit and copyright to InfoChimps. Using some simple Python code, I generated a new .txt file containing only words of length 3 or greater. Then, we can define an unordered set for quick addressing, and fill it with words from the file.
std::unordered_set<std::string> Dictionary;
void LoadDictionary(const std::string& filePath) {
std::ifstream file(filePath);
if (!file.is_open()) {
SDL_Log("Couldn't open dictionary file: %s", filePath.c_str());
return;
}
std::string word;
while (std::getline(file, word)) {
if (!word.empty() && word.back() == '\r') word.pop_back(); // handle CRLF
if (!word.empty()) {
std::transform(word.begin(), word.end(), word.begin(), toupper); // Convert to uppercase
Dictionary.insert(word);
}
}
}
The comparison code is very simple:
bool isValidWord(const std::string& word) {
return Dictionary.find(word) != Dictionary.end();
}
Finishing Touches
To draw a line between the connected tiles, we can use our vector SelectedTiles. Changing the lines’ colour based on the validity of the word, or whether the word has already been played gives some meaningful feedback to the player.
int x = 0;
int y = 0;
if (PlayedWords.find(CurrentWord) != PlayedWords.end()) {
SDL_SetRenderDrawColor(renderer, 255, 255, 0, 150);
}
else if (isValidWord(CurrentWord)) {
SDL_SetRenderDrawColor(renderer, 0, 255, 0, 150);
}
else {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, 150);
}
for (Tile* tile : SelectedTiles) {
int tilex = tile->gridX * windowWidth / gridDimensionX + windowWidth / gridDimensionX / 2;
int tiley = tile->gridY * (windowHeight - 50) / gridDimensionY + (windowHeight - 50) / gridDimensionY / 2 + 50;
if (x != 0 && y != 0) {
SDL_RenderLine(renderer, x, y, tilex, tiley);
}
x = tilex;
y = tiley;
}
A timer gives a sense of urgency! We can display it at the top of the screen along with the string currently being connected, and the number of words played.
int timerMinutes = static_cast<int>(Timer) / 60; int timerSeconds = static_cast<int>(Timer) % 60; std::string timerText = std::to_string(timerMinutes) + ":" + (timerSeconds < 10 ? "0" : "") + std::to_string(timerSeconds); SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE); SDL_SetRenderScale(renderer, 2.0f, 2.0f); SDL_RenderDebugText(renderer, 2, 2, CurrentWord.c_str()); SDL_RenderDebugText(renderer, 100, 2, std::to_string(PlayedWords.size()).c_str()); SDL_RenderDebugText(renderer, 150, 2, timerText.c_str()); SDL_SetRenderScale(renderer, 1.0f, 1.0f);
After the timer has counted to zero, we can display all the played words on the screen.
void RenderPlayedWords() {
SDL_SetRenderDrawColor(renderer, 255, 255, 255, SDL_ALPHA_OPAQUE); /* white, full alpha */
SDL_SetRenderScale(renderer, 2.0f, 2.0f);
int y_count = 0;
int x_count = 0;
for (std::string word : PlayedWords) {
SDL_RenderDebugText(renderer, 2 + x_count * 100, 2 + y_count * 10, word.c_str());
y_count += 1;
if (y_count > 20) {
x_count += 1;
y_count = 0;
}
}
SDL_SetRenderScale(renderer, 1.0f, 1.0f);
}
The AppIterate function ticks down the timer and sorts out which functions are called.
SDL_AppResult SDL_AppIterate(void* appstate)
{
Uint64 lastTick = Tick;
Tick = SDL_GetTicks();
float dt = (Tick - lastTick) / 1000.0f;
Timer = std::max(0.0f, Timer - dt);
if (Timer > 0) {
TileInput();
}
else {
NewRoundOnClick();
}
//Render
SDL_SetRenderDrawColor(renderer, 0, 0, 0, SDL_ALPHA_OPAQUE); /* black, full alpha */
SDL_RenderClear(renderer); /* start with a blank canvas. */
if (Timer > 0) {
RenderTiles();
}
else {
RenderPlayedWords();
}
SDL_RenderPresent(renderer); /* put it all on the screen! */
return SDL_APP_CONTINUE; /* carry on with the program! */
}