laitimes

After writing 100,000 lines of code in 3 years, developers complained: they were fooled when they used Rust

author:InfoQ

作者 | LogLog Games

Translator | Nuka-Cola

Curated | Li Dongmei

Editor's note: The author of this article is a foreign developer who develops games in the Rust programming language, and he and his friend have founded a small independent game development studio that has been working on a variety of games across different engines for the past few years.

They love gaming and have extensive experience in programming and creating a variety of applications, web or desktop. They built their own engine in Rust, called the Comfy Engine, for their games. This article tells the story of how they have been developing games using the Rust programming language for the past three years. The following has been translated and organized by InfoQ.

Disclaimer: This article is my personal summary of my feelings and dilemmas over the years, and it also validates some of the so-called "truths" that I have heard and repeatedly emphasized. I've worked on thousands of hours of game development with Rust, and I've done a lot of games along the way. This article is not meant to show off how good and successful I am, but rather to dispel the widespread myth that you don't use Rust because you don't have enough experience.

Nor is this a scientific assessment or rigorous A/B study. We're a small team of two indie game developers who simply want to make games with Rust and earn the money they need to continue to grow. So if you can get a lot of funding from investors, you have to work on a project for years, and you don't mind constantly developing the necessary systems, then our experience doesn't work for you. The goal was simple: to develop and launch the game in a maximum of 3 to 12 months, and earn a return on it. This is the basis of this article, and it has nothing to do with having fun learning and exploring Rust. It's not that the latter goal is bad, it's just that we're talking about whether or not we can support our families in Rust and whether we can find a viable path to self-sufficiency at the business level.

We've already developed games on Rust, Godot, Unity, and Unreal Engine, so chances are you've played them on Steam. We also made our own 2D game engine from scratch using a simple renderer. Over the years, we have also used Bevy and Macroquad in a number of projects, including some of the most important ones. I also have a full-time job working on backend Rust development. It's also important to note that this article is not intended for inexperienced beginners - we've written over 100,000 lines of Rust code in over three years.

My purpose in writing this article is to dispel a ridiculous view that has been repeatedly mentioned and misled countless novices. Hopefully, after reading this article, you'll understand why we're giving up on Rust as a game development tool. We're not going to stop game development, we just don't continue to use Rust.

If your goal is to learn Rust, feel like it's good, and take on technical challenges, that's fine. As an experienced developer, I'm going to make a clear distinction between use cases in my articles, rather than blindly touting the Rust language without asking a lot of so-called Rust veterans if you just need a technical demo or if you want to launch a game seriously. I've found that the entire Rust community is focused on the technicalities and ignores the "game" part of game development. For example, I attended an offline meetup for Rust game development, and I saw a note in the proposal that said, "Someone wants to show a game at the conference, please give me a comment...... I'm really speechless.

"You don't think Rust works because you don't have enough experience"

Learning Rust is a really fun experience, because people often find that "this must be a special problem that only I've had" is actually a common pattern that is bothering more developers, so every learner has to adjust their thinking and digest these "quirks" to be more productive. And these problems tend to be at a fairly basic level, such as &str and String or the difference between .iter() and .into_iter(). All in all, the things that we subconsciously think should be the same tend to be tightly bounded on the Rust side.

I'll admit that some of these are necessary pains, and with enough experience, users can anticipate potential problems and be more productive without thinking. I've really enjoyed writing utilities and CLI tools in Rust, and in most cases it's more efficient than Python.

That being said, there is an overwhelming force in the Rust community. Whenever someone mentions the basic problems they have with Rust, they respond rudely by saying "you don't think Rust is working because you don't have enough experience". Of course, this isn't just a Rust problem. We have this with ECS, and we have this with Bevy. Even when we make a GUI with any framework of our choice, whether it's a reactive solution or a real-time pattern, we're having a similar problem. "You don't think xx works because you don't have enough experience."

I've been convinced of this for many years and I've been trying to learn and try. I've been in a similar situation in multiple languages and found that I was more efficient with the tricks, slowly learning to anticipate the "temperament" of language and type systems and avoid problems effectively.

But I want to emphasize that after spending about three years writing over 100,000 lines of game-related code across Rust's entire framework/engine ecosystem, I found that many, if not most, of the issues persisted. So if you don't want to refactor code endlessly and see programming as a fun way to challenge yourself, but just want to do things quietly with it, don't use Rust.

The most basic problem is that the Inspector often chooses the most uncomfortable time to force a refactor. Rust fans thought it was okay because it allowed them to "write better code", but the more time I spent on the language, the more I wondered if it was reliable. Good code is made by iterating on ideas and experiments, and while borrowing an inspector can force more iterations, that doesn't mean it's the ideal way to write code. I often find myself stuck in a state where I can't fix it later – and I can't get the ideas in my head out of my head in a relaxed and smooth way to code.

In other languages, people can write code and forget about it, and I think that's the best way to achieve good code. For example, I'm writing a character controller with the sole goal of manipulating character movement and performing actions. Once that's done, I can start building levels and enemies. I don't need this controller to be good, it's enough to work. If I have a better idea, I can certainly cut it out later and replace it with a better one. But in Rust, everything is connected, and we often run into situations where we can't do one thing at a time. As a result, each of our development goals becomes extremely complex and eventually refactored by the compiler, even for one-off code.

Rust excels at large-scale refactoring, but this is to solve the problem of borrowing inspectors themselves

It's often said that one of the biggest strengths of Rust is its ease of refactoring. That's true, and I've experienced first-hand how you can fearlessly refactor important parts of your codebase without worrying about what will go wrong. But is it really that simple and beautiful?

In fact, Rust is also a language that forces users to refactor more often than other languages. Whenever I've been working on it for a while, I suddenly get borrowed, inspectors, and realize "okay, this new feature I added doesn't compile, and there's no other solution than refactoring".

Experienced people often say, "You don't think Rust works because you don't have enough experience." While this is true in principle, games are complex state machines whose needs are constantly changing. Writing a CLI or server API in Rust is a completely different experience than writing an indie game. After all, our goal in developing games is to provide a great experience for our players, not a rigid set of generic systems, so it's important to consider how people's needs change over time, especially those that need to be fundamentally adjusted. Rust's highly static nature and tendency to over-check are clearly contrary to the natural needs of game software.

Many people might argue that borrowing inspectors and code refactoring is not a bad thing, as they can improve the quality of the code. Indeed, for projects that emphasize code, Rust's features have their positive side. But at least on the game development side, most of the time I don't need "high-quality code", but "games that I can try out early" so that I can quickly test my gameplay design ideas. A lot of Rust's insistence is forcing me to choose between "break the process and spend a full 2 hours refactoring" and "objectively make the code worse". I'm really on the verge of a breakdown......

I'd like to make a big irreverent remark here: maintainability, at least for indie games, is a pseudo-claim, and we should be aiming for speed of iteration. Other languages make it easier to solve the problem at hand without sacrificing code quality. In Rust, we always have to choose whether to add an 11th function to a function, whether to add another Lazy<AtomicRefCell> to another <T>object, whether to add an indirect (function pointer) and worsen the iteration experience, or simply take the time to redesign the code.

Indirectly only solves part of the problem, and it always comes at the expense of the development experience

One of the basic solutions advocated by Rust, and which is particularly effective, is to add an indirect layer. Let's take the Bevy incident as a typical case, and the Bevy incident is the preferred solution for problems such as "17 parameters need to be used to complete the task". I've tried hard to understand the situation on the bright side, but Bevy does rely heavily on events and always wants to cram everything into a single system.

Many of the problems with borrowing the inspector can be solved by doing something indirectly. Or you can copy/remove something, do that, and then transfer it back. Or store it in the command buffer and execute it later. This often leads to some magic in design patterns, such as the fact that I found that a large part of the problem could be solved by keeping the entity id in advance and combining it with the command buffer (e.g. world::reserve in hecs, note that &world instead of &mut world). Sometimes these patterns do work well and are enough to solve extremely difficult problems. Another example is the get2_mut mentioned in Thunderdome, which at first glance doesn't make much sense, but after more experience, people find that it can solve a lot of unexpected problems.

I don't really want to argue too much about Rust's steep learning curve, because every language has its own characteristics. But I'd like to remind you that even after a lot of experience, there are still a lot of fundamental problems in Rust.

To get back to the point, while some of the previously mentioned approaches can solve specific problems, there are also quite a few cases that simply can't be solved with specialized, well-orchestrated library functions. Because of this, many people recommend using command buffers or event queues to "defer the problem" in order to solve the problem effectively.

The specific problem with game development is that we often need to focus on multiple interrelated events with specific schedules, and we have to manage a large number of states at the same time. Moving data across event barriers means that the code logic of things is split into two parts – meaning that even if the business logic itself is still a whole, it should be cognitively independent of each other.

Friends who hang out in the Rust community know that the old folks over there will say that this is a good thing, that it keeps the focus separated, that the code is "cleaner", and so on. In their opinion, the designers of Rust are the smartest, so if something normal is difficult to implement - it's not that the design is wrong, but that they want to force you to do it right......

So, what can be done with 3 lines of code in C# takes 30 lines of code in Rust and has to be split in two. I'll give you a typical example: "When iterating on the current query, I want to inspect another functional component that involves a bunch of related systems" (e.g. spawning particles, playing audio, etc.). You don't have to ask, the Rust community will say, "This is obviously an event, so you shouldn't write code inline".

Imagine how cumbersome it would be to implement a feature under such rules (here's the Unity code):

if (Physics.Raycast(..., out RayHit hit, ...)) {
  if (hit.TryGetComponent(out Mob mob)) {
    Instantiate(HitPrefab, (mob.transform.position + hit.point) / 2).GetComponent<AudioSource>().clip = mob.HitSounds.Choose();
  }
}           

This is a relatively simple example, but such a need can arise at any time. Especially when implementing a new mechanism or testing a new feature, what we need most is to write it directly, and we don't want to think about maintainability for the time being. What we're going to do is something very simple, we just want it to run in the right place. I don't need MobHitEvent because I'm also going to check other related features like raycast at the same time.

I also don't want to check if there are transforms on the mob, because I'm developing a game, so of course every entity has transforms. But Rust doesn't allow me to use .transform, and if I accidentally let the query overlap with the prototype, the resulting double borrowing will cause an immediate crash.

I also don't want to check if the audio source exists. I could certainly use .unwrap().unwrap(), but careful Rust will notice that the world is not passed here. In Rust's view, is the runtime scene a global world? Shouldn't you use dependency injection to write queries as mid-car arguments in your system and have everything pre-arranged?. Does Choose assume that there is a global random number generator? What about threads?

I know a lot of fans will say things like "But it's not good for future expansion", "It could cause a crash later", "You can't assume a global world because of blabla", "Didn't you think about multiplayer games", or "Do you dare to use this kind of code quality...... I know. But while you're busy finicking your mistakes, I've completed the implementation and moved on. A lot of the code is really a one-off product, and I'm actually thinking about how the current implementation of the game's features will affect the player experience. I didn't care about technical issues like "what is the right random generator to use here", "can I assume a single-threaded scenario", or "what should I do with prototype coincidence in nested queries", and I didn't get any compiler errors or runtime borrowing checker crashes. I just want to use a bit of dumb language in a dumb engine and make sure that I'm only writing code with game logic in mind, okay?

Use ECS to solve error type problems

Due to the nature of Rust's type system and borrowing inspectors, ECS was a natural solution to our problem of how to make something reference something else. Unfortunately, I think there's a lot of confusion in terms of different people, not only do different people define it differently, but there are also a lot of people in the community who put things on it that don't belong in the ECS. Let's make a clear distinction and elaboration.

First, let's talk about what developers can't implement for a variety of reasons (I won't go into too much detail here):

  • Pointer-Y data with actual pointers. The problem here is simple, if character A follows B, and B is removed (and unassigned), the pointer will be invalid.
  • Rc<RefCell<T>> combined with weak pointers. While achievable, performance is often very important in games, and due to memory locality, such a resource overhead does have a perceptible impact.
  • The index of the Entity array. In the first case, an invalid pointer appears, and if we have an index and delete an element, the index may still be valid but point to something else.

That's when a magic solution came out that would help us get rid of all the problems - this is what I personally highly recommend Generational Arenas - which is small, lightweight, and able to do what is intended while maintaining the readability of the codebase. This ability to consistently implement a given functionality is actually quite rare in the Rust ecosystem.

Generational arena is essentially an array, except that our id is no longer an index, but an (index, generation) tuple. The array itself stores (generation, value) tuples. For the sake of simplicity, we can imagine that every time something is deleted at the index, it is enough to simply increase the generation counter at that index. After that, you just need to make sure that when you index Arena, you always check that the generation providing the index matches the generation in the array. If the entry is deleted, the slot will have a higher generation and the index will be "invalid" as if the entry did not exist. This approach also solves other very simple problems, such as keeping a list of free slots that you can insert there if necessary to speed things up - none of this, of course, for the user.

Crucially, this approach finally allows languages like Rust to avoid borrowing inspectors altogether, allowing us to "use arenas for manual memory management" without touching any pointers while being 100% safe. If there's one thing Rust likes about it, it's this. Especially for libraries like Thunderdome, the two really work well together, and the data structure is very much in line with the design of the language.

Here's where the fun comes in. What most people think of as an advantage of ECS is very much the advantage of generational arenas. When people say that "ECS provides good memory locality", the Query<Mob, Transform, Health, Weapon> queries they use for mobs are essentially equivalent to Arena<Mob>. A specific struct is defined as:

struct Mob {
  typ: MobType,
  transform: Transform,
  health: Health,
  weapon: Weapon
}           

Of course, this way of defining it doesn't capture the full benefits of ECS. But I want to emphasize that we don't necessarily have to rely on ECS if we want to use Rust while trying to avoid the Rc< RefCell <T>> - on the contrary, it may be generational arena that can really help as soon as possible.

Going back to ECS, we can understand the role of ECS from a number of different perspectives:

ECS, as a dynamic composition, allows multiple components to be combined to store, query, and modify together without having to be bound in a single type. The prime example here is that a lot of people actually use the "state" state component to tag entities in Rust (because there's no better way to do that). Let's say we want to query all the mobs in the game, but some of them may have turned into different types. We can simply execute world.insert(entity, MorphedMob) and then query (Mob, MorphedMob), (Mob, Not<MorphedMob>) or (Mob, Option<MorphedMob>), or check if the said component exists in the code. Depending on the ECS implementation, the exact operation of these methods may vary, but in essence they are "marking" or "splitting" entities.

Not only that, but in addition to Mob, similar situations can occur with Transform, Health, Weapon, or other elements. For example, an unarmed mob doesn't have a Weapon component, but we need to plug it into the entity after it picks up the weapon. This way, we can borrow all the mobs with weapons in a separate system.

I'll also introduce Unity's "EC" approach to dynamic compositing, which isn't purely "ECS with systems", but does use components for compositing for the most part. And aside from the performance issues, the end result is really very similar to "pure ECS". I'd also like to boast about Godot's node system here, where subnodes are often used as "components". While this is not related to ECS, it is related to "dynamic composition" because it allows nodes to be inserted/removed at runtime, which in turn changes the behavior of entities.

It should also be noted that "splitting components into the smallest possible form to maximize reuse" has also become a best practice. I've been involved in a lot of arguments, and some people have tried to convince me that it's better to separate Position from Health. And if I don't, my code will end up tangled up and messed up like spaghetti.

After trying these things many times, I'm now convinced that for the vast majority of development scenarios, there's no need to split up to this extent unless you're focused on extreme performance. I've tried the so-called "fat component" approach in addition to splitting, and I feel that "fat component" is actually better suited for games that need to build a unique logic on top of a lot of what's going on. For example, modeling Health as a generic mechanic would work well in a simple simulation, but in different games, the player's health and the enemy's health actually correspond to different logics. I also often want to have different types of non-player entities use different logic, such as monsters and walls each with their own health. Practical experience tells us that a rough generalization of it as "health" can make the code obscure, resulting in a health system full of if player { ... } else if wall { ... } Such a statement. It's not a good idea, it's better to keep the so-called "fat" players or wall system alone.

As a dynamic structure of an array, influenced by how the components are stored in ECS, we can iterate over the Health components and arrange them in memory next to each other. Some of you may not understand it, but this means that we no longer need to use Arena<Mob>, but can use:

struct Mobs {
  typs: Arena<MobType>,
  transforms: Arena<Transform>,
  healths: Arena<Health>,
  weapons: Arena<Weapon>,
}           

And the values on the same index belong to the same "entity". Doing this manually is annoying, and due to our previous development experience and the language we use, we may sometimes be forced to do it manually. But thanks to modern ECS, we can easily do this by simply writing out our own types in the tuple, and then the underlying storage mechanism will put the right stuff together.

I also refer to this type of use case as ECS as performance, because the purpose of this is not "because we need to compose", but "we want more memory locality". I'll admit that some of its counterparts are valid, but at least for the vast majority of indie games that have been released, there's really no need. The reason I emphasize "released" games is that naysayers can easily design highly complex prototypes that require this mechanic, but that has little to do with the player experience and isn't worth the trouble of this article.

As Rust's solution to borrowing inspectors, I think that's what most of the people who are doing with ECS - or rather, that's why they chose ECS. If there is no difference, it is that ECS is really popular, and it is the recommended option for Rust, and it does solve a lot of problems. Anyway, if we're just passing struct Entity(u32, u32), since it's so simple and straightforward to copy, there's really no need to worry about the lifecycle as Rust requires.

The reason I've separated this section is because a lot of people are using ECS to solve the "where do I put objects" problem, rather than really using it to compose or improve performance. There's nothing wrong with that, it's just that when people end up arguing online, there's always someone who wants to emphasize that someone else's approach is wrong and that they should use ECS in a particular way. Don't make a fuss, I've found that a lot of people don't even understand why other people use ECS.

ECS is a dynamically created generational arenas that is simply designed to provide the most basic functional guarantees. In other words, in order to achieve something like storage.get_mut::<Player>() and storage.get_mut:: at the same time<Mob>, I'm either forced to reinvent a bunch of wacky internal variability, or I'm just going to have to choose it. One of the things about Rust is that when you do things according to its temperament, it's fun and beautiful; But when you want to do something it doesn't like, it quickly becomes "I'll have to re-implement my own RefCell for this particular feature".

I would say that while Generational Arenas is good, one of the biggest drawbacks is having to define a variable and type for each Arena we need to use. If you only use one component in each query, you can of course solve it with ECS; But wouldn't it be better if you didn't need a full prototype ECS and could use the arema for each type as needed? Now, of course, there are many ways to do this, but I don't want to continue to bother with partially reinventing the Rust ecosystem. After saying goodbye to Rust, I'm now at ease.

ECS is popular because of Bevy, and I think it's definitely just a joke. But it must be admitted that with its extreme popularity and all-encompassing approach, Bevy deserves to be a separate angle in the ECS. Because for most engines/frameworks, ECS is an option, and it's a library that people choose whether to use or not. But Bevy is indispensable to gaming, and in many cases the entire game is ECS.

I'd also like to emphasize that despite my personal grievances, Bevy has made a huge improvement over the ease of use of the ECS API and ECS itself. Anyone who has seen, or used, specs or anything like that, knows how well Bevy has done to improve the ease of use of ECS and how far it has improved over the years.

That being said, I think that's the core reason why I'm unhappy with the way the Rust ecosystem looks at ECS, and Bevy in particular. ECS is a tool, a very specific tool that solves a very specific problem, and at a cost.

Let's digress a little bit and talk about Unity. Regardless of the changes in licensing, executives, or business models, we must acknowledge that Unity is one of the main drivers of indie success. Looking at the SteamDB chart, there are currently nearly 44,000 Unity games on Steam; In second place is the Unreal Engine with 12,000; Other engines are far behind.

Those of you who have been following Unity in recent years have surely heard of Unity DOTS, which is essentially Unity's version of "ECS" (and other data-oriented stuff). Now, as a past, present, and future user of Unity, I'm very excited about it. And what makes it so exciting is that it can coexist with existing GameObject methods. While there are bound to be a lot of complexities involved, essentially, users are looking forward to such an upgrade. We can either use DOTS for certain effects in one game, or we can continue to use the standard GameObject Scene Tree as we did before, and have a smooth combination of the two.

I don't believe anyone in the Unity world will understand the significance of DOTS and think it's a bad feature that shouldn't exist. Of course, I don't think anyone would think that DOTS is the whole future of Unity, and that they could just delete the game objects and force all Unity titles to move to DOTS. Even if you don't talk about maintainability and backward compatibility, this is a pretty stupid thing to do, as there are still a lot of workflows that are naturally suited to the GameObject mechanic.

I believe that many friends who have used Godot will have similar views, especially those who have used gdnative (e.g. via godot-rust). While node trees may not be the best data structure for every need, they can be very handy in many scenarios.

Going back to Bevy, I don't think a lot of people realize how broad the "ECS takes care of everything" approach is. As an obvious example, one of the big tricks in my opinion is Bevy's UI system - it's not been a day or two since this thing has been a pain point, especially with promises like "we'll definitely start working on the editor this year". A little look at Bevy's UI example shows that there's not much in it at all; If you casually go back to the source code, like a button that changes color when hovered and clicked, it's easy to see why. In fact, after trying to do some important development tasks with the Bevy UI, I can say it publicly, and it's even more uncomfortable than you think. This is because ECS is extremely cumbersome and painful to perform any UI-related operation. Therefore, the closest solution to an editor on Bevy is to use a third-party crate such as egui. And it's not just the UI, I would say it's a bit anti-human to insist on leaving so much stuff to ECS, including the UI.

The ECS in Rust is the equivalent of turning the ordinary tools of other languages into something almost religiously necessary. We use a tool that should be easier and easier to use, but now we don't need it.

The programming language community tends to have different tendencies. I've spoken multiple languages over the years and found these tendencies interesting. The one I can think of similar to ECS to Rust is Haskell. It's a bit simplistic, but my personal feeling is that the Haskell community as a whole is a bit more mature and friendly to other approaches, just seeing Haskell as a "fun tool for solving the right problems."

Rust, on the other hand, tends to be paranoid like a rebellious teenager when it comes to expressing its preferences. Their words are categorical and reluctant to discuss more nuances. Programming is a delicate task that borders on metaphysics, and people often have to go through a series of trade-offs and make many sub-optimal choices to get timely results. The perfectionist insistence and obsession with the "right way" that prevails in the Rust ecosystem often makes me wonder if it's attracting a lot of people who are new to programming and are convinced of a theory as soon as they hear about it. Again, I know this doesn't apply to everyone, but I think that's probably where the overall obsession with ECS comes from.

The generic system doesn't allow for interesting gameplay

To prevent this as mentioned earlier, a common solution is to fully generalize the system. As long as the components are divided in a more fine-grained way and the appropriate system is used, then all these special problems can certainly be avoided, right?

I don't really have a lot of strong arguments against it, aside from the fact that generic systems don't allow for interesting gameplay. I'm very active in the Rust game development community, and I've seen a lot of projects developed by other people, and their suggestions are often highly relevant to the game being developed. And systems that are cleverly designed with completely generic operating properties are often not really making games. Programming becomes a "simulation" of the game's logic, resulting in "creating a movable character" as the gameplay itself, with one or more of the following at its core:

  • Procedurally generated worlds, planets, spaces, dungeons.
  • Anything voxel-based, with a focus on the voxels themselves, rendered voxels, world size, and performance.
  • Interaction generalization, i.e., "anything can be xx with anything else".
  • Rendering in the best possible way, "How can you make a game without drawing indirect?" ”
  • Design a good type and "framework" for the game build.
  • Build an engine to make more follow-up works like this.
  • Consider multiplayer needs.
  • Use a lot of GPU particles, thinking that the more particles you have, the better the visuals.
  • Write a well-structured ECS and clean code.
  • Etcetera......

These are great goals for technical exploration and learning Rust. But I want to reiterate the principle I mentioned at the beginning of this article: I'm not trying to hone my skills or learn Rust the way I make games. My goal is to develop an indie commercial game and sell it to as many players as possible at a reasonable time, so that they will be willing to pay for it and be featured on the Steam homepage based on popularity. Don't get me wrong, I'm not trying to make money at all costs, this article is about how Rust slowly wears away a practitioner's focus on games, gameplay, and players from the perspective of a serious game developer.

There's nothing wrong with being passionate about technology, but I think it's best to think about what you're really after, and especially to be honest about what you're trying to do. Sometimes, I feel like a project has gone off the rails and twisted its meaning into how much it makes sense technically. It's not normal, at least in my eyes as a serious game developer.

Now back to the generic system. I think there are a few principles that make a great game, but directly or indirectly violate the generic ECS approach:

  • Most of the levels are designed manually. It's not about "linear" or "narrative", it's just about "how to control the player's behavior in a guided way".
  • Carefully design each interaction in each level.
  • VFX doesn't equal a large number of particles, but rather time-synchronized events that run on all game systems (e.g., triggering multiple different emitters with a manually designed scheduling mechanism).
  • Test the game repeatedly, verify the game's features multiple times, experiment and discard the parts that aren't fun.
  • Deliver your game to players as quickly as possible so it can be tested and it's time to iterate. After all, the later the release, the less enthusiastic the players will be after it is released.
  • It's important to provide a unique and memorable experience.

I know that reading this, many of you will think that I want to mention the kind of game that is full of art, rather than the kind of engineering work like Factorio. Absolutely not, I like games with strong system settings and a strong code aesthetic, and I'm willing to do something programming-driven, after all, I'm a programmer through and through.

I think most people make the mistake of thinking that if you design player interactions, you're making art. No, careful design of player interaction is the essence of game development. Game development isn't about building a physical model, it's not designing a renderer, it's not about a game engine or scene tree, and it's not about a data-bound responsive UI.

A positive example of this is The Binding of Isaac, a seemingly rudimentary "meat pigeon" game with hundreds of upgrades. These upgrades change the game experience in a very complex and subtle way of interacting. Note that instead of using a simple and crude upgrade mechanic like "+15% damage", it offers clever options like "make the bomb stick to the enemy", "convert the bullet to a laser", or "the first enemy killed in each level will never appear in subsequent levels".

At first glance, it may seem like you can design a game like this with a pre-prepared generic system, but that's another mistake I think most people make in game development. That's not the case with game development, you can't just lock yourself up in a dark room for a whole year, build a generic system with all the extremes in mind, and expect it to capture all the needs of such a great game. No, we're just going to have to build a prototype with a handful of mechanics and give it to everyone to play around, see if the core mechanics are interesting, and then add some new designs and gather feedback. Some of these interactions are only available after playing the early version for a few hours and trying different things out with a deep knowledge of the game.

The design of the Rust language deviates from this logic entirely, and any new upgrade could force us to refactor all systems. A lot of people might say, "That's great, now my code is higher quality and can accommodate more features!! "Well, that seems true, I've heard it countless times. But I'd like to remind you that, as a front-line game developer, this problem with Rust has caused me to waste a lot of time trying to find a so-called reasonable answer to a wrong question.

Other, more flexible languages allow game developers to implement new features in a simple and crude way, and then get the game up and running right away to see if the mechanic is really interesting. This allows us to iterate quickly in a short period of time. While Rust developers are still refactoring, C++/C#/Java/JavaScript developers have implemented a whole host of new game features, experimented with playthroughs, and become more aware of where their work should go.

Jonas Tyroller explains this in his video tutorial on game design, and I recommend every game developer to take a look. If you don't know why the games you make aren't fun (and myself), then there's probably the answer. A good game is not made in a laboratory environment, but by the feedback of the corresponding type of expert players. Game makers should also be good at games themselves, understand all aspects of design, and have experienced a lot of failed designs before coming up with the final product. All in all, a good game is one that tries all sorts of bad ideas in a non-linear process, and finally filters and polishes them.

The Rust game development ecosystem is a product of pure hype

The Rust game development ecosystem is still very young, and it's widely acknowledged when we talk to the community. At least until 2024, community members will be able to come to terms with their immaturity.

But from an outside perspective, it's a completely different story, thanks mainly to excellent marketing for projects like Bevy. Just a few days ago, Brackeys released a video of their return to Godot for game development. I watched the video for the first time and had high expectations for the amazing open-source game engine mentioned in it. By around 5:20, the video showed a share chart of the game engine market, and I was shocked to see that there were three Rust game engines listed: Bevy, Aret, and Ambient.

Now I would like to clarify this in particular. More precisely, I feel that Rust itself has become a symbol, a kind of online meme, just like emoji GIFs, which have become fodder for people to stand in line and ridicule. That's not good.

The normal way the Rust ecosystem works is to be high-profile for those who dare to make the most promises, showcase the most beautiful websites/readmes, have the most flashy gifs, and most importantly, the most abstract values. As for the actual availability? It doesn't matter. In fact, there are many people who do the work quietly, they don't promise that they may never be able to implement the function, but just try to solve a problem in an effective way, but because they are not "sexy" enough, these projects are almost never mentioned, and even when they appear, they are only considered "second-class citizens".

A prime example of this is the Macroquad. It's a very useful library of 2D games that can be run on almost all platforms, offers a very simple API, compiles extremely fast with few dependencies, and is most exaggerated by a single person. There is also an accompanying library, miniquad, which is responsible for providing graphical abstraction on Windows/Linux/MacOS/Android/iOS and WASM. However, Macroquad has committed one of the worst "crimes" in the entire Rust ecosystem - using the whole book state, and perhaps even being unsound. I'm saying "probably" here, and this description isn't accurate to the more literal, because it's still completely safe for all intents and purposes, unless you're going to use the lowest level of APIs in an OpenGL scenario. I've been using the Macroquad myself for almost two years now and have never had a problem. But it's such a great library that is met with merciless ridicule and relentless scoffs whenever it's brought up, on the grounds that it aligns with Rust's value proposition of being 100% safe and correct.

The second example is Fyrox, a 3D game engine with a full 3D scene editor, animation system, and everything you need to make a game. The project was also made by a single player, and he also developed a full 3D game using the engine. Personally, I haven't used Fyrox, and I'll admit that I've been blinded by the pretty websites, the tons of GitHub stars, and the hype. Fyrox has been gaining a bit of attention on Reddit lately, but I'm saddened that despite providing a full editor, it's almost never mentioned in any promotional videos — instead Bevy is constantly making appearances and making sense of presence.

The third example is godot-rust, which is part of the Rust bundle for Godot Engine. The most criminal "crime" of this library is that it is not a pure Rust solution, but rather a bundle for the dirty C++ engine. I may be exaggerating, but from an outside perspective, the Rust community is basically something that no one can look down on. Rust is the purest, the most correct, and the safest; C++ is bad, old, ugly, dangerous, and complex. Because of this, we don't use SDL in Rust game development because we have winit; We don't use OpenGL because we have wgpu; We don't use Box2D or PhysX because we have Rapier; We also have Kira for game audio; We don't use ImGUI because we have egui. On top of that, we definitely don't use the original game engine written in C++, which would blaspheme the supreme "crab" code god! Any developer who wants to speed up compilation with rustup default nightly must sign this sacred pact with the "crab".

If anyone is serious about developing a game in Rust, especially a 3D game, my first recommendation is to use Godot and godot-rust, as they at least provide all the necessary features and are mature engines that can actually deliver work. Our group spent a year secretly using Godot 3 to develop BITGUN and godot-rust to build gdnative. While it was a painful experience, it wasn't the bundle's fault, but we were always figuring out ways to mix GDScript and Rust together in all sorts of dynamic ways. This is our first and largest Rust project, and it's why we chose Rust. But I want to tell you that every game we make in Rust after that is not a game, but a practical lesson in solving the technical flaws, ecological scarcity, or design decision-making problems of the Rust language, and all the time suffering from the rigid nature of the language. I'm not saying that interoperability between GDScript and Rust is simple, absolutely not. But at least Godot offers the option to "put the issue on hold and move on". I don't think most developers who opt for code-only solutions don't pay attention to this, especially in the Rust ecosystem, and the language is really destroying my creativity with all sorts of awkward designs.

I don't have much to say about Ambient. After all, it's a new project, and I haven't used it myself. But I haven't heard of anyone else using it, but it was featured in the Brackeys promotional video.

Arete released version 0.1 a few months ago, but its very vague and closed-source announcement has generated a lot of negative reviews in the Rust community. Despite this, I have seen outsiders mention it on many occasions, mainly because the main creative team dares to "blow".

As for Bevy, I certainly believe it makes sense as the "main" Rust game engine, at least in terms of project size and number of participants. They've managed to build a large tech community, and while I don't necessarily agree with their commitment and some of the leadership choices, I have to admit that Bevy is really popular.

What I want to talk about in this section is to give you a sense of the strange state of the Rust community. Friends outside of Rust might have taken it for their marketing content and announcements, but I've believed them myself more than once and heard what seemed to be very convincing, only to find out that they were just good at and poor at actual feature delivery.

It's also worth mentioning that Rapier isn't a game engine per se. It's a well-received physics engine, but promises to be a pure Rust alternative to Box2D, PhysX, and more at the physics level. After all, Rapier is written in pure Rust, so it has all the benefits of WASM support, which is blazing fast, parallel-core, and very secure...... Probably.

My judgment on it is mainly based on 2D applications, and while the basic functionality does work, many of the more advanced APIs have fundamental problems - for example, convex decomposition can crash on relatively simple data, and multibody joints can also crash. This is particularly interesting because it makes me wonder if I was the first to try to remove the joints. It's not a rare or extreme usage, is it? But overall, I also found Rapier's simulations to be extremely unstable, which eventually forced me to write my own 2D physics engine, and at least in my personal tests, I found that it performed better on simple things like "preventing enemies from colliding".

I'm not advertising my physics library, as it hasn't been fully tested. The point is, if a newcomer to Rust wants a physics engine, the community will most likely recommend Rapier, and many will say it's a great and popular library. It also has a beautiful website that is well known in the community. Okay, I'll admit it may just be a personal issue, but I don't think it's going to work, and I've even reinvented it myself.

One thing that many Rust ecosystem projects have in common is that they use PUA to make users feel like it's their fault - they shouldn't think about a problem, they shouldn't build a feature in a certain way. It's similar to using Haskell and wanting to achieve side effects ...... "you shouldn't do this."

It's strange to note that it's not just Rust, but libraries that generally give users this feeling tend to receive general praise and recognition, probably because most ecosystem projects rely on hype rather than real deliverables.

The global state is annoying/inconvenient, and the game is still single-threaded

I know that when you talk about the "global state", many people immediately realize that this is a serious mistake. And this is one of the extremely harmful and unrealistic rules that the Rust community has set for projects/developers. The needs vary greatly from project to project, and I don't think a lot of people actually realize what they're solving, at least in the case of game development. The "hatred" of global state is also scoped, and most people don't want to be 100% against it, but I still feel that the Rust community is going in the wrong direction on this issue. Again, we're not talking about engines, toolkits, libraries, simulations, or anything like that, we're talking about games.

As far as a game is concerned, there is only one audio system, one input system, one physical world, one deltaTime, one renderer, and one resource loader. Maybe in some extreme cases, there may be a little bit of someone without a global state; And if you're making a physics-based multiplayer online game, the requirements may be different. But most develop either 2D platformers, vertical shooters, or voxel-based walking simulators.

After years of "pure" approach of injecting everything as arguments (starting with Bevy 0.4 and working all the way up to 0.10), and trying to build my own engine, I hated the pure global design, and the fact that I had to rely on play_sound ("beep") to play sound. That's right, it's hateful.

I don't mean to target Bevy specifically, but I've found that the entire Rust ecosystem makes this mistake to a large extent, with the exception of Macroquad. And the reason why Bevy is used here as an example is because it has a sense of presence there every day.

Here are some of the game development features that I use a lot in Comfy, and they all use global state:

  • play_sound ("beep") is used to play a sound effect. If you need more control, you can use play_sound_ex(id: &str, params: PlaySoundParams).
  • texture_id("player") is used to create a TextureHandle to reference the resource. There is no asset server available for passing, and in the worst case I have to use the path as an identifier; And because the path is unique, so the identifier is unique.
  • draw_sprite for drawing (texture, position,...) or draw_circle(position, radius, color). Since each serious engine inevitably makes a batch of draw calls, none of them can queue draw commands for more complex functions. I really wish I had a global queue so I could draw circles whenever I need to.

As a Rust developer (not necessarily a game developer), you may be reading this article thinking, "What about threads?" "That's right, it's a good example of a Bevy server. Because Bevy is asking this question and trying to solve it in the most general way, it's only natural that we're curious about how we can get all the systems to run in parallel.

This is a very logical corollary, and it is also a good way for many people who are new to game development. Because just like the backend, having everything running asynchronously on top of the thread pool seems to easily lead to better performance.

But sadly, I feel like this is also one of the biggest mistakes Bevy has made. Many Rust developers are realizing (though few are willing to admit it) that Bevy's parallel system model is so flexible that it doesn't maintain a consistent order even across frameworks (at least the last time I tried). If you want to maintain ordering, you must specify a constraint.

This may seem reasonable at first glance, but after several attempts to develop a large-scale game under Bevy (with a development cycle of several months and tens of thousands of lines of code), the end result is that the developer has to specify a whole bunch of dependencies, because things in the game often need to happen in a certain order to avoid dropped frames or even unexpected errors caused by something running randomly first. But don't try to bring it up to the community, because you'll be swallowed up in saliva right away. Bevy's design is technically correct, but when it comes to actually using it for game development, it always raises questions of one kind or another.

Surely there are benefits to this design now, right? For example, can parallel parts make the game run faster?

Sorry, after all the work that went into sequencing the system strictly, there wasn't much left that could be parallelized. On a practical level, this is the equivalent of parallelizing a purely data-driven system in exchange for a small performance gain. It's not worth it at all, and it's easy to do with Raycon.

Looking back over the years, I've written a lot more parallel code in Unity with Burst/Jobs than I have implemented in Rust myself. Most of my energy was spent on the technicals, both in Bevy and in custom code, leaving little time to seriously think about how to make the game more fun. No kidding, I'm always struggling with the language or designing around its features, at least to make sure that some of Rust's "quirks" don't seriously ruin the development experience.

Global states are a prime example of this. I know this section is already quite long, but I feel it is really necessary to explain further. Let's start with a clear definition of the problem. Rust, as a language, typically offers the following options:

  • static mut, which is not safe, so it needs to be unsafe every time it is used, which can lead to UB in case of accidental misuse.
  • static X: AtomicBool (or AtomicUsize, or any other supported type...... A nice solution, still annoying, but at least it's okay to use, but only for simple types.
  • static X: Lazy<AtomicRefCell<T>> = Lazy::new(|| AtomicRefCell::new(T::new()))…… This is necessary for most types and is not only annoying in terms of definition and use, but also leads to potential runtime crashes due to double borrowing.
  • …… And, of course, "pass directly, don't use global state".

I've lost count of the number of times I've accidentally crashed due to double borrowing, not because the code was "bad at design", but because other parts of the codebase forced a refactoring. In the process, I had to refactor the use of global state, which led to an unexpected crash.

Rust users might say that it comes down to my code being wrong, and Rust is helping me find bugs. That's why it's said that the overall state is not good, and it should be avoided as much as possible. It makes sense that this kind of check does prevent these bugs to some extent. But combined with the practical problems I have with simple global state languages like C#, I want to remind you that at least in game development scenarios, these problems rarely occur in code.

On the other hand, crashes due to double borrowing can really happen when doing anything with dynamic borrowing checks, and often for unnecessary reasons. One example of this is querying an ECS that coincides with a prototype. For those who are not familiar with Rust, the following code is actually problematic in Rust (simplified for readability):

for (entity, mob) in world.query::<&mut Mob>().iter() {
  if let Some(hit) = physics.overlap_query(mob.position, 2.0) {
    println!("hit a mob: {}", world.get::<&mut Mob>(hit.entity));
  }
}           

The problem is that we are exposed to the same thing in both locations. A simpler example is to iterate on two things by doing something similar (again simplified):

for mob1 in world.query::<&mut Mob>() {
  for mob2 in world.query::<&Mob>() {
    // ...
  }
}           

Rust's rules prohibit two mutable references to the same object, neither of which is allowed with actions that might cause this. In the above case, we experience a runtime crash. Some ECS solutions can solve this problem, such as in Bevy, where there is at least partial overlap when queries do not intersect, such as Query<(Mob, Player)> and Query<(Mob, Not<Player>) >, but this only solves cases where there is no coincidence.

I also mentioned this in the section on global state, because once things become global, this limitation becomes particularly noticeable, and it's easy to accidentally cause other parts of the codebase to fire a RefCell in some way a global reference<T>. Again, Rust developers will find this okay because it prevents potential bugs! But I still insist that this didn't help, and I haven't had any problems with languages that don't have such restrictions.

Then there's the threading issue, and I think the biggest misconception is that Rust game developers tend to think that games are the same thing as backend services, and that everything has to run asynchronously to stay in good shape. In the game code, the content must eventually be packaged in Mutex<T> or AtomicRefCell to<T> "avoid the problems that can arise from forgetting synchronous access, as is the case with C++ programming". But that's really just satisfying the compiler's insistence on thread safety, even if there isn't a single thread::spawn in the entire codebase.

Dynamic borrowing checks cause unexpected crashes after refactoring

As I write this, I just found another issue where the game crashes due to the overlap of World::query_mut. We've been using hecs for almost two years now, so the root of the problem is definitely not the "I accidentally nested two queries" little problem when we first started using this library. Instead, there is a part of the code at the top that runs the system that does certain things, and a separate part of the code that uses ECS to perform some simple operations at the deep level. After a massive refactoring, they always end up unexpectedly overlapping.

This isn't the first time I've encountered this situation, and the usual suggested solution is "Your code is too poorly structured, that's why you're running into these problems." You should redesign and recalibrate the design idea. "I don't know how to argue with that, because it's right, it's true that this happens because some parts of the codebase are not well designed. But the problem is that it's Rust's forced refactoring that ultimately triggers the crash, which is not the case with other languages at all. Archetypal overlap is not a crime, and non-Rust ECS solutions like flecs are very tolerant of this.

But this problem is not limited to ECS. <T>We've had the same situation with RefCell over and over again. Two of the .borrow_mut() ended up remerging, resulting in an unexpected crash.

It's hard to accept that it's not just "poor code" that triggers crashes. The community's advice is generally to "borrow as little as possible", but this is essentially an emphasis on building code the right way. I'm a game developer, not a server developer, so I can't always focus all my time and energy on code organization. So, sometimes there's a loop that uses something in the RefCell, so what's wrong with extending the borrowing to the whole loop? But as long as the loop is a little larger and calls to a system that needs the same cell internally (usually with some conditional logic), it is likely to cause problems immediately. Proponents will say "conditional actions should be performed indirectly and via events", but we're talking about game logic that is scattered throughout the codebase, not just 10 or 20 lines of easy-to-read code.

In a perfect world, everything would be tested on every refactoring, every branch would be evaluated, and the code flow would be both linear and top-down – but that would never have happened. The reality is that even if we don't use RefCells at all, we have to design their functions carefully so that they can pass the correct context objects or only the required parameters.

And all of this is simply not realistic for indie game development. Refactoring features that might have been removed in a few days is a waste of time, and makes RefCell a partially borrowed understanding. Otherwise, we have to reorganize the data into different contextual structures, change function parameters, or separate things indirectly.

Context objects are not flexible enough

Because Rust has a relatively unique set of constraints for programmers, it often leads to a number of unique problems that may not have been addressed in other languages.

One example of this is passing context objects. In almost all other languages, introducing global state is not a big problem, either in the form of global variables or singletons. But for all of these reasons, Rust once again complicates simple problems.

The first solution that people came up with was to "only store references to whatever they wanted", but any developer with some Rust experience will realize that this is simply not possible. The borrowing inspector requires each reference field to track its lifecycle, and since the lifecycle becomes generic and pollutes every point of use of that type, we can't even experiment easily.

There's another issue that I think makes explicit to talk about here, because inexperienced Rust developers may not even notice it. On the surface, "I'll just use the lifecycle" doesn't seem like much:

struct Thing<'a>
  x: &'a i32
}           

But the problem is, if we need an fn foo(t: &thing) right now...... The conclusion is no, because Thing is generic throughout its lifecycle, so it must be converted to fn foo<'a>(t: &Thing <'a> or worse. If we try to store Thing in another structure, what we end up with is:

struct Potato<'a>,

size: f32,

thing: Thing<'a>,

}

While Potato may not really be affected by Thing, Rust takes its lifecycle seriously and forces us to take it seriously. And it's worse than it seems, because even if you don't want to get rid of the problem, Rust doesn't allow for an unused lifecycle, so:

struct Foo<'a> {

x: &'a i32,

}

And when we refactor the codebase, we end up wanting to change it to:

struct Foo<'a> {

X: i32,

}

This will definitely not work, because there will be an unused lifecycle. This doesn't seem like much of a problem, and it tends to be the same in other languages, but the problem is that the Rust lifecycle often requires a lot of "problem solving" and "debugging" processes. For example, we may try to add and remove lifecycles. Deleting the lifecycle means that Rust is no longer in use, and it has to be removed everywhere, leading to massive cascading refactoring. I've been in this situation many times over the years, and honestly, the most frustrating thing is just trying to do a very simple change in a lifecycle iteration and only to end up being forced to change 10 different locations.

And even in other cases, we can't simply "store a reference to something" because the lifecycle doesn't allow it.

Rust offers an alternative here, which is to<T> share ownership in an RC or Arc <T>way. It works, but it tends to inspire strong opposition. So after using Rust for a while, I realized that the best thing to do was to use it quietly and quietly. There's no need to confess to those Rust fans, just pretend it's not.

Unfortunately, this shared ownership is not a good idea in many cases, for example, for performance reasons, and sometimes we don't have control over ownership at all, and we can only get references.

The number one trick of Rust game development is that "if you pass references from top to bottom in every frame, all lifecycle/reference issues will go away". Yes, it's very effective, similar to React's top-down props. The only problem is that now we need to pass everything to every function that needs it.

This doesn't seem difficult at first glance, and as long as the code is designed correctly, there won't be any problems. Well, a lot of people say that, but I really don't know if they write their own code that is always correct, or if they deliberately use this standard to disgust others. You get the idea......

Fortunately, there's another way to do this, which is to create a conetxt struct for passing and include all references. Although there is still a corresponding life cycle, there is at least only one, which is actually as follows:

struct Context<'a> {

player: &'a mut Player,

Room: &'a mut Room,

// ...}

This allows each function in the game to take a simple c: &mut context and get what it needs. That's great, right?

But the premise is that we can't borrow anything. Imagine if we wanted to run a player system, but at the same time maintain the content of the shot, the player_system would need c: &mut Context as well, because we wanted to be consistent and avoid passing all 10 different parameters. In doing so:

let cam = c.camera;
 
player_system(c);
 
cam.update();           

When touching a field, you often run into the problem of "you can't borrow C because it's already borrowed", and some borrowing rules explicitly mention that when an object is touched, its entirety is borrowed.

It doesn't matter if player_system only touches c.player, Rust doesn't care about what's in it, it only cares about types. The type says it wants c, so it has to get it. This may seem like a silly example, but in large-scale projects with large context objects, we do often run into situations where we want to use a subset of certain fields in one place and easily pass the rest of the fields elsewhere.

But the developers of Rust are certainly not stupid, as it allows us to execute player_system (c.player) because partial borrowing allows us to borrow disjoint fields.

As a result, the developers who support the Borrowing Inspector will say that we have designed the wrong context object and should have split it into multiple context objects, or group fields according to their purpose in order to play part of the borrowing mechanism. For example, if you put all the shot content in the same field, and put all the player-related content into another field, you can pass that field to the player_system instead of the whole C. Wouldn't that solve it?

It's not that simple, and again going back to the beginning of the article, I said that I just want to develop a game. I'm not doing this to tinker with the type system, or to find the best way to organize the structure to meet the compiler's requirements. I didn't get anything out of the maintainability of single-threaded code when it came to reorganizing context objects. And after going through this countless times, I can responsibly say that the next time I playtest and gather feedback, I'll probably have to do it again.

The essence of the problem is that even though the code has not changed, the compiler has forced the code to be refactored out of too strict requirements because the business logic has changed. The specific problem could be that the borrowing checker doesn't follow the way it works, and only focuses on the correctness of the type. As long as we pass all the fields that are currently in use, then the compilation process is fine. That said, Rust forces us to choose between passing seven different arguments and refactoring the structure of the code at any time. Both options are annoying and a pure waste of time.

Rust doesn't have a structure type system, or a "type that owns the fields", or any other solution to the problem without having to redefine the structure and the associated content. It insists on only one thing: forcing programmers to do the "right" thing.

Pros of Rust

Reading the whole article, it seems like I'm criticizing Rust for nothing. But in this section, I'd like to enumerate the positives I've found in Rust that have really helped me during development.

As long as it compiles successfully, the code is running normally. This is the golden sign of Rust, and it dispells my original skeptical attitude towards "compiler-driven development". By far the biggest advantage of Rust is that as long as people can write the right code, everything will run smoothly, and the language will use rules to guide users to the right way to write it.

From my personal point of view, Rust's greatest strength is in the development of CLI tools, data manipulation, and algorithms. I spent a lot of time writing "Python scripts for Rust", which included small Python or Bash utilities that most people use on a regular basis. It's both a process of development and learning, and I'm surprised that it works. I definitely don't want to do the same thing in C++.

Performance is emphasized by default. Going back to C#, we're going to look at the performance differences between Rust and C# at a more granular level. For example, try to write the same specific algorithm in two languages and see if you can mention the same performance. And despite a little bit of effort on the C# side, Rust still won by a margin of 1:1.5 to 2.5. For those who regularly run benchmarks, this result seems to be expected. But after experiencing it myself, I was really shocked at how fast Rust code could be written.

I'd also like to point out that Unity's Burst compiler greatly improves C#'s performance. But I don't have enough A/B data to provide a concrete conclusion, except to observe that the C# code is significantly faster.

That being said, I've been pleasantly surprised by how well its code works over the years I've been using Rust. I've noticed that it's all based on the following in Cargo.toml:

[profile.dev]
opt-level = 1
[profile.dev.package."*"]
opt-level = 1           

I've seen a lot of people asking why their code is slow, only to find out they're doing a debug build. Just as Rust is fast when optimization is turned on, it slows down significantly when optimization is turned off. I'm using opt-level = 1 instead of 3 here because I didn't notice any difference in performance in my tests, but 3 compiles significantly slower on my test code.

The implementation of the enumeration is also beautiful. As everyone who has ever used Rust will receive, over time I have preferred to use more dynamic structures rather than strict enumeration and pattern matching. But at least in cases where enums are more suitable, it works really well, and it's pretty much my favorite implementation of any language I've ever used.

Rust parser. I'm not sure if that's an advantage or a disadvantage, so let's put it in the pros here. After all, I wouldn't be able to write Rust code 100% without it. Since I first got my hands on Rust around 2013, the tool has seen significant improvements, and it has worked very, very well on a practical level.

The reason why I considered it as a disadvantage is that it's still one of the worst language servers I've ever used. I know this is because Rust itself is very complex, and my own project is unique (which may be my fault), so its crashes tend to be isolated (I've been keeping it updated, but it still crashes on various devices/projects). But despite this, the profiler has been very useful and helpful, and has been an indispensable companion in my Rust development experience.

Traits。 While I don't mean to eliminate inheritance entirely, I do admit that the trait system is pretty awesome and a great fit for Rust. It would be nice if there was a little relaxation on the principle of orphans. Still, being able to extend traits is one of the happiest things about Rust.

Write at the end

Since mid-2021, we've been using Rust on almost all of our games. BITGUN started out as a Godot/GDScript project, and then we had pathfinding issues on Godot (which was not ideal in terms of performance and functionality), so I started looking at alternatives, and then found gdnative and then godot-rust. This isn't my first time working with Rust, but it's the first time I've used it seriously in game development - I've only used it in a game-jam-y project before.

Since then, Rust has been the only language I've stuck with. I was inexplicably excited about building my own renderer/framework/engine and so on, and that's how early versions of Comfy were born. A lot of things followed, from mini-game jams that supported CPU ray tracing, to experimenting with simple 2D IK, writing physics engines, implementing behavior trees, implementing asynchronous executors centered around single-threaded coroutines, to building analog NANOVOID and even Unrelaxing Quacks, the first and final Comfy game we released. Unrelaxing Quacks has just been released on Steam, so by the time you read this, you should be able to play it.

The feeling of this article comes mainly from the struggle we had with the development of two games, NANOVOID and Unrelaxing Quacks. After all, by this point, we don't have the same lack of Rust experience as we did when we first developed BITGUN, which is why the problems are especially unbearable. We've used Bevy a few times before that — BITGUN was the first game we tried porting, and Unrelaxing Quacks was the last. During the two years of developing Comfy, we rewrote the renderer, first from OpenGL to wgpu and then from wgpu back to OpenGL. As of this writing, I have about 20 years of programming experience, starting in C++ and then experimenting with languages including PHP, Java, Ruby, JavaScript, Haskell, Python, Go, C#, and publishing games on Steam in Unity, Virtual 4, and Godot. I'm the type of person who likes to experiment with all kinds of methods, and is willing to actively explore and experience everything. Our game may not be the best by most people's standards, but we've tried everything we can to find the best solution.

I say all this to let everyone know that we've put enough effort and patience into Rust, and this article is in no way out of ignorance or inexperience. I think of the time when someone asks a question about Rust, and someone responds half-jokingly by saying, "You don't think Rust works because you don't have enough experience." No, we've tried highly dynamic and purely static methods over and over again, we've tried pure ECS and no ECS, really.

Original link: Rust ecology is pure hype? Wrote 100,000 lines of code in 3 years, and the developer complained: I was fooled when I used Rust_ Programming language _InfoQ featured article

Read on