Making a game easier to change: porting WizardQuest to Rust and Bevy
A Journey into game making in contemporary Entity-Component Systems, porting a few big old Obj-C classes with lot of state to many small systems.
Making a game easier to change: porting WizardQuest to Rust and Bevy
WizardQuest is an old match-3 adventure for iOS, originally written in Objective-C on Cocos2d. The wizard walks up a scrolling board on his own. You clear his path by matching tiles, and when a creature blocks the way, your matches become spells.
The development started when the first iPhone came out, and finally culminated in the release in Jan 2014. It was loved by many, but had failings as well, which could be a topic for a different article. Here, the focus is on how the same game rules look when restructured as an entity-component system, and what that means for someone learning or changing the code.
The original grew over years of development. As we added features, the code accumulated more responsibilities and interactions. Throughout those years we were also trying to finish the game: testing what we had, finding bugs and fixing them, while the design continued to evolve.
About a year ago, I started experimenting with LLMs to see if the old game could be ported to current day technologies, notably to make it cross-platform (the original was never released for Android). In this effort, the idea is also to make a solid core, while the metagame is re-designed. Earlier attempts with TypeScript and canvas, and with C++ and cocos2d-x, were difficult to get working reliably. That led me to try Rust and Bevy, and to look more closely at how the game should be structured for this way of working.
The port starts from a very different position. For the core functionality, the completed original implementation is a precise specification, down to the interactions and special cases. We still have to read the code to recover those rules, but we can explore how best to implement them with the behaviour already available for comparison.
This has been a spare-time hobby project over the past year, sometimes left untouched for months. There is no deadline. That gives me room to experiment, reconsider designs and work towards an architecture that makes the game's rules as clear as I can make them. Those are considerable freedoms when comparing this work with the original development.
The question for me is maintainability, including how a developer learns the code. Given a feature to add, can an agent find the relevant rules, understand the existing implementation, and make the change without breaking something elsewhere? The same questions apply to a human joining the project, or returning to a part of it after a long time.
Rust and Bevy's entity-component-system model offered tools for making responsibilities more explicit. The port also needed written rules, examples of completed features, and checks that could tell us when a change had broken those rules. This post describes how those pieces fit together, using a creature that reflects spells as a worked example.
1. The code a new feature had to fit into
The original app has about 90,000 lines across 300 Objective-C implementation files, including the world map, shop, experience and conjuring screens, sound and saves. Most of the central gameplay lives in four classes:
- Board.m — 9,090 lines: tile grid, matching, scrolling and encounters.
- Player.m — 3,530 lines: wizard movement, route finding, health and damage.
- Creature.m — 3,426 lines: enemy movement, attacks, status effects and abilities.
- GameManager.m — 3,102 lines: enemy list, targeting, game state and rewards.
These four files contain 19,148 lines. The port covers this gameplay core and the smaller classes around it: spells, creature subtypes, abilities and board objects. It does not yet replace the whole application.
The difficulty was how responsibilities overlapped. The wizard's damage method, for example, used a health buffer, changed health, updated the health bar, chose a damage-number size, played sounds and created particles. In one branch it also started the health leak used in infinity mode. A change to damage required understanding several of these concerns together.
There were also many booleans, such as leakingHealth, infinityLeakingHealth, tookDamage and damageImmune, set and cleared in different methods. A boolean is a reasonable way to represent a fact. The trouble comes when the meaning of a combination depends on which other method ran first, and that relationship is only visible by following calls across the classes.
I think this accumulation of responsibilities and state contributed to the fragility of the original spell system, especially when effects such as freeze and reflection interacted.
The Rust port has more code than those four files. At the revision described here, its src directory contains about 68,000 lines of Rust, including comments and blank lines. That includes the game, unit and flow tests, developer arenas, validators and supporting tools. It is a different scope from the original's four central classes.
There are now more names and files to learn. The useful question is how much of that code someone needs to understand to make a particular change.
2. Finding the rules: Mirror and freeze
A Mirror creature can reflect a spell back at the wizard while its ability is active. Interactions between spells, creature state and animation were troublesome in the original. I remember freeze bugs recurring from time to time over the years, leading to more bug hunting and retesting. When we added it to the port, the first task was to establish what it actually did.
The answer was spread across the original classes. The base spell handled reflection by changing the spell's target and caster. The freeze spell had its own hit method, including a special case that refreshed an existing freeze before reaching the base spell's reflection check. As a result, a second blue spell hitting an already frozen creature refreshed the freeze even during a Mirror window. That behaviour is easy to miss if you read only the Mirror ability or only the base spell.
There were also decisions to confirm. Should the reflected projectile visibly fly back? What happens when a blue spell reaches the wizard? Does the ability timer continue while the creature is frozen? Can activating Mirror interrupt an attack? These affect both the code and what a test should expect.
We wrote down the source behaviour and resolved these questions before treating the feature as complete. The chosen rules include a visible return flight, no effect from a reflected blue spell on the wizard, a timer that continues during freeze, and cancellation of an attack when the ability activates. The second-freeze exception is preserved too.
For an agent implementing Mirror, these concrete cases provide a starting point. Redirecting a spell is only one part of the feature; the freeze exception and the creature's attack cycle also determine what the implementation must do. Establishing those interactions first gives us specific behaviours to check as the code takes shape.
3. Locating the data
I had explored entity-component design earlier in An Entity-Component Model for Extensible Virtual Worlds, published in IEEE Internet Computing in 2011. The WizardQuest port returns to that interest in extensibility, this time using Rust and Bevy.
In an ECS, an entity identifies something in the world, components hold its data, and systems operate on entities with the data they need. Bevy also has resources for shared data such as the board. The first diagram shows how responsibilities from the original classes are divided between those kinds of data and the systems that use them.
Figure 1. Selected responsibilities in the port. Blue boxes hold data; green boxes describe system responsibilities. Shared data appears in more than one row.
One rule we hold to is that BoardState owns discrete board occupancy. It contains the tile grid, creature and board-object locations, the wizard's cell, collision information and scrolling state. To find the cell occupied by a creature, a system asks the board. It does not read a second copy from a component on the creature.
Rendering follows those board positions, with animation interpolating movement between cells. Spell projectiles are a different case: their flight and hit detection use Transform. The board's ownership rule concerns occupancy on the grid; it does not make every spatial calculation in the game a grid operation.
This distinction helps when adding board objects. A gate, trap or chest has its own data, but the board records the cell it occupies and whether that cell blocks movement. Route finding and tile movement can use that existing information. They do not each need another branch for every new kind of object.
Centralising occupancy also creates an obligation: the board's index must agree with the entities in the world. Changes go through its registration, movement and removal methods. It keeps explicit creature spawn order as well, because the original resolves some targeting ties by spawn order, and a Bevy entity index can be recycled.
Other data belongs on the entity. A wizard's health is a small component:
#[derive(Component)]
pub struct WizardHealth {
current: i32,
pub max: i32,
}The current health is private to its module, which contains the systems that apply damage and healing. Other modules can read it through an accessor and request changes through messages. Rust enforces that field privacy. It does not prevent another system from replacing the whole component, so the ownership rule also depends on how the game uses the interface.
A creature can have several independent poison instances, each with its own timer, so those are a collection. Its melee attack is in one stage at a time, so that is an enum: InitialWait, WalkIn, HitDelay, PostHit or WalkBack. The enum prevents two attack stages from being selected simultaneously.
The port still has booleans where they express useful facts, including whether an ability window is active and whether a projectile has already been reflected. The aim is to make the meaning and ownership of state clear enough that a reader can work out what changes it.
4. Connecting the rules through messages
Separating data is only part of the design. Several behaviours can hurt the wizard: melee attacks, projectiles, poison, traps and reflected spells. They need to agree on how damage is applied without each owning a version of that rule.
Each source writes a DealDamageToWizard request. One system applies it to WizardHealth and publishes a WizardDamageEvent with the health before and after the hit. Other systems can react to that result without participating in the health change.
Figure 2. The wizard damage path. Messages connect the systems; scheduling determines when they run. Healing has its own system in the health module.
The amount has its own type, Damage, containing a private integer. Its validating constructor, Damage::new, rejects negative values, and loading damage from the RON data files uses the same validation. A typo such as damage: -10 therefore fails when the data loads. The type travels from spell and creature data through projectiles and messages to the health code. A receiving function can accept Damage without checking its sign again.
Calculations that produce damage must also satisfy that constraint. Where a calculated value may fall below zero, an explicit clamping constructor makes it zero. These are small rules, but expressing them in the interface means a developer adding a new attack has fewer conditions to discover and remember.
A new attack therefore needs to produce a request; it can reuse the existing health and feedback paths. The result retains the attacker and source, so a trait or validator need not infer them from a health change when poison and another hit occur close together.
Bevy's own message example uses a damage pipeline too. Its message machinery handles storage and tracks what each reader has seen. The game still has to arrange for readers to run at the right time and often enough to see the messages they need. Messages are one way to organise this communication; ECS does not require a particular messaging design.
A question from a Unity DOTS engineer after the talk prompted this comparison. In Unity Entities, we could build a similar flow around a DynamicBuffer on an entity, choosing where the requests live and when to clear them after their readers have run. Bevy's registered Messages<T> provides storage, tracks each reader's progress and expires old messages. The game still decides the order of producers and consumers in either design.
Reflection reuses the flight and damage paths
Mirror fits into these existing responsibilities. At a hit, the spell system decides whether the outcome is normal, reflected, absorbed by Shadow, or a freeze refresh. For reflection, it redirects the same projectile. The central change is small:
projectile.reflected = true;
projectile.caster = target_entity;
projectile.target = wizard;
continue; // Keep the projectile flying.The projectile's movement system can now follow the new target. When it reaches the wizard, its colour determines the reflected effect, which enters the existing damage or poison path. A reflected blue spell has no effect on the wizard. The health system does not need to know how Mirror activated or how the projectile travelled.
That short fragment is only the redirection. Mirror also needed an ability timer, decisions about interruptions and freeze, outcome messages, cleanup rules and checks. Its simplicity comes from reusing a flight path and damage path that already have defined responsibilities.
The outcome selection makes the freeze exception visible in one place: during an active ability window, a blue spell hitting an already frozen creature produces FreezeRefresh before reflection or absorption is considered. Reflection also requires a wizard to send the spell to. This last condition was caught during review: reporting a reflected hit without a return flight would have left the feature only partly implemented.
Requests and results elsewhere in the game
The same distinction is useful for tile matches. A producer detects a match and writes ComboDetected. A board-interaction stage can cancel it through a fizzler or increase its power through an enchanted tile, then publishes the final ComboEvent. Spell casting, healing, scoring and locks consume that result. Where its meaning depends on the game state, they use the state recorded when the match was made.
These are separate message types so that consumers can distinguish a detected match from a resolved combo. Bevy also supports mutable message access through MessageMutator; building the combo in stages is a choice in this game, rather than a restriction that messages cannot be changed.
This context matters because input and gameplay run on different schedules. A tile drop can form a match during Update, while the combo is resolved later in FixedUpdate. An encounter may begin between the two. The wandering match should still award score; it should not become a spell aimed at the newly arrived creatures. ComboDetected therefore records the creation state and carries it through to ComboEvent.
Readers run every fixed tick so they do not stop and later pick up retained messages under a different state. But running regularly is only part of the rule: they must also interpret each combo using its recorded context. Regression tests make that boundary explicit by performing a drop, applying the state transition, and then running the fixed tick.
Recording the mode preserves what a match means, but its consequences still need ordering. A follow-up fix addresses the chain bonus: when an encounter begins, the score system must count pending wander combos before resetting their chain. State entry therefore marks the reset as pending, and the score system applies it after those combos.
Game-state transitions have a similar separation. Gameplay systems publish facts such as EncounterEnded and WizardDied. Each screen declares what those facts mean for it. The main game opens its game-over screen when the wizard dies; an arena that runs successive battles can handle the same fact differently while using the same combat systems.
All gameplay messages are registered together, and readers and writers are required system parameters. Earlier optional writers could silently omit a result when a test app had failed to register the message. Requiring registration makes that setup error visible. The port uses observers for UI interactions such as button actions, while gameplay messages are processed in the scheduled systems. This is a project convention that keeps those responsibilities predictable for the next person working on them.
5. Deciding how much belongs in a system
The decomposition reaches further than the selected responsibilities in Figure 1. A source inventory counts about 260 named systems and UI handlers outside the developer-arena modules and test-only code. That includes gameplay, presentation, setup, menus and debugging; the median function spans 18 lines, including its signature, comments and blank lines.
A small function can still be hard to understand if its job is spread across many other small functions. We have found it useful to keep a coherent decision together and separate the consequences that have different owners.
The melee attack system, for example, advances the attack's state machine. Splitting each stage into its own system would also require a reader to reconstruct their relationship from the schedule. Keeping that cycle together makes its transitions easier to follow. Pure decisions, such as choosing an approach or fallback cell, can be extracted and tested independently without breaking up the cycle itself.
Other boundaries are clearer. A creature death check marks a creature dead and starts its death sequence. Spawner refill and the encounter-over rule follow the death mark; the creature remains on the board until its own sequence ends. One death_sequence_system removes all creature kinds. These dependencies are declared in the schedule. Visual feedback reads changes or messages and handles flashes, damage numbers and overlays. It does not belong in the system that applies damage.
Who removes a projectile?
Lifetime is another responsibility that needs an owner. A reflected spell must continue flying after its first hit, stop on arrival at the wizard, and be removed if its reflecting creature dies before it arrives.
During gameplay, spell_cleanup_system is responsible for despawning spell projectiles. Hit handling and the reflected-spell death rule mark a projectile as finished; cleanup removes it. This gives a reader one place to find the removal rule and an explicit dependency on the systems that mark the projectile finished.
Where a lifetime naturally belongs to something else, the port uses Bevy's facilities. A creature's health bar is its child and is removed with it. Screen UI uses DespawnOnExit. Gameplay entities carry a GameWorld marker for whole-screen teardown, with checks for leftovers when entering a game screen. Arena checks also report transient entities that outlive their expected lifetime during play.
Resources need a lifetime too. Pending encounter-end and scroll-restart timers must survive a death and revival, but must not carry over into another game. A computed state, InGame, groups wandering, encounters and game over. Leaving that group resets or removes the timers; moving between its states does not. This gives the resource lifetime one explicit boundary.
6. Making the order part of the rule
The port runs combat and spell logic at a fixed 30 Hz; wandering and player input also use the frame-based Update schedule. Within a fixed tick, two individually reasonable systems can produce the wrong result if they run in the wrong order. Messages make the connection visible, but do not establish the order themselves.
For example, a heal and a hit might both reach the wizard in the same tick. The chosen rule resolves healing first, then damage, then the death check. This gives a last-moment heal a chance to save the wizard. The original resolved events through Cocos2d actions and update callbacks, with consequences such as death following directly from damage. Porting that behaviour requires examining the inherited order and deciding what simultaneous events mean in the new model.
The schedule expresses dependencies with system sets and .before or .after constraints. Attacks and spell hits precede application of their damage. The wizard's damage results are available before Vampiric healing is resolved for a creature. Creature healing precedes its death check. These are dependencies between rules; the schedule does not need every system to be placed in a single sequence.
Mirror makes the boundary cases concrete. The port updates its ability window before spell hit resolution, so a hit on the transition tick sees the new window state. The original processed the spell hit before the creature's update, so it saw the old state. This leaves a timing difference at the window boundary even when the behaviours on either side agree. Comparing that case explicitly lets us decide whether the difference is acceptable.
There is also a test that builds the real game's fixed-update schedule and examines pairs of systems with conflicting data access and no declared order. A new pair fails unless we order it or add a reviewed explanation for why either order is acceptable. Stale entries in that allowlist fail too. The list can change, but each addition needs a reason.
The schedule check finds unordered access conflicts; flow tests exercise the gameplay consequences of the chosen order.
7. Checking the interactions
An agent can produce a locally plausible change while breaking a rule shared with another module. The checks make those relationships visible, and the design gives tests specific boundaries to exercise. The flow tests build small Bevy apps with a board and the relevant production systems, advance time, and inspect the results. For Mirror, they check the return flight, tracking of the wizard's position, damage and poison on arrival, and cleanup when the reflecting creature dies. They also check the freeze exception:
#[test]
fn blue_on_a_frozen_mirror_creature_refreshes_the_freeze_instead_of_reflecting() {
let mut arena = Arena::new(MIRROR_MELEE, FAR, |_| {});
arena.open_window();
arena.set_frozen(true);
arena.cast(SpellType::Blue);
arena.run_until(arena.t + 0.3);
assert_eq!(
arena.outcomes.last().map(|o| o.2),
Some(HitOutcome::FreezeRefresh),
);
assert_eq!(arena.reflected_in_flight(), 0);
}This test sets up the frozen state and ability window directly. It isolates the outcome rule; other tests cover actual projectile flight. The combo-transition tests described earlier need a different boundary: a real drop, a state transition, and then a fixed tick. Choosing that boundary is part of understanding the feature.
The game also contains developer arenas that run the production systems with scripted input or an autopilot. The spell dummy sends particular colours at Mirror and Shadow inside and outside their ability windows. The battle arena combines attacks, spell flight, freeze and poison over a longer run. These screens remain available on phones, where they also help us inspect the feature visually.
The battle arena records casts, hits, status effects and violations in SQLite. The attributed damage results in Figure 2 let its checks distinguish a reflected spell from a poison tick. The same behaviour trackers can run in a focused flow test or observe a full battle.
We learned to check the checks themselves. Some creature validators queried a position component that the creatures no longer carried. They matched no entities and reported no violations, while their unit tests used hand-built entities with that component. A validator that never fires looks exactly like one that passes. The arenas now report missing observations when a scenario requires an attack or ability to occur, and review checks that a new regression fails with the bug present or under a deliberate break.
At the time of writing, development verification reported 933 passing library tests, along with wander and battle arena runs. These checks support changes to the design and leave examples of intended behaviour for the next developer.
8. What the next feature can reuse
Mirror already had examples to follow. The Summoner work included behaviour tracking and flow tests, while the projectile and damage systems provided paths that reflection could reuse. Later traits, including Regeneration, Venom Strike and Vampiric, could use the same creature-data tooling, message registration and arena infrastructure.
Each still needed its own domain work. Vampiric healing depends on a landed hit and its attribution; regeneration requires checking health increases as well as damage. Vampiric is implemented and exercised through an arena option, but is not yet assigned to a creature in normal game data. Reuse gives a feature a starting point without deciding its rules for us.
That is the accumulated understanding I want the repository to preserve. A completed feature should leave an example of where its data belongs, which facts it publishes, how its timing is defined and how to check it. An agent starting the next task can follow those examples without reconstructing our earlier conversations. Human decisions and review remain necessary, especially when deciding whether to preserve a quirk of the original.
9. What I take from the port so far
For me, the useful change is that a feature has a more discoverable path through the code. With Mirror, we can locate the ability state, the hit-outcome decision, the projectile's return flight, the damage result and the cleanup rule. We can also find the decisions about freeze and tests that exercise them. A developer still has to understand the interaction, but there are clearer places to begin and boundaries to check.
This comes with costs. The port has more modules, message types, setup code and explicit scheduling decisions. It covers only part of the original application, and some spell and item interactions remain unported. We also have recorded timing differences. The experience so far does not establish that Rust or ECS alone improves productivity, or provide a measured comparison with the years spent on the original.
Rust helps express constraints such as nonnegative damage in types. Messages can carry the context needed to interpret a player's action. ECS gives us a way to separate data and responsibilities. Bevy provides scheduling, messages and lifetime tools. The specifications, examples, tests and reviews make our particular use of those tools understandable and checkable. Together they have given us a more explicit way to approach the next feature: find its rules, identify what it can reuse, and check the interactions it introduces.
At the time of writing, the port used Bevy 0.20. Source counts include comments and blank lines; test results come from the development verification report.
Background and further reading
- Toni Alatalo (2011), An Entity-Component Model for Extensible Virtual Worlds, IEEE Internet Computing. My earlier article on entity-component design and extensibility.
- Anisha Tasnim and Tian Zhao (2026), The Essence of Entity Component System, ACM/SIGAPP Symposium on Applied Computing. A formal treatment of archetype ECS, with a tower-defense performance comparison.
- Kirill Fedoseev, Nursultan Askarbekuly, Ekaterina Uzbekova and Manuel Mazzara (2020), A Case Study on Object-Oriented and Data-Oriented Design Paradigms in Game Development. A case study comparing the two design approaches in game development.
- Sander Mertens, Tower Defense. Source code for a game written with Flecs, offering another concrete ECS design to explore.
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.