Build a Data-Driven Enemy System in JavaScript

Franks laboratory| 00:35:46|Apr 3, 2026
Chapters8
The video advocates building games in a fully modular, data-driven way, separating data from logic so adding new enemies or features becomes a simple data update. It demonstrates placing static values in data files and writing logic that operates on that data.

Franks Laboratory shows how to build a fully modular, data-driven enemy system in vanilla JavaScript for scalable game design.

Summary

Franks Laboratory demonstrates a path to scalable game code by separating data from logic. The core idea is to drive all enemy behavior from data blocks so adding new enemy types becomes as simple as dropping in a data object. Franks introduces a data folder with enemy data and player constants, then wires these into classes like Enemy and EnemyManager. The video emphasizes consistency across the codebase: caching DOM references, using addEventListener, and replacing magic numbers with centralized constants. A key design improvement is exposing a single play sound entry point via the Game class to avoid cross-manager dependencies. The result is a modular, plug-and-play system where new enemies (drifter, seeker, turret, etc.) can be added without touching the core gameplay logic. Franks also projects future work, like swappable enemy behaviors and richer AI, while keeping the core loop lean and maintainable. The overarching message is that data-driven design speeds iteration, balance tweaks, and feature expansion without breaking the underlying architecture.

Key Takeaways

  • Enemy behavior is driven by a single data source (enemy data.js) so new types (drifter, seeker, turret) require only data changes to introduce new logic.
  • Centralized constants (constants.js) replace scattered magic numbers, enabling easy balance tweaks for width, height, speed, and aspect ratios from one place.
  • UI and asset managers are made robust by caching DOM references, using addEventListener, and optional chaining to prevent runtime errors.
  • A dedicated Game method playSound consolidates audio control, reducing cross-manager coupling and simplifying future sound management.
  • The system uses an EnemyManager to spawn, update, and render enemies, while keeping enemy logic encapsulated in an Enemy class for reuse across types.
  • Game states are formalized via a gameStates enum to prevent typos and coupling errors, improving reliability and debuggability.
  • Future-proofing is baked in: swappable enemy behaviors and data-driven expansion are planned without rewriting core systems.

Who Is This For?

Essential viewing for JavaScript game developers who want scalable, data-driven enemy systems and clean architecture that supports rapid iteration and easy addition of new enemy types.

Notable Quotes

""The key is going fully modular and fully data-driven. Our logic shouldn't care about specific enemies. It should operate purely on data.""
States the core philosophy behind the approach: separate data from logic to enable easy expansion.
""Enemy data.js serves as the single source of truth ... all static, never-changing values from.""
Highlights the data-driven configuration pattern for enemies.
""Coupling managers to each other creates a web of dependencies that can become increasingly hard to trace and refactor as the code base grows.""
Explains the motivation for routing sounds through a single game-level method to reduce cross-manager coupling.
""This is the core idea: data-driven design, separating the how from the what.""
Summary of the design principle applied throughout the project.

Questions This Video Answers

  • How do you implement a data-driven enemy system in JavaScript?
  • What is the role of enemy data.js in a modular game architecture?
  • How can I prevent cross-manager dependencies in a game engine?
  • What are best practices for replacing magic numbers with centralized constants in JS games?
  • How does an EnemyManager interact with an Enemy class in a scalable game?
JavaScriptData-driven designEnemy data.jsEnemyManagerEnemy classGame statesConstants.jsDOM cachingEvent handlingModular architecture
Full Transcript
We want to build games that scale. So, how do you structure our code to make adding new enemy types effortless? The key is going fully modular and fully data-driven. Our logic shouldn't care about specific enemies. It should operate purely on data. That way, adding a new enemy becomes simple. Just drop in a new data block. By designing everything around data, expanding the game becomes fast and predictable. Whenever I managed to do this right, I stop fighting my code and just have fun designing my game. In this video, we'll separate our data from our logic. Static values in data files with code that runs automatically on top. This class is part of a vanilla JavaScript game development series where we build web games, learn JavaScript, and pick up solid game development fundamentals along the way. Full playlist linked below. If you want me to continue the series, don't forget to hit the like button. Source code for the project at this stage is available to download in the resources section below. I called it game starter kit part 11. Use it if you hit a bug and want to compare your code to mine or just save it as a checkpoint you can always come back to. I'll be providing source code checkpoints throughout this project to make sure everyone can follow along and nobody gets left behind. By the end, we'll all have a well-structured optimized starter kit we can use to build many different games. Before we go ahead, let's analyze the code base and make sure it's clean and consistent. I will start here in render system.js. We are using game width and game height constants for everything except here in render menu background. So, let's use them here as well. Every time I make any of these small changes, it's good to check if the code is still working and that we get no console errors. Next, let's go to the managers folder and open both image manager and audio manager.js side by side. I'm trying to mirror how these managers work for consistency. It's not a major thing, but using similar patterns across the code base will help to make it more usable and easier to navigate. Image manager has this get method that uses a ternary operator to check if the image is loaded before it gives it to us. Here inside audio manager, the play method would be a direct equivalent of that. Just for consistency, I replace this line with a ternary operator that says if the sound we are trying to play has been loaded, save it as a temporary sound variable. Otherwise, set it to null. Then, we don't need this loaded check here. And we say if the sound we are trying to play is not null, rewind it to the beginning and play it. It still works exactly the same, but now our managers are more consistent. Should we take this closer to Vampire Survivors or Binding of Isaac or go in a completely different direction? We could lean into a roguelite, a top-down shooter, a survival arena, or even something like a tower defense or auto battler might work here. Let me know what direction you want to take this because I'm making each episode based on your feedback. Now, let's have a look at the repeated DOM queries inside the UI manager. Show timer, hide timer, and update timer all call document.getElementById every time they get invoked. Update timer is called every frame while playing, which means it's doing a live DOM query at 60 FPS. Every other class in our code base caches dependencies once in the constructor, so let's do that here for the UI elements as well. I will create this.timerElement, spelled timerL, and I do the DOM query once here in the constructor. We will be using this cached reference instead. I will cache main menu element here. I will also cache the pause menu as this.pauseMenuL. L simply means element. I will also cache this.loadingScreenElement. Now, instead of searching the DOM every time we need to show the timer, we will use that cached reference instead. If the timer element is actually there, if we are able to find it and cache it in the constructor, we show it by setting the display to block. Here inside hide timer again, if this.timerL is truthy, we set its display to none. And inside update timer, if there is no timerL, we return, preventing the rest of this code base from running. If there is a timerL, we format minutes and seconds and set its text context to display the time values. Again, no functionality changed, but by caching this.timerL in the constructor, we avoid the overhead of searching the DOM every time we need to update the UI, making the code both cleaner and more performant. Here inside hide all panels, instead of querying the DOM, I create an array that will contain our cached panel references. We have an array with main menu element, pause menu element, and loading screen element. And for each of them, we remove the active class to hide all panels. Inside show panel, I use a line that dynamically constructs the property name using the panel ID string, resulting in this.mainMenuL, for example. The question mark.dot symbol is the optional chaining operator, which acts as a built-in safety net. If the element exists, we add the active class. But if it's missing or null, the code simply short-circuits instead of throwing a script-breaking error. Show panel still simply first calls hide all panels and then shows only the panel we want to display based on the panel ID we pass to it. Same functionality, but instead of querying the DOM, we are using our cached references. Inside setup event listeners, assigning directly to properties like onclick or onmouseover enter means only one handler can ever exist per element. If another script tries to attach a second handler, it silently overrides the first. Using addEventListener is a better approach here, so I will replace those properties because it allows multiple listeners to stay attached to the same element without conflict, giving us much better control over the game's event flow. The functionality stays exactly the same, but we are creating a more robust code base by using industry best practices. Our code base has something called magic numbers like this.speed equal to 300 inside the player class constructor. Magic numbers are hardcoded values that lack context, making them difficult to track and even harder to update as the project grows. Better approach is to source these values from a dedicated data file because it centralizes your game's tuning knobs in one place, allowing you to balance the gameplay without hunting through hundreds of lines of logic. To fix this, inside my JS folder, I will create a folder called data and a file called player data.js. This file will serve as our single source of truth, exporting a clean configuration object that our classes can import. Inside, I export a constant named player data, which is a configuration object containing essential key-value pairs. I set the width and height to 64, the speed to 300, and I include a collision radius of 28, which we will use later. We'll expand this object later, but for now, these values provide a centralized source for our player's dimensions and movement logic. Inside player.js, I import player data up here, and now I can use these values instead. This.width is player data.width, this.height is player data.height, and this.speed is player data.speed, directly referencing these values from the data file. The speed multiplier will be used for temporary boost, slow downs, or meta game progression. Either way, this value doesn't belong in player data because that data is static and represents the base blueprint of the player. This multiplier is a piece of dynamic state that changes during live gameplay. I could also choose to reset this.speed inside the reset method. It really depends on how we design our game later. So, for now, this doesn't really matter. We have a few more magic numbers in our code base, so let's put them here inside constants.js. I will have a constant for aspect ratio and another one for canvas margin. Inside game.js, I import them up here, and I use them here on line 115 inside the resize canvas method. I delete these variables here, and instead, we will use these imported constants. Canvas margin here and here, and also down here, and aspect ratio here, here, and here. Replacing those scattered magic numbers with constants from a dedicated data file is a major upgrade for our code's organization. It essentially separates the rules of the game from the settings. By turning random numbers into clear named constants, we create a single source of truth. This makes it much easier to fine-tune the game balance in one spot from these data files without worrying about accidentally breaking the underlying logic in other files. Putting constant, never-changing values into data files like this is the core of data driven design. I can always check if my data is correctly connected by changing the values and seeing if it's reflected in the game. I increase canvas margin from 15 to 100. And we have a large margin now between the game and the edges of the browser window, so it works. I set this back to 15. Data driven design is about separating the how, your logic, from the what, your data. By moving constants into their own data files, you can tweak the game's balance as much as you want without touching or accidentally breaking the underlying code. To continue with the cleanup inside image manager js down here inside its load all method, I add a clear comment to remind myself that this line here is only for testing. This artificial delay is here to make the loading screen appear for a little bit longer so that we can properly see it. I can even create a clearly named temporary constant, set it to 1 second, and use it here just for code clarity. Inside game js inside the game loop, this code is doing nothing. This dot last time can never be zero with our current setup, so I can remove this. The game state is managed as a raw string with no enforcement. Any typo here would silently break state transitions. Moving these two constants would prevent that kind of bug entirely. So inside constants js, I export the constant game states. It will be an object literal that acts as an enum mapping friendly names to their string values. This way, we use game states dot playing in our code instead of the raw string playing. If we make a typo in that, the editor or browser will immediately warn us with an error rather than letting the game fail silently. So back in game js, I import game states up here. And now I will use the properties of that object instead of raw strings. By referencing game states dot menu rather than the string menu, I'm letting the computer do the work for me. If I mistype the property name here, the console will throw an error immediately, making the bug obvious and easy to fix. Here on line 45, I reference game states dot playing. Also here on line 55. Here on line 65 as well. I reference game states dot paused here on line 67. Playing on line 86. Paused on line 97. Playing on line 102. And game states menu on line 107. Inside render system js, I also import game states up here. And here on line 11, I check if the state that's been passed to it is equal to game states dot menu so that render system can decide if it should draw menu or game play elements. Inside UI manager on line 15, we have this situation where UI manager is accessing audio manager to call play button hover. Game exposes audio manager as a public property that UI manager reaches into directly here. This is a direct cross manager dependency. It works, but as the game grows, it means UI manager is coupled to audio manager's interface. Ideally, we want to route all sound calls through a single point on game so managers never reach into each other directly because each manager should only know about game, not about the internal structure of other managers. Coupling managers to each other creates a web of dependencies that can become increasingly hard to trace and refactor as the code base grows. To follow this principle, I will create a simple helper method here in game js called play sound. It will expect the name of the sound we want to play. Inside game, we'll handle the logic of reaching into the audio manager to trigger the sound. Now, back into UI manager, I can simply call this dot game dot play sound. How that sound is actually produced is no longer a concern for the UI. The game class handles the orchestration. And to stay consistent, I will use play sound method back in game js as well. Here on line 101, I call this dot play sound from line 112, and I pass it the name of the sound I want to play. Also here on line 106 where I want the button click sound to play. Here on line 85. And here on line 96. Everything still works the same, but our code base is more scalable and we are ready to add more features that trigger all kinds of sounds in a clean way. I'm building this series based on your feedback. If you find some value, let me know by clicking the like, and you can also leave a comment to help me decide how to approach the visuals. Should we keep the art style generic for as long as possible so you can plug in any asset pack you like, or would you prefer we lock in a specific art style? You can see that for today's episode, we have some fantasy grotesque pixel art, but I've built the same game in many different art styles just for fun, so we've got options. Pixel art or hand drawn, top down or side scrolling angle. This code base can handle all of it. What should we go with? Leave a comment and let me know. Some console logs like this one inside audio manager are here only for development purposes. So I will mark it as a dev like this just so we can easily find it and hide it or delete it before we release our game to the public. This console log that says audio failed will stay for now because it's arguably more important. Same here inside image manager. I add a dev comment here. Now I know that all these console logs with a dev prefix are here just for the development purposes and they should be removed before we ship our game. I am caching all my UI panels. Just for consistency, I will also cache all the buttons. I create a property called play button element and I point it to the play button using its ID. Same here for resume button element. And also for quit button element. Now, instead of searching through the DOM here, I will use the cached references here. And here. And also here. And here we have an array that will contain all three of them like this. And from this array of cached references, I call for each and add event listener. Down here inside hide all panels, to be safe, I can use optional chaining. I'm saying only remove the active class if you were actually able to find the element. Using this technique is good because it prevents the script from throwing a runtime error if a selection returns null or undefined, ensuring that a single missing element doesn't break the execution of the rest of your JavaScript. game starter kit part 12. Use it if you In the data folder, I create a new file called enemy data js. This is where we will store all configuration values for our different enemy types, things like size, speed, health, damage, and so on so that all the enemies are fully data driven and easy to tweak without modifying gameplay logic. Inside, I export the constant called enemy data. It's an object that holds key value pairs where each key represents an enemy type. The first key is drifter and its value is another object containing its configuration with height, speed, health, damage, and collision radius. Drifter will be an enemy type that randomly roams around the map completely ignoring the player. With this data driven system, we can easily add dozens or even hundreds of different enemy types without changing our core logic. For example, a seeker will actively look for the player and chase them. A turret enemy could remain stationary and shoot projectiles toward the player. We could also introduce an enemy that patrols between fixed points or enemy that explodes on contact. Or maybe an enemy that buffs nearby enemies. We're building a complete, robust, and scalable system that can support virtually any enemy behavior we can imagine. The behavior type and stats are defined here in the data while the underlying gameplay systems remain unchanged. Everything is modular and plug and play. We can introduce new enemy types simply by adding new data and if needed also a corresponding behavior without rewriting existing systems. Inside the entities folder, I create a new file called enemy js. The job of this file is to define the enemy class, which represents a single enemy in the game. It will store its properties and keep the enemy logic encapsulated and reusable across all enemy types. Inside, I export the enemy class and its constructor expects a data object as a parameter. I store this object as a class property called this.data. Then, we define some class properties for position and dimensions. We will need the X and Y position. This.width will be coming from which we set here to 48 pixels. We will connect this enemy data object to this data parameter a little bit later when we spawn enemies. We will also have this.height here. The next block of properties will be for enemy stats. Health will be coming from enemy data, speed as well. Then, we will have damage and collision radius. All of these will be set to the values coming from the data. We will pass that enemy data in here at the point when the enemy object is created. Enemy data.js is the single source of truth, where we source all our static, never-changing values from. Each enemy will have a spawn method that takes X and Y coordinates and positions the enemy at those coordinates in the game world. An important point is that enemies will be reusable, pulled objects. We will explain object pooling in a moment. So, whenever we spawn an enemy, we also want to reset any properties that may have changed during its life cycle back to their default values from the data. For example, we reset the enemy's health to the base value defined in enemy data.js for that enemy type. Notice that in the constructor, I'm taking directly the data that was passed in, but inside methods like spawn, I don't have access to that constructor parameter directly anymore. So, I have to use this.data the reference we saved earlier. Enemy data.js serves as a single source of truth whenever an enemy object is created or used. It uses these static data values to correctly initialize all its properties. Now, I want to create an enemy manager, which will be responsible for handling all the enemies in the game, spawning them, keeping track of active enemies, updating them each frame, and resetting them when needed. I could put all this logic directly inside game.js, but that would make the main game class bloated and harder to maintain. By separating it into its own manager here, the code becomes modular, organized, and easier to expand, allowing us to add new enemy types or behaviors without touching the core game loop. So, inside managers, I create a new file called enemy manager.js. Inside, I export the enemy manager class. In its constructor, I create a property called this.enemy and set it to a new instance of the enemy class. The enemy constructor expects a data object as a parameter, so it can initialize all its properties from that data. At the top of the file, I import enemy data from data/enemy data.js and the enemy class from entities/enemy.js. This allows me to create new enemies using the configuration from enemy data. Since enemy data is structured as an object where each key is an enemy type, for example, drifter, I can pass the specific block of data for that type to the enemy constructor. So, this.enemy is equal to new enemy enemy data.drifter. For now, we are creating just a single enemy instance and seeding it with the drifter data values, which will give it all the default stats and behavior defined in enemy data.js. The enemy manager will have a spawn method. Its job will be to create or activate an enemy of a specific type at a given position. For now, the spawn method will simply take the X and Y values it receives and it will pass them along to this.enemy.spawn, the spawn method defined directly on the enemy class. That method, which we defined earlier, will use those coordinates to position the enemy on the map, initializing its properties so it appears at the correct location with the default stats from enemy data. So, for now, the spawn method on the enemy manager is just passing the X and Y values along to the enemy's own spawn method. Later, it will become much more important when we have multiple enemies and different enemy types because the manager will handle selecting the right type. I create a method called get active enemies. When called, it will return an array of all enemies that are currently active in the game. Right now, since we only have one enemy, it simply returns that single instance. Later, this method will be important for the game loop and rendering system. We will use it to figure out which enemies need updating and drawing this frame, which enemies are the active ones. Now, to connect it all together, inside game.js, I import enemy manager up here. I instantiate it here with all the other managers. When we start the game, we will call this.enemy manager.spawn, which will spawn that one enemy we have so far. We know the spawn method expects an X and Y position, so I pass it 200 200. So now, we are spawning an enemy. We also need to actually draw it in the game. This will be handled by the render system. Here on line 53, when we call render system.render, we pass it all the enemies we want to draw. But how do we know which enemies are active in the game and need to be drawn? We created a special method for this. This.enemy manager.get active enemies. This method returns an array of all currently active enemies, which at the moment contains just our single enemy. And we pass that array to render system.render method, which handles drawing them on the canvas. To support this, we update the render method's signature on line 10 to accept enemies as a parameter. We also give it a default value of an empty array. Enemies equals an empty array like this. This means that if no enemies are passed in, the method will still work without errors because enemies will default to an empty array. It's a simple safeguard that makes sure the render logic is flexible and won't break if, for example, there are no active enemies. Here, I will call render enemies. For now, I want to draw enemies over the grid, but under the player, like this. I will define this method down here. I will cycle over the enemies array, which will contain all active enemies. For now, we know this array will have only one enemy, but later, there will be more. For each enemy in that array, I first save it as a helper variable called enemy. As the loop runs and the index increases, this enemy variable will represent each individual enemy in that array, one by one. And we will draw each one on the canvas. I set fill style to this red color for now, and I call the built-in fillRect method to draw a rectangle. I pass it enemy.x, y, width, and height. And we will draw a rectangle representing the enemy, just like [clears throat] we did earlier with the player. Okay, so to follow the logic that will work nicely at large scale, first, we import and instantiate enemy manager, which will be responsible for managing the enemies. The new keyword automatically runs the class constructor, so the enemy manager, as soon as it is created, will automatically create the first enemy here using the enemy class and passing it enemy data.drifter. The enemy class will create one new blank object and use this data to initialize all its values. So far, we have only the drifter enemy type in our data. So, enemy manager is instantiated and the first enemy is automatically created. Inside start game, we call this.enemy manager.spawn and pass it the X and Y coordinates. That method, for now, only passes those X and Y values along to this.enemy.spawn, which will actually set the enemy's X and Y position properties to these values. As the game loop runs over and over, we call render system.render, passing it the array of active enemies that need to be drawn. For now, we see that get active enemies returns just an array with a single enemy inside. Render expects the enemies array as the third parameter. It passes those enemies along to render enemies. Render enemies expects that enemies array, cycles over it, and for each enemy, draws a red rectangle at the enemy's current X and Y position. If I play, we see that enemy here. Inside game.js, in its startGame method, I'm passing enemy manager.spawn these coordinates, so the enemy is drawn 200 pixels from the left and 200 pixels from the top. If I pass it a different horizontal or vertical coordinate, the enemy will be drawn at that position. So, now I know the code works. I will close all these files for now. Next, we want the enemy to actually move around the game world. To do that, we give it an update method. I remove these spaces just so we can see everything clearly. I could start by simply increasing enemy X by 100 pixels per second here to make it move to the right, but that would be very simple. Our enemies will have a more complex set of behaviors. Let's start with the most typical enemy behavior in games like Vampire Survivors. We want enemies that hunt and chase the player. To make an enemy move toward the player, we first need to calculate the direction from the enemy to the player. We calculate DX as the difference between the player's horizontal position and the enemy's horizontal position, and DY as the difference between the player's vertical position and the enemy's vertical position. The update method for this behavior will expect DT, delta time, and player as parameters. Next, we calculate len, the length of the vector from the enemy to the player using the Pythagorean theorem. So, I say const len is equal to square root from DX * DX + DY * DY because Pythagoras theorem is a square root from DX squared and DY squared or A squared and B squared, depending how you name your sides. Imagine a right-angled triangle formed between the enemy and the player. The horizontal difference, DX, represents one side, and the vertical difference, DY, represents the other side. The distance between the enemy and the player is the hypotenuse, the longest side opposite the right angle. This is the standard way to calculate the distance between two points in a 2D space using the Pythagorean theorem. It's one of the most common formulas in 2D game development. So, to calculate this side, I use the Pythagorean theorem formula. We know DX and DY, and we use them to calculate len, which is this side. This calculation gives us the If len is greater than zero, we calculate normalized DX and normalized DY, which represents the direction from the enemy to the player as a unit vector. By dividing DX and DY by len, we scale the vector so its length is one, keeping only the direction. We do this so the enemy moves at a consistent speed toward the player regardless of the distance. Then, we update the enemy's position each frame by multiplying the normalized direction by the enemy's speed. We also multiply by delta time to make sure the movement is based on real time and not on frame rate. This keeps all motion in the game consistent across any device, so enemies move at the same speed regardless of the performance of the device running our code. I do the same calculation for the vertical position. Now, the enemy moves at a consistent speed regardless of the distance to the player or the direction. This also means the enemy moves at the same speed when moving diagonally, maintaining uniform motion in all directions. I give enemy manager an update method. It expects delta time and player as parameters. This method will handle updating all enemies. For now, we just have one enemy, so inside I call this.enemy.update. This calls the update method on the enemy class we just wrote, which makes the enemy move toward the player. Inside game.js, notice we have the enemy manager property here on line 16 from before. I go down to its update method and call this.enemyManager.update. I know it expects delta time and the player, so I pass it DT and this.player from line 19. This function is very simple for now. It just passes the delta time and player reference along to enemy update method to update that single enemy in our game. We wrote that method earlier, and we know that it calculates the distance between the enemy and the player and makes the enemy chase the player. It works by calculating the direction vectors, DX and DY, normalizing them, and then applying the enemy's speed multiplied by delta time. Doing this gives us consistent movement in all directions. This is a very common formula in 2D game development, and it isn't limited to enemy movement. It can also be used for projectiles, player-following cameras, or any situation where one object needs to move towards another object at a consistent speed. What we've done for now is bake this chasing behavior directly into the enemy class. Later, we'll update the system to use swappable behaviors, so each enemy type can do more than just chase the player. For example, an enemy could drift randomly, move along a set path, or shoot projectiles. This system will also allow enemies to cycle between different behavior patterns or even combine multiple behaviors, making them more dynamic and interesting without changing the core enemy or game logic. What we have built here is a robust and incredibly powerful setup for creating and managing enemies in a modular, scalable way. It allows us to define enemy stats and behaviors in a single source of truth in enemy data.js, spawn and track any number of enemies efficiently, and easily swap and combine behaviors to create diverse and dynamic gameplay without [music] touching the core game systems. If this is the kind of thing you need in your games, let's keep going.

Get daily recaps from
Franks laboratory

AI-powered summaries delivered to your inbox. Save hours every week while staying fully informed.