# hatetris: a Tetris that always deals you the worst piece

> hatetris is the source of a web Tetris whose opponent chooses each piece to be as unhelpful as possible, and the repository doubles as a small research archive on unbeatable piece selection algorithms. It also exposes a clean interface for writing your own selection AI, with a determinism requirement that the documentation explains carefully.

**qntm/hatetris** — Tetris which always gives you the worst piece

- Repository: https://github.com/qntm/hatetris
- Stars: 896 · Forks: 44
- Language: TypeScript
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/qntm-hatetris

## The problem: a Tetris where the pieces are chosen against you

Ordinary Tetris gives you a sequence of pieces from a random bag, which means the game is mostly about your own decisions. The pieces are an obstacle course that happens to be random.

hatetris changes the premise. The project description is one line: Tetris which always gives you the worst piece. The game computes, for each piece type, what would happen if the player played that piece perfectly, ranks the outcomes, and then serves the piece whose best case is the least good for you. It is the same game, with the dealer working against the player.

The audience is two groups, and the repository serves both. The first is players, who use the running site at qntm.org/hatetris, of which this repository is the source code. The second is people interested in the mathematics, because the AI list in the documentation is a small, curated history of the problem.

The code is TypeScript, with a package version of 1.2.0, the MIT licence, and a repository that is not archived with the last push on 2026-10-12. There are no GitHub releases, so the site is deployed from the repository rather than from a tagged artefact, and the version in package.json is the version of record.

The technical interest is in the piece selection problem, which turns out to have a surprisingly clean answer, and the project is at its best when it is read as a worked example of that answer rather than as a game.

## The AI list, including two results that proved Tetris unwinnable

The documentation lists the available opponents, and two of them are named after published results.

HATETRIS is the original and, in the author's phrase, the worst. It was designed by qntm in 2010, and the intent was to be the toughest piece selection AI ever. HATETRIS Mild is a slightly simplified version that omits a piece of specialised logic intended to prevent wells from repeating. That omission has a consequence the documentation spells out: because a well can repeat, it is possible to play against HATETRIS Mild forever and get an unbounded number of lines. The source link points at a test in the repository that demonstrates it.

LOVETRIS is the trivial case: it provides I pieces every time.

Brzustowski, from 1992, implements the algorithm described in John Brzustowski's paper Can you win at Tetris?. The answer to the question is no, Brzustowski proved it is impossible to play against this algorithm forever, and he was the first to prove that result.

Burgiel, from 1997, implements the algorithm from Heidi Burgiel's paper How to lose at Tetris. Burgiel provides a much simpler algorithm which pays no attention to the current state of the well when selecting the next piece, and simply alternates S and Z pieces forever, and proves that this algorithm too is undefeatable.

So the list runs from the trivial to the theoretically perfect: I pieces forever, S and Z alternating forever, a proven unbeatable algorithm from 1992, a simplified 2010 version that can be looped indefinitely, and the full version designed to be as bad as possible for the player.

## The AI interface: three arguments and a piece identifier

Writing a custom AI is documented in detail, and the interface is small enough to describe in a paragraph.

An AI is a possibly asynchronous JavaScript function expression, or an arrow function, called every time the game needs a new piece. The example in the documentation returns a constant stream of I pieces unless the well is set up for a Tetris, in which case it returns an S piece:

```js
(currentWellState, currentAiState, getNextWellStates) => {
  const nextWellStates = getNextWellStates(currentWellState, 'I')
  for (const nextWellState of nextWellStates) {
    if (nextWellState.score === currentWellState.score + 4) {
      return 'S'
    }
  }

  return 'I'
}
```

The first argument is the current well state object, which has the form of a well and a score. The well is an array of twenty binary numbers, one per row from top to bottom, where each bit in each number represents a cell in that row, zero if the cell is clear and one if it is obstructed. The least significant bit represents the first cell, so a value of 0b0000000011 means the leftmost two cells in the row are occupied. The score is the current score, a non-negative integer.

The third argument is the helper that does the heavy lifting. getNextStates takes a well state and a piece identifier, one of I, J, L, O, S, T or Z, and returns an array of every possible new well state that could ensue, taking into account every possible location where the player could land the piece. The documentation is explicit about why this helper exists: it makes it possible to model future possibilities without laboriously reimplementing all of the game's movement code.

## Determinism and the AI state, which is where replays are won or lost

Two rules govern a custom AI, and both exist to protect the replay system.

The first is that the function must be deterministic, otherwise replays cease to work. The documentation is direct about the two usual culprits: do not use Math.random and do not refer to the current date. A piece selection algorithm that is not a pure function of the game state cannot be replayed, because replaying it produces a different game.

The second rule concerns the AI state value. When spawning the very first piece, that value is undefined. For later spawns, it is whatever value the AI returned last time. The documentation says this value should be considered deeply immutable, because mutations break the behaviour of undo and redo, which in turn breaks replays. If the value is an object or an array and you need to change it, such as adding a property or splicing content out of the array, you have to clone it first.

The return value has two forms. Normally it is a piece identifier, and the AI state is then implicitly left unchanged for the next call. Alternatively it is an array of a next piece identifier and a next AI state, and that state is passed back in on the next call. The documentation shows the state being used to alternate pieces:

```js
// Ignore well state. Return S and Z pieces, alternating
(_, currentAiState = { last: 'Z }) =>
  currentAiState.last === 'S'
    ? ['Z', { last: 'Z' }]
    : ['S', { last: 'S' }]
```

Note the default parameter, which handles the undefined first call, and note the immutability rule again in action: the returned object is a fresh literal rather than a mutation of the previous one.

More capable AIs use the helper to search further. The default HATETRIS algorithm uses getNextStates to find all possible outcomes for all possible pieces, ranks those outcomes to find the best for each piece, and then selects the piece with the worst best outcome. Other AIs could plug those possible futures back into the helper to explore deeper, or use different heuristics to rank the wells and prune the search tree.

## The build and the test rig: 100 percent coverage and a browser pass

The package.json is a good description of how the project is built and, more usefully, how it is checked.

The build is a single script that runs a Node build script, and the type check is tsc with noEmit. The interesting one is the unit command:

```json
    "unit": "node --enable-source-maps --import ./register.js --require global-jsdom/register --test --experimental-test-coverage --test-coverage-functions=100 --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-exclude \"**/*.test.{ts,tsx}\" -- \"**/*.test.{ts,tsx}\"
```

The coverage thresholds are set to 100 for functions, lines and branches, with the test files themselves excluded from the measurement. That is an unusually strict bar, and it is the kind of constraint that keeps a project with a lot of small pure functions honest. The test runner is the Node built-in test runner, with a global DOM registered so the React components can be tested, and source maps enabled so failures point at the original TypeScript rather than the bundle.

The full test script chains everything together, and the order matters:

```json
    "test": "npm run eslint && npm run tsc && npm run unit && npm run cypress-dev && npm run build && npm run cypress-built"
```

So linting, then type checking, then the unit tests, then the end to end suite against the development server, then a production build, and then the end to end suite again against the built output. The Cypress runs are driven by start-server-and-test against a local URL, and the repository carries a cypress/ directory, a cypress.config.ts, a build-workflow.yml, an .eslintrc.cjs, an eslint.config.js, a tsconfig.json, a register.js and a loader.js for the ES module setup, plus a papers/ directory and a config/ directory.

## The eval warning, which is the price of a game that runs your code

The documentation has a section that deserves to be read twice by anyone who writes an AI, and it is about eval.

The question is whether the game uses eval internally and whether that is a security risk. The answer given is yes, and yes: you are at mortal risk of attacking yourself, and the instruction is not to paste code into HATETRIS unless you trust it.

This is the honest cost of the design. The game is a browser application whose selling point is that anyone can drop a piece selection function into it, and the simplest way to do that in a browser is to evaluate the source text. The mitigation is not technical hardening, it is the rule that the code you paste has to be code you have read.

That has consequences for how you would use the project. Pasting an AI from a chat model or a forum post into a game running on your machine is a different act from installing a package, and the documentation wants that difference to be visible. It also means that the custom AI feature is a developer feature rather than an end-user feature, which is consistent with the rest of the repository being a source archive with a test suite that demands full coverage.

For a reader evaluating the project, the right question is not whether eval is used, since the answer is given, but whether the audience for the project understands the rule that comes with it.

## Licence, releases and the case for reading this as a research artefact

The licence is MIT, stated in the package metadata and in a LICENSE.txt file at the root of the repository, so the code can be reused, modified and shipped in a commercial product with the notice retained.

There are no GitHub releases, and the running site is at qntm.org/hatetris with this repository as its source. The project is not archived and the last push was on 2026-10-12, so the code is being worked on, but the deployment is continuous rather than tag driven, which is consistent with a project whose artefacts are a website and a set of algorithms.

The right way to read hatetris is as a research artefact with a game attached. The AI list is a curated set of results, two of them with published proofs attached, and the interface documentation is written for someone who wants to understand or extend the piece selection problem. The game is the demonstration.

That framing also explains the boundaries. If you want a modern Tetris with a good interface, this is the wrong project, because the game exists to be hostile. If you want a general framework for evaluating algorithms, this is the wrong project too, because the interface is specialised to a seven piece bag and a twenty row well. And if you want to embed a piece selection AI in your own game, the interface and the helper function are the parts worth taking, not the React application around them.

One more artefact points the same way. The Related section links to a separate repository, HATETRIS-public-beamsearch, which suggests the search strategy is still an active line of work rather than a settled implementation.

## Conclusion

Adopt hatetris if you want to play Tetris against an opponent that actively punishes you, or if you want a small, well documented reference implementation of a piece selection algorithm and the interface needed to write your own. Do not adopt it if you want a modern Tetris clone, because the rules are the point rather than the game, and do not paste unreviewed code into it, since the game evaluates the AI you supply. Before you write an AI, read the determinism rules and keep the AI state immutable, because a mutation there breaks undo, redo and replays rather than producing a visible error.

## FAQ

### What is hatetris and how does its AI choose pieces?

It is a Tetris implementation whose opponent always deals the worst piece. The default HATETRIS algorithm uses getNextStates to find all possible outcomes for all possible pieces, ranks the outcomes to find the best result for each piece, and then serves the piece with the worst best outcome.

### Which piece selection algorithms does hatetris implement?

HATETRIS, the original 2010 algorithm designed to be the toughest, HATETRIS Mild, a simplified version that omits the logic preventing wells from repeating, LOVETRIS which always gives I pieces, Brzustowski's 1992 algorithm from Can you win at Tetris?, and Burgiel's 1997 algorithm from How to lose at Tetris.

### How do I write a custom AI for hatetris?

Provide a possibly asynchronous JavaScript function expression taking currentWellState, currentAiState and getNextWellStates, and return a piece identifier or an array of a piece identifier and the next AI state. The function must be deterministic, so no Math.random and no current date, and the AI state must be treated as deeply immutable because mutating it breaks undo, redo and replays.

### Is it safe to paste custom AI code into hatetris?

The documentation states plainly that the game uses eval internally, that you are at mortal risk of attacking yourself, and that you should not paste code into HATETRIS unless you trust it.

### What licence is hatetris released under?

MIT, declared in the package metadata and in the LICENSE.txt file at the root of the repository. The package version is 1.2.0 and the repository has no GitHub releases.

## Sources

- [Issues](https://github.com/qntm/hatetris/issues)
- [License: MIT](https://github.com/qntm/hatetris/blob/main/LICENSE)
- [qntm/hatetris on GitHub](https://github.com/qntm/hatetris)
- [README](https://github.com/qntm/hatetris/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/qntm-hatetris
