Hi! I made tetris with powerups that runs fully in your terminal. To try it out, just generate an SSH key ssh-keygen if you don't already have one, then ssh into the server. Your SSH key is your identity; no account is required.
ssh play@cursedtetris.orangishcat.dev
If you want to play offline, you can also download from GitHub Releases.
It is recommended to use a Nerd Font so that all text renders correctly.
Star my repo if you liked the game :D
My friends challenged me to make a "text-based game". I assume they meant an RPG, but surely a working tetris game in the terminal counts as text-based, right? It's drawing text, except some of that text happens to be full unicode characters and look like tetris pieces.
I have no idea why I chose tetris as the game I want to make. I barely even know how to play!
Besides, this is a great excuse to learn Rust. I've always been a learning by doing person, and just reading about ownership and borrowing doesn't really give me a great understanding of it. Actually making a game with it would help significantly.
As with any project, even a "simple" game ended up being quite the challenge. Next are my devlogs where I wrote one pretty much every day I was working on the project until its eventual completion. Hope you enjoy!
2026-8-1
Now coming into this project, I have some basic knowledge of Rust but no real experience. I know some concepts like ownership and borrowing, but I've heard there's a lot more, like macros for example.
Reading through the macros documentation has kinda confused me though, so I'm counting on an actual project to teach me the real applications of these. And for ownership and borrowing, I'll just let the compiler teach me the rules.
Got some basic setup code working. The project builds and runs, plus it loads a basic counter in the terminal.
It's just a ratatui template created with some AI help. Through each step of the way, I read and reviewed the code and learned Rust, asking AI for help whenever I ran into Rust syntax I didn't really understand.
Today I learned: Rust closures.
They're pretty familiar to me as they're quite similar to Java, JS, and Python lambdas.
terminal.draw(|frame| self.draw(frame))?;
Now I am aware that they probably operate pretty differently and closures follow ownership and borrowing rules, but I use them similarly to lambdas. If the compiler doesn't complain, then I'm probably doing it correctly. The project isn't complicated enough for me to worry about design decisions yet I think.
Total time spent today: ~1.5 hours
I'm not really tracking my time too closely though, so this is just a very rough estimate.
2026-8-6
I broke my computer 5 days ago and didn't really code. Right now I'm using my old MSI gaming laptop with Debian installed on it. It's supposed to be a home server and my external keyboard has a Mac keyboard layout, but it'll have to do for the time being.
The board and pieces now render, with some basic controls and some minor issues.

After I fixed those, next I had to make full rows disappear.
However, pieces appearing totally at random didn't make sense much, so I made a queue for the pieces. The "queue" is really just a vector with a counter pointing to an index within the vector. Within the vector is two copies of all the board pieces, then shuffled. This ensures that no piece can appear more than twice in a row. When the counter reaches near the end of the vector, the vector is then shuffled and the counter/index resets to zero.
I know that everyone has different opinions over AI, but I think it's great and it's working out pretty well for me. Last devlog I was still asking AI a lot of questions about Rust and the most concise/idiomatic syntax to do things, but now I am coding entire features with minimal AI assistance. The ability for AI to deliver personalized results helps me learn basic syntax a lot faster, and I can use this to piece together a big feature.
2026-8-7
I added leveling to the game. The formula for this is pretty arbitrary as I decided to create some custom formulas for this.
The total number of pieces that have to be played to level up (this number does not reset to zero when you level up) is as follows:
floor(16 * level ^ 1.25)
So to level up from level 1 to 2, 16 pieces have to be played, then to level up from 2 to 3, 38 pieces have to be played, then 63, 90, 119, etc.
For each level up, the gravity duration (the amount of time between two gravity ticks, where the piece currently being played moves down one tile) decreases, with this formula:
750ms * level^(-0.68144)
So level 1 -> 750ms, level 2 -> 467ms, then 354ms, 291ms, 250ms, etc.
Both formulas are kinda arbitrary as I fitted them using some target values and a regression curve on Desmos, but they work well for the gameplay.
I also added a display in the UI for the level.

But so far, the game isn't really cursed. Just doing normal tetris seems kinda boring. I need something that is unusual, something that stands out, even if it's simple.
So I added powerups! Mainly it's just the bomb powerup for now.
Oh, and I changed the text displays on the left and right as well.
Total time: 2 hours
2026-8-8
Today is the end of the challenge. I demo'd what I had to my friends :)
I'll take a break from this project for now. Maybe in the future I can maybe polish and ship it? Probably no one would play it, but might as well. I don't like leaving my projects half finished; I've got way too many half-finished projects already...
2026-8-26
It totally hasn't been 19 days since the last devlog...
I'm planning to add more powerups. One of them includes a roller powerup, which erases all tiles of the same color touching the powerup's collision point.
I've already added a paintball powerup that turns nearby tiles into the same color as the one the powerup is touching:
And also, powerups are now stored globally; you get exactly 5 powerups per game. The code was already designed like this, I just made the UI reflect that too.
But for the roller, I must create a smooth animation of the tiles slowly being deleted. This involves recursively calling a neighbor search with a certain delay between each call, essentially doing BFS with a task scheduler.
Only thing is... I never wrote a task scheduler, and was trying to avoid it as it would add complexity to the project. And it makes sense, as I usually use the hardcoded 24fps frame rate as a scheduler by waiting for the next frame, and have my own basic delay checker for gravity. But for any complicated animations, a scheduler is very necessary and will make my life much easier in the future.
pub struct Task {
time: Instant,
run: Box<dyn FnOnce(&mut State)>,
}
impl PartialEq for Task {
fn eq(&self, other: &Self) -> bool {
self.time == other.time
}
}
// more comparison trait implementations
pub fn update_tasks(state: &mut State) {
let task_is_due = state
.task_queue
.peek()
.is_some_and(|Reverse(task)| task.time <= Instant::now());
if task_is_due {
let Reverse(task) = state.task_queue.pop().unwrap();
(task.run)(state);
update_tasks(state); // probably pretty easy to use a while loop, but recursion is even easier
}
}
pub fn add_task(time: Duration, callback: impl FnOnce(&mut State) + 'static, state: &mut State) {
state.task_queue.push(Reverse(Task {
time: Instant::now() + time,
run: Box::new(callback),
}));
}
Nice, that wasn't too bad. I finally had an opportunity to use Rust's closure types, and learned a bit about FnOnce, FnMut, and some borrowing rules.
And it works great! I moved gravity over to the task scheduler (in the form of a recursive task that spawns another task in its callback), and everything is working normally.
Total time: 1 hr
2026-8-27
Having a task scheduler is amazing! A lot of my code is a lot cleaner, and I could easily implement the roller powerup as I wanted to do yesterday.
Total time: 30 minutes
2026-8-28
Allowed scheduler tasks to store data. This means I could implement BFS through the scheduler, allowing for this cool traversal effect:
But I eventually decided I didn't like it and restored it to what it was before.
I also changed the title screen a bit, so that the pieces are to the side of the main content area.

Then I added an options button as shown above and made a basic options screen. This was easier said than done, as previously these configurable parameters were hardcoded Rust constants. Fortunately a config is easily implementable via a LazyLock<RwLock<Config>>, and can be easily accessed and written to using helper methods.
Next, I just had to do some very basic serde JSON code to save and load the config from a JSON file. The config is stored as <user data dir>/dev.orangishcat.cursed-tetris/config.json, where the dirs crate handles figuring out the user data directory.
Also the identifier dev.orangishcat is, by convention, apparently the reverse of a domain name, called reverse domain name notation. For example using com.example conventionally would be used as an identifier by someone who owns example.com. Of course this is just an identifier and doesn't really do anything, but me using dev.orangishcat means that if I were to get a domain, I have decided I would want orangishcat.dev. Hopefully it's at an affordable price.
(Only other domain I might want would be orangishcat.sh because it sounds cool, but .dev probably makes the most sense here.)
Total time: 3 hours
2026-8-29
I got a domain, https://orangishcat.dev. Now I can use dev.orangishcat as the identifier without feeling like a fraud.

I also got the options screen done, along with a hardcoded custom state that shows some pieces as preview.
Next I added pause functionality, including a way to return to title when paused.

And finally, updated the controls section with more controls.

I added hold functionality (shift / c to hold the last piece). I also updated the pause screen to hide the field when paused, and have a 0.5s cooldown between consecutive pauses. This is to make pause buffering much less effective as a cheat, and leave pause as the QOL feature it was meant to be.

Added high score tracking via the config, and also a +level * 10 score bonus every time you progress onto a newer level. (This means that clearing level 1 gives 10 point bonus, clearing level 2 gives 20 point bonus, etc.)

Added a warning when the terminal window is too small, disabling the play button when that's the case.


This is what horizontal scale 2 and vertical scale 1 looks like, basically a minature version of the board. The UI doesn't really scale along with the board though, so this doesn't provide that much use.

Added a little screen effect that appears whenever your score goes up.
Total time: 4 hours
2026-8-30
Today I realized that anything that can be run through a terminal, you can also run through ssh. So I spent the entirety of today setting up online play for the game. There's a ton of very minor changes that must be made for something like this to work.
I added a tracked time stat in the game screen. It's also shown in the lose screen.
Next, basic online functionality.
- I added an
--onlineand--idparameter.--onlinetakes a path to a SQLite database file, creating the database if it doesn't exist. The database stores the player id (from--id), their entered username (if any), and their score for the top 50 player scores in the database. - To keep it simple and very low storage, nothing is kept per each player, only the leaderboard information about the top 50 is kept.
- Options save to disk when not playing in online mode. Since in online mode the binary will be running on the server though, options will not persist when online.
- When the board width, board height, or starting level options are changed, scores will not be submitted to the leaderboard.
- Probably more minor changes that I forgot about
Not having to worry about cheated scores is nice, as literally everything is controlled by the server when playing online; only keypresses and the terminal buffer are relayed between the server and the
sshclient.
Total time: 3.5 hours
2026-8-31
Today is deployment day. To deploy the application, I will be using an e2-micro instance on Google Cloud, with the minimum possible specs. It still costs $7 / month though.
I don't really know much about deploying infrastructure, just basic Linux experience.
I bought the domain orangishcat.dev two days ago, so I can easily just set an A record to redirect cursedtetris.orangishcat.dev to the VM's IP.
And after a lot of copy pasting commands into the VM from ChatGPT, I got the SSH configuration working. I hope I didn't leave any security gaps... I've checked everything to the best of my limited knowledge.
But even if someone manages to gain RCE on the VM, they've got 2 CPU cores, 1GB of RAM, and limited network egress to work with. I have absolutely no credentials or Google Cloud service accounts or anything configured for the VM. It's a good failsafe, but I hope RCE never happens.
The basic setup is like this:
- Global game cap per server: 32. (There's only one server for now)
- Cap per SSH key: 1.
- Cap per public IP address: 2. Two legitimate people on the same WiFi network will fill this cap, but whatever.
- Lease (maximum time from SSH connect -> SSH session started successfully): 30 seconds. This supposedly will prevent any dead connections from filling the global cap.
- Max session time: 6 hours.
I've tested both online and offline with all three operating systems. Hopefully nothing goes wrong.
Total time: 3.5 hours
2026-9-1
Cool, the project is published. No one's submitted a score but everything seems to be working.
And wow, there's a lot of SSH bots on the internet. Every 15 minutes a bot tries to SSH in as root or with some random username. Since there's no password auth, there's no real need for me to add fail2ban or anything.
Final thoughts
This was a fun experience! It's quite a bit different from what I'm used to, as I usually make tools that are strictly function and useful in my own life. This project, though, was quite the opposite. I imagine I won't be playing this tetris game in my terminal much after I complete the project, and I don't expect others to do so either. This was mostly just a learning experience.
Well, how did the project do? Not too well, not a lot of people played, but that's no big deal I don't think, this project was mainly just for me to learn a bit of Rust.
And learn I did! I learned quite a few things over the course of this project.
- Rust! I completed an entire project with it. I battled against the borrow checker, used plenty of closures, used enums, experienced the power of the
matchstatement, wrote basic composition, used a box, used the dynamic trait type, and plenty more. If learning Rust was like a day trip, I'd say I managed to do most of the important activities.- As for the language itself, I quite like the language design! It has all the modern features that are severely lacking in other languages I've used. For example:
- Versatile
matchstatement, I've only seen something like this in Python 3.10+. - Return errors as values. Really nice as it makes control flow and error handling explicit, but also they managed to do it without needing an
ifstatement after every single line of code. - Composition! It's a very cool concept and probably even a better alternative to inheritance.
- I don't know what it's called, but I can add methods to other classes using trait implementations. Very cool concept.
- Ratatui! The most popular and probably the best terminal UI library out there. Its API was very intuitive, and I picked it up quickly due to my webdev experience in the past.
- I implemented basic layouts using the terminal UI, and it was simple yet very fun!
- Ratatui has a lot of features similar to CSS. For example it can handle centering entire layouts natively (not just text!), very similar to CSS Flexbox, and has a Fill constraint that fills a fraction of the available space within its container, similar to CSS Grid.
- Unfortunately I still hardcoded a lot of the layouts using fixed lengths and had to update them whenever I wanted to change it. This is because Ratatui doesn't have a constraint that sets the layout's height to the minimum possible height of its children, sorta like CSS
height: fit-content.
- I didn't make any assets this time. Nonetheless, I still used a bit of FFmpeg and ImageMagick to create animated demos and whatnot.
Anyway, this was a pretty fun experience. Cool, that's it for today! I can finally move on to another project.







Top comments (0)