DEV Community

Murat Efe Aras
Murat Efe Aras

Posted on • Updated on

Yet another Tetris clone with React

One more thing I wanted to add to the title was “and HTML elements” or “without Canvas” but I did not as It would make the title longer than the introduction. Before I started this small fun project I expected that using HTML elements would be the thing but it turned out that event handlers and react state was the thing.

This will be an article on tips and maybe tricks if you are a seasoned React developer who wants to develop a simple game while staying in the React territory. This is not a React gaming tutorial and if it was the only thing I would say would be “don’t! don’t develop a game with React!”.

On the other hand, developing a game in React definitely made me a better React developer and I strongly advise you to do it to improve your React skills if you have been a forms/lists/fetch developer since you started React development.

Before going over the tips I would like to inform you that all code is at https://github.com/efearas/yet-another-tetris-clone and feel free to use it in whatever way you want and if you want to give it a try: https://tetris-clone.s3.us-west-2.amazonaws.com/index.html

Tip 1: Game timer

While playing you may happen to think that you are in control as you are holding the controller but you are not, it is the game timer who is in charge of controlling the whole game and painting the next scene you are about to experience.

The problem about the timer(setInterval, setTimeout) which is actually an event(other event handlers also have the same problem) does not have access to the final state, what it has as a state is what state was present when the event was declared.

To overcome or maybe workaround this problem I created a state variable called timer and a useEffect function to watch this state variable which triggers a setTimeout to create a game loop.

const [timer, setTimer] = useState(0);

useEffect(
    () => {
        setTimer(1)
    }, []
)

useEffect(
    () => {
        if (timer > 0 && gameRunning) {
            tick();
            setTimeout(() => {
                setTimer(timer + 1);
            }, GAME_INTERVAL_MS);
        }
    }, [timer]
)
Enter fullscreen mode Exit fullscreen mode

Tip 2: Handling key and swipe events

If you are updating state while handling an event, it gets tricky. The event handlers normally use the state when they were first declared not when they are executed. Thankfully there is an alternative version of “setState” function which takes a function as a parameter and feeds that function with the current state as a parameter. Please see useKeyDown hook for details.

const handleKeyDown = (e) => {
        setShapes(
            shapes => {                
                let movingBlock = Object.assign(Object.create(Object.getPrototypeOf(shapes.movingBlock)), shapes.movingBlock)

                switch (e.keyCode) {
                    case 39://right
                        movingBlock.moveRight(shapes.frontierAndStoppedBlocks);
                        break;
                    case 37://left
                        movingBlock.moveLeft(shapes.frontierAndStoppedBlocks);
                        break;
                    case 40://down
                        movingBlock.moveAllWayDown(shapes.frontierAndStoppedBlocks);
                        break;
                    case 38://up
                        movingBlock.rotate(shapes.frontierAndStoppedBlocks);
                        break;
                }

                let currentShapes = { ...shapes }
                currentShapes.movingBlock = movingBlock;
                return currentShapes;
            }
        )
    }
Enter fullscreen mode Exit fullscreen mode

To handle the swipe events on mobile, I created the useSwipeEvents hook which just triggers keydown events that have already been implemented in useKeyDown.

Tip 3: Drawing shapes

All Tetris shapes consist of 4 squares positioned differently so what I did was to position 4 divs based on the shape type. There is a base class called Shape and the real shapes are derived from this class.

The points property of Shape class stores the points as an array of x and y values.

Tip 4: Moving shapes gracefully

Just applied the transition and transform css properties and the browser took it from there.

Do not worry about the calc and min css functions as they are for handling responsive layout. If you are targeting desktop or mobile only, then you will probably not need them.

const ShapeRender = ({ x, y, color, marginTop, transitionDuration }) => {
    return (
        <div  style={{
            backgroundColor: color,
            width: 'min(10vw,50px)',
            height: 'min(10vw,50px)',
            position: 'fixed',
            transition: transitionDuration ? transitionDuration : null,
            zIndex: 1,
            transform: `translate(min(calc(${x}*10vw),${x * 50}px), min(calc(${y}*10vw + ${marginTop}), calc(${y * 50}px + ${marginTop})))`,
        }} ></div>
    )
}
Enter fullscreen mode Exit fullscreen mode

Tip 5: Flashing animation

When a row of blocks without a space collapses (the aim of the game) a flashing animation occurs on collapsing rows. I used keyframes and styled components to mimic lightning.

const Animation = keyframes`
    0%   { opacity: 0; }
    30%   { background-color: yellow; }
    50%   { background-color: orange; }
    70% { opacity: 0.7; }
    100% { opacity: 0; }
    `;
Enter fullscreen mode Exit fullscreen mode

Tip 6: Rotating shapes

There are many different approaches involving Matrices. Please refer to https://stackoverflow.com/questions/233850/tetris-piece-rotation-algorithm for a thorough discussion. I chose the Ferit’s approach which is; first transpose the matrix representing the shape and then reverse the order of columns to rotate the shape clockwise.

The relevant code is in the rotate method of Shape base class. Since the square does not need to be rotated, the rotate method is overridden in inherited Square class.

 rotate(frontier) {
        this.rotationMatrix = reverseColumnsOfAMatrix(transpose(this.rotationMatrix));
        let leftMostX = Math.min(...this.points.map(([pointX, pointY]) => pointX))
        let topMostY = Math.min(...this.points.map(([pointX, pointY]) => pointY))        
        let newPointsArray = [];

        this.rotationMatrix.map(
            (row, rowIndex) =>
                row.map(
                    (col, colIndex) => {
                        if (col === 1) {
                            newPointsArray.push([leftMostX + colIndex, topMostY + rowIndex])
                        }
                    }

                )
        );

        if (this.isPointsInsideTheFrontier(newPointsArray, frontier))
            return this;

        this.points = newPointsArray;
        return this;
    }
Enter fullscreen mode Exit fullscreen mode

Closing Notes

As Kent C. Dodds says: "I think too many people go from "passing props" -> "context" too quickly." (https://kentcdodds.com/blog/application-state-management-with-react) , I stayed away using Context as much as I can and most of the application state is on component level or using props. Avoid over-engineering and enjoy simplicity!

Top comments (0)