···2626- [x] Make Column/Row count configurable, this allows for way bigger playing fields
2727- [x] Add modern multiplayer, where you have to clear the game together
2828- [x] Config persistence
2929-- [ ] Toggle T-Spins
2929+- [x] T-Spins
3030- [ ] Points for perfect clears
3131- [ ] Scoreboard/Leaderboard
3232- [ ] Add classic Tetris multiplayer (deleted rows will be a penalty for the opponent)
+18-11
block.go
···77type I_Block interface {
88 Init(playerId ff.Peer) I_Block
99 GetId() int
1010+ GetOffset() (int, int)
1111+ GetRotationState() int
1012 Draw(offsetX, offsetY int)
1113 DrawPreview(offsetX, offsetY int)
1214 Move(rows, columns int)
1315 GetCellPositions() []Position
1414- TryRotateWithKicks(grid *Grid, clockwise bool) bool
1616+ TryRotateWithKicks(grid *Grid, clockwise bool) (bool, int)
1517 FitsInGrid(grid *Grid) bool
1618}
1719···2426 Id int
2527 Cells [4][]Position
2628 cellPosCache [4]Position
2929+}
3030+3131+func (b *Block) GetOffset() (int, int) {
3232+ return b.RowOffset, b.ColOffset
3333+}
3434+3535+func (b *Block) GetRotationState() int {
3636+ return b.RotationState
2737}
28382939func (b *Block) GetId() int {
···8797 return true
8898}
89999090-// Returns true if rotation was successful, false otherwise
9191-// Adhering to the rules of the SRS system
9292-func (b *Block) TryRotateWithKicks(grid *Grid, clockwise bool) bool {
100100+// TryRotateWithKicks attempts to rotate the block using SRS wall kicks.
101101+// Returns (true, kickIndex) on success, (false, -1) on failure.
102102+func (b *Block) TryRotateWithKicks(grid *Grid, clockwise bool) (bool, int) {
93103 oldState := b.RotationState
9410495105 if clockwise {
···101111102112 kickTests := GetKickTests(b.Id, oldState, newState)
103113104104- // Try each kick test in order
105105- for _, offset := range kickTests {
114114+ for i, offset := range kickTests {
106115 b.Move(offset.Row, offset.Column)
107116108117 if b.FitsInGrid(grid) {
109109- // block is rotated and positioned correctly
110110- return true
118118+ return true, i
111119 }
112120113113- // kick invalid, undo the offset
114121 b.Move(-offset.Row, -offset.Column)
115122 }
116123117117- // kicks invalid, undo rotation
124124+ // All kicks failed — undo rotation
118125 if clockwise {
119126 b.rotateCounterClockwise()
120127 } else {
121128 b.rotateClockwise()
122129 }
123123- return false
130130+ return false, -1
124131}
125132126133func (b *Block) rotateClockwise() {
+124-5
game.go
···211211}
212212213213func (g *Game) HandleLocking(player *Player) {
214214+ tSpin := detectTSpin(&g.Grid, player) // must be before LockBlockToGrid replaces CurrentBlock
214215 dist, success := player.LockBlockToGrid(&g.Grid)
215216 if !success {
216217 g.GameOver = true
217218 g.Screen.SetScreen(ScreenResult)
218219 return
219220 }
220220- g.handleScoring(dist)
221221+ g.handleScoring(dist, tSpin)
221222}
222223223223-func (g *Game) handleScoring(hardDropDistance int) {
224224- // Add hard drop points if enabled
224224+func (g *Game) handleScoring(hardDropDistance int, tSpin TSpinType) {
225225 if CONFIG.HardDropPointsEnabled && hardDropDistance > 0 {
226226 g.Score += hardDropDistance * 2
227227 }
228228229229- // Clear rows and update score (shared for all players)
230229 rowsCleared := g.Grid.ClearFullRows()
231231- if rowsCleared > 0 {
230230+ if tSpin != TSpinNone {
231231+ g.scoreTSpin(tSpin, rowsCleared)
232232+ if rowsCleared > 0 {
233233+ g.Lines += rowsCleared
234234+ if CONFIG.Level != 0 {
235235+ g.UpdateLevel()
236236+ }
237237+ }
238238+ } else if rowsCleared > 0 {
232239 g.Lines += rowsCleared
233240 g.UpdateScore(rowsCleared, 0)
234241 if CONFIG.Level != 0 {
235242 g.UpdateLevel()
236243 }
237244 }
245245+}
246246+247247+// TSpinType distinguishes no T-Spin, Mini T-Spin, and full T-Spin.
248248+type TSpinType int
249249+250250+const (
251251+ TSpinNone TSpinType = iota
252252+ TSpinMini
253253+ TSpinFull
254254+)
255255+256256+// detectTSpin checks whether the player's current block qualifies as a T-Spin
257257+// at the moment of locking. Must be called before LockBlockToGrid.
258258+//
259259+// Detection rules (Tetris Guideline):
260260+// 1. Piece must be a T-block (id 6)
261261+// 2. Last player action must have been a rotation
262262+// 3. At least 3 of the 4 diagonal corners around the T's centre are occupied
263263+//
264264+// Mini vs Full:
265265+// - If the last kick used was index 4 (the ±2-row SRS kick) → Full T-Spin
266266+// - Otherwise: both "front" corners filled → Full; only one → Mini
267267+func detectTSpin(grid *Grid, player *Player) TSpinType {
268268+ if !CONFIG.TSpinsEnabled {
269269+ return TSpinNone
270270+ }
271271+ if player.CurrentBlock.GetId() != 6 {
272272+ return TSpinNone
273273+ }
274274+ if player.lastKickIndex == -1 {
275275+ return TSpinNone
276276+ }
277277+278278+ row, col := player.CurrentBlock.GetOffset()
279279+ centerRow, centerCol := row+1, col+1
280280+281281+ // Count filled diagonal corners (wall/floor counts as filled)
282282+ corners := [4][2]int{
283283+ {centerRow - 1, centerCol - 1},
284284+ {centerRow - 1, centerCol + 1},
285285+ {centerRow + 1, centerCol - 1},
286286+ {centerRow + 1, centerCol + 1},
287287+ }
288288+ filled := 0
289289+ for _, c := range corners {
290290+ if grid.IsCellOutside(c[0], c[1]) || !grid.IsCellEmpty(c[0], c[1]) {
291291+ filled++
292292+ }
293293+ }
294294+ if filled < 3 {
295295+ return TSpinNone
296296+ }
297297+298298+ // SRS kick index 4 always upgrades to a full T-Spin
299299+ if player.lastKickIndex == 4 {
300300+ return TSpinFull
301301+ }
302302+303303+ // Front corners by rotation state (the side the T's prong faces)
304304+ rotState := player.CurrentBlock.GetRotationState()
305305+ var frontCorners [2][2]int
306306+ switch rotState {
307307+ case 0: // prong up
308308+ frontCorners = [2][2]int{{centerRow - 1, centerCol - 1}, {centerRow - 1, centerCol + 1}}
309309+ case 1: // prong right
310310+ frontCorners = [2][2]int{{centerRow - 1, centerCol + 1}, {centerRow + 1, centerCol + 1}}
311311+ case 2: // prong down
312312+ frontCorners = [2][2]int{{centerRow + 1, centerCol - 1}, {centerRow + 1, centerCol + 1}}
313313+ case 3: // prong left
314314+ frontCorners = [2][2]int{{centerRow - 1, centerCol - 1}, {centerRow + 1, centerCol - 1}}
315315+ }
316316+317317+ filledFront := 0
318318+ for _, c := range frontCorners {
319319+ if grid.IsCellOutside(c[0], c[1]) || !grid.IsCellEmpty(c[0], c[1]) {
320320+ filledFront++
321321+ }
322322+ }
323323+324324+ if filledFront == 2 {
325325+ return TSpinFull
326326+ }
327327+ return TSpinMini
328328+}
329329+330330+// scoreTSpin awards points for a T-Spin according to the Tetris Guideline.
331331+// Base scores: Mini(0)=100, Mini Single=200, Full(0)=400,
332332+// Full Single=800, Full Double=1200, Full Triple=1600. All × (level+1).
333333+func (g *Game) scoreTSpin(tSpin TSpinType, lines int) {
334334+ var base int
335335+ if tSpin == TSpinMini {
336336+ switch lines {
337337+ case 0:
338338+ base = 100
339339+ case 1:
340340+ base = 200
341341+ case 2:
342342+ base = 400
343343+ }
344344+ } else { // TSpinFull
345345+ switch lines {
346346+ case 0:
347347+ base = 400
348348+ case 1:
349349+ base = 800
350350+ case 2:
351351+ base = 1200
352352+ case 3:
353353+ base = 1600
354354+ }
355355+ }
356356+ g.Score += base * (g.Level + 1)
238357}
239358240359func (g *Game) UpdateScore(linesCleared, movedDownPoints int) {
+17-3
player.go
···2929 hardDropDistance int // Cells moved by hard drop (for scoring)
3030 softDropCells int // Cells moved by soft drop this frame
31313232+ // T-Spin detection: kick index of last successful rotation, -1 if last action was not a rotation
3333+ lastKickIndex int
3434+3235 gravityAccum float32
33363437 // blogBag contains available blocks
···38413942func newPlayer(peer ff.Peer) *Player {
4043 return &Player{
4141- ID: peer,
4242- canHold: true,
4444+ ID: peer,
4545+ canHold: true,
4646+ lastKickIndex: -1,
4347 }
4448}
4549···5761 distance := p.computeDropDistance(grid)
5862 if distance > 0 {
5963 p.CurrentBlock.Move(distance, 0)
6464+ p.justHardDropped = true // Signal immediate locking
6565+ p.hardDropDistance = distance
6666+ p.lastKickIndex = -1
6067 }
6168 p.justHardDropped = true // Signal immediate locking (even if already at bottom)
6269 p.hardDropDistance = distance
···252259 p.justHardDropped = true
253260 } else {
254261 p.softDropCells++
262262+ p.lastKickIndex = -1
255263 }
256264 return
257265 }
···274282 p.justHardDropped = true
275283 } else {
276284 p.softDropCells++
285285+ p.lastKickIndex = -1
277286 }
278287 p.softDropARRCounter = 0
279288 }
···284293 p.CurrentBlock.Move(0, dir)
285294 if !p.CurrentBlock.FitsInGrid(grid) {
286295 p.CurrentBlock.Move(0, -dir)
296296+ } else {
297297+ p.lastKickIndex = -1
287298 }
288299}
289300···344355345356// rotate attempts to rotate the block
346357func (p *Player) rotate(grid *Grid, clockwise bool) {
347347- p.CurrentBlock.TryRotateWithKicks(grid, clockwise)
358358+ if ok, kickIdx := p.CurrentBlock.TryRotateWithKicks(grid, clockwise); ok {
359359+ p.lastKickIndex = kickIdx
360360+ }
348361}
349362350363// holdBlock swaps current block with held block
···406419 p.NextBlocks[len(p.NextBlocks)-1] = p.GetNewBlock()
407420 p.canHold = canHold
408421 p.gravityAccum = 0
422422+ p.lastKickIndex = -1
409423}
410424411425// Draw renders the player's current block