At Echobind, we all have up to 8 hours per week of self-directed investment time, where we stop working on client projects and we can build and learn the things that help us improve our craft. One week I decided to see what kind of simple game I could build with the full-stack Jamstack in one day; I came up with Emoji Battle, a game where all you have to do is click emoji. Here's how I built version 1.
The Emoji Battle GitHub repo is here. This article covers how I built version 0.1.
Technologies used
Barely a game
In one day, I didn't expect to build the next Animal Crossing. But I wanted to see what I could come up with that would achieve the following:
- It would qualify as a "game" by being fully interactive. Anyone could log in and play it.
- It had to be self-teaching; you wouldn't need instructions to play.
- It had to use the full-stack Jamstack tools that I typically use; Next.js, Prisma, Chakra-UI, and the other libs in my current stack.
In order to get this done I knew I had to:
- Make sure it wasn't elaborate. I could spend hours thinking of game mechanics; I didn't have that kind of time.
- Build the simplest features.
- Build the simplest UI.
- NOT build all of the things.
Decisions, decisions, decisions
So I limited myself to less than an hour thinking on it. The idea of using Emoji as the main UI element of the game was a practical decision: emojis are fun, universal, versatile, and textual. You don't need any UI to use them. That decision saved tons of time by itself.
The next time-saving idea I had was to make the game mechanics simple for v1. It would be easy to go down a rabbit hole thinking of different ways for players to compete, so I settled on the simplest of UIs: all you have to do is click. I found a react library for showing a list of emoji, and when you click on one it would register a vote for that emoji. The emoji with the most votes shows at the top of the leaderboard and becomes bigger and bigger.
Authentication
I knew from the start I wanted users to log in to play. But I also wanted it frictionless; it should be easy for me to set up and easy for users to log in.
NextAuth.js is my default authentication solution for Next.js, so I dropped it in with the Prisma adapter. And since the Github API keys are among the easiest to set up (and most people I would show this game to are developers), I set up my API key and one-click GitHub login was working in no time.
Note that at the time I developed Emoji Battle v1, I used NextAuth v3. I've since upgraded to v4 since it came out of beta. If you start using NextAuth, I recommend starting with the latest version (v4 as of this writing).
Page Layout
To limit how long I should spend on layout, I decided that this version would be desktop-only. Most people I demoed it to (at meetups or lunch-and-learns) would be using desktop browsers anyway.
I wanted a header up top with the name of the game, and below it, from left to right, the EmojiPicker, the Leaderboard, and the UserList. This was achieved easily with a Chakra-UI HStack
(horizontal stack):
// // pages/index.tsx // const Home: NextPage = () => { // ... return ( <Container maxW='100%' p={10}> <Heading mb={5} textAlign='center'> Emoji 🤪 ⚔️ 😀 Battle </Heading> <HStack spacing={20} alignItems='flex-start'> <EmojiPicker /> <Leaderboard /> <UserList /> </HStack> </Container> ) } export default Home;
Pretty clean right? Chakra-UI is 🔥. Later I would add some customization which displays the user's name if they're logged in, using the NextAuth.js useSession
hook.
Planning & building the React components
The interface was going to do a few things:
- List a bunch of emoji that players could click to vote
- Show a leaderboard of the emoji that were voted on (sorted by the most votes)
- Show a list of players
So I ended up with a few components:
- EmojiPicker: a list of emoji that players can click on
- Leaderboard: a list of emoji that were voted on sorted descending by vote count
- UserList: a list of the names of logged-in players
In React, you want to reduce renders as much as possible to preserve performance. To this end, I made each component in charge of fetching the data it needs to render as opposed to fetching at the page level so that a fetch would only cause the component to potentially re-render instead of the entire page.
For data fetching, I'd use Vercel's SWR library which is simple to use and handles keeping the data fresh automatically. This wouldn't give us real-time updates to the UI, but we could make it update components quickly enough that it was almost real-time, and made it feel like almost a real game. (In later versions, we'd make it true real-time with Ably Realtime. For now, data polling with SWR would be good enough.)
The EmojiPicker component
The first version of the picker code looks like this:
// ... imports and types ... export const EmojiPicker: FC<EmojiPickerProps> = ({ afterSelect }) => { const [emojiSet, setEmojiSet] = useState<EmojiSet>('apple'); const handleEmojiSelect = async (emoji: any) => { await fetch('/api/emoji/create', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ emoji }), }); if (afterSelect) { afterSelect(emoji); } }; return ( <Container textAlign='center'> <Heading size='md' mb={5}> Pick an emoji! </Heading> <HStack justifyContent='center' mb={5}> <Button onClick={() => setEmojiSet('apple')}>Apple</Button> <Button onClick={() => setEmojiSet('google')}>Google</Button> <Button onClick={() => setEmojiSet('twitter')}>Twitter</Button> <Button onClick={() => setEmojiSet('facebook')}>Facebook</Button> </HStack> <Picker set={emojiSet} onSelect={handleEmojiSelect} /> </Container> ); };
Breaking this down:
- The
<Picker>
from theemoji-mart
package is what displays the emoji. We give it ahandleEmojiSelect
handler which makes a POST request to a Next.js API route we talk about later in the article. This API route will record a single vote for the clicked emoji and current user. handleEmojiSelect
will call an optionalafterSelect
function if it's passed in via a prop. This is useful for letting us update other parts of the app after a vote is cast if we need to.- Since
emoji-mart
'sPicker
component lets us select from four emoji sets, we include aButton
for each set. We track the currently selected set with auseState
hook and set the current set when any button is clicked.
The UserList component
The Leaderboard
and UserList
are going to both follow a common pattern: they fetch data and render it. Easy peasy.
Here's the UserList
component:
// ... imports and types ... export const UserList: FC = () => { const { data: usersData, error: usersError } = useSWR( `/api/users/list`, fetcher, { refreshInterval: 200 } ); if (usersError) return <div>failed to load</div>; if (!usersData?.users) return <div>loading...</div>; const { users } = usersData; return ( <Container textAlign='center'> <Heading size='md' mb={5}> Who's battling? </Heading> <VStack> {users.map(({ id, name }) => ( <Text key={id}>{name}</Text> ))} </VStack> </Container> ); };
Breakdown:
useSWR
lets theSWR
library do all of the work here for us. It fetches the data from the API and keeps it fresh. We tell it to refresh at a 200ms interval so that there's very little delay between a user joining and the list updating.- Our error state and loading state are no-frills. Keep it simple!
- The rest is up to Chakra-UI. We use a
VStack
to display a vertical list of users' names.
The Leaderboard component
The Leaderboard
follows the same fetch-and-render pattern as the UserList
component, but has one tiny feature that is one of the minimal game elements of this version of the project: it uses some styling to make an emoji bigger based on the number of votes it has.
Here's the Leaderboard
:
// ... imports and types ... export const Leaderboard: FC = () => { const { data: emojiData, error: listError } = useSWR( `/api/emoji/list`, fetcher, { refreshInterval: 200 } ); if (listError) return <div>failed to load</div>; if (!emojiData?.emojis) return <div>loading...</div>; const { emojis } = emojiData; return ( <Container textAlign='center'> <Heading size='md' mb={5}> Leaderboard </Heading> <VStack> {emojis.map((e) => ( <Container key='e.native'> <span style={{ fontSize: 15 + e._count.votes * 5 }}> {e.native} </span> <Text fontSize='xs'>{e._count.votes} votes</Text> </Container> ))} </VStack> </Container> ); };
Breakdown:
- The same fetching as above in the
UserList
. - The same error and loading states.
- The
emojis
array will return from the API with a vote count. So when rendering it, we take a basefontSize
of 15px and add 5px for every vote. The 5x multiplier makes it obvious that an emoji grows when someone votes on it. (When I did it with a 1px increase, the growing was barely visible.)
Planning the data model
Since I wanted to require logins to the game, there would certainly be a User table. This part was easy since I knew I was using NextAuth.js and Prisma, and NextAuth's Prisma adapter has a template for this already (along with tables for Accounts, Sessions, and VerificationTokens)
The only remaining data I had to track for v1 was the votes on each emoji. So I added a Vote model and an Emoji model to my schema.prisma
.
model Emoji { id Int @id @default(autoincrement()) name String externalId String @map(name: "external_id") native String unified String @unique colons String votes Vote[] } model Vote { id Int @id @default(autoincrement()) userId Int emojiId Int @map(name: "emoji_id") createdAt DateTime @default(now()) updatedAt DateTime @default(now()) User User @relation(fields: [userId], references: [id]) Emoji Emoji @relation(fields: [emojiId], references: [id]) }
Note the relationships: a Vote
belongs to a User
and an Emoji
. An Emoji
has many Vote
s.
Planning & building the API calls
There were only a few things the app needed to do with data:
- Let a user click an emoji to vote for it.
- Pull up the voted-on emoji sorted by most votes.
- Show a list of people currently playing.
This meant we only needed 3 Next.js API routes:
POST /emoji/create
(/api/pages/emoji/create.ts
) - for voting. Yes, this will be renamed!GET /emoji/list
(/api/pages/emoji/list.ts
) - for getting the list of voted-on emoji.GET /users/list
(/api/pages/users/list.ts
) - for getting the list of players.
Note that Next.js doesn't have much API structure built-in. When I scaffold projects like this fast, I typically use a loose RESTful API structure to keep things simple. The benefit is that you don't have to overthink things and can move quickly. Then if and when the app grows, you can reassess whether you want to set up a GraphQL API, or a true REST API, or use an API framework like Nest.js, and so on.
All API routes would follow a similar pattern:
import type { NextApiRequest, NextApiResponse } from 'next'; import { User } from '@prisma/client'; // Import a single prisma instance so we don't open unnecessary database connections. import prisma from '../../../lib/prismaClientInstance'; // Define what the data looks like. The payload for /pages/api/users/list.ts is below as an example. type Data = { users?: User[]; error?: unknown; }; export default async function handler( req: NextApiRequest, res: NextApiResponse<Data> ) { try { const users = // ... Prisma.js database calls go here! res.status(200).json({ users }); } catch (error) { // I typically just add a log, until the project is ready for connecting to an error handling service like Sentry. console.log(`error`, error); res.status(500).json({ error }); } }
Prisma does all the work of getting or creating data. Notable is that the voting endpoint /emoji/create
does a bit more work:
// ... // Make sure it's a POST request. if (req.method !== 'POST') { return res.status(405).end(); } // Ensure we're logged in to vote. Otherwise return a 401 unauthorized. const session = await getSession({ req }); if (!session) { return res.status(401); } // ... // Insert the emoji into the database, if it's not already there. const emoji = await prisma.emoji.upsert({ create: { externalId: id, name, native, colons, unified, }, update: {}, where: { unified, }, }); const vote = await prisma.vote.create({ data: { Emoji: { connect: { id: emoji.id, }, }, User: { connect: { // @ts-ignore id: user.id, }, }, }, }); // ...
You can see that here we do an upsert
into the Emoji
table. This table is initially empty. The emoji are all stored in the emoji-mart library. But to associate them with Vote
s, we need to store them the first time anyone votes on them.
And yes, I cheated with the @ts-ignore
. MVP, baby! In retrospect I could have used the TypeScript non-null assertion operator (!).
The initial version of Emoji Battle was just enough for me to demo at the Prisma Meetup and have all the viewers log in and start clicking around. It was barely fun and interactive, and looked sorta messy, but was a great example of what I could throw together with this stack in a very short period.
Even more so, it was an excellent foundation to build on. Since then I've added realtime interaction, some animation, better layout, and am in the process of creating more game mechanics.
I'm planning more articles on this in the future. In the meantime check out these links:
- Emoji Battle on GitHub
- v0.1 source (for this article) on GitHub
- The Prisma Online Meetup where I demoed Emoji Battle for the Prisma Meetup and discussed the stack.