Beautiful code is living code. Making a cellular automaton in Godot and exporting it to HTML

Speaking of beautiful code, have you heard about the “Code Beauty Contest” from Sber? This is where you can make the most of it. Choose from the available tasks the one you like and create whatever your heart desires. The main thing is your ideas and the ability to translate them into code. So don’t be shy, register for the competition and show us what truly beautiful code should look like. And then, lo and behold, you’ll get some prizes. So, are you ready to take on the challenge? Then go ahead beautiful code!

Now let’s return to “Living Code”. Conway came up with the “Game of Life” – a cellular automaton that, for all its simplicity, is capable of generating incredibly complex patterns that imitate life. And today we will recreate this game using modern tools. But to make this a little less trivial than usual, we will do it on the open-source Godot engine, which has recently been gaining popularity as a worthy competitor to Unity, especially in the world of indie development.

Why Godot? Firstly, as already said, it is open source, which already gives +10 points relative to its proprietary counterpart, which requires licensing fees. And also because it’s like a Swiss Army knife in the world of game engines: it seems to have a ton of functions, but it weighs nothing, less than a gigabyte. And its web export, in theory, is as simple and one-button as in Unity, which we will check. And we just want to not only make a game, but also make it suitable for placement on some hosting as a demo, so that everyone can see the beauty of live code in action on a rendered HTML page.

Preparation: Project Structure

First, let's figure out what we need to create our “Game of Life”. Open Godot and create a new project. Here's what we need to do:

  1. Create two scenes:

  2. Add the following nodes to game.tscn:

    • Game (Node2D) (root node);

    • CheckButton (to start and stop the simulation);

    • InteractiveButton (also CheckButton but renamed, for switching interactive mode);

  3. In cell.tscn add only a sprite with a texture to display a living cell.

  4. In Editor → Manage Export Templates, let’s click “Download and Install” in advance for the export templates, which will be useful to us closer to the end.

As you can see, this is nowhere near rocket science and no more difficult than creating a project in either Unity or Unreal.

Foundation: basic functionality

Main script

Let's start by writing a script for the main scene. Create a game.gd file and attach it to the Game node. Here's the basic structure:

extends Node2D
@export var cell_scene : PackedScene
var row_count : int = 45
var column_count : int = 80
var cell_width: int = 15
var cell_matrix: Array = []
var previous_cell_states: Array = []
var is_game_running: bool = false
var is_interactive_mode: bool = false

What's going on here? We define the main variables for our game. @export var cell_scene : PackedScene is a special Godot feature that allows us to connect the cell scene with the main scene through the inspector. Very convenient, although nothing unusual.

But variables alone are not enough. We need to somehow initialize our playing field. Adding a function:

func initialize_game():
    cell_matrix.clear()
    previous_cell_states.clear()
    for column in range(column_count):
        cell_matrix.push_back([])
        previous_cell_states.push_back([])
        for row in range(row_count):
            var cell = cell_scene.instantiate()
            self.add_child(cell)
            cell.position = Vector2(column * cell_width, row * cell_width)
            cell.visible = false
            previous_cell_states[column].push_back(false)
            cell_matrix[column].push_back(cell)

It creates a two-dimensional array of cells, carefully placing them on the playing field. Each cell is initially invisible (considered dead). But that's it for now.

Rules of the game

Now the most interesting part is the implementation of the rules of the “Game of Life”. We add functions to count living neighbors and determine the next state of the cell:

func get_count_of_alive_neighbours(column, row):
    var count = 0
    for x in range(-1, 2):
        for y in range(-1, 2):
            if not (x == 0 and y == 0):
                var neighbor_column = column + x
                var neighbor_row = row + y
                if neighbor_column >= 0 and neighbor_column < column_count and neighbor_row >= 0 and neighbor_row < row_count:
                    if previous_cell_states[neighbor_column][neighbor_row]:
                        count += 1
    return count

func get_next_state(column, row):
    var current = previous_cell_states[column][row]
    var neighbours_alive = get_count_of_alive_neighbours(column, row)
    
    if current:
        return neighbours_alive == 2 or neighbours_alive == 3
    else:
        return neighbours_alive == 3

These features are the heart of our game. They implement the classic Conway rules: a cell comes to life if it has exactly three living neighbors, and survives if it has two or three neighbors. Just? Yes. Effective? Undoubtedly!

Lifecycle: Game State Update

Now we need a function that will update the state of the entire playing field. Add:

func update_game_state():
    for column in range(column_count):
        for row in range(row_count):
            previous_cell_states[column][row] = cell_matrix[column][row].visible
    
    for column in range(column_count):
        for row in range(row_count):
            cell_matrix[column][row].visible = get_next_state(column, row)

This function is the conductor of our cellular orchestra. It first stores the current state of all cells and then updates them based on the rules of the game.

Interactivity: let the player participate too

But what is a game without a player? Let's add user input processing so that our virtual biologist can interact with cells:

func _input(event):
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
            var click_position = get_global_mouse_position()
            var column = int(click_position.x / cell_width)
            var row = int(click_position.y / cell_width)
            if column >= 0 and column < column_count and row >= 0 and row < row_count:
                toggle_cell(column, row)

func toggle_cell(column, row):
    if not is_game_running or is_interactive_mode:
        cell_matrix[column][row].visible = not cell_matrix[column][row].visible
        previous_cell_states[column][row] = cell_matrix[column][row].visible

These functions allow the player to click on squares to toggle their states. Now our virtual ecosystem is not only alive, but also interactive.

Controls: buttons are everything

Remember we added two buttons to the scene? It's time to put them to work. Let's create separate scripts for them.

check_button.gd:

extends CheckButton

signal game_state_changed(is_running: bool)

func _ready():
    text = "Стоп"
    toggled.connect(_on_toggled)

func _on_toggled(button_pressed: bool):
    text = "Стоп" if button_pressed else "Старт"
    emit_signal("game_state_changed", button_pressed)

Interactive_button.gd:

extends CheckButton

signal interactive_mode_changed(is_interactive: bool)

func _ready():
    text = "Интерактив: Выкл"
    toggled.connect(_on_toggled)

func _on_toggled(button_pressed: bool):
    text = "Интерактив: Вкл" if button_pressed else "Интерактив: Выкл"
    emit_signal("interactive_mode_changed", button_pressed)

Tying it all together

Now we need to tie all these pieces together. Let's add to our main script:

game.gd
var row_count : int = 45
var column_count : int = 80
var cell_width: int = 15
var cell_matrix: Array = []
var previous_cell_states: Array = []
var is_game_running: bool = false
var is_interactive_mode: bool = false
var is_mouse_pressed: bool = false
var last_toggled_cell: Vector2 = Vector2(-1, -1)

@onready var check_button = $CheckButton
@onready var interactive_button = $InteractiveButton
@onready var update_timer = $UpdateTimer

# Константы для позиционирования и размера кнопок
const BUTTON_MARGIN: int = 10
const BUTTON_WIDTH: int = 120
const BUTTON_HEIGHT: int = 40
const UPDATE_INTERVAL: float = 0.5  # Полсекунды

# Цвет линий сетки и цвет фона
const GRID_COLOR: Color = Color.BLACK
const BACKGROUND_COLOR: Color = Color.WEB_GRAY

# Отдельный узел для сетки
var grid_node: Node2D

func _ready():
	# Подключаем сигнал изменения размера окна
	get_tree().root.size_changed.connect(self.on_window_resize)
	
	# Подключаем сигналы изменения состояния игры и интерактивного режима
	check_button.game_state_changed.connect(_on_game_state_changed)
	interactive_button.interactive_mode_changed.connect(_on_interactive_mode_changed)
	
	# Устанавливаем свойства кнопок
	check_button.size = Vector2(BUTTON_WIDTH, BUTTON_HEIGHT)
	check_button.text = "Старт"
	interactive_button.size = Vector2(BUTTON_WIDTH, BUTTON_HEIGHT)
	interactive_button.text = "Интерактивный режим"
	
	# Настраиваем таймер
	update_timer = Timer.new()
	add_child(update_timer)
	update_timer.connect("timeout", Callable(self, "_on_update_timer_timeout"))
	update_timer.set_wait_time(UPDATE_INTERVAL)
	update_timer.set_one_shot(false)
	
	# Создаем узел сетки
	grid_node = Node2D.new()
	add_child(grid_node)
	
	on_window_resize()

func draw_grid():
	# Удаляем старый узел сетки и создаем новый
	grid_node.queue_free()
	grid_node = Node2D.new()
	add_child(grid_node)
	grid_node.draw.connect(self._on_grid_draw)
	grid_node.queue_redraw()

func _on_grid_draw():
	# Рисуем белый фон
	var background_rect = Rect2(
		0, 
		BUTTON_HEIGHT + BUTTON_MARGIN, 
		column_count * cell_width, 
		row_count * cell_width
	)
	grid_node.draw_rect(background_rect, BACKGROUND_COLOR)

	# Рисуем вертикальные линии сетки
	for x in range(column_count + 1):
		var start = Vector2(x * cell_width, BUTTON_HEIGHT + BUTTON_MARGIN)
		var end = Vector2(x * cell_width, BUTTON_HEIGHT + BUTTON_MARGIN + row_count * cell_width)
		grid_node.draw_line(start, end, GRID_COLOR)

	# Рисуем горизонтальные линии сетки
	for y in range(row_count + 1):
		var start = Vector2(0, BUTTON_HEIGHT + BUTTON_MARGIN + y * cell_width)
		var end = Vector2(column_count * cell_width, BUTTON_HEIGHT + BUTTON_MARGIN + y * cell_width)
		grid_node.draw_line(start, end, GRID_COLOR)

func _on_game_state_changed(is_running: bool):
	is_game_running = is_running
	if is_game_running:
		update_timer.start()
	else:
		update_timer.stop()

func _on_interactive_mode_changed(is_interactive: bool):
	is_interactive_mode = is_interactive

func _on_update_timer_timeout():
	update_game_state()

func initialize_game():
	# Очищаем матрицы и удаляем старые ячейки
	cell_matrix.clear()
	previous_cell_states.clear()
	for child in get_children():
		if child != check_button and child != interactive_button and child != update_timer and child != grid_node:
			child.queue_free()
	
	# Рисуем сетку перед созданием ячеек
	draw_grid()
	
	# Создаем новые ячейки
	for column in range(column_count):
		cell_matrix.push_back([])
		previous_cell_states.push_back([])
		for row in range(row_count):
			var cell = cell_scene.instantiate()
			self.add_child(cell)
			cell.position = Vector2(column * cell_width, row * cell_width + BUTTON_HEIGHT + BUTTON_MARGIN)
			cell.visible = false
			previous_cell_states[column].push_back(false)
			cell_matrix[column].push_back(cell)
	
	# Включаем обработку ввода
	set_process_input(true)

func on_window_resize():
	# Пересчитываем размеры игрового поля при изменении размера окна
	var window_size = get_viewport_rect().size
	column_count = int(window_size.x / cell_width)
	row_count = int((window_size.y - BUTTON_HEIGHT - BUTTON_MARGIN) / cell_width)
	
	# Позиционируем кнопки
	check_button.position = Vector2(BUTTON_MARGIN, BUTTON_MARGIN)
	interactive_button.position = Vector2(BUTTON_MARGIN * 2 + BUTTON_WIDTH, BUTTON_MARGIN)
	
	initialize_game()

func is_edge(column, row):
	# Проверяем, является ли ячейка краевой
	return row == 0 or column == 0 or row == row_count-1 or column == column_count-1

func get_count_of_alive_neighbours(column, row):
	# Подсчитываем количество живых соседей для заданной ячейки
	var count = 0
	for x in range(-1, 2):
		for y in range(-1, 2):
			if not (x == 0 and y == 0):
				var neighbor_column = column + x
				var neighbor_row = row + y
				if neighbor_column >= 0 and neighbor_column < column_count and neighbor_row >= 0 and neighbor_row < row_count:
					if previous_cell_states[neighbor_column][neighbor_row]:
						count += 1
	return count

func get_next_state(column, row):
	# Определяем следующее состояние ячейки согласно правилам игры
	if is_edge(column, row):
		return false
	var current = previous_cell_states[column][row]
	var neighbours_alive = get_count_of_alive_neighbours(column, row)
	
	if current:
		# Ячейка жива
		return neighbours_alive == 2 or neighbours_alive == 3
	else:
		# Ячейка мертва
		return neighbours_alive == 3

func update_game_state():
	# Сохраняем текущее состояние ячеек
	for column in range(column_count):
		for row in range(row_count):
			previous_cell_states[column][row] = cell_matrix[column][row].visible
	
	# Обновляем состояние ячеек
	for column in range(column_count):
		for row in range(row_count):
			cell_matrix[column][row].visible = get_next_state(column, row)

func _input(event):
	# Обрабатываем пользовательский ввод
	if event is InputEventMouseButton:
		if event.button_index == MOUSE_BUTTON_LEFT:
			is_mouse_pressed = event.pressed
			if is_mouse_pressed:
				toggle_cell_at_mouse_position()
	elif event is InputEventMouseMotion and is_mouse_pressed:
		toggle_cell_at_mouse_position()

func toggle_cell_at_mouse_position():
	# Переключаем состояние ячейки под курсором мыши
	var click_position = get_global_mouse_position()
	var column = int((click_position.x) / cell_width)
	var row = int((click_position.y - BUTTON_HEIGHT - BUTTON_MARGIN) / cell_width)
	
	if column >= 0 and column < column_count and row >= 0 and row < row_count:
		var current_cell = Vector2(column, row)
		if current_cell != last_toggled_cell:
			toggle_cell(column, row)
			last_toggled_cell = current_cell

func toggle_cell(column, row):
	# Переключаем состояние конкретной ячейки
	if not is_game_running or is_interactive_mode:
		cell_matrix[column][row].visible = not cell_matrix[column][row].visible
		previous_cell_states[column][row] = cell_matrix[column][row].visible

Now our game starts, stops, allows you to interact with cells – everything starts to come to life.

The final touch: export to HTML5

Now that our “Game of Life” is ready, let's make it live and in the browser. Godot exports to HTML5 through a series of fairly simple steps:

  1. go to the menu “Project” → “Export”;

  2. click “Add” and select “Web”;

  3. set the parameters, or don’t touch anything;

  4. Click “Export All”.

We then rename the resulting file to index.html and no, we don’t run it, for this we need an HTTPS server. Let's run it with the following Python code (server.py), in the same folder as our index.html:

import http.server, ssl, os

# Абсолютный путь до сервера
thisScriptPath = os.path.dirname(os.path.abspath(__file__)) + '/'

# Создаём самоподписанный сертификат через openssl
def generate_selfsigned_cert():
    try:
        OpenSslCommand = 'openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out ' + thisScriptPath + 'cert.pem -keyout ' + thisScriptPath + 'key.pem -subj "/C=IN/ST=Maharashtra/L=Satara/O=Wannabees/OU=KahiHiHa Department/CN=www.iamselfdepartment.com"'
        os.system(OpenSslCommand)
        print('<<<<Certificate Generated>>>>>>')
    except Exception as e:
        print(f'Error while generating certificate: {e}')

# Запускаем сервер на заданном порту
def startServer(host, port):
    server_address = (host, port)
    httpd = http.server.HTTPServer(server_address, http.server.SimpleHTTPRequestHandler)

    # Создаём SSL-сертификат
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain(certfile=thisScriptPath + "cert.pem", keyfile=thisScriptPath + "key.pem")

    # Оборачиваем сокет в SSL
    httpd.socket = context.wrap_socket(httpd.socket, server_side=True)

    print("File Server started at https://" + server_address[0] + ":" + str(server_address[1]))
    httpd.serve_forever()

# запускаем скрипт
def main():
    try:
        generate_selfsigned_cert()
        # адрес и порт можно поменять
        startServer('localhost', 8000)
    except KeyboardInterrupt:
        print("\nFile Server Stopped!")
    except Exception as e:
        print(f"Error starting server: {e}")
# вызываем основную функцию
main()

We go further to the created address, and voila – the game is ready!

Well, we have gone from simple rules to a living organism on the screen. Beauty, isn't it? And this is just the tip of the iceberg of what truly beautiful code is capable of.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *