iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🎉

Building a Bingo App with Next.js, Pusher, and Upstash

に公開

Introduction

The other day, we had a year-end party.
A suggestion came up to do some entertainment, so we decided to play a bingo game.
I created a simple bingo app for the occasion.
This article covers how I implemented that bingo app.

Demo

bingo-game-demo.gif
As shown above, I created a screen for displaying the management number to draw numbers and a screen to display the list of drawn numbers.

Implementation

Technologies Used

Framework: Next.js (App Router)
Deployment: Vercel
Storage: Upstash For Redis
Notification Management: Pusher

I chose Next.js as the framework because it provides both backend and frontend capabilities, and it is the one I am most familiar with.
Another reason was the ability to deploy to Vercel quickly.

For storage, I initially intended to use Vercel KV given its affinity with Vercel.
However, since the documentation stated that Upstash KV should be used for new projects instead, I chose Upstash.
Incidentally, I couldn't find a service specifically named "Upstash KV." Since Vercel KV is a Redis database and Upstash's Redis offering is "Upstash For Redis," I used Upstash For Redis. (I'm not sure if this is exactly what they meant, but it worked for saving data, so I went with it.)

I will explain the reason for using Pusher for notification management later.

Architecture Diagram

bingo.drawio.png
The numbers generated on the admin screen are sent to Pusher, which then notifies the user screens.
Since the user screen maintains a connection with Pusher, it displays the number as soon as it receives the notification.
Drawn numbers are saved so that the list of results is still available even if the screen is reloaded.
This is a simple overview of the architecture.

Why I Used an External Service for Notification Management

For this project's requirements, I didn't feel like there were any complex notifications.
On the UI side, it's just about communicating the drawn numbers and a reset function.
Therefore, it might seem unnecessary to use an external notification service.
Server-Sent Events or WebSockets would likely have been sufficient.

However, the reason for using an external notification service this time was due to Vercel's constraints.
Vercel runs applications by executing individual Serverless Functions.
As noted in the documentation, each Serverless Function has an execution time limit.
Therefore, even if a connection was established on the server side, the connection would be lost after a certain period of time.
Re-establishing the connection every time a function starts is cumbersome. As Vercel mentions in the following article, they suggest connecting on the client side rather than establishing connections on the server.

https://vercel.com/guides/do-vercel-serverless-functions-support-websocket-connections

For this reason, I chose to use Pusher, which is linked in the article above, and had the client establish the connection.
This enabled real-time sharing of the numbers.

There was no deep reason for choosing Pusher as the external service.
It was simply because I found the following easy-to-understand article when I first searched for options:

https://zenn.dev/hayato94087/articles/0c266563626a27

Now, let's look at the actual implementation.
I will focus on Pusher notifications and Upstash For Redis storage.
Please note that I won't be covering the logic for generating the numbers themselves.

Implementation Details

Prerequisites

It is assumed that you have already registered for a Vercel account and that a Next.js project has been created and deployed.

Preparing the Storage

First, click the "Install" button for Upstash on the page displayed at the following URL:
https://vercel.com/marketplace/upstash
Then, you will be able to select the project to link with Vercel, so link the project you created.
2024-12-23_12h03_53.png
Once the linking is complete, click "Install Upstash For Redis."
2024-12-23_12h05_26.png
Next, after selecting the primary region you'll use, you will be able to configure the plan. If you want to use it for free, select the Free plan.
The database creation results and environment variables will then be displayed, so add them to your project's .env file.
The project should already be linked, but if the linking isn't configured in the "Storage" tab within the project, configure the connection settings in that tab.
2024-12-23_12h09_29.png
This completes the setup.

Preparing to operate the storage

As mentioned earlier, add the secret information displayed when you created Upstash For Redis to your environment variable file.
And then, install @upstash/redis.
After installation, add the following to any file:

import { Redis } from "@upstash/redis";
const redis = new Redis({
    url: process.env.KV_REST_API_URL,
    token: process.env.KV_REST_API_TOKEN,
})
export const db = {
    upsert: async ({ id, data }: { id: string, data: string }) => {
        const result = await redis.get<string | null>(id)
        if (!result) {
            return await redis.set(id, JSON.stringify([data]))
        }
        if (Array.isArray(result)) {
            return await redis.set(id, JSON.stringify([...result, data]))
        }
    }
    ,
    getById: async (id: string): Promise<string[]> => {
        const result = await redis.get<string | null>(id)
        if (!result) {
            return []
        }
        if (Array.isArray(result)) {
            return result as string[];
        }
        throw new Error('Invalid data') // 不正なデータです
    },
    delete: async (id: string) => {
        return await redis.del(id)
    }
}

Since I want to handle numbers as an array this time, I'm performing operations as such.
I assume id uses /:id from the URL.
This is to avoid values being shared across rooms and causing strange behavior.
With this ready, I called it within a Route Handler as follows to save and retrieve data.

import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import { db } from '@/lib/db';
export async function GET(request: NextRequest) {
    const searchParams = request.nextUrl.searchParams;
    const gameId = searchParams.get('gameId');
    if (!gameId) {
        return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); // 誤ったurl
    }
    const fileData = await db.getById(gameId)
    return NextResponse.json({
        fileData
    });
}
export async function POST(request: NextRequest) {
    try {
        const { number, gameId } = await request.json();
       /** Omitted */ // 省略
        await db.upsert({ data: number.toString(), id: gameId })
        return NextResponse.json({
            success: true,
            message: 'Number announced successfully',
            number
        });
    } catch (error) {
        console.error('Error in number announcement:', error);
        return NextResponse.json(
            { error: 'Internal server error' },
            { status: 500 }
        );
    }
}

Preparing Pusher

Now that we've covered storage, let's move on to notifications with Pusher.
First, regarding Pusher setup, you can complete it by following the article at the URL below, so I will only share it here.

https://zenn.dev/hayato94087/articles/0c266563626a27

Configuring Pusher

Once the Pusher setup is complete, set the secrets in your environment variable file.
After that, install the pusher and pusher-js modules.
Once installed, add the following to any file:

import PusherServer from 'pusher';
import PusherClient from 'pusher-js';
export const pusherServer = new PusherServer({
    appId: process.env.PUSHER_APP_ID!,
    key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
    secret: process.env.PUSHER_SECRET!,
    cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
    useTLS: true,
});
export const pusherClient = new PusherClient(
    process.env.NEXT_PUBLIC_PUSHER_KEY!,
    {
        cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
    }
);

I created pusherServer for sending values to Pusher and pusherClient for establishing a connection with Pusher.
Let's look at how each is used.

The pusherServer, which sends values to Pusher, is called within a Route Handler as follows:

import { pusherServer } from '@/lib/pusher';
/** Omitted */
export async function POST(request: NextRequest) {
        const { number, gameId } = await request.json()
        await pusherServer.trigger(
            `bingo-game-${gameId}`,
            'new-number',
            {
                number,
                timestamp: new Date().toISOString()
            }
        );
        /** Omitted */
}

When sending values to Pusher, the trigger method is used.
The first argument specifies the channel name for communication.
The second argument specifies the event name to be used within the channel.
The third argument sends the actual data to be exchanged.
This allows you to pass the information for sending notifications to Pusher.
After that, Pusher notifies the clients that have established a connection to the specified channel.
Specifically, it is implemented like this:

'use client'
import { useEffect } from 'react';
import { pusherClient } from '@/lib/pusher';
export function BingoBoard({ gameId }: { gameId: string }) {
    const [numbers, setNumbers] = useState<number[]>([]);
    const [currentNumber, setCurrentNumber] = useState<number | null>(null);
    useEffect(() => 
		// Specify the channel name to connect to
		const gameChannel = pusherClient.subscribe(`bingo-game-${gameId}`);
        // Receive events within the channel and specify a callback
        gameChannel.bind('new-number', (data: { number: number }) => {
            setCurrentNumber(data.number)
            setTimeout(() => {
                setNumbers(prev => [...prev, data.number]);
            }, 3500);
        });
        // Unsubscribe from the connection
        return () => {
            pusherClient.unsubscribe(`bingo-game-${gameId}`);
        };
    }, [gameId]);
    /** Omitted */
}

You use the subscribe method of the pusherClient prepared earlier to specify the channel you want to connect to.
Then, you wait for events communicated within that channel and register a callback to be executed when an event is detected.
This enables the frontend to receive notifications from Pusher.

My actual impression after trying it was that it was very easy.
Once the project is set up and the module for interacting with Pusher is installed, it's almost done, and I was able to implement it without stress.
Also, since you can exchange 200,000 messages a day and connect up to 100 devices simultaneously for free, it was perfectly fine for a small-scale app like this one.

That's all for the main features of the bingo app.
If you'd like to see the full code, I've provided a link below. (Note: There is quite a bit of unnecessary code and it's rather messy. Please be understanding.)

https://github.com/maronnjapan/bingo-game

Digression: About Animations

I was also asked how the bingo number animation works, but for this part, I just had generative AI work really hard on it.
So, I won't explain the implementation; I'll just leave the component code here.

import React, { useState, useEffect, useCallback } from 'react';
export const SpineMachine = ({ finalNumber = 42, duration = 3000 }) => {
    const [isSpinning, setIsSpinning] = useState(false);
    const [currentNumber, setCurrentNumber] = useState('00');
    const [drumRotation, setDrumRotation] = useState(0);
    const [ballPosition, setBallPosition] = useState({ x: 100, y: 120 });
    const [showBall, setShowBall] = useState(false);
    const spin = useCallback(() => {
        setIsSpinning(true);
        setShowBall(false);
        setBallPosition({ x: 100, y: 120 });
    }, []);
    useEffect(() => {
        spin()
    }, [finalNumber])
    useEffect(() => {
        if (!isSpinning) return;
        let startTime: number | null = null;
        let animationFrame: number | null = null;
        const animate = (timestamp: number) => {
            if (!startTime) startTime = timestamp;
            const progress = timestamp - startTime;
            const progressRatio = Math.min(progress / duration, 1);
            const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
            const currentSpeed = easeOut(1 - progressRatio);
            const rotationIncrement = currentSpeed * 30;
            setDrumRotation(prev => (prev + rotationIncrement) % 360);
            if (progress > duration - 1000) {
                setShowBall(true);
                const fallProgress = (progress - (duration - 1000)) / 1000;
                // Calculate natural fall curve
                const startX = 155;
                const startY = 160;
                const endX = 100;
                const endY = 270;
                // Fall considering gravitational acceleration
                const x = startX + (endX - startX) * fallProgress;
                const y = startY + (endY - startY) * (fallProgress * fallProgress);
                setBallPosition({ x, y });
                if (progress > duration - 500) {
                    setCurrentNumber(String(finalNumber).padStart(2, '0'));
                }
            } else if (progress % (16 / currentSpeed) < 16) {
                setCurrentNumber(String(Math.floor(Math.random() * 100)).padStart(2, '0'));
            }
            if (progress < duration) {
                animationFrame = requestAnimationFrame(animate);
            } else {
                setIsSpinning(false);
                setCurrentNumber(String(finalNumber).padStart(2, '0'));
            }
        };
        animationFrame = requestAnimationFrame(animate);
        return () => {
            if (animationFrame) {
                cancelAnimationFrame(animationFrame);
            }
        };
    }, [isSpinning, duration, finalNumber]);
    return (
        <div className="flex flex-col items-center justify-center " style={{ marginBottom: 0, marginTop: 0 }}>
            <div className="relative w-44 h-64">
                <svg viewBox="0 0 200 300" className="w-full h-full">
                    {/* Base */}
                    <rect x="40" y="200" width="120" height="80" fill="#2A4365" rx="5" />
                    <rect x="45" y="205" width="110" height="70" fill="#3B82F6" rx="3" />
                    {/* Main drum */}
                    <g transform="translate(100 120)">
                        <g transform={`rotate(${drumRotation})`}>
                            <circle cx="0" cy="0" r="60" fill="#1E40AF" />
                            <circle cx="0" cy="0" r="55" fill="#2563EB" />
                            {/* Internal partitions */}
                            {[...Array(12)].map((_, i) => (
                                <g key={i} transform={`rotate(${i * 30})`}>
                                    <line
                                        x1="0"
                                        y1="-55"
                                        x2="0"
                                        y2="-35"
                                        stroke="#fff"
                                        strokeWidth="3"
                                        className={isSpinning ? 'opacity-50' : 'opacity-100'}
                                    />
                                </g>
                            ))}
                        </g>
                    </g>
                    {/* Exit hole */}
                    <g transform="translate(155, 160)">
                        {/* Hole outer frame */}
                        <circle cx="0" cy="0" r="8" fill="#1E3A8A" />
                        {/* Inside of the hole (depth representation) */}
                        <circle cx="0" cy="0" r="6" fill="#152C61" />
                    </g>
                    {/* Tray */}
                    <path
                        d="M70,260 C70,250 130,250 130,260 L130,270 C130,280 70,280 70,270 Z"
                        fill="#2A4365"
                        stroke="#1E3A8A"
                        strokeWidth="1"
                    />
                    {/* Glass dome */}
                    <path
                        d="M40,120 A60,60 0 0,1 160,120 L160,200 L40,200 Z"
                        fill="rgba(255,255,255,0.1)"
                        stroke="#A3BFFA"
                        strokeWidth="2"
                    />
                    {/* Bingo ball falling */}
                    {showBall && (
                        <g transform={`translate(${ballPosition.x} ${ballPosition.y})`}>
                            <circle r="6" fill="white" stroke="#2563EB" strokeWidth="1">
                                <animate
                                    attributeName="r"
                                    values="6;5.5;6"
                                    dur="0.5s"
                                    repeatCount="indefinite"
                                />
                            </circle>
                            <text
                                textAnchor="middle"
                                dy="3"
                                fontSize="8"
                                fill="#2563EB"
                                fontWeight="bold"
                            >
                                {currentNumber}
                            </text>
                        </g>
                    )}
                </svg>
                {/* Final display of the drawn ball */}
                {showBall && ballPosition.y > 260 && (
                    <div className="absolute bottom-6 left-0 w-full flex justify-center">
                        <div className="bg-white rounded-full w-12 h-12 flex items-center justify-center shadow-lg border-2 border-blue-600 animate-bounce">
                            <span className="font-mono font-bold text-xl text-blue-600">
                                {currentNumber}
                            </span>
                        </div>
                    </div>
                )}
            </div>
        </div>
    );
};

Since I had generative AI put in quite a bit of effort for this, seeing articles where people write these things themselves makes me respect them all over again.

Why I made the bingo app

We've looked at the implementation, but let me also talk about why I made the bingo app in the first place.
There are many bingo apps out there, such as BINGO! Online (https://bingo-online.jp/).
So, there's no need to reinvent the wheel—if it's just a pure bingo app, that is.

Actually, in this bingo app, the numbers for the first 9 draws are predetermined.
So, winning cards were prepared beforehand.
You still need the luck to pick a winning card, but once you pull it, you win immediately.
I created the bingo app specifically to do this.
Among those who discussed this with me, we called it "Match-Pump Bingo" (staged bingo).
In reality, I heard voices around those who won in a straight line saying it was "immense luck," so it went exactly as planned.

Another reason was that I wanted to include a small joke, like showing a 500 error—familiar to engineers?—as shown below.
bingo-error-demo.gif
Since I was able to do everything I wanted to do, I am personally satisfied.

Vercel was surprisingly resilient to load

This time, since the event was held with over a dozen people accessing it simultaneously, I was quite worried about the load.
However, I was surprised that the application moved without any problems and didn't feel heavy at all.
Therefore, if I have the opportunity in the future, I would like to restore the features I stripped away considering the load and create an even more "match-pump" bingo app. (If I ever have the chance to use a bingo app again, that is...)

Thank you for reading this far.

Discussion