Всем привет! Меня зовут Яков, и я разработчик игр. Возможно, вы играли в мои предыдущие проекты: Dom Rusalok, Loretta и Anoxia Station. Сейчас я заканчиваю работу над новой игрой — Bonereader. И, поскольку я много думаю сейчас о балансе игры, я решил поделиться опытом, в свободной форме порассуждать обо разработке, и дать, надеюсь, полезные советы, которые помогут другим разрабам.

Костяной барон
Костяной барон

Bonereader — карточная игра с механикой покера на костях. Вас поджаривают на электрическом стуле, и вы оказываетесь в Чистилище, вдохновлённом романами Карлоса Кастанеды и Кормака Маккарти, где вынуждены играть в кости с разными духами за призрачный шанс на перерождение.

В основе механики лежит костяной покер (yatzy). В детстве мы с братом часто играли в одну из его вариаций. Когда работа над Anoxia Station подходила к концу, я, как и многие, увлёкся Balatro. А мне за короткое время нужно было придумать концепт для новой игры. И я вспомнил про покер «на костях».

Вот такое поле мы с братом рисовали, когда играли.
Вот такое поле мы с братом рисовали, когда играли.

Я не программист, а врач по образованию. Несмотря на то что занимаюсь этим уже лет восемь. Моя главная проблема в том, что у меня нет систематических знаний и я не знаю ни одного языка программирования, кроме GML. Если я нахожу элегантное решение какой‑то проблемы в чужом проекте на GitHub, я, конечно же, «заимствую» его, но всегда существенно переписываю. Хотя сейчас в коде для меня нет задачи, которая могла бы поставить меня в тупик. В крайнем случае, я всегда могу обратиться за советом к бесплатной версии Claude.

Пожалуй, единственное, чего я не освоил — сетевой код, но лишь потому, что такая задача никогда передо мной и не стояла. В остальном есть лишь два ограничения: невозможность работать в 3D и фантазия.

Код

По сути, в моём проекте есть три главных объекта: obj_dice, obj_combination и obj_controller, который отвечает за global.game_state (то есть за всё происходящее в игре).

/// [GAME STATES]
///  "start_round"
///  "rolling"
///  "in_play"
///  "tally_score"
///  "finalize_score"
///  "menu_screen"
///  "in_shop"
///  "in_hub"

Вы можете выбрать одну из стартовых колод, содержащую несколько карт‑комбинаций и россыпь разнообразных костей. Ваша цель: набрать необходимое количество очков за ограниченное количество попыток. Всё просто, верно? Ну, не совсем:)

Экран с выбором исходной колоды
Экран с выбором исходной колоды

После короткой беседы с тем или иным духом вам открывается магазин, в котором вы можете приобрести предметы и кости, продать или купить карты‑комбинации. У каждого монстра есть «правило дома» — специальное условие, которое необходимо выполнять.

Реролл магазина такой дорогой из-за выбранной колоды.
Реролл магазина такой дорогой из‑за выбранной колоды.
На своём пути вы встретите много заблудших душ, некоторые из которых полностью потеряли человеческое обличие.
На своём пути вы встретите много заблудших душ, некоторые из которых полностью потеряли человеческое обличие.

С каждым противником вы играете четыре партии, последняя проходит под случайным «Знамением» — по сути, дебаффом. Наконец, на каждый ход или бросок на случайную карту из вашей руки накладывается случайное: есть как выгодные, так и не очень, так и что‑то между.

Игровой стол у каждого противника тоже свой собственный!
Игровой стол у каждого противника тоже свой собственный!
Все виды костей (пока что).
Все виды костей (пока что).

Математика подсчёта примерно такая:

function _check_card_effect_to_final_score(add_to_score_all, card_level) {
    var final_score = add_to_score_all; 
    var temp_mult = 1;
    var temp_bonus = 0;
    
    var basepips = ceil(card_base + global.dice_slot1 + global.dice_slot2 
    + global.dice_slot3 + global.dice_slot4 + global.dice_slot5);

    var base_score_with_pips = basepips;
    var item_bonus_score = global.item_bonus_score;
    var item_bonus_mult = global.item_bonus_mult;

Затем мы накладываем проклятие карты, если оно есть:

if (ENEMY_EFFECT_CARD_HAND_CURSE) {
    final_score = max(0, final_score - (100 * eff_power));
}

Затем проверяем татуировки — перманентные баффы, награды за повышение уровня. Ну и наконец, считаем результат:

if (global.GET_BONUS_TATTOO_ODD_BONUS) {
    var countodd = 0;
    var dice = [global.dice_slot1, global.dice_slot2, 
    global.dice_slot3, global.dice_slot4, 
    global.dice_slot5];

    for(var i=0; i<5; i++) { if(dice[i] % 2 == 1) countodd += tattoo_odd_bonus; }
    temp_mult += countodd; 
}

if (global.GET_BONUS_TATTOO_EVEN_BONUS) {
    var counteven = 0;
    var dice = [global.dice_slot1, global.dice_slot2, 
    global.dice_slot3, 
    global.dice_slot4, global.dice_slot5];

    for(var i=0; i<5; i++) { if(dice[i] != 0 && dice[i] % 2 == 0) 
    counteven += tattoo_even_bonus; }
    
    temp_mult += counteven;
}

// --- ФИНАЛЬНЫЙ РАСЧЕТ ---
var result = (final_score + temp_bonus) * temp_mult;

Короче говоря, мы суммируем значения кубиков, умножаем на уровень карты и накидываем остальные эффекты.

Также в игре есть разнообразные предметы.
Также в игре есть разнообразные предметы.

Предметы работают по принципу:

if (sprite_index == spr_item_golden_tooth) 
{
    audio_play_sound(snd_item_golden_thoot, 0, false, global.SOUNDS_VOLUME_MAX)
    add_bonus_per_dice_value(6, 10); 
    ACTIVATED = true
}

Где add_bonus_per_dice_value ():

function add_bonus_per_dice_value(dice_value, bonus_per_die) {
    var count = 0;
    if (global.dice_slot1 == dice_value) count++;
    if (global.dice_slot2 == dice_value) count++;
    if (global.dice_slot3 == dice_value) count++;
    if (global.dice_slot4 == dice_value) count++;
    if (global.dice_slot5 == dice_value) count++;
    
    global.item_bonus_score += count * bonus_per_die;
}

Конечно, таких функций ещё очень много, но суть у них примерно одинаковая: предметы влияют либо на сумму значений кубиков, либо на множитель.

Всех монстров художница сначала рисует от руки, а затем уже создает digital-версию.
Всех монстров художница сначала рисует от руки, а затем уже создает digital‑версию.

Баланс

Поскольку в проекте я работаю один, то не веду таблиц (кроме локализации) и документации. У меня есть файл _Balance (раньше в GM это называлось script, но я не хочу никого путать, и когда‑то он мог нести в себе только одну функцию, но сейчас туда можно записывать сколько угодно функций), куда я в виде #macro записал основные показатели: например, базовое количество очков, которое даёт карта, её цена покупки/продажи и так далее.

В игре два режима: сюжетный и роглайк. Если сюжетный более выверен с геймдизайнерской точки зрения — враги идут по очереди, мы примерно представляем себе кривую прогрессии, определяем пул бонусов для того или иного уровня игрока, — то в роглайт‑режиме многое зависит от рандома.

Сет врагов определяется случайно, колоду игрок может взять любую, предметы и награды тоже. Детерминировано, пожалуй, только количество требуемых очков, но и оно может меняться в зависимости от того, взял ли игрок татуировку, уменьшающую очки, или использовал ли предмет для смены дебаффа.

Вот как идёт подсчёт требуемых очков очки для противника. Для своего удобства я использовал термин «анте», который взял из Balatro и который и в самом Balatro, на самом деле, означает не своё первое смысловое значение, но мы это опустим.

function calculate_roguelite_level_settings () {
    
    // Текущий анте (противник) - от 0 до 5
    var _current_ante = array_length(global.DEFEATED_ENEMIES_CASINO);
    
    // Базовые очки для каждого раунда (уровня)
    var _base_points = 0;
    
    switch (global.level) {
        case 0: _base_points = LEVEL_ONE_TARGET_POINT;   break;  
        case 1: _base_points = LEVEL_TWO_TARGET_POINT;   break;  
        case 2: _base_points = LEVEL_THREE_TARGET_POINT; break;  
        case 3: _base_points = LEVEL_BOSS_TARGET_POINT;  break;  
        case 4: _base_points = LEVEL_BOSS_TARGET_POINT;  break;  
    }
    
    var _ante_multipliers = [
        1.0,   
        3.2,   
        6.0,   
        10.0,  
        14.0,  
    ];
	
    // Если антов больше 6, продолжаем увеличивать множитель
    var _ante_multiplier = 1.0;
    if (_current_ante < array_length(_ante_multipliers)) {
        _ante_multiplier = _ante_multipliers[_current_ante];
    } else {
        // Для антов больше 6: продолжаем экспоненциальный рост
        _ante_multiplier = _ante_multipliers[array_length(_ante_multipliers) - 1] * 
                          power(1.6, _current_ante - array_length(_ante_multipliers) + 1);
    }
    
    // Рассчитываем финальные очки
    global.target_score_base = floor(_base_points * _ante_multiplier);
    
    // Применяем бонус татуировки (-15% к требуемым очкам)
    if (global.GET_BONUS_TATTOO_MINUS_10_TARGET_POINTS) {
        global.target_score_base = floor(global.target_score_base * 0.85);
    }
    
    global.target_score = global.target_score_base;
    
    if (global.BOSS_DEBUFF == "HALF_COMBO" && (global.level == 3))
    {
        global.target_score = round(global.target_score/3)
    }

    // Назначаем "Знамение" для финального раунда
    if (global.level == 0) {
        SET_UP_THE_BOSS_EFFECT(); 
    }
}

Другие технические сложности, вроде смены разрешений, локализации, системы визуальных настроек, системы сохранений, управления геймпадом для меня сегодня уже не представляют такого уж большого вызова. Я использую наработки из предыдущих проектов, оптимизируя под текущий и, по возможности, улучшая.

В Bonreader же я гораздо больше потратил времени на создание «ощущения» от карт и кубиков. Их анимаций, наведения, шейдеров для проклятий.

Моя идея в том, чтобы игрок мог выбирать, как двигаться к огромным числовым значениям.  Выборов и синергий масса.
Моя идея в том, чтобы игрок мог выбирать, как двигаться к огромным числовым значениям. Выборов и синергий масса.

Советы разработчикам

Из раза в раз я создаю определённую подборку советов для себя в будущем и, быть может, для других разработчиков, которые могут найти их полезными. Почти каждый раз я их в той или иной мере нарушаю, но искренне стремлюсь их соблюдать, а потому не стесняюсь повторить!

Если вы тоже хотите создать игру на GM, вот немного сермяжной правды:

  1. Используйте минимум шрифтов, при этом по возможности заранее подыскивайте аналоги в CJK‑шрифтах, чтобы стиль текста и дизайна игры был похожим для всех игроков.

  2. Продумывайте текстурные и аудиогруппы заранее. Вообще, продумывайте архитектуру проекта (систему сохранений, контроллер). Это сэкономит много времени при создании уровней. Учитывайте ограничения выбранного движка и свои навыки ещё на этапе планирования.

  3. Создавать игры с поддержкой геймпада выгоднее.

  4. Не создавайте «hard‑сoded» текст. Используйте.csv‑таблицы. Если не знаете, как это сделать, обязательно научитесь.

  5. Сохраняйте лицензии на все используемые звуки и музыку. Делайте это сразу, чтобы потом не терять время на поиск источников.

  6. Не используйте внутри игры видео. Кодеки — это отдельная головная боль, которая вряд ли стоит результата.

  7. По возможности используйте меньше шейдеров. Особенно если вы не пишете их сами, а покупаете или находите и внедряете. Нет гарантии, что шейдер на одной платформе будет работать так же хорошо, как на другой. Keep it simple.

  8. Чаще рассказывайте о своей игре. Общайтесь с аудиторией, делитесь процессом и идеями.

  9. Я бы советовал держать код для API‑разных магазинов в пределах одного участка кода. Ну, к примеру, ачивки я делаю так:

// Achievement #1
function  Achievement_Unlock_1() 
{
	if (IS_DEMO_BUILD) {exit}
	
	switch (os_type)
	{
		case os_windows:
			
			switch(WINDOWS_TARGET_PLATFORM)
			{
				case WindowsTargetPlatforms.Steam:
				
					if !steam_get_achievement ("ach_01_win_shaman") 
                    steam_set_achievement ("ach_01_win_shaman");
					
				break;
				
				case WindowsTargetPlatforms.GOG:
				
					if !instance_exists(obj_gog_ach_1) 
                    {instance_create_depth(x, y, -999, obj_gog_ach_1)}
					
				break;
				
				case WindowsTargetPlatforms.EOS:
	
					//
					
				break;
			}
    		break;
		case os_ps4:
			//
		break;
		case os_ps5:
			//
		break;
		case os_gdk:
			// 		
		break;
	}
}

В апреле вышла бесплатная демка Bonereader, в которую я приглашаю вас сыграть и поделиться со мной своими мыслями! Мы находимся на финишной прямой. Мне осталось дописать финальные тексты, перевести их на японский и два вида китайского, вставить звуки в концовки, которых в игре будет несколько, к слову. А Даше, моему партнеру и художнице, осталось закончить артбук и несколько больших и не очень артов.

Но сейчас у меня ещё есть время, а глаз, откровенно говоря, замылен. Мне не хватает фидбека от реальных игроков и любителей жанра, потому с нетерпением жду ваши отзывы. А написать вы можете в наш тг или вк, да и просто оставить отзыв в Steam, GOG или Itch.

И спасибо за внимание!:)

Комментарии (2)


  1. PechoraDev
    05.05.2026 09:06

    Крутая игрушка, кинул в закладки, на днях затестирую) И это…Удачи вам!


    1. butuzoff_ya Автор
      05.05.2026 09:06

      Спасибо-спасибо!