Home

Awesome

<div align="center"><img width="440" src="Gallery/tetrs_logo.png" /></div> <div align="center" style="text-align: center; width: 100%"> <h1>Tetromino Game Engine + Terminal Application</h1> </div>

This repo hosts


(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

Or compile it yourself:

[!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.

<details> <summary> Explanation. </summary>

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

</details>

Gallery

Classic game experience:

Unicode demo screenshot

Efficient rendering in consoles, configurable controls, etc.:

Unicode demo GIF

[!TIP] Play Puzzle Mode with its 24 stages to test the custom Ocular Rotation System (+ unlock the experimental Descent Mode).

Various tileset + coloring combinations possible:

<details> <summary>ASCII demo (GIF)</summary>

ASCII demo GIF

</details> <details> <summary>Electronika 60 demo (PNG)</summary>

Electronika60 demo PNG

*For display just like in the screenshot set your terminal text color to green and font to a Unicode-compatible one (e.g. DejaVu Sans Mono works)

</details>

Features of the Application

Gameplay

For more technical details see Features of the Tetrs Engine.

Gamemodes

Settings

Scoreboard

[!NOTE] If "keep save file for tetrs" is toggled ON then your settings and games will be stored in .tetrs_tui_savefile.json under a directory that tries to follow OS conventions [1, 2]:

WindowsLinuxmacOSother
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..)
}
<details> <summary> Using the engine in a Rust project </summary>

Adding tetrs_engine as a dependency from git to a project:

[dependencies]
tetrs_engine = { git = "https://github.com/Strophox/tetrs.git" }
</details> <details> <summary> Game Configuration Aspects </summary>

Currently, drop delay and lock delay* (*But not total ground time) are a function of the current level:

Most default values inspired by Guideline.

</details> <details> <summary> Game State Aspects </summary> </details> <details> <summary> Game Feedback Aspects </summary>

The game provides some useful feedback events upon every update, usually used to correctly implement visual frontend effects:

</details>

State of the Project

As much love and care went into building this project, it is of course not without its flaws;

A personal goal would be to (at least partially) amend these problems, step-by-step.

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. I'd particularly like to thank the following people:

In the following I detail various interesting concepts I tackled on my way to bringing this project to life - I was essentially new to the Tetris Community 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 official Super Rotation System 'gets its job done'™ - nevertheless it was the mechanic I wanted to redo even before starting this project.

<details> <summary> Creating a Rotation System. </summary>

My personal gripes with SRS are:

Good general criteria for a rotation system I can think of would be:

  1. Rotation must behave visually symmetrically;
    • equal-looking rotation states must behave the same,
    • and mirroring the board/pieces mirrors the rotation behaviour perfectly.
  2. The kicks should be intuitive
    • the way pieces rotate should look 'feasible' to any given person;
    • e.g. the new position cannot be completely disjoint from previously.
  3. Rotation should be fun! :D
    • but also any rotations should 'feel good' to a player.
    • (but don't overdo it - no teleporting pieces.)

The result of this was the 'Ocular' Rotation System, which was made by... looking at each piece and orientation and drawing the 'best' position(s) for it to land in after rotating (following above points and gut feeling).

I present to you - the Ocular Rotation System Heatmap:

Ocular Rotation System Heatmap

How to read it: This heatmap is created by considering each combination of (piece, orientation, rotate left or right). By overlapping all the new possible positions for a piece after rotation, one gets a compact visualization of where the piece will most likely land, going from brightest color (yellow, first position attempt) to darkest (purple, last attempt):

Here's a comparison with SRS - the Super Rotation System Heatmap:

Super Rotation System Heatmap

With SRS one starts to spot some rotational symmetries (you can always rotate back-and-forth between two positions), but I think it's overshadowed by all the asymmetrical kicks and very lenient (downwards and upwards) vertical kicks that contribute to SRS' unintuitiveness.

</details>

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 :-), hence it's name, Ocular rotation system.

<details> <summary> Ocular Rotation System - Comments </summary>

The general rationale behind most kicks is, "these first kicks feel most natural, any additional kicks after that serve flexibility, cool tricks and skill ceiling". However, more concrete heuristics can be stated:

*Notation: nTlr 0-3 describes kick positions 0 to 3 when rotating a north-facing T-piece to the left or right.

Ocular Rotation System Heatmap

</details>

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;

<details> <summary> Creating a Locking System. </summary>

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

<details> <summary> Classic Lock Down </summary>
</details>

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.

<details> <summary> Infinite Lock Down </summary>
</details>

The standard recommended by the guideline is therefore extended placement lock down.

<details> <summary> Extended Placement Lock Down </summary>

(*This probably misses a few edge cases, but you get the gist.)

</details>

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.) <sup>(*Also note that after the 15 moves run out one can still manipulate the piece till lock down.)</sup>

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 <sup>*technically arbitrarily many</sup> piece manipulations (2.) while still fulfilling the other points :D

<details> <summary> 'Timer' Placement Lock Down </summary>

Let 'ground time' denote the amount of time a piece touches a surface

Nice.

</details>

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.

</details>

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:

<details> <summary>Scoring Formula</summary>
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"
</details> <details> <summary>Table of Example Bonuses</summary>

A table of some example bonuses:

Score bonusAction
+10Single
+40Double
+90Triple
+160Quadruple
+40?-Spin Single
+160?-Spin Double
+360?-Spin Triple
+40Single (2.combo)
+90Single (3.combo)
+160Single (4.combo)
+90Double (2.combo)
+160Double (3.combo)
+250Double (4.combo)
+160Triple (2.combo)
+250Triple (3.combo)
+360Triple (4.combo)
+320Quadruple (2.B2B)
+480Quadruple (3.B2B)
+640Quadruple (4.B2B)
+1'000Perfect Single
+16'000Perfect L-Spin Double
</details>

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' obsession 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

moverotate
a d
z x

Frequent advice I saw was: "choose what feels best for you", which sounds about right. :P (*though some mentioned one should not hammer spacebar for hard drops, though it's the only button the Guideline binds to 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.

<details> <summary>Tetrs Terminal Menu Graph</summary>

tetrs menu graph

</details>

Combo Bot

Screencast from 2024-09-08 22-21-45 combot-demo.webm

Background

The goal of 'Combo Mode' is to keep a combo going for as many pieces/lines as possible, where combo is maintained by clearing lines with consecutive pieces.

The fact that this is playable as its own gamemode is due to a special strategy known as the 4 wide (3 residual) combo setup. Here the board is completely filled except for a dedicated 4-wide vertical tunnel, at the bottom of which there are always 'residual' cells that help in clearing a line with the upcoming piece.

It turns out there are only a finite number of these configurations inside a 4-wide well are useful to keep a combo:

<details> <summary>

Image of 4-wide 3-residual combo setup configurations.

</summary>

Notice how due to the fact that a tetromino consists of 4 cells, clearing a line like this will always leave another 3 cells in the otherwise perfectly vertical 4-wide tunnel.

harddrop.com 4wide 3res combo continuations

Graphic with modifications courtesy of harddrop.

</details>

Problem Approach

Given finite piece preview and armed with the knowledge of how these combo states transition into each other, how do we find the best way to continue a combo?

It turns out we can model this as a graph where we can see what paths we can take from our current state:

<details> <summary>

Graph of all states reachable with 4 preview pieces + hold.

</summary>

4-lookahead state graph

</details>

Different decisions can be made depending on whether we hold the current piece and use a different one, which can radically change the outcome.

What the bot does with this is to look at the farthest states it finds, and chooses the branch that maximizes depth and possibilities to make different decisions later on (states with many continuations).

While humans can vaguely do this for certain previews and also get a feeling for it, one can program a computer to do this automatically even for larger preview sizes (see a 12-lookahead state graph here).

The bot currently only supports lookahead up to 42 (number of bit triplets that fit into u128), although it already tends to get quite slow for values half of that. As we'll see, it still does pretty okay for reasonable preview sizes.

Results and Evaluation

<details> <summary> Sidenote: <i>weeeeeee</i> </summary>

Screencast from 2024-09-08 22-52-51 combot-weee.webm

</details>

In short, the bot is pretty good and gets exponentially longer combos with larger preview, as can also be seen from the following chart.

SamplesRandomizerLookaheadMedian comboAverage comboMaximum combo
10000'bag'0811114
10000'bag'11722232
10000'bag'22940426
10000'bag'35076695
10000'bag'4821291434
10000'bag'51502442435
10000'bag'63005025028
10000'bag'754098510663
10000'bag'81123212624040
10000'bag'92255419954664

It is also interesting to note the differences depending on the randomizer used to generate the next pieces.

<details> <summary> Another table. </summary>
SamplesRandomizerLookaheadMedian comboAverage comboMaximum combo
100000'uniform'31523287
100000'balance-relative'32333470
100000'bag'35074988
100000'bag-2'32537555
100000'bag-3'32029363
100000'bag-2_restock-on-7'32128328
100000'bag-3_restock-on-7'32128328
100000'recency-0.0'31523303
100000'recency-0.5'33455734
100000'recency-1.0'34673918
100000'recency-1.5'358921238
100000'recency-2.0'3681071477
100000'recency'3761201592
100000'recency-3.0'3831291837
100000'recency-7.0'31181842715
100000'recency-16.0'32233744961
100000'recency-32.0'32583479870998
</details>

Additionally, these benchmarks also produce visualization of the actual distribution of combos:

combo distribution, recency, 0-lookahead, 1'000 samples

<details> <summary> It is interesting to note how these distributions can spread out quickly for higher lookaheads. </summary>

combo distribution, recency, 4-lookahead, 2'500 samples

</details> <details> <summary> Running the bot on a uniform randomizer for 1'000'000 samples yields a much nicer, smooth curve. </summary>

combo distribution, uniform, 1-lookahead, 1'000'000 samples

</details>

Basically all common randomizers I implemented will have a exponential-decay-looking curves smoothing out for many samples like this.

According to some programming Okey_Dokey did on 4wide combos, there are deadly sequences of 'bags' that will kill the combo (and needless to say, randomizers which allow truer forms of randomness can kill even more easily). This somewhat explains that the curves are at least somewhat of exponential (decay) nature, as it gets more likely over time that the bot slips up and a random sequence appears that kills the combo.

<details> <summary> One more thing - I lied about all distributions looking the same. </summary>

Look at this curious chart for the bag randomizer, which despite a million samples does not seem to smooth out:

combo distribution, bag, 1-lookahead, 1'000'000 samples

What's happening with these obvious valleys and peaks? Upon further inspection, the peaks are at: 6, 13, 20, 27, 34, 41, ... can you spot the pattern?

The answer is that the most of the runs die toward the end of a (7-piece) bag, because the bot optimizes paths expecting arbitrary pieces, but due to how this randomizer works every 7th piece is in fact never random, while every 7th+1 piece is completely random. That's why the bot is tripping over hurdles at those points, and shows that it is not actually making use of all the information that would be available to make the best decisions.

</details>

Overall this is an interesting topic in the overlap of tetromino randomizers, graph theory and a bit of statistics. There's a larger space to be explored here - 4wide 3res is not the only combo setup (and this bot is pretty hardcoded in that respect). An interesting project would be to do something with 4wide 6res, which I heard is more resilient to combo killers, but that might have to wait for some other time :-)

[!NOTE] The bot can be enabled to run in Combo Mode with a cmdline flag (./tetrs_tui -e).

To make it output the lookahead graphs, a feature is needed at compile time: cargo run --release --features graphviz.

To produce statistics, cargo test <"simple"|"lookaheads"|"randomizers"> was used.

Miscellaneous Author Notes

In the two very intense weeks of developing a majority of this project I had my first proper learning experience with building: a larger Rust project, an interactive game (in the console no less), and the intricacies of modern tetrs mechanics, all at the same time.

Gamedev-wise I can mention the modern game loop; Finding a proper abstraction for Game::update (allowing arbitrary-time user input, making updates decoupled from framerate) wasn't so obvious at the beginning.

Frontend-wise I may as well have used Ratatui, but decided to just do some basic menus using trusty Crossterm for cross-platform terminal manipulation. However, next time I should use a TUI crate so as to sleep more peacefully at night not having to take responsibility for the horrible ad-hoc code I wrote for the interface aaaAAAA .

On the Rust side of things I read / learned about / read:

All in all, Rust (known for its safety and performance while still providing ADTs) - proved to be an excellent choice for this project!

Also, can we appreciate how nice the name tetrs fits for a Rust game that does not infringe on TTC's copyright? <sup>though there were like a million other tetrs's on GitHub before me oof</sup>.

Other stuff:

„Piecement Places!“ - CTWC 2016.

<div align="center">

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

</div>