Zemeroth v0.5: ggez, WASM, itch.io, visuals, AI, campaign, tests

Hi, folks! I'm happy to announce Zemeroth v0.5. Main features of this release are: migration to ggez, web version, itch.io page, campaign mode, AI improvements, visual updates, and tests.

demo fight

Zemeroth is a turn-based hexagonal tactical game written in Rust. You can download precompiled v0.5 binaries for Windows, Linux, and macOS. Also, now you can play an online version (read more about it in the "WebAssembly version" section below).

GitHub commits graph

The last release happened about a year ago. Since then the development mostly happened in irregular bursts, sometimes it even was completely stalled for weeks. But a year is a big period of time anyway, so there're still lots of changes.

Lots of text ahead, feel free to skip sections that you're not interested in particularly. Here's a table of contents:

Migration to the ggez Game Engine

An experiment with maintaining my own engine (even a simple and minimalistic 2D one) turned out to be too exhausting in practice: you have to fight a constant stream of reports about small corner case issues and deal with platform-specific tweaks and hacks (stuff like this, for example). It can consume surprisingly large amounts of time. But what's more important for a hobby project, it also sucks too much fun out of the development process.

And what made it worse in my case is that Häte2d intentionally wasn't a general-purpose game engine (to reduce the scope of work), so it was sad to know that all this work won't be reused by anyone. But converting Häte into a real general-purpose engine wasn't an option either, because it wouldn't have left any time for Zemeroth's development.

So I've surrendered and decided to give away some control over low-level parts of Zemeroth: Häte2d was discontinued and replaced by ggez, the most mature and actively developed Rust 2d game engine at that time.

ggez's logo

häte had some built-in basic scene management and GUI systems, but ggez is minimalistic by design and has none of this. So, two helper crates were extracted from Häte2d and rebuilt on top of ggez:

Since Icefoxen asked not to use ggez- prefix, I used ggwp- ("good game, well played!") to denote that the crate belongs to ggez's ecosystem, but is not official.

These helper crates are still tied to Zemeroth, not sure how helpful these libraries can be for a project that is not Zemeroth-like. But maybe someone will manage to extract some benefit from them.

These crates are still immature and aren't published on crates.io yet, while the rancor component library was renamed to zcomponents and is published.


Initially, I migrated to ggez v0.4 that was SDL2-based. But as soon as the first release candidate of winit-based ggez v0.5 became available I attempted to migrate to it. I've filed a bunch of mostly text-related issues in the process and tried to fix the most critical ones for Zemeroth: "Remove the generic argument from Drawable::draw", "Drawable::dimensions()" (big one!) and "Fix Text::dimensions height". These PRs took some time, but then I relatively easy ported Zemeroth to ggez v0.5.0-rc.0.

ggez v0.5 isn't released yet, so at the moment Zemeroth uses ggez 0.5.0-rc.1. It's stable enough for me.


nalgebra logo

Before the migration, I was using cgmath, because it's a simple and straightforward library. But ggez's "native" math library is nalgebra and even though ggez v0.5 uses mint types for all its public API, I still migrated to nalgebra, because of this.


One downside of the migration to ggez is that debug builds are much slower now because more code is pure Rust. Something like 3-5 FPS on my notebook. But it's ok, I don't need debug builds often, I prefer debugging through logs anyway. And when I really need a debug build to track down something extremely strange, I can use cargo's yet unstable feature "profile-overrides".

cargo-features = ["profile-overrides"]

[profile.dev.overrides."*"]
opt-level = 2

Another serious downside of the engine switch, though temporary (?), is that there's no native Android version of the game for now. But who really needs a native port when you have...

WebAssembly Version

After ggez v0.5-rc.0 was published, Icefoxen has posted "The State Of GGEZ 2019", where among other things he wrote that a web port is unlikely to happen soon because a lot of issues in dependencies need to be fixed first. It could be relatively easy to write a specialized web backend for ggez, but ggez's philosophy is against having multiple backends.

And that's where Fedor @not-fl3 suddenly comes in with his good-web-game WASM/WebGL game engine. He had been experimenting with 2d web prototypes (like this one) for some time and used a custom 2d web engine for this. The API of this engine was heavily inspired by ggez so he managed to write a partly ggez-compatible wrapper in a weekend.

Colors are slightly off and text rendering is a little bit different, but otherwise, it works nicely and smoothly, providing the same experience:

web version vs native

Zemeroth uses good-web-game for its web version as a quick-n-dirty immediate solution until a proper WASM support arrives to GGEZ (there're no plans of making good-web-game some kind of official GGEZ backend or anything like this). The currently implemented subset of ggez's API is quite limited and while it may be used for other games, it will probably require a lot of work to do.


You can't use crate renaming in Cargo.toml to reuse a name on different platforms,

# Cargo.toml with these dependencies wouldn't build:

[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
ggez = "0.5.0-rc.1"

[target.'cfg(target_arch = "wasm32")'.dependencies]
ggez = { git = "https://github.com/not-fl3/good-web-game", package = "good-web-game" }

So the crate substitution hack is done in main.rs using extern crate items in main.rs:

#[cfg(not(target_arch = "wasm32"))]
extern crate ggez;

#[cfg(target_arch = "wasm32")]
extern crate good_web_game as ggez;

99.9% of code stays the same, but I had to use a separate main, because good-web-game has a different initialization API:

#[cfg(target_arch = "wasm32")]
fn main() -> GameResult {
    ggez::start(
        conf::Conf {
            cache: conf::Cache::Index,
            loading: conf::Loading::Embedded,
            ..Default::default()
        },
        |mut context| {
            let state = MainState::new(&mut context).unwrap();
            event::run(context, state)
        },
    )
}

Finally, a short helper script utils/wasm/build.sh was added:

#!/bin/sh
cp -r assets static
cp utils/wasm/index.html static
ls static > static/index.txt
cargo web build

You can find a minimal example of good-web-game here.

itch.io

The web version needs to be hosted somewhere. itch.io is a nice place for this:

ozkriff.itch.io/zemeroth

it has a nice and simple UI (for both developers and consumers), it's extremely easy to upload a web game there and it's a relatively known store for indie games that can provide some exposure by itself.

screenshot of the itch.io page

(Zone of Control also got an itch.io page)


Note an "Enter fullscreen" button in the bottom right corner of the game area:

"enter fullscreen" button


As I've said in the ggez section above, the web version of the game seems to work fine on most mobile devices:

web version on android device


With a playable version only a click away I received a lot of fresh feedback: a lot of people that previously were only following the development now actually tried to play the game.

The most important things people want to see improved are:

@Yururu even wrote a giant comment on the itch page! It's inspiring when a stranger from the internet breaks through the crude primitive interface, figures out game mechanics on a quite deep level, and writes a detailed review of their experience and thoughts.


I've created an itch.io list of Rust games. When I find a Rust game on itch.io I add it there.

Also, I've sent a request to itch.io folks to add Rust as an instrument, so now a more official list is available: itch.io/games/made-with-rust (you can edit a game's instruments here: "edit game" -> "metadata" -> "engines & tools"). Looks like my original list will be deprecated with time but it's still useful for now because only authors of the games can add an instrument to the metadata.

Visual Improvements

The initial draft of the new sprites looked like this:

New style mockup

Tiles are flattened now. It's less a schematic top-down view as it was before. "Camera" is moved to the side so the tiles and agents are shown using the same projection.

There're many gradients in the mockup image above. Later I decided to get rid of all the gradients and curvy lines and stick with "pseudo lowpoly" style.

Floating Eye and Insecto-snake agent types from the mockup haven't made it to the master yet.


All objects now have a shadow. It makes the image a little bit more tangible. Walk and especially throw animations feel better now.

Initially, shadow was an ellipse with gradient. Later it was replaced by two semi-transparent hexagons for style consistency.


Added blood splatters and weapon flashes to make attacks more dramatic:

blood drops and weapon flashes demo

The number of drops depends on the attack's damage. Blood slowly disappears into transparency in three turns, otherwise, the battlefield would become a complete and unreadable mess.

Every agent now has WeaponType: "smash", "slash", "pierce", and "claw". For now, they are just visual information. They affect only what sprite is used during the attack animation.

Same as agent sprites, weapon flash sprites are not yet mirrored horizontally. That is most noticeable with curvy smash sprite.

Also, spearman's "pierce" weapon sprite is horizontal and it looks weird during vertical attacks. Either multiple sprites are needed or it should be rotated.


Added a Dust effect (for jumps and throws):

dust demo

It is created by a simple function that just emits a bunch of half-transparent sprites and attaches position and color change actions to them. Sprites' size, velocity, and transparency are a little bit randomized.

Campaign Mode

A basic campaign mode was added. It's just a linear sequence of battles with predefined scenarios. After each battle, your survived fighters are carried over to the next battle. If you lose a battle - the campaign is over for you. If you win a battle, you're shown a transition screen with a list of your dead fighters, your current squad, and possible recruits:

Campaign screen example

The campaign is defined by a RON config file with this structure:

initial_agents: ["swordsman", "alchemist"],
nodes: [
    (
        scenario: (
            map_radius: (4),
            rocky_tiles_count: 8,
            objects: [
                (owner: Some((1)), typename: "imp", line: Front, count: 3),
                (owner: Some((1)), typename: "imp_bomber", line: Middle, count: 2),
            ],
        ),
        award: (
            recruits: ["hammerman", "alchemist"],
        ),
    ),
    (
        scenario: (
            rocky_tiles_count: 10,
            objects: [
                (owner: None, typename: "boulder", line: Any, count: 3),
                (owner: None, typename: "spike_trap", line: Any, count: 3),
                (owner: Some((1)), typename: "imp", line: Front, count: 4),
                (owner: Some((1)), typename: "imp_toxic", line: Middle, count: 2),
                (owner: Some((1)), typename: "imp_bomber", line: Back, count: 1),
                (owner: Some((1)), typename: "imp_summoner", line: Back, count: 2),
            ],
        ),
        award: (
            recruits: ["swordsman", "spearman", "hammerman"],
        ),
    ),
]

Here's some real campaign scenario: campaign_01.ron

There's a known bug that you can exit from a battle that is not going well at any moment to start again. This will be forbidden - permadeath is the only way :) .

Hit Chances

In the previous version of Zemeroth the hit chance was always 50%. Now, attack_accuracy and dodge stats were added to the Agent component to implement a basic hit chances math.

When you select an agent that can attack (has an attack point and enemies in range) a hit chance is shown over all available targets:

Hit chances screenshot

During the attack animation, a hit chance is shown near the attacker with a smaller font.

Hit chance during the attack animation

This was added in order for the player to see how dangerous enemy attacks are.


Also, wounded agents now become less accurate. Each lost strength point results in -10% hit chance penalty (up to -30%).

Missing strength points (wounds) are shown by almost transparent green dots:

demo of transparent strength points

This gameplay change has two game balance consequences:

Also, attacks with strength > 1 have additional hit chances - with reduced damage (each attack strength point gives 10% hit chance improvement). This emulates the situation when an attacker barely touches their target but still manages to make some damage to it.

Armor

A basic armor system was implemented. Armor points are shown above the agent in one line with strength points using the yellow dots. Each armor point deflects one damage point on each attack. Some weapons can break armor (the attack_break parameter). Fire and poison ignore armor.

Here's a little demo:

old armor demo

In the current version of the game only imp summoners have armor, so be careful with them.

AI Updates

During the debugging of the above-mentioned features, I also wrote a simple helper function dump_map that takes a closure and dumps required map data as a char in the above GIF, pic 1 shows objects (A - an agent, O - a non-agent object) and pic 2 shows available positions (X).

Bombs and Commutative Effects

Illustration with multiple bombs

^ In the previous version of Zemeroth, each of these bombs would have exploded at the beginning of the next turn in order of their creation. But this order is hard to remember and it's not clear from the picture at all.

The order is very important as the explosions push back objects - if the first explosion is on the left, an agent will be damaged by the right bomb too.

Pushback is not the only possible effect suffering from this. Other possible examples of non-commutative effects: teleportation, armor-breaking acid, immunity to damage, etc. Anything where changing the order of application can change the final state.

I see two possible ways to solve this:

  1. "Into the Breach"-like approach with explicit numbers;
  2. Forbid non-commutative delayed effects.

ItB's approach means just adding this order information explicit in the game's interface. It looks like this:

Into the breach screenshot

Technically it's possible, but I don't think that it fits for Zemeroth because it's an extremely noisy interface feature, but it's not really a core mechanic.

So, I've taken the latter way: the "Bomb" ability was split into two abilities: instant "BombPush" and delayed "BombDamage".

The plan is to have three groups of objects with effects:

Other Game Rules Changes

Gameplay Video

So, putting these gameplay changes together:

youtube video

This is a piece of a campaign mode playtest: battles 3, 4, and 5.

SVG Atlas

Back to more technical updates.

As git is bad at storing non-text files and it's practically impossible to use Git LFS with a free GitHub plan (because of the bandwidth limitations), it looks like a good idea to keep text source files, assets source files, and built assets in separate repositories to make "optimization history editing" (removing old commits) easier.

The main repo and the assets repo already existed, but I wasn't storing assets source files in any VCS.

So, during v0.5 development, I've created another repo for assets sources: ozkriff/zemeroth_assets_src. The two key files of this repo are: atlas.svg and export.py.

The atlas contains all sprites in one file. Each sprite is stored as a named group. As the game isn't that much art heavy, using one file for all visual assets looks fitting, because of it:

The export script is quite simple, it just calls Inkscape using its CLI interface and tells what named group needs to be exported to PNGs. It boils down to:

for id in ['imp', 'imp_toxic', 'grass', ...]:
    subprocess.run([
        'inkscape',
        input_file_name,
        f'--export-id={id}',
        f'--export-png={out_dir_name}/{id}.png',
    ], check=True)

There's also a hack to avoid specifying exact sprite PNG sizes as raw numbers in the export script: each named group contains an invisible square (a rectangle for terrain tiles). It can be temporary made slightly visible for debugging purposes:

sprites in the debug mode

Assets Hash

Another technical assets-related update is that a md5 hash check was added. This should help to detect when someone who is building from source forgets to update the assets.

A small checksum.py python script is used to calculate the hash of all non-hidden files in the repo. CI uses it to check that the committed hashsum really corresponds to the committed assets.

The expected hash is hardcoded directly into main.rs. If the game is run with a wrong version of assets, now you get a clear error message about that:

Bad assets checksum abcdeabcdeabcdeabcdeabcdeabcdeab (expected 18e7de361e74471aeaec3f209ef63c3e)

Tests

One of the benefits of making a turn-based game is that you can relatively easy separate the logic from the visuals and cover the former with tests.

A few test scenarios were added.

They are completely deterministic. Randomness is mitigated with special agent types with unrealistic stats (for example, accuracy = 999 or strength = 1), that allows them to always pass required tests (for example, always hits or always dies on the first hit), and an additional no_random flag in the game state, that causes a panic if agent's stats during the "dice roll" may result in non-determined results (basically, it checks that the coefficients are large or low enough to shut off any dice value fluctuations).

"Exact objects" were added to the scenarios. Test scenarios mustn't contain any randomly-placed objects, otherwise the no_random debug flag will cause a panic.

Basic test looks like this:

#[test]
fn basic_move() {
    let prototypes = prototypes(&[
        ("mover", [component_agent_move_basic()].to_vec()),
        ("dull", [component_agent_dull()].to_vec()),
    ]);
    let scenario = scenario::default()
        .object(P0, "mover", PosHex { q: 0, r: 0 })
        .object(P1, "dull", PosHex { q: 0, r: 2 });
    let mut state = debug_state(prototypes, scenario);
    let path = Path::new(vec![
        PosHex { q: 0, r: 0 },
        PosHex { q: 0, r: 1 },
    ]);
    exec_and_check(
        &mut state,
        command::MoveTo {
            id: ObjId(0),
            path: path.clone(),
        },
        &[Event {
            active_event: event::MoveTo {
                id: ObjId(0),
                path,
                cost: Moves(1),
            }
            .into(), // small formatting issue, see a note below
            actor_ids: vec![ObjId(0)],
            instant_effects: Vec::new(),
            timed_effects: Vec::new(),
            scheduled_abilities: Vec::new(),
        }],
    );
}

(I didn't use the builder pattern for event construction, even though most of the time two or three its fields are empty vectors, because I've faced some method chains formatting issues)

Test scenario consists of a list of commands and a list of expected events. Occasionally, it can check some parts of the state.

A prototypes list and a scenario are created from scratch (though, with some helper functions) for each test. It takes more lines of code than reusing a small set of multi-cases scenarios, but the idea is that this way the amount of objects and components in each test is minimized. This way it's easier to diagnose the bug and makes tests less likely to break on unrelated game logic change.

A "dull" enemy agent is required only for the scenario not to end instantly. Because the win condition is when no enemy agents are alive.


colin-kiegel/rust-pretty-assertions is a super-useful crate when you need to debug failing assert comparisons of big hierarchical objects (some of which may be many screens long in my case).

a colored assert error

One peculiarity is that I had to replace all HashMap<ObjId, Vec<Foo>> in events with Vec<(ObjId, Vec<Foo>)> to preserve the order. Otherwise pretty-assertion has been exploding.

Other Technical Changes

Indikator

Gave a presentation about Zemeroth at 8th Indie-StandUp at Indikator. It went pretty good, local indie devs seemed to like the project, especially considering that it's opensource and uses an interesting tech. At least one of the devs has visited our local rustlang meetup afterward. 🦀

me presenting Zemeroth at Indikator


It's unrelated, but Zemeroth was mentioned on Amit's page about hex math.

Migrated This Devlog to Zola

During the preparation for this Zemeroth release, I've finally switched the static site generator behind this devlog from Python-based Pelican to Rustlang-based Zola.

Here's a twitter thread with some migration notes.

TLDR is that I've mostly automatically converted all RestructuredText post sources into Markdown, replaced Disqus comments with direct links to reddit/twitter/etc, set up redirects from old URLs.

Roadmap

What's next? Some things I hope to implement for v0.6 release are:

You can find a slightly more detailed roadmap in the project's README.


That's all for today, thanks for reading!

If you're interested in this project you can follow @ozkriff on Twitter for more news.

Also, if you're interested in Rust game development in general, you may want to check @rust_gamedev twitter account that I've started recently.

Discussions of this post: /r/rust, twitter.