Ambient 0.2: Multiplayer UI

For those joining us for the first time, Ambient is an open-source runtime for building high-performance multiplayer games and 3D applications powered by WebAssembly, Rust and WebGPU. Projects consist of assets and logic built around the currently Rust-only Ambient API, and these projects can be loaded by any compatible runtime running on any platform. For more details, see our original blog post.

With this update, we're focusing on interactivity. Projects can now run code on the client, which let us build our headline feature: multiplayer UI.

Multiplayer UI

In game development, UIs are often a source of frustration. They're hard to build, hard to maintain, and hard to make look good. Ambient's UI solution aims to change that.

Ambient UI is a UI framework built on top of Element, Ambient's React-like system for building, diffing and mutating arbitrary trees of game entities. It’s versatile and can be used to build any kind of UI, from a simple text box to a full-blown game UI.

#[element_component]
fn App(hooks: &mut Hooks) -> Element {
    let (count, set_count) = hooks.use_state(0);
    FlowColumn::el([
        Text::el(format!("We've counted to {count} now")),
        Button::new("Increase", move |_| set_count(count + 1)).el(),
    ])
    .with_padding_even(STREET)
    .with(space_between_items(), STREET)
}

Ambient UI features many of the features you'd expect from a UI framework, including widgets, layouting, styling, and more. All of this is built using Element without any magic, so you can implement your own widgets, too.

The real superpower of Ambient UI comes into play when combined with the rest of our runtime. Because we provide integrated multiplayer, an ECS, and messaging, you have an all-in-one solution for multiplayer UI. This is a powerful combination, and we're excited to see what people build with it.

To demonstrate this, we present a whirlwind tour of a simple multiplayer beat sequencer with a synchronized beat map and samples - in just over a hundred lines of code. This is a minimalistic version of the music_sequencer example, which also features BPM control and per-player colors for notes.

First, create a project with ambient new:

ambient new music_sequencer_basic

This will create a new Ambient project that is prepopulated with Rust code. Our plan is to represent each track of the sequencer as a separate entity in the ECS, with each track having a component attached to it with the notes. 

To do this, we’ll need to add the relevant components to the ambient.toml, which is the project manifest. All components, messages and more are defined in the manifest - this allows them to be used from any compatible language.

[project]
id = "music_sequencer_basic"
name = "Music sequencer (basic)"
version = "0.0.1"

[components.track]
type = "U32"
name = "Track"
description = "A track is a sequence of notes. The value corresponds to the index of the track."
attributes = ["Networked", "Debuggable"]

[components.track_audio_url]
type = "String"
name = "Track Audio URL"
description = "The URL of the audio file to play for a given track."
attributes = ["Networked", "Debuggable"]

[components.track_note_selection]
type = { type = "Vec", element_type = "Bool" }
name = "Track note selection"
description = "The notes that are currently selected for a given track."
attributes = ["Networked", "Debuggable"]

This is sufficient to get started with the server code. Place your favourite sounds into an assets folder next to src; for the example in the repository, we’ve used these sounds

Next, open server.rs, and add the following code to spawn in the track entities:

use ambient_api::prelude::*;
use components::{track, track_audio_url, track_note_selection};

pub const NOTE_COUNT: usize = 16;

#[main]
pub async fn main() {
   // Create the tracks.
   for (idx, (track_name, track_url)) in [
       ("Kick Drum", "assets/BD2500.wav"),
       ("Closed Hihat", "assets/CH.wav"),
       ("Low Conga", "assets/LC00.wav"),
       ("Mid Tom", "assets/MT75.wav"),
   ]
   .iter()
   .enumerate()
   {
       Entity::new()
           .with(name(), track_name.to_string())
           .with(track(), idx as u32)
           .with(track_audio_url(), track_url.to_string())
           .with(track_note_selection(), vec![false; NOTE_COUNT])
           .spawn();
   }
}

This will create an entity for each of our predefined tracks - feel free to use your own sounds! - which will be automatically replicated to each client, thanks to Ambient’s tightly-integrated ECS and networking.

Of course, you’ll need a way to view and edit the notes. That’s where the clientside WASM comes in - open up client.rs and add the following in:

use ambient_api::prelude::*;

#[main]
pub fn main() {
   App::el().spawn_interactive();
}

#[element_component]
fn App(hooks: &mut Hooks) -> Element {
   let mut tracks = hooks.use_query((components::track(), components::track_note_selection()));
   tracks.sort_by_key(|t| t.1 .0);

   FocusRoot::el([FlowColumn::el(tracks.into_iter().map(
       |(track_id, (_, track_selection))| Track::el(track_id, track_selection),
   ))])
}

#[element_component]
fn Track(_hooks: &mut Hooks, track_id: EntityId, track_selection: Vec) -> Element {
   let track_name = entity::get_component(track_id, name()).unwrap_or_default();

   FlowColumn::el([
       Text::el(track_name),
       FlowRow::el(
           track_selection
               .iter()
               .enumerate()
               .map(|(_, &active)| Button::new(" ", |_| {}).el()),
       ),
   ])
}

This uses Ambient UI to render the state of the tracks in the ECS. Every time the track state changes, each track is rendered using the Track component, which displays the track name and the state of each note. That’s all you need to get this:

Now, let’s make these buttons clickable. We know that these buttons are mirroring the state of the ECS, so we’ll need to update the ECS on the server. To do this, we can use Ambient’s messages, which offer structured communication between client, server and other modules. Add the following to your ambient.toml:

[messages.click]
description = "Select or deselect a note."
fields = { track_id = "EntityId", index = "U32" }

This defines a click message that the server can subscribe to, and the client can send. 

On the server, add the following code:

// When a player clicks on a note, toggle it.
messages::Click::subscribe(move |_, data| {
   entity::mutate_component(data.track_id, track_note_selection(), |selection| {
       let index = data.index as usize;
       selection[index] = !selection[index];
   });
});

This tells the server to update the ECS when it receives a message from the client, and to toggle the corresponding note for the requested track.

Now all we have to do is to wire up the client. Our present implementation uses buttons with an empty callback - all we need to do is send a message in the callback:

FlowColumn::el([
   Text::el(track_name),
   FlowRow::el(track_selection.iter().enumerate().map(|(index, &active)| {
       Button::new(" ", move |_| {
           messages::Click::new(index as u32, track_id).send_server_reliable();
       })
       .el()
   })),
])

We’re now more or less done, except for one little thing: actually playing notes! To get across to the finish line, let’s add a cursor that automatically moves in accordance with the beat.

Add the following to the App:

const BPM: f32 = 120.0;
let (cursor, set_cursor) = hooks.use_state(0);
hooks.use_interval_deps(
   std::time::Duration::from_secs_f32((60.0 / BPM) / 4.0),
   false,
   cursor,
   move |cursor| {
       set_cursor((cursor + 1) % NOTE_COUNT);
   },
);

This will create a cursor that will be updated on every note. This can then be passed down to the Track, and the Button can be highlighted based on the cursor:

#[element_component]
fn Track(hooks: &mut Hooks, track_id: EntityId, track_selection: Vec, cursor: usize) -> Element {
   let track_name = entity::get_component(track_id, name()).unwrap_or_default();

    FlowColumn::el([
        Text::el(track_name),
        FlowRow::el(track_selection.iter().enumerate().map(|(index, &active)| {
            Button::new(" ", move |_| {
                messages::Click::new(index as u32, track_id).send_server_reliable();
            })
            .el()
            .with(
                background_color(),
                if index == cursor {
                    vec4(0.6, 0.6, 0.6, 1.0)
                } else {
                    vec4(0.3, 0.3, 0.3, 1.0)
                } + if active {
                    vec4(0.3, 0.0, 0.0, 0.0)
                } else {
                    Vec4::ZERO
                },
            )
        })),
    ])
}

And now that it’s been wired up, you can see the cursor sweeping across the tracks: 

All that remains is to play a sound when the cursor changes to a note. To do this, add the following to Track:

let (sound, _) = hooks.use_state_with(|_| {
   let url = entity::get_component(track_id, components::track_audio_url()).unwrap();
   audio::load(asset::url(url).unwrap())
});

let (last_cursor, set_last_cursor) = hooks.use_state(0);
if cursor != last_cursor {
   if track_selection[cursor] {
       sound.play();
   }
   set_last_cursor(cursor);
}

This code will load the sound for the track the first time the Track is rendered. Every time the cursor or the note selection changes, the cursor will be checked against the note selection, and a sound will be played. With this, we finally complete our basic music sequencer.

Of course, as this is Ambient, you get multiplayer for free. Invite a friend to join you using the Ambient Proxy (described below), and enjoy sequencing together!

# On your computer:
$ ambient run music_sequencer_basic

# [...]
[2023-05-04T15:25:50Z INFO  ambient_network::server] Proxy allocated an endpoint, use `ambient join proxy-eu.ambient.run:9104` to join

# On your friend's computer:
$ ambient join proxy-eu.ambient.run:9104

We recommend using the full music_sequencer example, which is styled to look more like a traditional sequencer, and features more advanced features.

Screenshot from two clients using the full-featured music_sequencer example
The Ambient Proxy

An important part of building multiplayer projects is being able to share them with other people. Traditionally, this involves port forwarding, NAT punchthrough, running a dedicated server, and other complicated networking trickery. Ambient provides a solution to this problem: the Ambient Proxy.

As of 0.2, Ambient will automatically allocate a proxy for you when your project is running. This proxy will be accessible to anyone with the URL, and will allow them to connect to your project running on your computer. This means that you can share your project with anyone, and they'll be able to connect to it without any additional setup.

Ambient # ambient run guest/rust/examples/games/tictactoe 

...

[2023-05-02T15:56:03Z INFO  ambient_network::server] Proxy allocated an endpoint, use `ambient join proxy-eu.ambient.run:9609` to join

This means hosting an Ambient project is as simple as running ambient run or ambient serve and sharing the URL with your friends. No port forwarding, no dedicated servers, no complicated networking setup. Just run ambient run and share the URL.

Examples

We've also been working on a number of new examples to show off what Ambient can do. These examples can be found here.

Here's a quick run-through of some of the new examples:

Basics: Clientside

The clientside example makes use of Ambient's new support for clientside WASM to animate a grid of cubes. This grid of cubes is created on the server, and each client animates the cubes independently.

Basics: Fog

The fog example shows off Ambient's fog capabilities. The density and height of the fog can be adjusted using the sliders. This can be used for atmospheric effects, or to hide the edges of the world.

UI: Button

The button example shows off the Button widget in all of its various states and styles.

UI: Dock Layout and Flow Layout

The dock_layout example and flow_layout example demonstrate Ambient UI's layouting capabilities, which are inspired by the web and Windows Forms.

UI: Editors

The editors example uses per-type editors to allow the user to edit various types of data. These can be used in your own projects to allow users to edit data in a more user-friendly way.

Games: Music sequencer

The music_sequencer example is a more-fully-featured version of the sequencer from above. In addition to multiplayer sequencing, it also includes per-player colors and a BPM slider, demonstrating how UI can be used to control the ECS more directly.

Games: Minigolf

The minigolf example is a simple minigolf game. It demonstrates Ambient's physics capabilities, as well as the ability to show text in-world, animate objects, and more.

Games: Pong

The pong example is an implementation of Pong on Ambient. It shows how Ambient can be used for 2D games, how basic networking can be implemented, and what a game loop looks like in Ambient.

Games: Tic-Tac-Toe

Finally, the tictactoe example was in the 0.1 release, but it's been updated to make the best use of Ambient's new features. It now has a larger grid size, support for many players, and has decoupled presentation and state.

Getting Started

If you're interested in trying out Ambient, download a release from the GitHub release pages or see the Installing document.

You may also be interested in the runtime documentation and the API documentation. You can also find the source code here.

If you want to come to talk to us, you can join our Discord server

Conclusion

It's been an exciting few months for Ambient, and we're excited to see what the future holds. We're looking forward to seeing what you build with Ambient, and we hope you enjoy using it as much as we enjoy building it.

The full changelog for this release can be found here.

Work with us!

Build with us, from anywhere!

We're hiring a team of top talents that want to change how games are made. Apply now and join us on our adventure!

Check out our Career page!

Open Application