// TODO: write actual code

Spark: подивився «Чорнобиль» і сів писати ігровий движок

2026-05-14 | Alex Kucherenko | 6 min read

Ми з сином нещодавно передивилися «Чорнобиль» від HBO. Серіал, звісно, дико прикрашений і місцями просто вигаданий (самі лише байки про водолазів чого варті). Але навіть крізь усю драму видно, як там усе непросто влаштовано. Мене зачепило, і я пішов далі — подивився пару роликів на YouTube про те, як узагалі влаштовані й працюють електромережі. І до мене дійшло, яка ж це складна штука — тримати всю цю махину в балансі. Реально складно. Електрика для нас — просто вимикач на стіні, найзвичайнісінька річ. А щоб за тим вимикачем щось відбувалося, знадобилися десятиліття еволюції. Просто щоб у нас горіло світло й було тепло вдома.

Захотілося влізти в шкуру людей, які в усьому цьому живуть і реально розуміються. Так і народилася ідея: зробити гру про електромережі. Назвав я її Spark.

Але просто робити ігри — нудно

Просто пиляти гру мені нудно. Хочеться заразом поекспериментувати з ШІ й самому чогось навчитися. У мене вже були підходи до снаряда (движок на Rust), то чому б їх не воскресити. Перший проєкт мені свого часу набрид рівно тієї миті, коли дійшло до рендера: я вперся в купу проблем з ECS Shipyard. Її ні разу не було розраховано на те, щоб її так використовували.

Моя геніально тупа ідея

І тут вмикається мій улюблений жанр ідей — геніально тупі, як завжди. А що, як викинути майже весь движок разом з усім його API і замінити одним-єдиним ECS? Звучить дико, знаю.

Хай усе в движку буде однією з трьох речей. Компоненти — їх багато. Ресурси — вони в єдиному екземплярі. Системи — це вся логіка. А далі найвеселіше: рендер, ассет-менеджер, камера, звук — це теж просто ресурси. І будь-яка система, якій вони потрібні, просто запитує їх за іменем. Один світ, одні правила для всього.

Раз кожна система заздалегідь каже, що читає і що пише, я можу зібрати планувальник, який розкидає непересічні таски по потоках, і все закрутиться паралельно саме собою. Власне, тому ECS у мене й став серцем движка, а не просто однією з деталей.

У коді це виглядає приблизно так. Жодного «обʼєкта движка» з методами — є World, і в ньому живе все:

// In Spark there is no "engine object" with methods. There is a World, and
// everything lives inside it — as a Resource (one of a kind) or an Entity
// (many of a kind). The renderer, the GPU, the input, the power grid: all
// just Resources. Nothing hidden away in global statics.

#[derive(Resource)]
struct RenderContext {
    device: wgpu::Device,
    queue: wgpu::Queue,
    surface: wgpu::Surface<'static>,
}

#[derive(Resource, Default)]
struct PowerNetwork {
    supply: f32,
    demand: f32,
    ratio: f32,
}

// A system is just a function. Its parameters declare what it touches —
// and the scheduler hands it exactly that, nothing more.
fn balance_grid(mut grid: ResMut<PowerNetwork>) {
    grid.ratio = grid.supply / grid.demand.max(1.0);
}

Чому не взяти готове — Bevy чи Shipyard

Логічне питання: навіщо городити своє, якщо є Bevy і Shipyard. Мені дуже подобається синтаксис Bevy — він помітно логічніший за Shipyard. Але в Shipyard є ворклоадс (workloads), і ось вони зроблені чудово. І я тупо не можу вибрати.

При цьому Bevy величезний. А Shipyard — ті ще нетрі. І якщо мені знадобиться щось, чого там немає і що не підтримується, я це з жодним ШІ не витягну: я ж навіть приблизно не уявляю, як ці штуки влаштовані всередині. Що, власне, і є справжня причина всієї затії — я хочу розуміти. Хоча б на рівні структур і тих рішень, які ухвалюють автори таких бібліотек. За рахунок чого воно працює так швидко. На які компроміси вони йдуть.

Ось те саме в обох. Bevy:

// Bevy — a system is a plain function; you ask for data by its type.
fn movement(mut query: Query<(&mut Position, &Velocity)>) {
    for (mut pos, vel) in &mut query {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

let mut schedule = Schedule::default();
schedule.add_systems(movement);

Shipyard:

// Shipyard — a system takes "views" into storages, then iterates them.
fn movement(mut positions: ViewMut<Position>, velocities: View<Velocity>) {
    for (mut pos, vel) in (&mut positions, &velocities).iter() {
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

world.run(movement);

А ось ті самі ворклоадс з Shipyard, які я хочу поцупити до себе. Називаєш пачку систем одним імʼям, віддаєш світу, а він сам по «вʼюхах» розуміє, які системи можна ганяти паралельно:

// Shipyard workloads — name a batch of systems, add it to the world, and it
// works out which ones can run in parallel from the views they borrow.
Workload::new("simulation")
    .with_system(movement)
    .with_system(collide)
    .add_to_world(&world)
    .unwrap();

world.run_workload("simulation").unwrap();

Так що Spark, по суті, обкрадає обох. Синтаксис систем — у Bevy, іменовані ворклоадс — у Shipyard:

// Spark steals from both: Bevy's function-systems, Shipyard's named workloads.
// Because every system spells out what it reads and writes, the scheduler can
// batch the ones that don't collide and run them on separate threads.
app.add_workload(Workload::PowerGrid, Schedule::FixedUpdate, |w| {
    w.add(collect_supply);                      // reads plants
    w.add(compute_demand);                      // reads cities — runs in parallel
    w.add(distribute_power).after_all_prior();  // needs both, so it waits
});

Підхід не такий, як з Moku

Син теж зацікавився, може, у чомусь візьме участь. І зайти з цим проєктом я хочу з іншого боку. Якщо Moku — це історія, де на чільне місце поставлено автогенерацію коду (сказав промт — пішов дивитися серіал), то тут я хочу рівно навпаки. Залізти в код, який воно генерує. Розуміти, хоча б почасти, чому воно обирає те чи інше рішення. Як це лежить у памʼяті. І спроєктувати API, який я особисто вважаю правильним. Найпевніше, ту саму суміш Bevy і Shipyard.

Заразом цікаво подивитися, як моделі пишуть Rust. На TypeScript вони, чесно кажучи, так собі.

Як влаштована сама розробка

Роблю поетапно, і тут є принцип, на якому все тримається. Спершу обговорюємо загальний задум — цілі, чорновий API. З розмови народжується дизайн-документ: ECS, рендер, ассет-сервер і так далі. Далі роадмап, розбитий на етапи. Заводиться таск, ШІ береться за реалізацію. Коли PR готовий, я його оглядаю, намагаюся зрозуміти, обговорюю з ШІ, щоб переконатися, що реально розумію, як і чому воно працює. Або не працює. Пропоную правки. Прийняв, мержимо, йдемо далі. План — це добре. Оманливе відчуття контролю.

Кодити будуть тільки Codex чи Claude Code — і тільки код. Усі обговорення проходять зі звичайними, не-код агентами. А важливо ось чому: агент, з яким я обговорюю рішення, не повинен нічого знати про код, лізти в нього і забивати контекст. Хай обговорює зі мною, а не лізе «лагодити» і, як водиться, ламати.

Ідея + APIОбговорюю з ШІДизайн-докРоадмапНовий таскКодер-ШІPull RequestОгляд з ШІМерж у main
***

Подивимося, на якому місці я зіллюся

Загалом, задум амбітний. Подивимося, де я здуюся цього разу. Минулого — це була друга реалізація рендера на WebGPU. Чи дійду далі?