n-player Tetris, with | |
---|---|
- local multi-player | - local hi-scores |
- customisable Tetrominoes | - mobile touch functionality |
- resizable play field | |
- redefinable controls | - and ... bombs?!? |
Deployed at tentris.dbb.tools via Netlify and on Github Pages.
tentris.mp4
Mobile | Multiplayer |
---|---|
![]() |
![]() |
Move pieces left and right, and rotate them, to fill rows of your play field. Don't let the Tetrominoes reach the top row!
- Players 1 and 2 have default controls. Click the control listing in the player's key to (re)define the control for that player.
- Complete a line (or many at once) to block lines in all other player's fields.
- Knock out all other players to win!
- Click again to blow up a section of your play field.
- Layout starts to have problems at around 100x100.
- HTML - CSS - JavaScript |
- StackOverflow - Coffee |
---|
These instructions will run a copy of the project on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system.
Open index.html
in a web browser.
Deploy to any static site hosting service, e.g. Github Pages.
- globalPlayers holds an array of TetrisGames, which populate the DOM with their UI on initialisation.
- Each TetrisGame holds references to its DOM elements for rendering, a map of the cells occupied by the placed Tetrominoes in its landedShape, and an activeTetromino.
- The keypress handler passes input through the control handlers to the method on the target player's active Tetrominoes:
function handleKeyPress(e) { // ... inputKeyBindings[e.code].control[e.type](keyBoundPlayerIndex, e.repeat) // ... }
- mobile touch controls are handled in fundamentally the same manner.
- the globalGameStateManager starts, stops, and resets games.
- Checks for mobile browsers to enable touch functionality on mobile devices with high resolution screens.
- Debug Modes for more console feedback and an auto pause, that maintains user input, after the first block would hit the bottom of the play field at default game speed to aid debugging.
- Default settings set the initial time between game ticks and score scaling.
- Initial tetromino matrix meshes which are transformed into renderable offsets of cells for rendering via their css classes.
- inputKeyBindings hold key mappings for each player and translate these through the playerControls object to each
TetrisGame
. - gameTimers on each player's
TetrisGame
control the interval between each processing of a gameTick. EachgameTick
, rotation, or horizontal movement triggers a check that the next space is free to move or rotate into, moves the shape if possible, and checks for any completed rows. - The hiscoresManager handles storing and retrieving the Leaderboard and high scores when each player loses their game, and populates the scrolling hiscores display.
- The shapeCreator displays a modal overlay allowing the user to create their own custom Tetrominoes, managing any necessary playfield size changes required to correctly deploy the new shapes.
- A window resize listener retains playfield aspect ratios as the window is resized.
Software Engineering Immersive Project 1 - MyFirstJavaScript
Individually, build a game from the subset of choices available in /brief/briefs.
Top of the difficulty list? 2 player tetris. So here's n-player Tetris.
Timeframe: 7 days.
Once one-player tetris is working, as a "player" or "playspace" object in JS, it should be easy to duplicate that object, then add some extra functionality to make the player objects interact.
Handle all gameplay in a data layer (ie JavaScript objects), use HTML DOM only to render the state of the data layer.
As all JavaScript will be in a single file, code order is not entirely optimal for readability to ensure correct initialisation order.
Github Projects Beta provided a framework to assemble a Kanban style board of issues, broken down into the smallest possible steps, to ensure a working MVP was released asap. Github issues provided an organised place to include pseudocode where possible solutions occurred to problems that were not yet prioritised.
Designed with mobile in mind, the original design required little more than a max-width media query to switch the main Section flex direction to column to achieve a functional mobile view.
MVP - 1 player desktop Tetris | 1 player mobile view | multi-player desktop view |
---|---|---|
![]() |
![]() |
![]() |
Tetrominoes:
Although I had wanted to follow a TDD-lite approach, building tests where possible, I was unable to get Jest up and running with the knowledge I had at the time and moved on without it. There were certainly a number of bugs in the development process that could have been resolved a lot faster if the developer's errors had been exposed by unit tests, even if only written during the troubleshooting phase. This was particularly apparent when certain parts of the tetromino movement and interactions were reversed.
- Use a 2D matrix to hold the play field data layer. Inject this into the HTML to retain flexibility.
- Use a CSS normalizer.
-
Basic DOM layout to provide containers for JS DOM manipulation.
-
Represent the simplest tetromino in the data layer.
-
Render tetromino on page.
-
Core gameplay ticks - piece moves down, stops when it hits a border or an occupied space.
-
Player control - movement.
-
More Tetromino shapes.
-
Tetromino rotation.
-
Game Over handling - alert user, stop gameplay.
-
MVP DONE - We have a game that meets the specified brief.
-
Refactor to a Class, and duplicate the player object.
-
Separate controls.
-
Competitive interactions - one player completing a row adds to other's blocked rows.
-
2-Player complete - Stretch goals met, on to fun additions.
-
Wallkicks
-
Game art - Draw pixel art into the playFields for, eg, GameOver.
As we've built in a flexible manner, show that off :
-
Dynamically resize play fields.
-
Create Custom Shape modal with colourpicker.
-
Bombs? Bombs.
-
Basic DOM layout:
- Use blocks of bright colours to fill the initial dom layout and inject playfield:
const playMatrixHeight = 24 const playMatrixWidth = 16 const playMatrix = [] // * Build play window function buildPlayMatrix(height, width){ for (let x = 0; x < height; x++){ playMatrix.push([]) for (let y = 0; y < width; y++){ const playCell = document.createElement('div') playCell.textContent = `${x}, ${y}` playMatrixView.appendChild(playCell) playMatrix[x].push(playCell) } } return playMatrix } buildPlayMatrix(playMatrixHeight, playMatrixWidth)
-
Represent the O tetromino in the data layer, and
-
- Build a reusable object that takes the desired shape as a 2x2 matrix:
class Tetromino { constructor(shapeOffsets, fillColor = 'red'){ this.location = TetrominoSpawnRef this.occupiedSpaces = shapeOffsets.map((offset)=>{ return [TetrominoSpawnRef[0] + offset[0],TetrominoSpawnRef[1] + offset[1]] }) this.fillColor = fillColor } colorPlayMatrixView(){ console.log('coloring', this.fillColor) this.occupiedSpaces.forEach((space)=>{ playMatrix[space[0]][space[1]].style.backgroundColor = 'red' //`"${this.fillColor}"` }) } } activeTetromino = new Tetromino([[0,0], [0,1], [1,0], [1,1]]) activeTetromino.colorPlayMatrixView()
- Build a reusable object that takes the desired shape as a 2x2 matrix:
-
Core gameplay ticks:
- Build a function that moves the active tetromino in the data layer, then re-renders the playfield. Call that function on a timer to progress the game. (If only I knew about React at this stage!)
function gameTick(){ activeTetromino.baseLocation[0]-- activeTetromino.updateOccupiedSpaces() activeTetromino.colorPlayMatrixView() } const gameTimer = setInterval(()=>{ gameTick() },400)
- Why is the game state getting into an infinite loop of thinking it has a collision when spawning a new tetromino?
- Because there's an accidental reference between Tetromino current location from spawn location. Break it.:
- this.baseLocation = TetrominoSpawnRef + this.baseLocation = [...TetrominoSpawnRef]
- Build a function that moves the active tetromino in the data layer, then re-renders the playfield. Call that function on a timer to progress the game. (If only I knew about React at this stage!)
-
Player controls:
- Speed up the game / drop pieces by simply speeding up the game timer on keypress. Reuse our collision detection functionality to detect if horizontal movement should be allowed.
- How can we DRY with all the inputs that will be required, and design to accommodate upcoming multiplayer?
This was an interesting exercise in thinking through object boundaries and how to avoid generating excessive numbers of duplicated methods.- Define some control functions
const playerControls = { speedUpPlay: { name: 'Speed Up', keydown(){ if (isGameOngoing){ setTickSpeed(gameTickTime / 5) } }, keyup(){ if (isGameOngoing){ setTickSpeed() } }, // ... other controls },
- Assemble an object to reference them by a keyCode (and include a symbol for the controls legend)
const playerInputScheme = { ArrowDown: { name: '↓', control: playerControls.speedUpPlay, }, // ... other controls }
- Capture keystrokes, and pass them through the control handlers:
function handleKeyPress(e) { try { playerInputScheme[e.code].control[e.type]() } catch (err) { console.log('unrecognised key event:', e.code, e.type) } } document,addEventListener('keydown', handleKeyPress) document,addEventListener('keyup', handleKeyPress)
- Define some control functions
-
More shapes, and ...
-
Rotation
- After a little research and trial-and-error, discover that you can rotate 2-dimensional matrices by transposing, then reversing:
function rotateMatrix(matrix, isClockwise = true){ //rotate clockwise by default if (isClockwise){ //transpose, then reverse row content return matrix.map((val, index) => matrix.map(row => row[index]).reverse()) } //transpose, then reverse column content return matrix.map((val, index) => matrix.map(row => row[index])).reverse() }
- After a little research and trial-and-error, discover that you can rotate 2-dimensional matrices by transposing, then reversing:
-
Game Over handling - alert user, stop gameplay.
-
MVP DONE - We have a game that meets the specified brief.
-
Refactor the game into a Class, and duplicate it as a Player object.
- Pleasantly simple! Move some DOM references into a Class, set up an array to hold all the players, and push new player, then build a playfield for them!
const players = [] class TetrisGame { constructor(playerNumber = 1, displayParent = pageMain){ this.playerNumber = playerNumber this.playerName = 'player' + playerNumber this.displayParent = displayParent this.coreHTML = playerCoreHTML this.initPlayspace() } initPlayspace(){ const newPlayerSection = document.createElement('section') newPlayerSection.classList.add(this.playerName) newPlayerSection.innerHTML = this.coreHTML this.displayParent.appendChild(newPlayerSection) this.playerSection = newPlayerSection } } players.push(new TetrisGame) buildNewPlayMatrix(rows, columns, playMatrixView)
- Pleasantly simple! Move some DOM references into a Class, set up an array to hold all the players, and push new player, then build a playfield for them!
-
Separate controls.
- Attach a
playerId
to each set of controls, and pass that through the control handlers to call the required method on the correct player's object without duplicating code or objects.
- Attach a
-
Competitive interactions - one player completing a row adds to other's blocked rows.
- Some refactoring to correctly handle moving another playfield against the normal flow of the game.
-
2-Player complete - Stretch goals met, on to fun additions.
- A titanic battle - allowing responsive design, that resizes across screen sizes and with additional players being added, whilst retaining SQUARE cells without spaces between them. After nearly an entire day spent on various attempts that almost met all of these criteria, a solution was found. Allow FlexBox to set one dimension of a cell, then use JS to set a CSS variable which fixes the other.
-
Wallkicks - Attempted rotation into an occupied or offgrid space attempts to move the tetromino away form the obstruction. If that movement is permitted, move the tetromino.
- Surprisingly slow to convert from pseudocode to functioning due to an error in the chosen vector through the playfield.
-
Game art - Draw art into the playFields for, eg, GameOver.
As we've built in a flexible manner, show that off :
-
Dynamically resize play fields.
-
Create Custom Shape modal with colourpicker.
-
Bombs? Bombs.
- An interesting exercise in DOM manipulation gathering the correct element from the cursor, and recursively mapping an explosion.
- If the game is ended, the reset button requires two clicks to reset the game state.
- Inconsistent behaviour had been observed the using the bomb. The fixes in place do not seem to have resolved this in all circumstances.
- Retaining aspect ratios with responsive design and flexboxes is a little tough.
- Project went broadly to plan. Vertical execution of the basic game followed by horizontal expansion into multiplayer worked well.
- JavaScript understanding greatly improved.
- Built an understanding of DOM event bubbling.
Project Board / Outstanding Issues
- Better styling. It was supposed to be retro, but this is maybe too retro.
- Sound effects
- Display next incoming shape to user, and allow swapping current shape into storage.
- Refactor the checks for next occupied spaces to reduce the number of intermediate states that are stored on the Tetromino objects.
- Reduce some of the mutually exclusive flags to enums (eg debug modes, gameOngoing states).
- Refactor the
Tetromino.moveDown
method into.move([vector])
. Alternatively, MoveDown is a special state as it can triggerTetromino.addToLandedShape
, so it may be clearer if it remains separate.
- Keep committing frequently, this makes backtracking and experimenting easier.
- Quickly building some testing or console.assert() to ensure that difficult to visualise code is behaving as expected would be very valuable.
- CSS variables.
- Keeping a project board as a place to drop issues and to stay focussed on the next-most-important task is vital.
- A designer would be really valuable to reduce indecision at design and CSS time.