December 24, 2024
tl;dr
I’m working on a game, demos are available on itch (use the password frog)
Quatrix was a shower thought from earlier this year: I love Tetris and what if instead of blocks dropping from just above, they could also “drop” from the sides and even below? This evolved into a rotating play space to choose which side to “insert” the block to clear rows or columns.
Pretty simple premise, but it’s been a fun exercise in making a game that is tangible. I have a lot of issues with immediate design over-scope when it comes to game design, as a lot of people do getting into the space do. You have these grandiose aspirations that stem from the creation of others, usually disconnected from how long those projects took to complete that lead you towards frustrating development purgatory. ABA Game’s lovely Joys of Small Game Development helped push me in the right direction: being able to see a game fully through it’s development, no matter the scope, can be incredibly rewarding and fun. So Quatrix was my first attempt: Can I get this game from start to finish, no nonsense, just focus on fun?
The first thing to tackle was game logic. Quatrix’s play field was of first interest when developing. If you think about it mathematically the play field is a matrix grid that we get to perform operations on. Rotation was surprisingly easy: to rotate clockwise, you first transpose the matrix and then swapping the columns from the edge inwards,
fn rotate_board_right(mut board: DMatrix<u8>) -> DMatrix<u8> {
board.transpose_mut();
let width = board.ncols();
let half_width = width / 2;
(0..half_width).for_each(|i| board.swap_columns(i, width - i - 1));
board
}
I don’t exactly remember where I found out this method for the rotation (since the swap column isn’t formally modeled in linear algebra and is just some silliness), it does lead to a pretty intuitive solution that also thanks to the nalgebra requires no additional memory allocations! Rotating counterclockwise is a reversal of these steps.
Insertions were a little interesting. I wanted the placement logic to be reusable across all side insertions, which means I needed to be able to pass some sort of generic slice that was oriented correctly for the slice to view insertions as always from the “top” (think tetris dropping). This way the placement function could just worry about the “dropping” of the block, which I determined was best modeled as finding the filled square that would block it and then attempt to back up one.
/// For a slice, insertion begins at 0 and goes to the length of the slice.
pub fn place(&self, slice: &mut [u8]) -> Result<usize, GameError> {
let first_one_found = slice.iter().position(|&x| x == 1);
if let Some(one_found) = first_one_found {
// Place before
if one_found > 0 {
if slice.get(one_found - 1).map(|&x| x == 0).is_some_and(|x| x) {
slice[one_found - 1] = 1;
return Ok(one_found - 1);
}
}
// Otherwise, error as there's no space
return Err(GameError::NoSpace);
} else {
slice[slice.len() - 1] = 1;
}
Ok(slice.len() - 1)
}
But unfortunately, this made determining how to build that slice complicated. I had to work out which “slot” (which I defined as going clockwise around the playfield for each slot the block is being dropped in) actually maps to what insertion direction and therefore how to build the slice (since rows and cols needed to be handled differently thanks to nalgebra memory models).
/// Returns the coordinate where the tile was placed (column, row)
pub fn place(&mut self, slot: usize) -> Result<(usize, usize), GameError> {
let insertion_direction = InsertionDirection::for_board_insertion(&self.board, slot)?;
debug!("Dropping into {slot} ({:?})", insertion_direction);
let index = insertion_direction.get_side_index(&self.board, slot);
let pos = match insertion_direction {
InsertionDirection::FromTop => {
let mut column = self.board.column_mut(index);
let slice = column.as_mut_slice();
(index, insertion_direction.place(slice)?)
}
InsertionDirection::FromLeft => {
let row = self.board.row(index);
let mut data = row.iter().map(|&x| x).collect::<Vec<_>>();
let res = insertion_direction.place(&mut data)?;
self.board.set_row(
index,
&RowDVector::from_row_iterator(data.len(), data.into_iter()),
);
(res, index)
}
[bottom and right redacted]
In writing this article, a point of feedback for myself is this system could use a little work since
InsertionDirection
doesn’t actually give any context clues to
InsertionDirection::place
like it should.
The final part this leaves is clearing rows and columns, which uses the insertion direction and slot to figure out which column and rows could be scored in and calculates them accordingly.
fn check_full_rows(&mut self, insertion_direction: InsertionDirection, index: usize) {
let mut rows = Vec::new();
let mut cols = Vec::new();
match insertion_direction {
InsertionDirection::FromTop | InsertionDirection::FromBottom => {
let data = self.board.column(index);
let slice_full = data.into_iter().all(|&x| x == 1);
if slice_full {
cols.push(index);
}
(0..self.board.nrows())
.filter(|&index| self.check_row(InsertionDirection::FromLeft, index))
.for_each(|index| {
rows.push(index);
});
}
InsertionDirection::FromRight | InsertionDirection::FromLeft => {
let data = self.board.row(index);
let slice_full = data.into_iter().all(|&x| x == 1);
if slice_full {
rows.push(index);
}
(0..self.board.ncols())
.filter(|&index| self.check_row(InsertionDirection::FromTop, index))
.for_each(|index| {
cols.push(index);
});
}
}
...
But with all rotation, insertion, and scoring well modeled, this means it was easy to test! This is the first time I have bothered writing unit tests while working on a game and it provided a lot of peace of mind that the underlying board logic was strong while working on the graphical presentation.
To turn this into an actual game, it needed a goal. Already the rough idea was to drop blocks, clear rows, score points, but it needed more to be able to keep players engaged and give a reason to keep going. Right now, scoring is very basic and gives a static amount per block cleared. Calling back to the Joy of Small Game Development and the Flow Model, which describes the relationship between skill and challenge and why so many games adopt a rising difficulty curve, I would like scoring to impact some sort of timer on when to drop, in addition to the entire pacing of the game. So overall, the reason to play is “number go up”, but it should feel engaging while trying to reach that high score.
Graphically I have been keeping it very basic so far to ensure that the game itself is very readable. However, some dashes of color to keep our money brains engaged was a very fun addition, and I created a seed-based gradient for the background that slowly changes overtime.
pub fn build(&self, seed: u32) -> DynamicImage {
let grad = colorgrad::magma();
let scale = 0.05;
let ns = noise::OpenSimplex::new(self.0 + seed);
let mut imgbuf = image::ImageBuffer::new(32, 32);
for (x, y, pixel) in imgbuf.enumerate_pixels_mut() {
let t = ns.get([x as f64 * scale, y as f64 * scale]);
let rgba = grad.at(t.remap(-0.5, 0.5, 0.0, 1.0)).to_rgba8();
*pixel = image::Rgba(rgba.map(|x| (x as f32 * 0.75) as u8));
}
DynamicImage::ImageRgba8(imgbuf)
}
I would put this game at the 70% - 80% completion point. It is fully playable, but is rough around the edges and needs a lot of work to make it more fun, a better user experience, etc.
Completed:
What’s Left:
I have a lot of ideas for the music, which is definitely design creep and may get axed. At the moment, I would like to programmatically build the backing soundtrack based on what’s going on in the game. This is inspired by things like Wii Play’s Tanks game (great video on it by scruffy), which would use the games current state to change what tracks are currently being played. As someone with a lot of love for playing music, and very little production/composition experience, this seemed like a fun way to inch a little closer to the production side of things. If I pull it off, it’ll most likely be a blog post in its own right 🙂
This past year has seen a lot of project time being eroded way by day job work and exhaustion. When days start getting super busy (partially load, partially layoff prevention given my particular role at the company) I start losing the motivation to moonlight these types of projects. I initially told myself I wanted to finish Quatrix by the end of the year but I’m giving myself the grace and patience to let myself have fun with it. Because at the end of the day, isn’t that what this is all about?
Quatrix has demo versions available on Itch Io that are updated automatically as I work on it (Use the password frog to access). Consider following me on Itch as I continue to release new versions!