sugarcraft / candy-tetris
Tetris clone built on the SugarCraft stack — SugarCraft runtime, SugarStyle styling. Standard SRS rules (7-bag RNG, ghost piece, hard drop, hold, level/speed curve, line-clear scoring). A real demo of the runtime, not a toy.
Requires
- php: ^8.3
- sugarcraft/sugar-cellbuffer: dev-master
- sugarcraft/sugar-core: dev-master
- sugarcraft/sugar-style: dev-master
Requires (Dev)
- phpunit/phpunit: ^10.5
- sugarcraft/sugar-testing: dev-master
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-05 01:01:16 UTC
README
CandyTetris
Tetris built on the SugarCraft stack. SugarCraft runtime, SugarStyle for the rounded borders and per-piece colours, deterministic 7-bag RNG, ghost piece, hard drop, hold, level-driven gravity ramp, line-clear scoring.
Run it
From a source checkout:
composer install ./bin/candy-tetris # Single player mode ./bin/candy-tetris -v # VS Computer mode ./bin/candy-tetris --vs # VS Computer mode (long form) ./bin/candy-tetris -h # Usage screen (also --help) ./bin/candy-tetris --version # Print the version and exit 0
As a consumer, depend on the published package instead — the bin entry
installs the launcher into your project's vendor bin:
composer require sugarcraft/candy-tetris ./vendor/bin/candy-tetris
A rolling development phar is published on GitHub Releases under
candy-tetris/dev as candy-tetris.phar (with .sha256 and .sig
sidecars); stable cuts ride the same release pipeline on candy-tetris/v*
tags. The launcher detects that it is running from inside a phar and loads
the autoloader bundled in the archive, so the downloaded file needs nothing
else installed.
Requirements: php-cli 8.3+ with ext-intl and ext-mbstring. Both are
hard-required transitively by sugar-core,
whose cell-width math segments through ICU — on a bare php-cli machine
composer install is the step that refuses, naming sugar-core.
--version short-circuits wherever it appears in the argv — even alongside a
bogus flag — and exits 0. Any other unrecognised flag is refused with a usage
note on stderr and exit code 1.
Needs a colour-capable TTY of at least 40 columns by 24 rows (the full
frame is 40x32 — at squeezed heights the sidebar drops its help card
first, and anything under the floor shows a resize notice and freezes
play until you widen back). The VS duel wants 54x29. Empty cells
render as · so the grid stays readable even without colour.
VS Computer Mode
Compete against an AI opponent in split-screen mode. When you clear lines, garbage rows are sent to the computer. When the computer clears lines, garbage rows are sent to you. Last player standing wins!
VS Mode Controls
| Key | Action |
|---|---|
| ← / → | Move left / right |
| ↑ / x | Rotate clockwise |
| z | Rotate counter-cw |
| ↓ | Soft drop |
| Space | Hard drop |
| c | Hold piece |
| p | Pause / resume |
| q | Quit |
Hold works on your field, and the VS sidebar does paint your hold card — it
carries an (used) cue while the slot is spent for the current drop. The
computer's queue stays private by design: the strip shows its score, not its
next piece.
The computer opponent uses weighted heuristics (board height, holes, gaps, lines) to make optimal moves. If both boards top out on the same tick, the computer wins: the player's field is checked first.
Controls
| Key | Action |
|---|---|
| ← / → | Move left / right |
| ↑ / x | Rotate clockwise |
| z | Rotate counter-cw |
| ↓ | Soft drop |
| Space | Hard drop |
| c | Hold piece |
| p | Pause / resume |
| r | New game (after game over only) |
| q | Quit |
The table above is the shipped default. The on-screen help card and the versus strip are derived from the live table, so a remap shows up wherever the game teaches its own keys (see "Remapping keys").
Remapping keys
Write overrides to $XDG_CONFIG_HOME/candy-tetris/keys.json (falling back
to $HOME/.config/candy-tetris/keys.json) as a JSON object that maps an
action id to a non-empty list of key tokens:
{ "hold": ["b"], "hard-drop": ["enter"] }
Action ids: quit, pause, restart, hold, move-left, move-right,
soft-drop, rotate-cw, rotate-ccw, hard-drop, begin. A token is a
single character ("b") or a key name ("left", "down", "space",
"enter", …), case-folded when the file loads. An override REPLACES that
action's whole list; ids you do not name keep their shipped binding, and
the versus table is shared — one file drives both fields of a split.
The edges, honestly:
- No file, no change: launches are byte-identical to the defaults, and the game reads nothing at boot.
- A malformed file is refused LOUDLY — candy-tetris prints
candy-tetris: ignoring unreadable keys file (…)on stderr and launches on the defaults. One bad entry discards the whole file rather than silently applying half a map. - Modifier chords are not in the vocabulary (a deliberate v1 non-goal).
The legacy no-modifier-match semantics are preserved exactly:
Ctrl+qstill quits, because nothing consults modifiers. - Unknown action ids load fine and simply never fire until something dispatches on that id.
- One token may serve two actions (by default
spaceis bothhard-dropandbegin): each screen dispatches in one context.
First run
A solo launch whose record has never seen the on-ramp opens on a card over
the fresh board: what to do (clear lines), what loses (reaching the top),
which keys start play, and the versus affordance (candy-tetris -v). The
start tokens on the card are the live ones — remap begin and the card
says so. Space or Enter (the defaults) dismisses the card and marks
the record onboarded through the same scores.json; q quits without
marking, so a game closed unseen shows the card again next launch. Versus
mode never opens the card (it is advertised on it instead). A machine with
no resolvable HOME cannot write the mark, so the card honestly re-shows
every launch rather than pretending the visit was remembered.
Game over
The game ends when the next piece has no room to spawn, the stack reaches the ceiling, or garbage crushes a piece into the board — the banner painted across the playfield names the reason and the final score. Press r to start a completely new game — the score does not carry over (it embeds the level, so a restarted board falls at level-0 gravity again). q quits.
The record survives the exit. A finished game settles into
$XDG_CONFIG_HOME/candy-tetris/scores.json (falling back to
~/.config/candy-tetris/scores.json): the solo best score, how many solo
games ended, and a win/loss tally for versus matches. The banner reads the
best against your final score and flags new best! when the table top was
beat. Versus games never touch the solo best — the two player boards run
unwired by design. The file is written atomically at mode 0600; a corrupt or
unwritable record is announced loudly on stderr and the game starts fresh
without zeroing the old file, and a refused save is announced while the
in-memory record stays honest. A launch with no resolvable HOME writes no
file and shows no best — byte-identical to the pre-record game.
There is still no saved state: only the record persists, so r restarts from a clean board and quitting mid-drop loses the run (only the record of finished games survives).
Hold
Press c to stash the falling piece into the hold slot; press it again on the next piece to swap it back in. You can only hold once per drop — the slot dims until the current piece locks.
Lock delay
A grounded piece does not lock instantly — you get a real-time window
(~500 ms by default, independent of the gravity speed, so it stays a constant
half-second even at level 29) to slide or rotate it before it settles. The
binary arms it via Game::startWithLockDelay() — single-player only: the VS
boards run plain Game::start(), so a piece locks on the gravity tick that
finds it grounded (no window). Every successful move or
rotation while grounded re-arms the full window, up to the SRS-style 15-reset
cap — once the budget is spent the open window locks on expiry and further
inputs no longer extend it. Moving the piece back off a surface closes the
window and hands timing back to gravity; a hard drop bypasses it and locks
immediately. Pausing extends the window instead of locking a frozen game:
while paused, gravity and lock-delay ticks re-arm rather than step, so a paused
board never idles out from under you — nothing falls, nothing locks, and
unpausing resumes the same window where it stopped.
Scoring
Line-clear scoring
| Lines cleared | Base points × (level + 1) |
|---|---|
| 1 | 40 |
| 2 | 100 |
| 3 | 300 |
| 4 (Tetris) | 1200 |
Level rises every 10 lines. Gravity speeds up at every level — by level 9 pieces fall every 6 frames, by level 29+ they fall every frame. The frame-rate-agnostic Score::framesPerRow() is what the gravity tick consults.
Drop points
Drops pay too, and the award is flat (never scaled by level):
| Action | Points |
|---|---|
| Soft drop (↓) | +1 per cell moved |
| Hard drop (space) | +2 per cell fallen |
A soft drop that cannot move the piece down pays nothing; a hard drop counts
the cells from the piece's position at release to where it comes to rest. The
awards are applied in src/Game.php (softDrop() / hardDrop()) through
Score::withDropPoints().
T-Spin scoring
When the last successful input before a T piece locks was a rotation (moves and drops clear that flag; gravity's fall neither sets nor clears it, so a rotate-then-settle spin is legal) and the guideline three-corner rule holds — at least three of the four cells diagonal to the T's centre are filled, walls and floor counting as filled — it scores as a T-Spin:
| Type | Base points |
|---|---|
| T-Spin | 400 × (level + 1) |
| T-Spin Mini | 100 × (level + 1) |
T-Spin Mini is detected when exactly two of the centre-diagonal corners are filled and at least one of them sits on the nub side of the T: the top pair at rotation 0, the right pair at rotation R, the bottom pair at rotation 2, the left pair at rotation L. Two filled corners with no nub-side corner score nothing — the spin never engaged the slot. A mini that clears two or more lines is promoted to a full T-Spin (difficulty parity).
Last-input rule: the flag is Game::$lastActionWasRotation — set by
rotate, cleared by move/soft drop/hard drop; gravity steps leave it alone. The
corner test runs against the board before the piece is written, on the four
diagonals of the T's centre cell. See Scoring\TSpin::detect().
Back-to-Back (B2B) bonus
Consecutive difficult clears — a 4-line Tetris, or a full T-Spin that actually cleared at least one line — carry a 1.5× multiplier on that lock's bonus points:
- 4-line Tetris after a prior Tetris → 1200 × 1.5 × (level + 1)
- Full T-Spin Single/Double/Triple after a prior eligible clear → base × 1.5
A mini that clears two or more lines is promoted to a full T-Spin and is eligible too. The chain BREAKS on any line clear that is not a difficult clear (1–3 lines, or a small mini); a clear-less lock — even a 3-corner spin, which still pays its bonus — neither arms nor breaks the chain. Arming requires a clear: no-clear spins can never start a B2B run.
Combo bonus
Consecutive line clears (regardless of type) build a combo counter — each combo step adds combo × 10 × (level + 1) bonus points. The counter resets to 0 on any clean (zero-line) piece placement.
Perfect clear bonus
When all lines are cleared at once and the board becomes completely empty, an additional +5000 × (level + 1) bonus is awarded.
Where the seams are
- Bag / RNG:
Bagtakes an injectable pick closure;Game::start(?Bag)andGame::startWithLockDelay(?Bag)thread it through, so tests (and any deterministic replay tool) pin the sequence. - Kick table:
src/Rotation/SrsKickTable.phptranscribes the published SRS offsets verbatim (spec+y= up); the only sign conversion to board coordinates lives atPiece::rotationsWithKicks. - Scoring:
src/Scoring/TSpin.phpandsrc/Score.phpare pure value logic, independent of the renderer. - Modes: VS is a second Model (
VsGame) wrapping twoGameinstances — copy that pattern for a third mode rather than branching insideGame.Game::mutate()is public on purpose: the wrapper (and the tests) plant child-board state through it, whileVsGame::mutate()itself stays private. Narrow your third model the same way. - Renderer:
Renderer::render()is a pure static view function (state → ANSI string); it never mutates the game.
Shared foundations
The single-player playfield is painted into a canonical sugar-cellbuffer Buffer cell grid (10×20 visible); per-tetromino background style and ghost-piece foreground style are applied per-cell. The hold/next/score sidebar and the VS split-screen panels are sugar-style cards joined with its Layout (Layout::joinVertical / Layout::joinHorizontal), composited alongside the buffered playfield. Snapshot tests via sugar-testing pin canonical game states (starting board, T-spin completion, pause overlay).


