DEV Community

Cover image for Event Horizon: A Journey
sandrockj
sandrockj

Posted on • Edited on

1

Event Horizon: A Journey

Preface



Today, I’d like to share some insights into a project our team has been passionately working on as part of our senior thesis with Operation Spark. Let me start by discussing what we aimed to achieve and the conceptual journey that led us to this project.

Our project, Event Horizon, is a turn-based card-battling game designed to connect players in exciting, strategic game play. Currently, it’s in an early alpha stage as we experiment with core ideas and work on establishing a solid foundation for scalability.

Team Members


How We Chose This Project



Developing a video game is undeniably complex, so why did we decide to tackle it? Let me provide some context about our team. Each of us has a diverse gaming background, with preferences ranging from first-person shooters (FPS) and role-playing games (RPGs) to mobile battle arenas (MOBA) and real-time strategy (RTS) games. When we brainstormed ideas for our senior thesis, it became clear that gaming was a shared passion we were eager to explore.


The Challenges Our Team Faced



Building Event Horizon pushed us far outside our comfort zone. Until now, our experience had primarily revolved around traditional web applications. Here's what we were used to:

  • Users needing accounts to access features.
  • Authentication leading to isolated, private data interactions.
  • Basic CRUD (Create, Read, Update, Delete) operations within a session.
  • No real-time data sharing or direct connections between users.

However, creating a multiplayer game required us to rethink how we approached these principles entirely. It was no longer about private, isolated data but about managing complex, dynamic relationships between players and shared game states. This led us to ask some tough questions:

  • How do we establish a game session to bridge two users for real-time data sharing?
  • What happens if a player loses connection—how do we recover their game and its data?
  • How do we track and manage player actions, game state, and outcomes at the end of each turn?
  • How do we enforce data privacy, ensuring a player’s client doesn’t reveal unauthorized information (like the opponent’s cards or actions) to maintain fair play?
  • How can we make our game visually appealing and engaging?

These challenges were far more intricate than anything we’d encountered in traditional web development. Real-time data exchange, data privacy, and client-side rendering introduced layers of complexity that tested our ability to approach problems, diagnose them, and resolve them with our technical skills.


Navigating These Challenges with Tools and Innovation



Thankfully, frameworks like Socket.io provided an excellent foundation for real-time communication. However, we still faced significant hurdles in implementing server-side logic to handle the game state comprehensively. Here’s what that entailed:

  • Processing player actions and selected cards.
  • Managing card effects over multiple turns, including buffs, debuffs, and damage.
  • Validating player actions to prevent cheating (e.g., ensuring a card played belongs to the player’s deck).

One of our key priorities was server-side data validation as an anti-cheat mechanism. For instance, if a player attempted to play a card they didn’t own or that wasn’t part of their deck, our server would immediately flag and block the action. While this added complexity, it was crucial to ensure fair gameplay.

Developing Event Horizon has been a rewarding and eye-opening journey. It’s been an opportunity to challenge ourselves, learn new technologies, and push the boundaries of what we thought we could achieve as a team. While we’re still early in the process, this experience has deepened our understanding of game development and given us valuable insights into overcoming technical and conceptual hurdles.


Alpha Version

EH-Part1 - Album on Imgur

Discover the magic of the internet at Imgur, a community powered entertainment destination. Lift your spirits with funny jokes, trending memes, entertaining gifs, inspiring stories, viral videos, and so much more from users.

favicon imgur.com

EH-Part2 - Album on Imgur

Discover the magic of the internet at Imgur, a community powered entertainment destination. Lift your spirits with funny jokes, trending memes, entertaining gifs, inspiring stories, viral videos, and so much more from users.

favicon imgur.com

Lessons Learned



Among the many mistakes that our team has now learned from, one of the largest factors that affected the development of our application was the planning phase. In the infancy of this project, we dedicated a week for conceptualizing how our game should work and what architecture would be necessary to support it. Despite our efforts, the reality that we found is that there are some things you just don't know until you begin working on your project.

As we outlined our tech stack, we included many technologies which we hadn't worked with previously. These technologies were necessary for us to reach our end-goal and, while we had verified that they would help us get there, we didn't have enough exposure with these technologies to understand their limitations (i.e. what they can do but also what they can't).

As a preface to the following section, I wanted to inform you that some code has been redacted for the purposes of brevity and legibility.


Matchmaking Prerequisites



The Problem: When a player attempts to join a game in Event Horizon, several processes need to occur before they can fully connect. First, we create a record linking the player to the game. Then, we initialize the player’s in-game state, including critical details like their health, armor, and their chosen deck of cards. This raised an intriguing challenge: how could we ensure that players were prompted to select a deck before joining the game? Moreover, how could we make this process consistent across both public and private games? Above all, how could we make this seamless and intuitive for the players?

The Solution: We addressed this challenge by designing a versatile, reusable modal that accepts a callback function. This modal acts as a unified entry point, enabling players to confirm and connect to a game from anywhere within the application. By centralizing this interaction, we ensured that the process is consistent, straightforward, and relatively easy to integrate across different contexts in the game.

/* The basic functional component accepts a few vital properties. */
const SelectDeck = ({toggleModal, callback, callbackParams}) => {}
Enter fullscreen mode Exit fullscreen mode
Property Description
toggleModal Used to signal the calling component that the modal has completed its task and no longer needs to be rendered.
callback Defines the action to execute after the user selects a card deck and confirms their choice. This action can vary, such as connecting to a private game or a public game.
callbackParams Any parameters required by the callback, such as a game session identifier or a private game invite.

A critical design consideration for this modal (and a recurring theme in our application) was ensuring the correct timing of code execution. It was vital to trigger certain actions only after the user provided explicit confirmation to avoid sending invalid requests to the server. Additionally, we encountered challenges with React's lifecycle methods, which initially complicated our early implementations of the modal. However, our team agrees that the effort was worthwhile: the modal ultimately streamlined processes and simplified future refactors, particularly those related to responsiveness.


Public Game Connections



The Problem: The introduction of Socket.io helped significantly in our development process; it was the primary means by which game data (current round information, cards played, player states) was emitted within a game session. However, as we began working with these sockets, we realized that we needed a way to "save" and "preserve" a game session as well as which players were registered under those game sessions.

The Solution: On the back-end, we developed a games table in our RDBMS and an endpoint for interacting with that data. An entry to the games table stores a few different pieces of data whenever it is created, the structure of this table within our schema (Prisma) ended up looking something like this:

model games {
  id         Int       @id @default(autoincrement()) 
  status     Boolean   @default(true)  // used to indicate if game active
  start_date DateTime  @default(now()) // tells us when the game was started
  end_date   DateTime?                 // filled in when game ends
  victor_id  Int?                      // relational field to a user's ID
  private    Boolean   @default(false)
}
Enter fullscreen mode Exit fullscreen mode

Whenever a user clicks Play Game within our application, it sorts through games with a status of true in order to determine which games are active. Although this works, it also partially feeds into another problem...how could we save which users belong to a game? Our solution was to create a join table that would store player connections; this would enable the redirect of users to their active game sessions in the case that they had lost connection. It would also enable for us, as developers, to limit the amount of players that could be associated with a game session to two.

model public_connections {

  id            Int  @id @default(autoincrement())
  game_id       Int  // this would be used to link a game
  user_id       Int? // to the player specified here
  selected_deck Int? // it also informs us what data (deck) we need to initialize
}
Enter fullscreen mode Exit fullscreen mode

Private Game Connections



The Problem: As we expanded our application's features, we faced a complex challenge: implementing a system for private game sessions. This posed two major concerns:

  1. Data Concurrency: How could we isolate data for each game session to prevent overlaps or unintended interactions? Mishandling this could leave game mechanics vulnerable to exploitation.
  2. User Connections: How would users initiate and connect to private sessions? Previously, our system only supported searching for open public matches.

The Solution: We began by thoroughly reviewing the game logic across the application to ensure all operations occurred in isolation, avoiding malformed data or indiscriminate bulk operations. Once we confirmed that our system could handle data concurrency, we moved forward with drafting new architecture and wireframes to enable private game sessions.

model private_connections {
  id            Int  @id @default(autoincrement())
  game_id       Int
  user_id       Int?
  selected_deck Int?
}

model game_invites {
  id       Int      @id @default(autoincrement())
  date     DateTime @default(now())
  from     Int      // who is the invite from?
  to       Int      // who is the invite to?
  game_id  Int      // if we want to connect, what game session are we using?
  accepted Boolean  @default(false) // has this been accepted yet?
}
Enter fullscreen mode Exit fullscreen mode

During the development of our architecture, we recognized the need for a system to track private game connections and manage user invitations. Reflecting on our implementation, we acknowledge that the design could be improved. The creation of two separate but similar tables introduces redundancy. A more streamlined approach would combine these models into a single table with a type field to differentiate between public and private game sessions. However, given our time constraints, the current implementation met our immediate requirements.

After deploying this architecture, we finalized our wireframes and reviewed the application for necessary changes. One significant improvement we identified was the need to establish socket connections earlier in the process. This adjustment enabled us to immediately notify users of new game invites. By initiating the socket connection earlier and recording data in real-time, we ensured that users would receive updates promptly whenever they were online.

interface ConnectedUsers {
  [userId: string]: string;
}

const connectedUsers: ConnectedUsers = {};

socket.on('register_user', (userId) => {
  connectedUsers[String(userId)] = socket.id;
  socket.join(userId);
});

socket.on('disconnect', () => {
  users = users.filter(user=>user!==sockId)
  players = users.length;
  
  for (const user in connectedUsers){
    if(connectedUsers[user] === sockId){
      delete connectedUsers[user];
    }
  };
});  

socket.on('send_invite', (data, invited) => {
  const invitedBy = connectedUsers[String(data.from)];
  const invitedSock = connectedUsers[String(invited)];

  if (invitedSock) {
    io.to([invitedSock, invitedBy]).emit('incoming_invite', data);
  }
});
Enter fullscreen mode Exit fullscreen mode

This server-side implementation listens for events from the invite sender and notifies the recipient of an incoming game invite. While it functions as intended, there is room for optimization and increased security. For instance, the current design could be improved for better time complexity and to introduce stronger protections against spoofing attempts. However, we believe that the risks associated with this implementation are minimal, as the exposed data is not sensitive. Furthermore, due to the way our server processes these interactions, we believe that it is unlikely for a spoofed user to gain control of or interfere with private game sessions.


Maintenance and Scalability



The Problem: Our application handles a diverse and growing volume of data. To maintain fast query performance and ensure scalability, we needed to carefully design our database architecture. However, even with well-structured plans, the reality is that as your application gains more users, databases naturally grow in size. This introduces challenges not only in maintaining database performance but also in ensuring server stability as workloads increase.

The Solution: To address this, we implemented a periodic database cleanup operation designed to remove outdated game records. Since user statistics like wins, losses, and score were already being updated elsewhere, we realized that retaining old game records served no practical purpose once the games were terminated. By regularly purging these records, we could keep our dataset smaller and queries faster.

const halfHour = 60 * 30 * 1000;

setInterval( async () => {

  try {
  
    const deletedGames = await database.games.deleteMany({ 
        where: { status: false }
    })
    
    if (deletedGames) {
      console.log(`Routine database maintenance: purged ${deletedGames.count} closed games in database at ${new Date()}.`)
    }
    
  } catch (error) {
    errorHandler(error);
  }
}, halfHour);
Enter fullscreen mode Exit fullscreen mode

To enhance error handling, we developed an errorHandler() function using Node.js's file system library. This function creates a folder for the current day and logs errors within it. This approach not only provided us with a useful record of issues during development but also became indispensable in production. For example, when we deployed our application using PM2 (a process manager for Node.js), we noticed that terminal access to error messages was no longer available. The errorHandler() function bridged this gap by providing detailed error logs, enabling us to monitor and debug issues effectively in a production environment.

import { promises as fs } from 'fs';
import path from 'path';

export default async function errorHandler(data: Error) {

  try {
  
    const date = new Date();  // create a date
    const directoryName = date.toISOString().slice(0, 10);
    const directoryPath = path.resolve(`<REDACTED>/error_logs/${directoryName}`); 
    const timestamp = `${date.getHours()}${date.getMinutes()}hr-${date.getSeconds()}${date.getMilliseconds()}ms`
    
    await fs.mkdir(directoryPath, { recursive: true });
    
    const fileName = path.join(directoryPath, `${directoryName}_${timestamp}_${Math.floor(Math.random() * 1000)}.txt`);
    
    await fs.writeFile(fileName, data.toString());
    
    console.log(`Error successfully written to new file ${fileName}.`);
    
  } catch (error) {
    console.error(`Error handler: It’s not me, it’s you.\n`, error);
  }

}
Enter fullscreen mode Exit fullscreen mode

By combining routine database maintenance with robust error logging and leveraging tools like PM2, we improved server stability and ensured smoother operations as our application scaled.


Conclusion



Working on Event Horizon has been an incredible learning experience that challenged us to step out of our comfort zones and develop skills beyond traditional web application development. This project taught us the intricacies of building real-time systems, managing multiplayer interactions, and designing for scalability. While there are areas we recognize for improvement, we’re proud of what we’ve accomplished within our time constraints and with the tools available.

More than anything, this journey has emphasized the importance of teamwork, adaptability, and embracing challenges—lessons that will guide us as we continue to grow as developers. Thank you for following our story, and we can’t wait to share what’s next for our team!

Reinvent your career. Join DEV.

It takes one minute and is worth it for your career.

Get started

Top comments (0)

👋 Kindness is contagious

Dive into an ocean of knowledge with this thought-provoking post, revered deeply within the supportive DEV Community. Developers of all levels are welcome to join and enhance our collective intelligence.

Saying a simple "thank you" can brighten someone's day. Share your gratitude in the comments below!

On DEV, sharing ideas eases our path and fortifies our community connections. Found this helpful? Sending a quick thanks to the author can be profoundly valued.

Okay