W tym artykule będzie o pewnym podejściu do tego jak robić gry.
Może i od 10 lat siedzę robiąc gry w Unity, ale moje początki były związane z pisaniem własnego silnika do (oczywiście) RPGa, na szczęście nie MMO. Będzie o tym, czym są właściwie gry, ale nie po wierzchu tylko tam głęboko w środeczku. Tym razem temat będzie dotyczył zarówno projektowania jak i programowania. Jeśli nie jesteś programistą i tak może Cie to zainteresować, po prostu nie przejmuj się, że nie zrozumiesz pewnych fragmentów. W tym temacie istotniejsze jest zrozumienie pewnego podejścia, niż nauczenia się jak pisać kod.
Baza danych jako dwie zmienne na krzyż
Na początek zaproponuje takie najprostsze co tylko się da. Kiepskim podejściem jest ręczne wpisanie kilku danych. Dodatkowo, nie powinno się zaczynać robienia gier od RPGów, ale i tak nikt tego nie słucha, dlatego będziemy rozważać hipotetycznego RPGa z systemem walki ukradzionym pokemonów.
W naszym hipotetycznym RPGu mamy jakiegoś głównego bohatera i jego przeciwnika. Każdy z nich ma pewien zasób zdrowia, a co za tym idzie jakiś pasek zdrowia. Skoro pasek zdrowia to maksymalną i obecną ilość punktów życia. Jeśli chcemy móc ubić bestię potrzebne są jeszcze obrażenia.
Stworzyłem prosty projekt w Unity na potrzeby pokazania Wam jak to wszystko działa.
Mam przygotowany skrypt, a w nim podłączony system do obsługi pasków zdrowia, obiekty postaci, po to, żeby mogły się animować oraz przycisk, który wykonuje atak. Poza tym umieszczam w nim wymienione wyżej parametry czyli maksymalne i obecne zdrowie oraz atak postaci.
Podstawowy system walki
Spora część kodu zwłaszcza z pierwszych części tego artykułu nie nadaje się do tego, żeby używać ich w finalnym projekcie i ma charakter prototypowy. Przykładowo poniżej zrobiłem, bardzo nieładną rzecz polegającą na wyborze, który pasek aktualizować za pomocą zmiennej typu bool czyli prawda lub fałsz. Jest co bardzo nieeleganckie podejście. Natomiast dość dobrą rzeczą jest już używanie wartości fillAmount z klasy Image jako wypełnienia. Jest to bardzo powszechna i sprawnie działająca praktyka.
using UnityEngine;
using UnityEngine.UI;
public class BattleUI : MonoBehaviour
{
[SerializeField] private Image heroBar;
[SerializeField] private Image enemyBar;
public void SetBar(float percentage, bool hero)
{
if(hero)
heroBar.fillAmount = percentage;
else
enemyBar.fillAmount = percentage;
}
}
Drugi skrypt opisuje podstawową mechanikę gry. Mamy jakiś początek walki Init, możemy zadać obrażenia, przeciwnik może zadać obrażenia nam. No i możemy jeszcze wygrać lub przegrać.
using UnityEngine;
public class BattleSystem: MonoBehaviour
{
[SerializeField] protected BattleUI battleUI = null;
public virtual void Init()
{
}
public virtual void DealDamageFromHero()
{
}
public virtual void DealDamageFromEnemy()
{
}
public virtual void Win()
{
}
public virtual void Lose()
{
}
}
Podstawowa inicjalizacja mogłaby wyglądać tak, że na start walki: zdrowie wraca do maksymalnej wartości. Choć jest też sporo gier, w których tak się nie dzieje i potrzebujemy osobnych mikstur żeby się uleczyć. Dobrą praktyką jest podpinanie przycisków w kodzie. Dzięki temu łatwiej będzie wyszukać czy dany guzik coś robi czy nie. Zwłaszcza jak podłączał go ktoś inny. Dodatkowo metody, które są wpinane do przycisków w inspektorze nie pokazują referencji w kodzie, a co za tym idzie mogą być takie, które tych referencji mają 0 i wyglądają jak nieużywane czyli takie do wyrzucenia. Później się ktoś zastanawia czemu dany przycisk nie działa. Inną zaletą wpinania i wypinania guzików w kodzie jest to, że przechodząc do jakiejś sekcji gry np: menu pod escapem możemy wpiąć wszystkie guziki pokroju: wróć do gry, wczytaj grę, ustawienia, wyjdź, a powracając do gry wypiąć je. Dzięki temu nie ma przypadku, że dany guzik może zostać kliknięty i wykonać niepożądaną akcję.
public override void Init()
{
enemyHealth = enemyHealthMax;
heroHealth = heroHealthMax;
attack.onClick.AddListener(AttackHero);
}
Najprostszy system obliczeń
Skoro mamy już dane i inicjalizację, pora się pookładać po głowach. Tutaj znów mam przykład mocno prototypowy, którego nie warto cytować dalej. Zamiast robić 2óch metod „Bohater Atakuje Potwora” oraz „Potwór Atakuje Bohatera” powinno być coś w stylu odejmij statystykę z tych statystyk. Wtedy unikniemy powtarzania kodu. Poza tym co jak będzie więcej niż 2 postacie? Trzeba by było robić kombinację, że ten tego, a tamten tego. Co więcej oba przykłady zakładają tylko atakowanie, a gdzie choćby leczenie? Dlatego musicie wybaczyć mi pewne uproszczenia i przyjąć, że inaczej nie mógłbym wam wytłumaczyć zamierzonego konceptu.
public override void DealDamageFromHero()
{
enemyHealth -= heroDamage;
if(enemyHealth <= 0)
{
enemyHealth = 0;
Win();
}
battleUI.SetBar((float)enemyHealth / (float)enemyHealthMax, false);
}
public override void DealDamageFromEnemy()
{
heroHealth -= enemyDamage;
if(heroHealth <= 0)
{
heroHealth = 0;
Lose();
}
battleUI.SetBar((float)heroHealth / (float)heroHealthMax, true);
}
public override void DealDamage(Statistics statistics, int dmg)
{
statistics.health -= dmg;
if(statistics.health <= 0)
OnCharacterDead.Invoke(statistics);
battleUI.SetBar(
statistics,
(float)Mathf.Max(statistics.health, 0) / (float)statistics.maxHealth
);
}
Baza danych jako tablica lub lista
Gdy chcemy mieć dużo różnych przeciwników możemy machnąć tablice na szybkości i powpisywać tam kolejne potwory. Dane byłyby przechowywane w jednym miejscu. Dla ułatwienia ktoś mógłby zrobić, w tym miejscu coś co gamdev-y lubią najbardziej czyli Singleton.
Czym jest Singleton nie jest absolutnie do rozważań w tym miejscu. Podam tylko jedną jego funkcjonalność, która jest nadużywana. Pozwala on stworzyć jakaś pojedynczą rzecz danego rodzaju przykładowo jedną bazę danych. I gdy jest ona już stworzona w dowolnym miejscu można się do niej odwołać bez żadnego wcześniejszego podpinania czy dodatkowej inicjalizacji. Zachęcam o poczytaniu więcej tutaj, a generalnie starajcie się go nie nadużywać.
Żeby identyfikować konkretne potwory warto dopisać jakieś ID, lub zamiast trzymać w tablicy czy liście użyć słownika, z którego dane o potworze będą wyciągane po ID. Warto też dodać obrazek, żeby każdy potwór wyglądał trochę inaczej. Tutaj wspomnę o jednej dobrej praktyce zwanej cache-owaniem. Polega ona na tym, że jak podbieracie jakąś daną z tablicy, albo co gorsza z jakiejś listy czy słownika jeszcze używając Find czy innego grupowania, to znalezioną wartość zapiszcie do tymczasowej zmiennej i używajcie jej zamiast za każdym razem ją pobierać.
public override void Init()
{
int monsterID = Random.Range(0, monsterStats.Count);
MonsterStats monster = monsterStats[monsterID];
enemyHealthMax = monster.health;
enemyHealth = monster.health;
enemyDamage = monster.damage;
monsterImage.sprite = monster.sprite;
heroHealth = heroHealthMax;
attack.onClick.AddListener(AttackHero);
}
Inicjalizacja jak widać trochę się różni ponieważ teraz jest losowany jeden z potworów. Nie będziemy przecież walczyć cały czas z tym samym. Obliczenia walki na tym etapie nie różnią się niczym.
Baza danych jako ScriptableObject
W pewnym momencie dodawanie do tablicy staje się niezbyt czytelne. Dobrym rozwiązaniem jest zamiast podtrzymywać wielkiego kloca agregującego wszystkie zmienne, rozbić je na ScriptableObject. Każda postać będzie miała swój plik zawierający wszelkie potrzebne dane. Dodatkowo przyda się jakiś agregator z którego będziemy mieli dostęp do wszystkich danych. Dla rozwinięcia rozgrywki dodałem 2 nowe parametry: Protection oraz Classification, które będą omawiane dalej.
public class Statistics : ScriptableObject
{
public string id;
public int health;
public int damage;
public int protection;
public Classification classification;
public Sprite sprite;
}
[System.Serializable]
public enum Classification
{
Hero = 0,
Monster = 1
}
Jest jeszcze kilka zalet tego rozwiązania. Po pierwsze zmniejszenie konfliktów, ponieważ wtedy rzadko się zdarza, żeby kilka osób robiło zmiany w jednym pliku. Kolejna zaleta to lepsza widoczność zmian w kontekście repozytorium. Bo dokładnie widać ile i jakie pliki zostały dodane, a nie tylko, że mamy jakieś nowe linijki w wielkim klocu danych. Jest też to sposób agregacji danych przy którym możecie zostać już do końca projektu. Musimy nieco rozbudować inicjalizację. Każdy nowy wpis w statystykach to kolejna statystyka do dopisania w inicjalizacji.
public override void Init()
{
Statistics monster = CharacterDB.me.GetRandomCharacter(Classification.Monster);
Statistics hero = CharacterDB.me.GetCharacter("hero_01");
enemyHealthMax = monster.health;
enemyHealth = monster.health;
enemyDamage = monster.damage;
enemyProtection = monster.protection;
monsterImage.sprite = monster.sprite;
heroHealthMax = hero.health;
heroHealth = hero.health;
heroDamage = hero.damage;
heroProtection = hero.protection;
heroImage.sprite = hero.sprite;
attack.onClick.AddListener(AttackHero);
}
Pobieranie danych postaci może być na wiele sposobów. Można mieć słownik i pobierać je po kluczu lub listę i przeszukiwać id. Natomiast to co ja wam zaproponuje to pewna segregacja właśnie poprzez klasyfikację. Czym jest dokładnie Classification omówię trochę niżej teraz tylko ważna jest informacja, a o tym, że można odfiltrować w ten sposób ludzi od potworów. Dzięki temu mamy jedną bazę postaci, a nigdy nie pobierzemy naszego bohatera jako przeciwnika.
public Statistics GetCharacter(string id)
{
return characters[id];
}
public Statistics GetCharacterFromList(string id)
{
return charactersList.Find(x => x.id == id);
}
public Statistics GetRandomCharacter(Classification classification)
{
List<Statistics> c = charactersList.FindAll(c => c.classification == classification);
if(c.Count == 0)
{
Debug.LogError($"There is no entry for {classification}");
return null;
}
int rand = Random.Range(0, c.Count);
return c[rand];
}
Jak obliczać obronę?
Dla pokazania wam większej ilości statystyk i jak wpływają one na obliczenia zacząłem od dodania ochrony. Jest to dość popularna statystyka. Dzięki niem możemy mieć już jakieś lepsze rozróżnienie na postacie pancerne i lekkie. Obliczenie jakie zaproponuje to proste odejmowanie. Od tego ile zadajemy obrażeń odejmujemy obroną i tylko upewniamy się czy licznik nie przekręcił się poniżej zera.
Cała reszta bez zmian.
public override void DealDamageFromHero()
{
enemyHealth -= Mathf.Max(heroDamage - enemyProtection, 0);
if(enemyHealth <= 0)
{
enemyHealth = 0;
Win();
}
battleUI.SetBar((float)enemyHealth / (float)enemyHealthMax, false);
}
public override void DealDamageFromEnemy()
{
heroHealth -= Mathf.Max(enemyDamage - heroProtection, 0);
if(heroHealth <= 0)
{
heroHealth = 0;
Lose();
}
battleUI.SetBar((float)heroHealth / (float)heroHealthMax, true);
}
Dodajmy do obliczeń jeden dodatkowy myk. Jeśli obrona jest mniejsza lub większa od ataku to nie zeruj obrażeń tylko ustaw je na minimalną wartość. Minimalne zadane obrażenia powodują, że jeśli gracz jest masochistą, bo przeciwnik ma 1000 zdrowia, a on bije go tylko po 1, to i tak będzie mógł go w jakiś sposób pokonać.
public override void DealDamageFromHero()
{
enemyHealth -= Mathf.Max(heroDamage - enemyProtection, 1);
if(enemyHealth <= 0)
{
enemyHealth = 0;
Win();
}
battleUI.SetBar((float)enemyHealth / (float)enemyHealthMax, false);
}
public override void DealDamageFromEnemy()
{
heroHealth -= Mathf.Max(enemyDamage - heroProtection, 1);
if(heroHealth <= 0)
{
heroHealth = 0;
Lose();
}
battleUI.SetBar((float)heroHealth / (float)heroHealthMax, true);
}
No to teraz coś z zupełnie innej beczki, bo obrona może być czymś zupełnie innym. Zamiast odejmować wartość od ataku możne blokować go w 100%. I każdy atak zbija pancerz o 1. Dopiero jak nie ma pancerza, to możemy walić ile fabryka dała. Powoduje to, że warto dzielić ataki na wielorazowe słabe i mocniejsze.
public override void DealDamageFromHero()
{
if(enemyProtection > 0)
{
--enemyProtection;
return;
}
enemyHealth -= heroDamage;
if(enemyHealth <= 0)
{
enemyHealth = 0;
Win();
}
battleUI.SetBar((float)enemyHealth / (float)enemyHealthMax, false);
}
public override void DealDamageFromEnemy()
{
if(heroProtection > 0)
{
--heroProtection;
return;
}
heroHealth -= enemyDamage;
if(heroHealth <= 0)
{
heroHealth = 0;
Lose();
}
battleUI.SetBar((float)heroHealth / (float)heroHealthMax, true);
}
Więcej o tego typu statystykach możecie poczytać w artykule „Dobór odpowiednich statystyk do gry jRPG” oraz w „Ekonomia walki w grach jRPG”.
Baza danych jako excel

Kolejnym krokiem jest trzymanie danych poza silnikiem. Niewątpliwą zaletą jest to, że można nie wchodzić do silnika i dokonać naprawdę rozległe zmiany w balansie czegokolwiek. Nie będę tu mówił o zdalnej konfiguracji, bo to już inny temat, ale też bardzo przydatny.
Gdy designer dokona już zmiany wszystkiego z osoba, która siedzi w silniku klika tylko jeden przycisk, który odpali ściągnięcie wszystkiego. W przypadku silników bez możliwości pisania pluginów, samo parsowanie CSV/TSV też się sprawdzi.
Warto zobaczyć, że liczne tabele są ze sobą powiązane poszczególnymi id. Jest to zazwyczaj relacja jeden do wielu. Mając konkretną postać po identyfikatorze jesteśmy w stanie zejść krok po kroku bardzo głęboko w zależności między nim, a wszystkim innym.
Przykładowo [Character] czyli postać ma pole [Slot] odnoszące się do ekwipunku. Pole jest równe weapon czyli patrzymy w [Slot], na id weapon w którym może być przedmiot typu weapon i kategorii weapon. Co więcej na tym polu przedmiot ma status wear (czyli założony przedmiot), co w zupełnie innym miejscu przekłada się na odpowiednie przeliczenie statystyk. No i ma jedno takie pole bo w count jest wpisane 1. Tyle informacji pozwala nam zdobyć pojedynczy wpis weapon w odpowiedniej tabeli.
Więcej o poszczególnych danych
Jest to rozwinięcie rozdziału „Rozpis walki na tabelki” z artykułu „Mechanika walki w grach jRPG”.
Jest to również objaśnienie obrazka powyżej.
Czym jest Klasyfikacja [Classification]?
Jest to coś, co pozwoli ci wrzucić potwory, twoją postać i postaci niezależne do jednego wora, ale jednocześnie pozwoli na ich sortowanie i wyławianie poszczególnych z nich. Klasyfikować możemy między innymi po:
- Potwory zależne od poziomu
- Potwory zależne od obszaru
- Wędrowni handlarze
- Pula NPC-ów na tłum
- Postacie, którymi będziemy sterować
- NPC z którymi możemy wejść w konkretną interakcję
- …
Postać może mieć więcej niż jedną klasyfikację.

Czym jest sztuczna inteligencja [AI]?
Jest to sposób zachowywania się danej postaci. Nie ma to nic wspólnego z sieciami neuronowymi. Więcej o tym można poczytać w artykule „Mechanika walki w grach jRPG” w rozdziale „Potwory”.
W skrócie jest to sposób w jaki postać zachowa się gdy z nią walczymy. Może np: atakować postać z najmniejszym zdrowiem lub taką, która zaatakowała ją ostatnio.

Czym jest zasięg [Range]?
Jest to opis tego na kogo będzie działać dana akcja wykonana przez postać. Przykładowo może być to jeden przeciwnik, wszyscy z przeciwnej drużyny lub któryś ze sprzymierzeńców. Więcej o tym można poczytać również w artykule „Mechanika walki w grach jRPG” w rozdziale „Zbiór ruchów”.

Czym jest handlarz [Merchant]?
Jest to opis tego co dani handlarze będą mieli w swoich zasobach. Jest to proste przypisanie id postaci [Character] do id przedmiotu [Item]. Pole event, gdy jest puste oznacza, że dany przedmiot jest dostępny u handlarza od zawsze. Gdy jest coś w nim wpisane to handlarz będzie miał ten przedmiot dopiero gdy gra to na nim wymusi. Przykładowo kończy się akt gry i zaczyna drugi. Chcemy wtedy, żeby na początku tego drugiego aktu handlarz miał nowe lepsze bronie. Dlatego w pole eventu wpisujemy jakąś nazwę ala „akt2”.
Jak zdobyć dane z arkusza?
Jeśli macie alergie na czytanie to tutaj proszę instrukcja wideo po angielsku od innej osoby: Link
Większość osób robi sobie kuku i ściąga wszystko najpierw do pliku, a potem ten plik przerabia, natomiast ja pokaże wam trochę bardziej skomplikowaną wersję. Za to taką już możecie używać nawet na produkcji. Po pierwsze musimy mieć skądś Google API. Żeby je pobrać do Unity przyda nam się menadżer pakietów NuGet, który pozwoli nam dobrać do Unity potrzebne biblioteki. Czym jest menadżer pakietów to może innym razem, na razie wystarczy Wam wiedza o tym, że pozwala na pobieranie zewnętrznych bibliotek. Nie jest dostępny na Asset Store, ale jest za to na publicznym GitHub-ie więc pobranie go jest to jeszcze prostsze.
Pobieramy plik z rozszerzeniem .unitypackage i instalujemy do w projekcie.
Link do NuGet-a: Link
Powinna pojawić się nam zakładka na pasku NuGet. Możemy z niej wybrać Manager NuGet Packages i w pasku wyszukiwania wpisać Sheets. Jeśli zaznaczymy ptaszkiem i zainstalujemy Google.Apis.Sheets.v4 automatycznie pobiorą nam się pozostałe istotne biblioteki widoczne na zdjęciu wyżej.
Jeśli nie macie jeszcze konta na Google Cloud to pora je założyć Link
W zakładce IAM & Admin/Service Accounts należy stworzyć konto + CREATE SERVICE ACCOUNT, a następne udostępnić na utworzonego maila udostępnić arkusz, który chcecie. Wystarczy, że będzie Viewerem., nie mysi być Edytorem
Wracamy do Google Cloud i wyszukujemy API a w nim Library, żeby uruchomić opcję Google Sheets API. Polecam użyć opcji szukaj. Domyślnie wszystkie usługi są powyłączane i nie warto uruchamiać niepotrzebnych. Wchodzimy w nią i klikamy przycisk ENABLE.
Wracamy do zakładki IAM & Admin/Service Accounts klikamy na maila i przechodzimy w zakładkę KEYS gdzie mamy opcję ADD KEY. Gdy dodamy klucz przeglądarka powinna nam go automatycznie pobrać.
Pobrany plik należy wrzucić do folderu Assets. Nie polecam wrzucać do StreamingAssets lub Resources ponieważ nie chcemy, żeby ktoś kto dostanie grę miał dostęp do naszych kluczy i arkusza. Arkusz jest istotny tylko dla developerów.
Pora zabrać się za kod. Poniżej daje Wam, kompletne minimum tego, żeby coś się pobrało. Potrzebujemy przede wszystkim sheetID oraz pliku z kluczami, który przed chwilą pobraliśmy z Google Cloud. Wartość dla sheetID pobieracie z adresu waszego arkusza.
using System.Collections.Generic;
using System.IO;
using Google.Apis.Services;
using Google.Apis.Auth.OAuth2;
using Google.Apis.Sheets.v4;
using Google.Apis.Sheets.v4.Data;
using UnityEngine;
public class SheetFetch
{
static public string sheetID = "1_SA-atxoZx1r8a3kMdEc0NReqhFMkarsSHEMhrXXd9w";
static public string jsonPath = "/getsheetexample-1a7ff3da5504.json";
static private SheetsService service;
static SheetFetch()
{
string fullJsonPath = Application.dataPath + jsonPath;
Stream jsonCreds = (Stream)File.Open(fullJsonPath, FileMode.Open);
ServiceAccountCredential credential = ServiceAccountCredential
.FromServiceAccountData(jsonCreds);
service = new SheetsService(new BaseClientService.Initializer()
{
HttpClientInitializer = credential
});
}
public IList<IList<object>> GetSheetRange(string sheetNameAndRange)
{
var request = service.Spreadsheets.Values.Get(sheetID, sheetNameAndRange);
ValueRange response = request.Execute();
IList<IList<object>> values = response.Values;
if(values != null && values.Count > 0)
{
return values;
}
Debug.LogError("No data found.");
return null;
}
}
Teraz jak pobrać dane? Wystarczy wywołać metodę GetSheetRange z poleceniem takim jak byście odpytywali excela o jakieś wartości. Najbardziej podstawowe zapytanie to NazwaTabeli!Od:Do. Zapytanie jak w kodzie niżej da nam wszystkie wartości jakie tylko są w arkuszu Character i kolumnach od A do F. W moim przypadku będzie to cała zawartość. Dobrą praktyką jest też filtrowanie pierwszego wiersza w taki sposób, że dane są przerzucane do słownika, a ten wiersz służy za klucz.
SheetFetch sheetFetch = new SheetFetch();
var data = sheetFetch.GetSheetRange("Character!A:F");
foreach(var item in data)
{
foreach(var val in item)
{
Debug.Log(val.ToString());
}
}
Przeniesienie danych na Playdate
Jak już wspomniałem na początku, duże silniki to nie wszystko i dobrze przygotowana baza danych może pasować do czegokolwiek. Dlatego postanowiłem przepisać grę na zupełnie inne urządzenie.
Grę na Playdate możecie pisać w C lub Lua. Na oficjalnej stronie jest dostępny symulator/emulator,
który można zainstalować nawet na SteamDeck-u (jakby ktoś był na tyle szalony).
Nie mamy tutaj żadnego silnika, za to mamy dobrze przygotowany framework. Dokładnie te same obliczenia i dokładnie te same dane. Jedyne co to trzeba przetłumaczyć grę na inny język. Najwięcej różnic jest w samej obsłudze wczytania, wyświetlania i animowania grafik.

Mapa zapisana w grafice
Może kojarzycie taką grę jak SUPERHOT. W jednej z mini gier w Mind Control Delete mapy były zapisane jako grafiki, a paint służył jako edytor. Mój pierwszy silnik jaki robiłem też tak działał. Rysowałem w nim ziemię, drzewa, wodę, lód (miała setting zimowy) i pagórki za pomocą odpowiednich kolorów. Tylko postacie były osobno wstawiane. Pamiętajcie, że nie wolno wtedy zapisywać map w formacie .jpg czy innych stratnych formatach, bo poprzestawiają Wam kolory i koniec.
Mapa w formie tekstowej

Mapy mogą być zapisywane binarnie lub tekstowy. W grze nad, którą ostatnio pracuje zdecydowałem się na format tekstowy. Dlatego, że taką mapę łatwo jest zedytować i dać innej osobie. Gdy ta osoba wie jak wygląda format mapy jest w stanie ręcznie w niej coś zmienić bez korzystania z edytora. Działa to oczywiście do pewnej skali, bo edytowanie mapy 100 na 100 mija się z celem. Jednak jeśli jest to coś co może można ogarnąć swoim umysłem to nie warto iść w binarny format. Do tej gry zrobiłem także wewnątrz-growy edytor, który zapisuje mapy do formy tekstowej. Wystarczy włączyć mapę poprzestawiać kilka rzeczy po czym kliknąć zapis i graj. Zaletą tego podejścia jest szybkość iteracji oraz to, że jeśli edytor będzie graficznie dopracowany może zostać potem udostępniony użytkownikom, a tu już blisko do bycia modowalnym.

Podsumowanie
Zamiast zaczynać tworzenie na hura to zastanówcie się co wam w ogóle będzie potrzebne i czy możecie sobie pozwolić na to żeby zaorać prototyp i zacząć grę od niemal początku. Niemal, bo właśnie zawsze zostają pewne założenia, fragmenty poziomów czy właśnie dane, które wypracowaliście podczas testowania.
Dzielcie pracę na różne działy. Nie zmuszajcie programisty do tego, żeby wypełniał tabelki za designera. Nazywajcie dane z sensem. Nie bójcie się wyrzucać rzeczy, ale też przede wszystkim kończcie projekty, bo 1 skończony jest wart więcej niż 1000 zaczętych.