Skip to content
/ tetrs Public

Tetromino Game Engine + Terminal Application in Rust

License

Notifications You must be signed in to change notification settings

Strophox/tetrs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Tetromino Game Engine + Terminal Application

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)


How to run

  • 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/ and cargo 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.)*

Gallery

Classic game experience with different gamemodes:

Tetrs demo screenshot

Smooth rendering on all platforms, configurable controls and more:

Tetrs demo GIF

ASCII graphics available:

ASCII demo GIF

Tetrs ASCII demo GIF

Retro 'Electronika 60' graphics available:

Electronika 60 demo PNG

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)!

Puzzle Mode demo GIF

Tetrs Puzzle Mode demo GIF

Features of the Application

Gamemodes

  • '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).

Gameplay

  • 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.

Settings

  • 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"!

Scoreboard

  • 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 (./)!)

Features of the Tetrs Engine

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!

Project Highlights

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 Generation

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.

Ocular Rotation System

"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:

  1. Symmetric rotations if the board and piece were appropriately mirrored.
  2. Equal-looking states must have the same behaviour.
  3. The kicks should look sensible (first try the first position one would expect, only then more lenient positions).
  4. The kicks should be fun (if it looks like 'it could reasonably rotate', it should).
  5. "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):

Ocular Rotation System Heatmap

Ocular Rotation System Heatmap

Here's a comparison with SRS:

Super Rotation System Heatmap

Super Rotation System Heatmap

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!

Piece Locking

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:

  1. Keep players from stalling / force players to make a choice eventually.
  2. Give players enough flexibility to manipulate the piece even if it's on the ground.
  3. Force players to react/input faster on higher levels, as speed is supposed to increase.
  4. 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..

Scoring

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).

Controls

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.)

Menu Navigation

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.

Tetrs Terminal Menu Graph (via Graphviz)

tetrs menu graph

Miscellaneous Author Notes

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;

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.

██ ▄▄▄▄ ▄█▀ ▀█▄ ▄█▄ ▄▄█ █▄▄