Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

初めに

もしデータ志向、ECSについて理解していない場合はEcson ECS解説へ。リファレンスが見たい場合はEcson リファレンス

Ecsonに興味を持っていただきありがとうございます。 EcsonはECS駆動の双方向サーバーフレームワークで、WebSocket, WebTransportに対応しています。

Arc<Mutex<T>>のようなロック機構を一切使用することなく、マルチプレイヤーバックエンドやリアルタイムコラボレーションツール、空間シミュレーションアプリケーションを構築することが可能です。

以下のポイントを抑えておいてください。

  • 接続エンティティとして扱う
  • データコンポーネントとして管理
  • ロジックシステムとして実装

本書では、以下のプロジェクトを作成していくことでEcsonを習得することを目標にしています。

エコーサーバーを作ろう

エコーサーバーは素早く直感的に作ることができます。

システムを定義する

// 頻繁に使用されるものが詰まってます
use ecson::prelude::*;

fn echo_system(
    mut messages: MessageReader<MessageReceived>,
    mut outbound: MessageWriter<SendMessage>,
) {
    for message in messages.read() {
        outbound.write(SendMessage {
            target: message.entity,
            payload: message.payload.clone(),
        });
    }
}

引数

  1. mut messages: MessageReader<MessageReceived>:
    受け取ったメッセージ(MessageReceived)を読み取ります(MessageReader)。
    MessageReaderは内部にカーソルを持つことで「どこまで読んだか」を更新するため、mutを付けましょう。
  2. mut outbound: MessageWriter<SendMessage>:
    送るメッセージ(SendMessage)を書き込みます(MessageWriter)。

ロジック

for message in messages.read() {

}

ev_receivedMessageReaderなので.read()が使えます。.read()は、Messagesという保管庫内のMessageReaderが未読のメッセージを順に処理します。中身(この場合MessageReceived)を参照で順番に返します。

outbound.write(SendMessage {
   target: message.entity,
   payload: message.payload.clone(),
});

outboundMessageWriterなので.write()が使えます。.write()は、Messagesという保管庫に引数に渡されたラベルを貼って書き込んでいきます。 この場合SendMessageという構造体を渡していますが、フィールドのtargetは送信したい相手、payloadは内容を入れます。

まとめ

つまり、

  1. MessageReaderで受信したメッセージを読み取り、
  2. そのメッセージの情報をもとにSendMessageを作り、
  3. MessageWriterでそれを返しています。

アプリを初期化してシステムを登録しよう

fn main() {
    EcsonApp::new()
        .add_plugins(EcsonWebSocketPlugin::new("127.0.0.1:8080"))
        .add_systems(Update, echo_system)
        .run();
}

上から見ていきましょう。

  1. EcsonApp::new()
    EcsonAppインスタンスを作ります。これがECSやネットワーク等すべてを統括しています。
  2. .add_plugins(EcsonWebSocketPlugin::new("127.0.0.1:8080"))
    .add_pluginsでプラグインを登録します。EcsonWebSocketPluginはWebSocketサーバーをEcsonに統合します。::new("127.0.0.1:8080")によってアドレスを127.0.0.1:8080で起動しています。
  3. .add_systems(Update, echo_system)
    add_systemsでシステム(ロジック)を登録します。第一引数にはスケジュールを、第二引数にはシステムを渡します。Updateは可能な限り毎フレーム実行します。
  4. .run();
    実行!

フロントエンドからテストをしよう

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>echoテスト</title>
</head>
<body>
    <h1>Ecson Echo Test</h1>
    <input type="text" id="msgInput" placeholder="メッセージを入力">
    <button onclick="sendMessage()">送信</button>
    <ul id="log"></ul>

    <script>
        const ws = new WebSocket('ws://127.0.0.1:8080');
        const log = document.getElementById('log');

        ws.onopen = () => log.innerHTML += '<li>✅ 接続成功</li>';
        
        ws.onmessage = (event) => {
            log.innerHTML += `<li>📩 サーバーから: ${event.data}</li>`;
        };

        function sendMessage() {
            const input = document.getElementById('msgInput');
            ws.send(input.value);
            log.innerHTML += `<li>📤 送信: ${input.value}</li>`;
            input.value = '';
        }
    </script>
</body>
</html>

サーバーを起動し、HTMLにアクセスします。

cargo run --release # 任意

alt text

お疲れ様でした! 次回は接続者全員に対してメッセージを送信します。

メッセージを全員に送信したい

前提として、前回作ったものはエコーサーバーと言うことで、自身にしか返信しませんでした。

alt text

この章ではメッセージを接続者全員に送信させるブロードキャストサーバーを作ります。

ブロードキャストサーバーを作ろう

システムを定義する

先に完成形を見てください。

use ecson::prelude::*;

fn broadcast_system(
    mut ev_received: MessageReader<MessageReceived>,
    mut ev_send: MessageWriter<SendMessage>,
    client_query: Query<Entity, With<ClientId>>,
) {
    for msg in ev_received.read() {
        let NetworkPayload::Text(text) = &msg.payload else { 
            continue; 
        };

        let broadcast_text = format!("User {}: {}", msg.client_id, text);
        let payload = NetworkPayload::Text(broadcast_text);

        for target_entity in client_query.iter() {
            ev_send.write(SendMessage {
                target: target_entity,
                payload: payload.clone(),
            });
        }
    }
}

引数

  1. mut ev_received: MessageReader<MessageReceived>:
    evはイベントの略で、ev_receivedは受信したイベントを読み取るためのものとしてます。エコーサーバーの引数と同じです。
  2. mut ev_send: MessageWriter<SendMessage>:
    送るほうです。
  3. client_query: Query<Entity, With<ClientId>>:
    以下で説明します。

Ecsonは内部でbevy_ecsを使用しています。QueryEntityWithbevy_ecsのものです。

Queryどのコンポーネントを持っているエンティティが欲しいかを定義する。検索窓口のようなもの。
Entity取得したいデータの中身。Ecsonでは接続者として扱う。
Withフィルター

つまり、Query<Entity, With<ClientId>>は「ClientIdという印が付いているエンティティ」を探しています。

ロジック

let NetworkPayload::Text(text) = &msg.payload else { 
   continue; 
};

今回はTextだけ扱うので、それ以外はスルーするようにしています。

let broadcast_text = format!("User {}: {}", msg.client_id, text);
let payload = NetworkPayload::Text(broadcast_text);

送信するメッセージを作っています。

for target_entity in client_query.iter() {
   ev_send.write(SendMessage {
       target: target_entity,
       payload: payload.clone(),
   });
}

ClientIdを持ったエンティティ(Query<Entity, With<ClientId>>)をイテレータで取り出しています。 取り出したエンティティに対して上で作ったメッセージを内容にして送信しています。

フロントエンドからテストをしよう

先ほどと同じHTMLで構わないので、複数開いてください。 どちらかでメッセージを送信すると、自身含めて他のクライアントにも飛ぶことがわかると思います。

alt text

次回はルーム付きのチャットサーバーを作ります

ルーム機能が欲しい

全員に対して送信するブロードキャストサーバーを作りましたが、ルーム機能がほしいところです。

/join xxxでルームに参加できるようにしましょう!

ルーム付きサーバーを作ろう

例えばRoom(String)というコンポーネントを作ればサクッとルーム機能が実装できます。

コンポーネントを作ろう

preludeComponentが含まれています。

use ecson::prelude::*;

#[derive(Component)]
struct Room(String);

システムを定義しよう

一旦、ロジックは書かずに関数を作りましょう

1つめにチャットサーバーシステム本体です。

fn chat_server_system(
    mut commands: Commands,
    mut ev_received: MessageReader<MessageReceived>,
    mut ev_send: MessageWriter<SendMessage>,
    clients: Query<(Entity, &Room)>
) {

}

2つめにクリーンアップシステムです。接続が切れたときにエンティティを消してみましょう。

fn cleanup_system(
    mut commands: Commands,
    mut ev_disconnect: MessageReader<UserDisconnected>
) {

}

UserDisconnectedはEcsonが提供するものです。切断されたクライアントに対応するECSエンティティや、切断されたクライアントのネットワークIDが入っています。

Commandsbevy_ecsが提供するものです。ECSは「世界」を持ちます。その中で変更処理をする際は基本的にCommandsを介します。借用規則や並列処理、不整合をいい感じにやってくれます。

  • エンティティの生成
  • エンティティの削除
  • コンポーネントの追加・削除
  • リソースの挿入

といった操作には必ず使用してください。

ロジックを書こう

chat_server_system

{
    for msg in ev_received.read() {
        let NetworkPayload::Text(text) = &msg.payload else { continue };
        let text = text.trim();

        if let Some(room_name) = text.strip_prefix("/join") {
            // --- 【入出処理】 ---
            let new_room = room_name.trim().to_string();
            commands.entity(msg.entity).insert(Room(new_room.clone()));

            ev_send.write(SendMessage {
                target: msg.entity,
                payload: NetworkPayload::Text(format!("[System] Joined: {}", new_room)),
            });
        } else if let Ok((_, room)) = clients.get(msg.entity) {
            // --- 【発言処理】 ---
            // 同じルームにいる全員を検索して送信
            for (target_entity, target_room) in clients.iter() {
                if target_room.0 == room.0 {
                    ev_send.write(SendMessage {
                        target: target_entity,
                        payload: NetworkPayload::Text(format!("[{}]: {}", room.0, text)),
                    });
                }
            } 
        }
    }
}

ブロック分けして解説します。

if let Some(room_name) = text.strip_prefix("/join ") {
    // --- 【入室処理】 ---
    let new_room = room_name.trim().to_string();
    commands.entity(msg.entity).insert(Room(new_room.clone()));
    
    ev_send.write(SendMessage {
        target: msg.entity,
        payload: NetworkPayload::Text(format!("[System] Joined: {}", new_room)),
    });
} 

例えば「/join rust」というメッセージが来た時に/join を取り除き、rustという文字に加工します。それをルーム名にして、commands.entity(msg,entity).insert(Room(new_room.clone()))でエンティティ(すなわち接続)にRoomコンポーネントを付与しています。

else if let Ok((_, room)) = clients.get(msg.entity) {

}

/joinから始まるメッセージ以外は、if let Ok((_, room)) = clients.get(msg.entity)で送ってきた接続者(msg.entity)がRoomコンポーネントを持っているかを篩います。Query.get()メソッドを使うことで、エンティティを指定してその中身をピンポイントで覗き見することができます。

// --- 【発言処理】 ---
// 同じルームにいる全員を検索して送信
for (target_entity, target_room) in clients.iter() {
    if target_room.0 == room.0 {
        ev_send.write(SendMessage {
            target: target_entity,
            payload: NetworkPayload::Text(format!("[{}]: {}", room.0, text)),
        });
    }
}

Roomコンポーネントを持っているすべてのエンティティをイテレート(clients.iter())し、ルーム名が一致(if target_room.0 == room.0)する場合に、ev_send.writeしています。

cleanup_system

for event in ev_disconnect.read() {
    if let Ok(mut ent) = commands.get_entity(event.entity) {
        ent.despawn();
    }
}

あとで追加するEcsonWebSocketPluginはネットワークを監視しており、通信が途切れたりした瞬間にUserDisconnectedというイベントを発行します。 これを活用し、不要なエンティティをデスポーンさせています。メモリリークを防ぐためです。

システムを登録しよう

fn main() {
    EcsonApp::new()
        .add_plugins(EcsonWebSocketPlugin::new("127.0.0.1:8080"))
        .add_systems(Update, (chat_server_system, cleanup_system))
        .run()
}

(chat_server_system, cleanup_system)のようにタプルで複数一気に渡すことができます。

フロントエンドからテストをしよう

これも同じHTMLで構いません。

alt text

お疲れさまでした。 これであなたはEcsonにおけるチャットアプリ開発方法を身に付けました。 次の章では、チャットアプリを爆速で開発するためのプラグインを紹介します。

プラグイン

2026/04/01現在提供されているプラグインは下記のとおりです。

ecson::plugins::networkより、

  • EcsonWebSocketPlugin
  • EcsonWebTransportPlugin

ecson::plugins::chatより、

  • ChatCorePlugin
  • ChatRoomPlugin
  • ChatFullPlugin

ecson::plugins::heartbeatより、

  • HeartbeatPlugin

ecson::plugins::presenceより、

  • PresencePlugin

chat系プラグインを使ってみよう

実は、前章で作ってきたようなサーバーはプラグインによって爆速で開発できます。

ChatCorePluginはブロードキャストなど、ChatRoomPluginはルーム関係を実装しています。ChatFullPluginはそれらを総合しています。

では、ルーム付きチャットサーバーを作ってみましょう。

use ecson::prelude::*;
use ecson::plugins::chat::ChatFullPlugin;

fn main() {
    EcsonApp::new()
        .add_plugins(EcsonWebSocketPlugin::new("127.0.0.1:8080"))
        .add_plugins(ChatFullPlugin)
        .run()
}

これだけで簡単なチャットサーバーが作れます。