Thread Reader
Twitter

I'm trying something new today and over the weekend. I'm making a simple 3D game prototype using Goodluck, a hackable template I created with @Michał Budzyński. I'll be taking notes along the way and tweeting them in this thread together with progress updates. #gamedev #javascript

The goal of this experiment is to create a tutorial about Goodluck which can be followed commit by commit. We don't currently have good onboarding docs for Goodluck, but you can learn a bit more about it at github.com/piesku/goodluck.
I'll be making a simple Wolfenstein3D clone, except that I'll use actual 3D rendering which I'll then try to stylize to give it a retro look. The codename for the project is Castle Peskenstein, after a 14th centrury castle in Southern Poland: en.wikipedia.org/wiki/Pieskowa_…
Yes, it's literally called a Little Dog's Rock :)
I've prepared an outline of the work I'd like to accomplish. I usuall try to follow the same 5-steps approach: 1. Generate a new repo from Goodluck. 2. Evaluate the idea with a prototype. 3. Implement the core gameplay loop. 4. Add features and polish. 5. Optimize.
(I should find a catchy acronym for this; something like GECCO for Generate, Evaluate, Core loop, feature Complete, Optimize; or TEMPO for Template, Experiment, MVP, Polish, Optimization... Ideas welcome!)
Let's take a closer look at each of the 5 steps. 1. Generate a new repo from Goodluck. Goodluck is a hackable template which you can create a completely new repository from. You don't install it from npm. Instead the code lives in your repo; change it any way you like!
2. Evaluate the idea with a prototype. In this step I'm going to build an extremely simple and rough prototype just to see if the main gameplay idea is a promising one. The goal is to experiment and evaluate the idea for fun and feasibility.
3. Implement the core gameplay loop. Here I'll start adding features which are absolutely required for the core gameplay mechanic. I'll add projectile and wall collisions, terrain generation, and simple title and victory screens.
If this was a game I was making for a competition, step 3 is where I'd have a game which can be submitted at any time. It might be ugly and have a single level, but it's already a *game*.
4. Add features and polish. Here's where the MVP becomes something more. I'll add textures for terrain and enemies, perhaps re-implement the terrain generation, add some animations, UI, and audio. This step is about the juiciness of the final product.
5. Optimize. And finally: optimization, both in terms of the size and performance. If this was a game for @js13kGames 🚀 this step could easily take a few days or more. For my little experiment today I plan to remove some unused code to see what size the final build is.
OK, let's get started. For some inspiration, I'm going for a look of the early fake-3D raycasted games like Wolfenstein3D and the old Elder Scrolls games.
I'll get to the textures later, so for now I'll just use solid colored cubes. I'll start with one of the examples from the Goodluck repo called FlyCamera, which gives me a simple diffuse shader in WebGL1 and mouse and keyboard controls. gdlck.com/#FlyCamera
I've cloned the newly generated repo, removed all examples except FlyCamera, which I've renamed to "src". I also moved FlyCamera/index.html to the root of the repo.
There are a few features that I can remove before I even start working on the prototype. I won't need any of the following: - Mouse events and pointer capture. - 2D billboard-like rendering ("Hello" in the video above). - Lights.
The diffuse shader in the FlyCamera example supports multiple directional and point lights. In the final game I hope to get away with using unlit textures, but for now, I'll needs some basic shading to tell meshes apart. I'll leave a single directional light for now.
Side note: solid-color unlit meshes appear flat; it's hard to get the sense of depth. From the screenshot below it's not clear that it's a single yellow cube sitting on a yellow platform.
However, when you add movement, it turns out that our brains are pretty good at perceiving the depth. If you want to try it yourself, check out our 2017 game called 'A moment lost in time,' developed for @js13kGames 🚀! js13kgames.com/entries/a-mome…
Here's what I have after these initial removals. The original FlyCamera example is 5054 bytes when minified and gzipped. After the cleanups, I'm at 4606 bytes. I'm ready to start prototyping!
Let's fix the controls. I want the player to be able to move only in the horizontal plane and only with the keyboard. I'll hardcode the arrow keys for moving forward and backward and rotating left and right. I'll also keep the WASD controls with strafing for now.
Let me talk a little bit about how the controls work. The DOM exposes event-driven APIs while Goodluck uses a data-driven architecture called ECS (entities, components, systems). The Game instance handles keyboard events and stores the state of the keyboard for the entire frame.
medium.com/@paul_irish/re… is a good overview of how and why this works. Input events are fired before the requetAnimationFrame callbacks.
The game loop runs ~60 times a second and executes systems (i.e. logic) one by one. Systems pull data from the Game instance (like the state of the keyboard) or from components which store state for entities (like the movement speed of the camera entity).
In fact, there are 6 different systems in the example recorded above which work together to provide the basic functionality you see in the video. Let me go through them one by one.
1. sys_control_player reads the keyboard state and writes movement directions into the camera's Move component. 2. sys_move reads those directions and translates them into algebraic operations on the camera's translation and rotation, stored in the Transform component.
3. sys_transform then commits those operations into the transformation matrix in the world space. 4. sys_camera left-multiplies the inverted transformation matrix (also called the view matrix) by the camera's projection matrix (stored in the Camera component).
5. sys_light collects all entities with the Light component and reads their transformation matrices to create an up-to-date array of light positions. (This is useful when lights can move which they can't in our prototype; we'll remove this system later.)
And finally… 6. sys_render passes the transformation matrices of all renderable entities together with other data requried by the shader (light positions, diffuse color) to the GPU, and renders the scene.
ECS is a really interesting architecture to structure the code and it takes some time to get used to it. In object-oriented architecture, you'd group logic by game objects. ECS reverses this: you group objects by the logic they're capable of.
I've added a simple maze which I can navigate through. The layout is hardcoded and there are no collisions with the walls, but this should be good enough to imagine what the gameplay will feel like later on.
Here's how the maze is generated. Not very sophisticated, but it gets the job done, and it's good enough for the prototyping stage.
After a short break, I've now added enemies: little red cubes scaled such that they're a bit taller than wide. Not very menacing, but again, good enough for the prototype. They're hardcoded, too. Let's implement shooting!
Shooting is interesting, so I'll dedicate a few tweets to it. I need at least two new systems: one for spawning projectiles and another one for moving them (and handling collisions later on). I'll also add a lifespan system for destroying old projectiles.
I start by adding a Shoot component with a single boolean field: Trigger. sys_control_player sets it to true when the player hits the spacebar key. Then, sys_shoot checks the Trigger flag; if true, it spawns a small cube at the current position and orientation of the player.
I like this separation between a "control" system which reads input and commands another "dumb" system which performs the actual shooting. Later on I'll want to add a simple enemy AI, and I'll only need a sys_control_ai system which will set Shoot.Trigger when it decides to.
It might be useful to mention that when sys_shoot spawns projectiles, it reads the player's transformation matrix which was last updated in the previous frame. It doesn't reflect the player's movement from the current frame yet because sys_shoot runs before sys_transform.
This should be OK for this game, as well as for many other games. A difference of one frame isn't going to impact the gameplay for them. I'm pointing it out here because I think that in order to master Goodluck, it's important to understand its limitations and quirks, too.
Ordering the systems right may be the hardest challenge of ECS. I've learned the hard way that the weirdest bugs in Goodluck are usually due to a wrong order of systems. Today, when I see some strange behavior, I look straight into the Game.FrameUpdate method :)
And here's what I've got so far. I can spawn projectiles at my current position (exactly at it, so I move backwards to see them). I'm creating a lot of projectiles because I haven't added rate limiting yet; a new cube is spawned every frame as long as I keep the spacebar pressed.
I implemented rate limiting so that the player can only shoot every 0.2 seconds. I also copied the Lifespan system from Goodluck's RigidBody example and added the corresponding component to all projectiles; they are now automatically destroyed after 3 seconds.
Lastly, I added a new system, sys_control_projectile, which is responsible for advancing projectiles forward. It's a very simple system implementation wise, but there are few interesting things to note about it.
First of all, let's take a look at the component file. You'll note that it doesn't define any interface to store the data associated with the ControlProjectile component. That's because there's no data to store! We're only tagging entities as projectiles with a bit mask.
Next up, the system. It doesn't do much other than control the Move component on the same entity. I've talked about this separation before: the actual movement is performed in sys_move; sys_control_projectile only writes the direction of the movement every frame.
I like this example because it nicely illustrates how different entities (the player, projectiles) can use the same component (Move) for the same kind of logic (movement). What differs is how the direction of the movement is chosen: upon player input vs. forward at all times.
Lastly, let's talk about ordering system again: sys_control_projectile runs before sys_shoot. It's a good practice to run all control systems first before other systems run. This way you can safely assume that the transformation matrix has been updated in the previous frame.
This is particularly important when spawning new entities. sys_shoot creates a new projectile whose matrix is an identity matrix. If sys_control_projectile ran right after sys_shoot, it would set the direction on Move and sys_move would move the entity in the forward Z direction.
But the transformation matrix is composed only in sys_transform, which runs even later! The forward Z direction would then be computed from the identity matrix, and the projectiles would spawn at the world's origin rather than the shooter's current position.
(I should perhaps explain what it means to compose a transformation matrix. Systems write to Transform.Translation, Rotation, and Scale, and then sys_transform computes the matrix once for each entity.)
The solution is to spawn the projectile at the right place, but not move it the first frame of its lifetime; the next time sys_control_projectile runs is during the next frame. This won't have impact on the gameplay, and it fixes the ordering issue.
(Sometimes it's reasonable to instead move the spawning code to a requestAnimationFrame callback to make sure it runs at the end of the frame.)
Alright, this concludes the prototype phase. You can try it out at piesku.com/peskenstein/pl…. I'll be back tomorrow to work on the core game play loop: collisions (wall, projectile, pick-ups), terrain generation, and simple title and victory screens.
I'm currently at 5318 bytes, so if this was a @js13kGames 🚀 game, I'd still have plenty of space for simple models, AI, maze generation, UI, audio, and some additional polish :)
Last but not least, the source code up to this point is at github.com/piesku/peskens….
Part 2 of this experiment starts right now! Today, I plan to implement projectile, wall and pickup collisions. I'll generate the terrain procedurally, and I'll add a simple UI. twitter.com/stas/status/12…
Stanisław Małolepszy
I make small 3D games at @pieskucom, and I tweet about them sometimes. Cloud at @google, previously @mozilla. https://t.co/cSNtvaDJA3
Follow on Twitter
Missing some tweets in this thread? Or failed to load images or videos? You can try to .