This repo hosts
tetrs_terminal
, a simple and polished cross-platform TUI implementation of the typical singleplayer game,- and
tetrs_engine
, a tetromino game engine implementing an abstract interface handling modern mechanics.
(Author's Note : Due to irl circumstances I cannot continue development right now - issues may be worked on at a later time)
- Download a release for your platform if available.
- Run the application in your favourite terminal
Or compile it yourself:
- Have Rust (1.80.0+) installed.
- Download /
git clone
this repository.- Navigate to
tetrs_terminal/
andcargo run
.
Important
Use a terminal like kitty (or any terminal with support for progressive keyboard enhancement) for smoother gameplay experience. Note that otherwise DAS/ARR/Soft drop speed will be determined by Keyboard/OS/terminal emulator settings.
Explanation.
Terminals do not usually send "key released" signals, which is a problem for mechanics such as "press left to move left repeatedly until key is released". Crossterm automatically detects 'kitty-protocol'-compatible terminals where this issue is solved, allowing for smooth, configurable gameplay controls.
(*This also affects holding Soft Drop locking pieces on ground instantly, as opposed to only upon press down -- for ergonomics this is explicitly mitigated by the 'No soft drop lock' configuration.)*
Classic game experience with different gamemodes:
Smooth rendering on all platforms, configurable controls and more:
ASCII graphics available:
Retro 'Electronika 60' graphics available:
Electronika 60 demo PNG
*For display like in the screenshot set your terminal text color to green and font to a Unicode-compatible one (e.g. DejaVu Sans Mono
works)
Tip
Play Puzzle Mode with its 24 stages to try the special 'ocular' rotation system (feat. T-spin Triple)!
- 'Marathon' (reach lvl 20) - 'Sprint' (40-Lines) - 'Ultra' (Time Trial) - Master (20G).
- Puzzle Mode
- Perfect-clear yourself through short puzzle stages with piece acrobatics enabled by the self-developed 'ocular' rotation system! (*Up to five attempts per puzzle stage.)
- Custom Mode
- Change start level, toggle level increment, set game limit (Time, Score, Pieces, Lines, Level, or None).
- Familiar game experience of moving, rotating, hard-/softdropping tetrominos with the aim to clear lines.
- Colorful pieces (following guideline).
- Next piece preview.
- Ghost piece.
- Animations: Hard drop, Line clears and Piece locking.
- Game stats: Level, Score, Lines, Time, Pieces generated.
For more technical details see Features of the Tetrs Engine.
-
Look of the game:
- Graphics (Unicode, ASCII, 'Electronika 60').
- Coloring (RGB Colors; 16 Colors (should work on all consoles), Monochrome).
- Adjustable render rate and toggleable FPS counter.
-
Play of the game:
- Change controls.
Default Game Controls
Key Action ←
Move left →
Move right A
Rotate left D
Rotate right (not set) Rotate around (180°) ↓
Soft drop ↑
Hard drop (not set) Sonic drop Esc
Pause game Ctrl
+D
Forfeit game Ctrl
+C
Exit program - Configure game.
- Rotation system (Ocular, Classic, Super),
- Piece generator (History, Uniform, Bag, Total-Relative),
- Preview count (0 - 8),
- DAS, ARR, hard drop delay, line clear delay, appearance delay,
- soft drop factor, ground time max,
- Advanced, No soft drop lock (Enables soft drop not instantly locking pieces on ground even if keyboard enhancements are off, for better experience on typical consoles (soft drops for piece spins)).
-
Keep Savefile: By default this program won't store anything and just let you play the game. If you do want
tetrs_terminal
to restore your settings and past games in the future then make sure this is set to "On"!
- History of games played in the current session (/in the past, if "Keep savefile" is turned on).
- (*Games where 0 lines have been cleared are auto-deleted upon exit.)
Note
If "Keep savefile for tetrs" is turned on then your settings and games will be stored in .tetrs_terminal.json
under a directory that tries to follow OS conventions [1, 2]:
Windows | Linux | macOS | other | |
---|---|---|---|---|
location | %APPDATA% |
~/.config/ |
~/Library/Application Support/ |
(home directory) |
(If this fails it tries to store it locally (./
)!)
The frontend application is proof-of-concept; Ultimately the tetrs engine tries to be modular and shifts the responsibility of detecting player input and chosen time of updates to the client. Basic interaction with the engine could look like the following:
// Starting a game.
let game = tetrs_engine::Game::new(Gamemode::marathon());
// Application loop.
loop {
// Updating the game with a new button state at a point in time.
game.update(Some(new_button_state), update_time);
// ...
// Updating the game with *no* change in button state (since previous).
game.update(None, update_time_2);
// View game state
let GameState { board, .. } = game.state();
// (Render the board, etc..)
}
Using the engine in a Rust project
Adding tetrs_engine
as a dependency from git to a project:
[dependencies]
tetrs_engine = { git = "https://github.com/Strophox/tetrs.git" }
Game Configuration Aspects
- Gamemodes: Are encoded as a combination of starting level and whether to increment level and (one/several positive/negative) limits.
- Rotation Systems: Ocular Rotation System, Classic Rotation System, Super Rotation System. See Ocular Rotation System.
- Tetromino Generators: Recency-based, Bag, Uniformly random. Default is recency. See Tetromino Generation.
- Piece Preview (default 1)
- Delayed Auto Shift (default DAS = 167ms) (*Note: at very high levels DAS and ARR equal lock delay - 1ms.)
- Auto Repeat Rate (default ARR = 33ms)
- Soft Drop Factor (default SDF = 15.0)
- Hard drop delay (default 0.1ms)
- Line clear delay (default 200ms)
- Appearance Delay (default ARE = 50ms)
Currently, drop delay and lock delay* (*But not total ground time) are a function of the current level:
- Drop delay (1000ms at lvl 1 to 0.833ms ("20G") at lvl 19, as per guideline)
- 'Timer' variant of Extended Placement Lockdown (step reset); The piece tries to lock every 500ms at lvl 19 to every 150ms at lvl 30, and any given piece may only touch ground for 2250ms in total. See Piece Locking.
All default values loosely based on the Guideline.
Game State Aspects
- Time: Game time is held abstract as "time elapsed since game started" and is not directly tied to real-world timestamps.
- Game finish: The game knows if it finished, and if session was won or lost. Normal Game Over scenarios are:
- Block out: new piece spawn location is occupied.
- Lock out: a piece was completely locked above the skyline (row 21 and above).
- Forfeit: player stopped the current.
- Event queue: All game events are kept in an internal queue that is simulated through (up to the provided timestamp in the
Game::update
call). - Buttons pressed state: The game keeps an abstract state of which buttons are currently pressed.
- Board state: (Yes).
- Active piece: The active piece is stored as a (tetromino, orientation, position) tuple plus some locking data.
- Next Pieces: Are polled from the generator, kept in a queue and can be viewed.
- Pieces played so far: A counter for each locked piece by type is stored.
- Lines cleared: (Yes)2.
- (Speed) Level: Increases every 10 line clears and influences only drop/lock delay.
- Scoring: Line clears trigger a score bonus, which takes into account number of lines cleared, spins, combos, back-to-backs; See Scoring.
Game Feedback Aspects
The game provides some useful feedback events upon every update
, usually used to correctly implement visual frontend effects:
- Piece locked down, Lines cleared, Hard drop, Accolade (score bonus info), Message (generic message, currently unused for base gamemodes)
*Many small details of the tetrs_engine
may have been left out of this readme (such as the initial rotation mechanic that immediately rotates a piece if a rotation button is pressed upon spawning). It is intended that this might be elaborated on at a later point in time.
*Another minor defect is current crate documentation (cargo doc --open
) being unfortunately sparse at this time. Apologies from the author; I hope this can be fixed sometime!
While the 2009 Tetris Guideline serves as good inspiration, I ended up doing a lot of amateur research into a variety of game details present in modern games online (thank you Tetris Wiki and HardDrop!) and also by getting some help from asking people. Thank you GrBtAce and KonSola5!
In the following I detail various interesting concepts I tackled on my way to bringing this project to life - I was essentially new to Tetris and couldn't remember playing it for more than a couple minutes (in the last decade), so I had to figure all this out from scratch!
Tetromino generators are interesting, and a core part of the game.
A trivial generator chooses tetrominos uniformly at random. This already works decent for a playable game.
However, typically players tend to get very frustrated when they don't get "the pieces they need" for a while.
(*There's even a term for not getting an I
-piece for an extended period of time: Drought.)
In modern games, the bag-based generation system is ubiquitous; The system simply takes all 7 pieces once, hands them out in random order, repeat.
It's quite nice knowing an I
piece will come after 12 pieces, every time. One may even start strategizing and counting where one bag ends and the next starts.
It also takes a bit of the "fun" out of the randomness.
An alternative that seems to work well is recency-based generation: Remember the last time each piece was generated and when choosing the next one randomly, do so weighted by when each one was last seen so it is more likely to choose a piece not played in a while.
This preserves "possibly complete randomness" in the sense that any piece may be generated at any time, while still mitigating droughts.
Unlike bag it possibly provides a more continuous "gut feeling" of what piece(s) might come next, where in bag the order upon refill really is completely random.
"tetris has a great rotation system and is not flawed at all"
— said no one ever, not even the creator of Tetris himself.
Considering the sheer size of the franchise and range of players coming from all sorts of niches and previous game version the "Super Rotation System" gets its job done™, but it does not change the fact that it was the mechanic I thought about redoing when thinking about this project.
Creating a Rotation System.
To summarize, my personal problems with SRS were:
- The system is not symmetric.
- Symmetric pieces can look exactly the same in different rotation states, but have different behaviour.
- Doing rotation, then Mirroring board and piece ≠ Mirroring board and piece, then Doing mirrored rotation.
- It's an advanced system with things like different rotation points for different purposes, yet it re-uses the exact same kicks for 5 out of the 7 pieces, even though they have completely different symmetries.
- Not a hot take, but some rotations are just weird (to be chosen over other possibilities).
- Piece elevators.
Good criteria for a rotation system I can think of would be:
- Symmetric rotations if the board and piece were appropriately mirrored.
- Equal-looking states must have the same behaviour.
- The kicks should look sensible (first try the first position one would expect, only then more lenient positions).
- The kicks should be fun (if it looks like 'it could reasonably rotate', it should).
- "Don't overdo it with crazy kicks but still allow some neat stuff"
The result of this was the 'Ocular' Rotation System, which was made by... looking at each piece orientation and drawing the most visually sensible position(s) for it to land in after rotating.
By overlapping all the kicks that are tested sequentially in order, one gets a compact heatmap of where the piece will land, going from hottest color (bright yellow, first kick) to coldest (darkest purple, last kick attempt):
Here's a comparison with SRS:
So with SRS, although one does start to spot the vague rotational symmetries that were intended), they're overshadowed by asymmetrical kick states and quite lenient (upwards) vertical kicks all over the place.
In the end I'm happy with how the custom rotation system turned out. It vaguely follows the mantra "if the rotation looks like it could reasonably work visually, it should" (+ some added kicks for flexibility and fun).
(*Bonus: the code can use the symmetry of the system and only store the minimum kick table required!)
On the fun side, Puzzle Mode in the playable application is intended to show off some of the spins possible with this system; Feel free to try it out! I somehow still managed to include a ("sensible") T-Spin Triple!
The mechanics of locking down a piece on the grid can be more complicated than it might sound at first glance.
Good criteria for a locking system I can think of would be:
- Keep players from stalling / force players to make a choice eventually.
- Give players enough flexibility to manipulate the piece even if it's on the ground.
- Force players to react/input faster on higher levels, as speed is supposed to increase.
- Implement all these limitations as naturally/simply as possible.
So I started looking and deciding which locking system to implement;
Creating a Locking System.
Classic lock down is simple, but if one decreases the lock timer at higher levels (3.) then it might become exceedingly difficult for players to actually have enough time to do adjustments (2.).
Classic Lock Down
- If the piece touches a surface
- start a lock timer of 500ms (*var with lvl).
- record the lowest y coordinate the piece has reached.
- If the lock timer runs out, lock the piece immediately as soon as it touches the next surface.
- If the piece falls below the previously lowest recorded y coordinate, reset lock timer.
Infinite lock down essentially mitigates the flexibility issue by saying, "if the player manipulated his piece, give him some more time". It's very simple, but the fact that it lets players stall forever (1.) is less nice.
Infinite Lock Down
- If the piece touches a surface
- start a lock timer of 500ms (*var with lvl).
- If the lock timer runs out, lock the piece immediately as soon as it touches the next surface.
- If the piece moves/rotates (change in position), reset lock timer ('move reset').
The standard recommended by the guideline is therefore extended placement lock down.
Extended Placement Lock Down
- If the piece touches a surface
- start a lock timer of 500ms (*var with lvl).
- start counting the number of moves/rotates the player makes.
- record the lowest y coordinate the piece has reached.
- If the piece moves/rotates (change in position), reset lock timer ('move reset').
- If the number of moves reaches 15, do not reset the lock timer anymore.
- If the lock timer runs out, lock the piece immediately as soon as it touches the next surface.
- If the piece falls below the previously lowest recorded y coordinate, reset counted number of moves.
(*This probably misses a few edge cases, but you get the gist.)
Yeah.
It's pretty flexible (2.) yet forces a decision (1.), but the 'count to 15 moves' part of this lock down seems somewhat arbitrary (4.) (*Also note that after the 15 moves run out one can still manipulate the piece till lock down.)
Idea.
What if we limit the total amount of time a piece may touch a surface (1.) instead of number of moves/rotates (4.), though but at higher levels the piece attempts to lock down faster (3.), re-attempting later upon move/rotate; This still allows for plenty *technically arbitrarily many piece manipulations (2.) while still fulfilling the other points :D
'Timer' Extended Placement Lock Down
Let 'ground time' denote the amount of time a piece touches a surface
- If the piece touches a surface
- start a lock timer of 500ms (*var with lvl).
- start measuring the ground time.
- record the lowest y coordinate the piece has reached.
- If the piece moves/rotates (change in position), reset lock timer ('move reset').
- If the lock timer runs out or the ground time reaches 2.25s, lock the piece immediately as soon as it touches the next surface.
- If the piece falls below the previously lowest recorded y coordinate, reset the ground time.
Nice.
Although now it may potentially be abused by players which keep pieces in the air, only to occasionally touch down and reset the lock timer while hardly adding any ground time (note that this problem vanishes at 20G).
A small patch for this is to check the last time the piece touched the ground, and if that was, say, less than 2×(drop delay) ago, then act as if the piece had been touching ground all along. This way the piece is guaranteed to be counted as "continuously on ground" even with fast upward kicks of height ≤ 2.
In the end, a timer-based extended placement lockdown (+ ground continuity fix) is what I used. Although there might be a nicer system somehow..
The exact scoring formula is given as follows:
Scoring Formula
score_bonus = 10
* (lines + combo - 1) ^ 2
* maximum [1, backToBack]
* (if spin then 4 else 1)
* (if perfect then 100 else 1)
where lines = "number of lines cleared simultaneously"
spin = "piece could not move up when locking occurred"
perfect = "board is empty after line clear"
combo = "number of consecutive played pieces where line clear occurred"
backToBack = "number of consecutive line clears where spin, perfect or quadruple line clear occurred"
Table of Example Bonuses
A table of some example bonuses:
Score bonus | Action |
---|---|
+10 | Single |
+40 | Double |
+90 | Triple |
+160 | Quadruple |
+40 | ?-Spin Single |
+160 | ?-Spin Double |
+360 | ?-Spin Triple |
+40 | Single (2.combo) |
+90 | Single (3.combo) |
+160 | Single (4.combo) |
+90 | Double (2.combo) |
+160 | Double (3.combo) |
+250 | Double (4.combo) |
+160 | Triple (2.combo) |
+250 | Triple (3.combo) |
+360 | Triple (4.combo) |
+320 | Quadruple (2.B2B) |
+480 | Quadruple (3.B2B) |
+640 | Quadruple (4.B2B) |
+1'000 | Perfect Single |
+16'000 | Perfect L-Spin Double |
Coming up with a good scoring system is easier with practical experience and playtesters.
I did actually try to come up with a new, simple, good formula, but it's tough to judge how much to reward the player for any given action (how many points should a 'perfect clear' receive? - I've never achieved a single perfect clear in my life!). The one I came up with, put mildly, probably sucks.
But I still allowed myself to experiment, because I really liked the idea of rewarding all spins (and don't understand modern Tetris' obession with T-spins when S-, Z-, L- and J-spins are also so satisfying).
A search for the 'best' / 'most ergonomic' game keybinds was inconclusive. In a sample of a few dozen opinions from reddit posts there was about a 50/50 split on
move | rotate |
---|---|
a d |
← → |
← → |
z x |
Frequent advice I saw, "Choose what feels best for you", which sounds about right.
(*though some mentioned one should not hammer spacebar
for hard drops, the only button the Guideline suggests for this action.)
Modeling how a TUI should handle menus and move between them was unclear initially. Luckily, I was able to look at how Noita's menus are connected and saw that it was quite structured: The menus form a graph (with menus as nodes and valid transitions as directed edges), with only some menus ('pop-ups') that allow backtracking to a previous menu.
In the two very intense weeks of developing this project I've had my first proper learning experiences with programming a larger Rust project, an interactive game (in the console no less), and the intricacies of modern tetrs mechanics themselves.
Gamedev-wise I can mention learning about the modern game loop;
Finding the proper abstraction for Game::update
(allow arbitrary-time user input, make updates decoupled from framerate) was still hard.
Frontend-wise I may have used Ratatui, but decided to just do some basic menus myself using the trusty Crossterm for cross-platform terminal manipulation.
Next time I'd use a TUI crate so as to sleep more peacefully at night not having to think about the horrible ad-hoc code I wrote for the interface
On the Rust side of things I learned about;
- Some coding style guidelines &
cargo fmt
(),#[rustfmt::skip]
- "How to order Rust code",
- introduction to writing documentation (and the fact they can contain tested examples) &
cargo doc
, - the
std
traits, - using serde a little for a hacky way to save some structured data locally,
- conditionally derive feature flags &
cargo check --features serde
, - conditionally compile,
- basic file system shenanigans,
- clap to parse simple command line arguments &
cargo run -- --fps=60
, - formatting the time with chrono my favourite way,
- the
format!
macro (which I discovered is the analogue to Python's f-strings my beloved), debug_struct
proved quite helpful to ensureDebug
for all structs,- some annoyances with terminal emulators, including how slow they are
on Windows, - the handy drop-in
BufWriter
wrapper to diminish flickering, - settings a custom panic hook (since TUI shenanigans mess with error output),
- more practice with Rust's module system,
- multithreading with
std::sync::mpsc
- cargo workspaces to fully separate frontend and backend,
- cargo git dependencies so other people could reuse the backend,
- learning about cross-compilation for releases,
- and as last honourable mention: Looking for a good input reading crate for ages, failing to get device_query to work, and settling on trusty Crossterm, which did its job perfectly and I couldn't be happier, considering how not-made-for-games consoles are.
All in all, Rust, known for its safety and performance - while still having high-level constructs like abstract datatypes - proved to be an excellent choice for this project.
Also, I'd like to appreciate how nice the name tetrs fits for a Rust game that does not infringe on copyright though there are, like, a quadrillion other .tetrs
's on GitHub, ooof
For the terminal GIF recordings I used asciinema + agg:
agg --font-family="DejaVu Sans Mono" --line-height=1.17 --renderer=resvg --font-size=20, --fps-cap=30 --last-frame-duration=0 my_rec.cast my_rec.gif
„Piecement Places!“ - CTWC 2016.
██ ▄▄▄▄ ▄█▀ ▀█▄ ▄█▄ ▄▄█ █▄▄