Build a Database for Web Game Level Editors Without Server Crashes

How to Build a Custom Level Database for Web Games That Scales


A few years ago, a client came to me with a massive problem. They had just launched a simple HTML5 puzzle game on their web portal. To make the game more exciting, they added a built-in level editor. It let players design their own puzzles, save them, and share them with friends using a simple link.

Everything worked great during my local tests. But on day three, a popular online gaming community shared the link. Within four hours, over twenty thousand players were designing and saving custom puzzle levels at the exact same time.

Suddenly, my client's database went down. The server couldn't handle the massive wave of write requests. The CPU hit 100%, and the site displayed the dreaded "502 Bad Gateway" error page.

I spent that entire night rewiring the database and rewriting the backend code. Through that stressful experience, I learned exactly how to build a super-fast, lightweight, and highly scalable database system specifically for user-generated game levels.

Today, I’m going to share the exact system I built. I will show you how to design a lightweight SQL database, write a fast Node.js API, and optimize your indexes so your game portal never crashes, no matter how many players are online.


1. Why Storing Game Levels Is Harder Than It Looks

When you save a typical blog post or user comment, you are storing simple text. But game levels are different. A game level represents a state. It tells the game engine where every brick, player, hoop, or enemy is located on the screen.

If you save this data poorly, you will quickly ruin your database. Let me show you what I mean.

The Wrong Way: Large JSON Blobs

When developers build level editors in HTML5 engines like Construct 3, they often export the level as a huge, deeply nested JSON file. This file might look something like this:

{
  "levelName": "Super Hard Puzzle",
  "gridSize": [10, 10],
  "blocks": [
    {"type": "red_hoop", "x": 0, "y": 1, "rotation": 90, "layer": "foreground"},
    {"type": "blue_hoop", "x": 0, "y": 2, "rotation": 0, "layer": "foreground"}
  ]
}

If a level has hundreds of items, this JSON file can easily reach 100 KB or more. If you store this raw JSON as a giant string for every single user submission, your database size will blow up incredibly fast. Fetching these levels will take forever, and search queries will crawl to a halt.

The Right Way: Compact Level Strings

Instead of saving raw JSON, we should compress the game state into a tiny, custom string. For example, instead of writing out the full properties of every object, we can assign a simple coordinate map:

1:0:90|2:0:0

Here, 1 represents the red hoop, 0 is the x-position, and 90 is the angle. This tiny string takes up almost no space. It is incredibly easy to store in a standard SQL database column. When the game loads the level, the game engine parses this simple string and places the objects where they belong.


2. Designing a Fast SQL Database Schema

Let’s build a database structure that can handle millions of custom levels. I highly recommend using PostgreSQL or MySQL for this. They are rock-solid, free, and handle simple read-and-write operations faster than NoSQL databases when configured correctly.

Here is the exact PostgreSQL schema I use for custom level databases.

-- Enable the UUID extension so we don't expose simple incremental IDs
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE custom_levels ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), game_slug VARCHAR(100) NOT NULL, level_name VARCHAR(100) NOT NULL, creator_name VARCHAR(50) NOT NULL, level_data TEXT NOT NULL, plays_count INTEGER DEFAULT 0, likes_count INTEGER DEFAULT 0, is_approved BOOLEAN DEFAULT TRUE, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP );

Why We Use UUIDs Instead of Auto-Incrementing IDs

Many developers use standard auto-incrementing integers (1, 2, 3...) for their primary keys. This is a bad idea for game levels.

If your share links look like this: mysite.com/play?level=45, anyone can easily write a simple script to scrape every single level from your site by counting up from 1 to 10,000.

By using UUIDs, your share link looks like this: mysite.com/play?level=8b9e4a83-bf9c-49a7-96be-8efd23871cb4. This is impossible to guess, which protects your database from scraping bots and adds an extra layer of privacy.


3. The Secret to Speed: Database Indexing

If your database does not use proper indexing, it has to scan through every single row in your table to find a specific level. If you have 500,000 levels in your database, that query will take several seconds.

We want our search queries to take less than 1 millisecond. To do that, we must create indexes on the columns we search most often.

When players look for custom levels, they usually want to see: 1. The newest levels (ordered by created_at). 2. The most popular levels (ordered by likes_count).

To make these queries instant, we can read the PostgreSQL documentation on index types to learn about B-tree and composite structures. Based on those guidelines, we should set up composite indexes like this:

-- Index for finding the newest approved levels for a specific game
CREATE INDEX idx_game_newest ON custom_levels (game_slug, is_approved, created_at DESC);

-- Index for finding the most popular approved levels for a specific game CREATE INDEX idx_game_popular ON custom_levels (game_slug, is_approved, likes_count DESC);

How This Optimizes the Database:

With these indexes in place, the database engine creates a pre-sorted lookup map. When a user requests "the top 10 most liked levels," the database doesn't search the entire table. It immediately jumps to the pre-sorted list, pulls the top 10 records, and serves them. This simple change can reduce your server load by over 90%.


4. Writing a Lightweight Node.js API

Now let's build the middleman: the API server. We want a lightweight Node.js and Express app that receives the level data from our HTML5 game, validates it, and saves it to our database.

I prefer Node.js for game backends because it handles asynchronous operations (like database writes) extremely well without blocking other incoming traffic.

Here is a complete, working API setup using the pg pool library to connect to PostgreSQL.

const express = require('express');
const { Pool } = require('pg');
const cors = require('cors');

const app = express(); app.use(cors()); app.use(express.json());

// Setup our PostgreSQL database connection pool const db = new Pool({ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/gamedb', max: 20, // Max number of connections in the pool idleTimeoutMillis: 30000, connectionTimeoutMillis: 2000, });

// Endpoint to save a new custom level app.post('/api/levels', async (req, res) => { const { game_slug, level_name, creator_name, level_data } = req.body;

// 1. Simple validation to prevent bad data if (!game_slug || !level_name || !creator_name || !level_data) { return res.status(400).json({ error: 'All fields are required.' }); }

if (level_data.length > 50000) { return res.status(400).json({ error: 'Level data size is too large.' }); }

try { const queryText = INSERT INTO custom_levels (game_slug, level_name, creator_name, level_data) VALUES ($1, $2, $3, $4) RETURNING id;; const values = [game_slug, level_name.trim(), creator_name.trim(), level_data];

const result = await db.query(queryText, values);
const newId = result.rows[0].id;

return res.status(201).json({ success: true, id: newId });

} catch (err) { console.error('Error saving level:', err); return res.status(500).json({ error: 'Database error. Please try again.' }); } });

// Endpoint to fetch a single level by its UUID app.get('/api/levels/:id', async (req, res) => { const levelId = req.params.id;

try { const queryText = 'SELECT * FROM custom_levels WHERE id = $1 AND is_approved = TRUE LIMIT 1;'; const result = await db.query(queryText, [levelId]);

if (result.rows.length === 0) {
  return res.status(404).json({ error: 'Level not found.' });
}

// Increment the plays count in the background (no need to wait for this)
db.query('UPDATE custom_levels SET plays_count = plays_count + 1 WHERE id = $1;', [levelId])
  .catch(err => console.error('Error updating play count:', err));

return res.status(200).json(result.rows[0]);

} catch (err) { console.error('Error retrieving level:', err); return res.status(500).json({ error: 'Server error.' }); } });

// Start our API server const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(Game API running on port ${PORT}); });


5. Integrating the API inside Construct 3

Once your API is live on your server, you need to configure your HTML5 game engine to communicate with it. In Construct 3, you can use the AJAX object to make these API calls.

Let's look at how to structure this process inside your game logic.

Sending the Custom Level to the Server:

  1. Serialize the Level: Use the game's array or dictionary objects to convert your grid layout into a simple string (e.g., 3,4,1|2,3,0).
  2. Trigger the POST Request:
    • Set the AJAX header: Content-Type to application/json
    • Set the post data to: json { "game_slug": "hoop-sorting-puzzle", "level_name": "My Custom Level", "creator_name": "Player One", "level_data": "3,4,1|2,3,0" }
    • Post the data to: https://your-api-domain.com/api/levels
  3. Handle the Response: Once the server responds with { "success": true, "id": "uuid-here" }, display a shareable URL to the player like this: mysite.com/play?level=uuid-here.

Loading the Level via URL Parameters:

When your game loads, check the URL for a query parameter. If the parameter is present, fetch the level data before starting the game.

  1. Read the URL: Inside Construct 3, use the browser plugin to read the full current URL.
  2. Extract the ID: Grab the string after ?level=.
  3. Request Level Data: Use the AJAX object to make a GET request to https://your-api-domain.com/api/levels/{id}.
  4. Parse and Build: Take the returned level data string, split it by your delimiter, and spawn the objects in their correct positions.

6. Sourcing and Customizing Quality HTML5 Game Source Code

If you want to master this architecture, the best way is to study clean, pre-made source projects that already have level editors and saving systems built into them. Building these from scratch can take months, but using a solid framework can get you up and running in days.

Case Study: Puzzle Game Mechanics

For a practical study on puzzle level editors, I highly recommend checking out Hoop Sorting Puzzle + Level Editor - HTML5 Game (Construct 3).

This game utilizes a beautiful puzzle concept where players organize colored hoops on peg systems. What makes it special is the built-in level editor. The project is clean, fully documented, and shows you exactly how to save hoop configurations into dynamic arrays.

+-----------------------------------+
|      [ HOOP SORTING PUZZLE ]      |
+-----------------------------------+
|     | | |       | | |     | | |   |
|     |R| |       |B| |     | | |   |
|     |R| |       |B| |     | | |   |
|    =======     =======   =======  |
|     Peg 1       Peg 2     Peg 3   |
+-----------------------------------+
| [SAVE LEVEL] [LOAD LEVEL] [RESET] |
+-----------------------------------+

By connecting this game to our database schema, players can design a level, click save, and generate a dynamic sharing link in seconds. It is a fantastic way to test if your SQL indexes are handling complex level strings smoothly.

Case Study: Card Game Engines

While puzzle level editors are great for studying visual asset grids, card games are perfect for testing state preservation and secure session storage. If you want to learn how to store progress, high scores, or player bankrolls securely in your database without players cheating, you should look at how casino games handle math.

For instance, downloading an HTML Game download of a card game like Pirate 21 Blackjack will show you how Construct 3 manages bet values, player states, and logic flow.

Comparing how you store custom puzzle levels with how you store card game state records will make you a much more rounded web game architect. You can find both of these clean, licensed game files and helpful development tools on GPLPAL, which is my go-to repository when setting up demo projects and testing server setups safely.


7. Crucial Security and Moderation Measures

When you allow anonymous players to save text directly to your database, you are opening your server up to significant risks. You must protect your database from spam, hacking, and inappropriate content.

Here are the safety systems I implement on every portal:

  • Rate Limiting: Do not let a single user submit a hundred levels in a minute. Use a library like express-rate-limit to restrict level creation to a maximum of 5 posts per hour per IP address.
  • Content Sanitization: Bad actors might try to type HTML or JavaScript inside the "Creator Name" input field. This is called a Cross-Site Scripting (XSS) attack. Always strip out HTML tags from user-submitted text before saving them to your database or displaying them on your site.
  • Swear Word Filters: Implement a simple array of banned words on the backend. If a user tries to name their level with inappropriate language, reject the submission automatically.
  • Spam Level Detection: Sometimes players will save an empty level with no objects on the screen. Write a small helper function on your server to verify that the level_data string contains at least a minimal set of components before allowing it to write to your database.

By keeping your game backend simple, secure, and properly indexed, you can run a thriving HTML5 arcade site that easily handles thousands of players without breaking the bank or crashing your server. Take your time, test your database connections under artificial load, and watch your player engagement grow!

评论 0