Browse Source

Merge flip_world from https://github.com/jblanked/FlipWorld

# Conflicts:
#	flip_world/callback/callback.c
Willy-JL 9 months ago
parent
commit
cb3d389e71
48 changed files with 4269 additions and 2218 deletions
  1. 9 3
      flip_world/README.md
  2. 25 52
      flip_world/alloc/alloc.c
  3. 4 1
      flip_world/alloc/alloc.h
  4. 20 5
      flip_world/app.c
  5. 1 1
      flip_world/application.fam
  6. BIN
      flip_world/assets/01-home.png
  7. 24 14
      flip_world/assets/CHANGELOG.md
  8. 10 4
      flip_world/assets/README.md
  9. BIN
      flip_world/assets/icon_alien_gun_10x10px.png
  10. BIN
      flip_world/assets/icon_axe_10x10px.png
  11. BIN
      flip_world/assets/icon_axe_16x16px.png
  12. BIN
      flip_world/assets/icon_axe_alt_10x10px.png
  13. BIN
      flip_world/assets/icon_axe_alt_16x16px.png
  14. BIN
      flip_world/assets/icon_bow_10x10px.png
  15. BIN
      flip_world/assets/icon_title_screen_128x64px.png
  16. 398 0
      flip_world/callback/alloc.c
  17. 9 0
      flip_world/callback/alloc.h
  18. 56 1256
      flip_world/callback/callback.c
  19. 17 53
      flip_world/callback/callback.h
  20. 192 0
      flip_world/callback/free.c
  21. 9 0
      flip_world/callback/free.h
  22. 1091 0
      flip_world/callback/game.c
  23. 18 0
      flip_world/callback/game.h
  24. 590 0
      flip_world/callback/loader.c
  25. 39 0
      flip_world/callback/loader.h
  26. 25 0
      flip_world/callback/utils.h
  27. 453 9
      flip_world/flip_world.c
  28. 21 11
      flip_world/flip_world.h
  29. 80 80
      flip_world/flipper_http/flipper_http.c
  30. 3 3
      flip_world/flipper_http/flipper_http.h
  31. 14 50
      flip_world/game/draw.c
  32. 3 8
      flip_world/game/draw.h
  33. 325 149
      flip_world/game/enemy.c
  34. 1 1
      flip_world/game/enemy.h
  35. 60 8
      flip_world/game/game.c
  36. 46 185
      flip_world/game/icon.c
  37. 38 6
      flip_world/game/icon.h
  38. 15 13
      flip_world/game/level.c
  39. 2 2
      flip_world/game/level.h
  40. 21 18
      flip_world/game/npc.c
  41. 1 1
      flip_world/game/npc.h
  42. 112 50
      flip_world/game/player.c
  43. 35 20
      flip_world/game/player.h
  44. 292 144
      flip_world/game/storage.c
  45. 3 1
      flip_world/game/storage.h
  46. 200 65
      flip_world/game/world.c
  47. 6 4
      flip_world/game/world.h
  48. 1 1
      flip_world/jsmn/jsmn_h.h

+ 9 - 3
flip_world/README.md

@@ -1,8 +1,14 @@
 # FlipWorld
 The first open-world multiplayer game for the Flipper Zero, best played with the VGM. Here's a video tutorial: https://www.youtube.com/watch?v=Qp7qmYMfdUA
 
+## Connect Online
+- Discord: https://discord.gg/5aN9qwkEc6
+- YouTube: https://www.youtube.com/@jblanked
+- Instagram: https://www.instagram.com/jblanked
+- Other: https://www.jblanked.com/social/
+
 ## Requirements
-- WiFi Developer Board, Raspberry Pi, or ESP32 device with the [FlipperHTTP flash](https://github.com/jblanked/FlipperHTTP).
+- WiFi Developer Board, Raspberry Pi, or ESP32 device flashed with [FlipperHTTP v1.8.2](https://github.com/jblanked/FlipperHTTP) or higher.
 - 2.4 GHz WiFi access point
 
 ## How It Works
@@ -21,7 +27,7 @@ FlipWorld and FlipSocial are connected. Your login information is the same in bo
 - **Press/Hold RIGHT**: Turn right if not already facing right, then walk right if the button is still pressed.
 - **Press/Hold UP**: Walk up.
 - **Press/Hold DOWN**: Walk down.
-- **Press OK**: Interact/Attack/Teleport (set to attack until all enemies are defeated and interact when colliding with NPCs)
+- **Press OK**: Interact, attack, or teleport. Attacks enemies when colliding with them until all enemies are defeated. Interacts with NPCs when colliding with them.
 - **HOLD OK**: In-Game Menu.
 - **Press BACK**: Leave the menu.
 - **HOLD BACK**: Exit the game.
@@ -85,7 +91,7 @@ NPCs are friendly characters that players can interact with. Currently, you can
 - New game features
 
 **v0.8**
-- Multiplayer support
+- Multiplayer support (PvP Beta)
 
 **v1.0**
 - Official release

+ 25 - 52
flip_world/alloc/alloc.c

@@ -1,15 +1,29 @@
 #include <alloc/alloc.h>
 #include <callback/callback.h>
+#include <callback/loader.h>
+#include <callback/free.h>
 
-/**
- * @brief Navigation callback for exiting the application
- * @param context The context - unused
- * @return next view id (VIEW_NONE to exit the app)
- */
-static uint32_t callback_exit_app(void *context)
+uint32_t callback_exit_app(void *context)
 {
     UNUSED(context);
-    return VIEW_NONE; // Return VIEW_NONE to exit the app
+    return VIEW_NONE;
+}
+
+uint32_t callback_to_submenu(void *context)
+{
+    UNUSED(context);
+    return FlipWorldViewSubmenu;
+}
+
+uint32_t callback_to_wifi_settings(void *context)
+{
+    UNUSED(context);
+    return FlipWorldViewVariableItemList;
+}
+uint32_t callback_to_settings(void *context)
+{
+    UNUSED(context);
+    return FlipWorldViewSubmenuOther;
 }
 
 void *global_app;
@@ -35,34 +49,16 @@ FlipWorldApp *flip_world_app_alloc()
     {
         return NULL;
     }
-    view_dispatcher_set_custom_event_callback(app->view_dispatcher, custom_event_callback);
-    // Main view
-    if (!easy_flipper_set_view(&app->view_loader, FlipWorldViewLoader, loader_draw_callback, NULL, callback_to_submenu, &app->view_dispatcher, app))
-    {
-        return NULL;
-    }
-    loader_init(app->view_loader);
-    if (!easy_flipper_set_widget(&app->widget_result, FlipWorldViewWidgetResult, "", callback_to_submenu, &app->view_dispatcher))
-    {
-        return NULL;
-    }
 
     // Submenu
     if (!easy_flipper_set_submenu(&app->submenu, FlipWorldViewSubmenu, VERSION_TAG, callback_exit_app, &app->view_dispatcher))
     {
         return NULL;
     }
-    if (!easy_flipper_set_submenu(&app->submenu_game, FlipWorldViewGameSubmenu, "Play", callback_to_submenu, &app->view_dispatcher))
-    {
-        return NULL;
-    }
+
     submenu_add_item(app->submenu, "Play", FlipWorldSubmenuIndexGameSubmenu, callback_submenu_choices, app);
     submenu_add_item(app->submenu, "About", FlipWorldSubmenuIndexMessage, callback_submenu_choices, app);
     submenu_add_item(app->submenu, "Settings", FlipWorldSubmenuIndexSettings, callback_submenu_choices, app);
-    //
-    submenu_add_item(app->submenu_game, "Tutorial", FlipWorldSubmenuIndexStory, callback_submenu_choices, app);
-    submenu_add_item(app->submenu_game, "PvP", FlipWorldSubmenuIndexPvP, callback_submenu_choices, app);
-    submenu_add_item(app->submenu_game, "PvE", FlipWorldSubmenuIndexPvE, callback_submenu_choices, app);
 
     // Switch to the main view
     view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
@@ -73,11 +69,7 @@ FlipWorldApp *flip_world_app_alloc()
 // Function to free the resources used by FlipWorldApp
 void flip_world_app_free(FlipWorldApp *app)
 {
-    if (!app)
-    {
-        FURI_LOG_E(TAG, "FlipWorldApp is NULL");
-        return;
-    }
+    furi_check(app, "FlipWorldApp is NULL");
 
     // Free Submenu(s)
     if (app->submenu)
@@ -85,27 +77,8 @@ void flip_world_app_free(FlipWorldApp *app)
         view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewSubmenu);
         submenu_free(app->submenu);
     }
-    if (app->submenu_game)
-    {
-        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewGameSubmenu);
-        submenu_free(app->submenu_game);
-    }
-    // Free Widget(s)
-    if (app->widget_result)
-    {
-        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewWidgetResult);
-        widget_free(app->widget_result);
-    }
 
-    // Free View(s)
-    if (app->view_loader)
-    {
-        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewLoader);
-        loader_free_model(app->view_loader);
-        view_free(app->view_loader);
-    }
-
-    free_all_views(app, true, true);
+    free_all_views(app, true, true, true);
 
     // free the view dispatcher
     view_dispatcher_free(app->view_dispatcher);
@@ -116,4 +89,4 @@ void flip_world_app_free(FlipWorldApp *app)
     // free the app
     if (app)
         free(app);
-}
+}

+ 4 - 1
flip_world/alloc/alloc.h

@@ -1,6 +1,9 @@
 #pragma once
 #include <flip_world.h>
-
 FlipWorldApp *flip_world_app_alloc();
 void flip_world_app_free(FlipWorldApp *app);
 void flip_world_show_submenu();
+uint32_t callback_exit_app(void *context);
+uint32_t callback_to_submenu(void *context);
+uint32_t callback_to_wifi_settings(void *context);
+uint32_t callback_to_settings(void *context);

+ 20 - 5
flip_world/app.c

@@ -4,6 +4,13 @@
 // Entry point for the FlipWorld application
 int32_t flip_world_main(void *p)
 {
+    // check memory
+    if (!is_enough_heap(sizeof(FlipWorldApp) + sizeof(FlipperHTTP), true))
+    {
+        easy_flipper_dialog("Memory Error", "Not enough heap memory.\nPlease restart your Flipper Zero.");
+        return 0; // return success so the user can see the error
+    }
+
     // Suppress unused parameter warning
     UNUSED(p);
 
@@ -36,17 +43,25 @@ int32_t flip_world_main(void *p)
     while (fhttp->state == INACTIVE && --counter > 0)
     {
         FURI_LOG_D(TAG, "Waiting for PONG");
-        furi_delay_ms(100); // this causes a BusFault
+        furi_delay_ms(100);
     }
-    flipper_http_free(fhttp);
 
     if (counter == 0)
         easy_flipper_dialog("FlipperHTTP Error", "Ensure your WiFi Developer\nBoard or Pico W is connected\nand the latest FlipperHTTP\nfirmware is installed.");
 
     // save app version
-    char app_version[16];
-    snprintf(app_version, sizeof(app_version), "%f", (double)VERSION);
-    save_char("app_version", app_version);
+    // char app_version[16];
+    // snprintf(app_version, sizeof(app_version), "%f", (double)VERSION);
+    save_char("app_version", VERSION);
+
+    // for now use the catalog API until I implement caching on the server
+
+    if (flip_world_handle_app_update(fhttp, true))
+    {
+        easy_flipper_dialog("Update Status", "Complete.\nRestart your Flipper Zero.");
+    }
+
+    flipper_http_free(fhttp);
 
     // Run the view dispatcher
     view_dispatcher_run(app->view_dispatcher);

+ 1 - 1
flip_world/application.fam

@@ -17,5 +17,5 @@ App(
     ),
     fap_author="JBlanked",
     fap_weburl="https://github.com/jblanked/FlipWorld",
-    fap_version="0.7",
+    fap_version="0.8.1",
 )

BIN
flip_world/assets/01-home.png


+ 24 - 14
flip_world/assets/CHANGELOG.md

@@ -1,45 +1,55 @@
+## 0.8.1 (2025-04-09)
+- Improved memory allocation, saving 26 KB.
+- Added auto-updating (currently uses the catalog API).
+- Major code refactoring.
+
+## 0.8 (2025-04-05)
+- Improved memory allocation.
+- Added multiplayer support.
+- Updated the default settings for vibration and sound.
+
 ## 0.7 (2025-03-21)
 - Sped up player movement.
-- Added a Tutorial mode.
-- Fixed transition of worlds.
+- Added tutorial mode.
+- Fixed issues with transitioning between worlds.
 
 ## 0.6.1 (2025-03-15)
-- Switched the server backend to prepare for multiplayer in version 0.8.
+- Switched the server backend in preparation for multiplayer features introduced in version 0.8.
 
 ## 0.6 (2025-03-10)
-- Fixed saving of player attributes so that it works as intended.
-- Updated the player's level and strength as XP increases.
+- Fixed saving of player attributes to ensure they are stored as intended.
+- Updated the player's level and strength progression with XP increases.
 - Started implementing multiplayer (requires FlipperHTTP v1.7).
 - Fixed the display of user stats when switching worlds.
 
 ## 0.5 (2025-01-31)
-- Fixed saving errors.
+- Fixed errors with saving.
 - Improved memory allocation.
 - Added NPCs.
 
 ## 0.4 (2025-01-23)
-- Added an In-Game menu.
-- Added new controls (HOLD OK to access the In-Game menu, PRESS BACK to exit the menu, and HOLD BACK to leave the game).
-- Added option to choose player's weapon in the Game Settings.
-- Added transition icon for switching worlds.
+- Added an in-game menu.
+- Added new controls: hold OK to access the in-game menu, press BACK to exit the menu, and hold BACK to exit the game.
+- Added an option to choose the player's weapon in the game settings.
+- Added a transition icon for switching worlds.
 - Doubled the size of each world (from 384x192 to 768x384).
 - Improved memory allocation.
 
 ## 0.3 (2025-01-14)
 - Added new worlds.
 - Improved memory allocation.
-- Updated API integration to load and save player attributes.
+- Updated API integration for loading and saving player attributes.
 - Upgraded FlipperHTTP to the latest version.
 
 ## 0.2 (2025-01-02)
-- Added support for the Video Game Module (requires a FlipperHTTP flash).
+- Added support for the Video Game Module (requires FlipperHTTP flash).
 - Introduced various enemy types to enhance gameplay.
 - Added features for player health, XP, level, health regeneration, attack, and strength.
 - Implemented vibration, sound, and LED notifications when a player is attacking or being attacked.
-- Displayed the player's username above their character and showed the player's health, XP, and level in the bottom left corner of the screen at all times.
+- Displayed the player's username above the character and continuously showed health, XP, and level in the bottom-left corner of the screen.
 - Updated all game icons for improved visual appeal.
 - Upgraded to the latest version of the FlipperHTTP library.
-- Revised toggles in the Game Settings to ensure they work as intended.
+- Revised toggles in the game settings to ensure they work as intended.
 - Improved collision mechanics for more accurate interactions.
 - Updated the default icon representing the player's character.
 

+ 10 - 4
flip_world/assets/README.md

@@ -1,7 +1,13 @@
 The first open-world multiplayer game for the Flipper Zero, best played with the VGM. Here's a video tutorial: https://www.youtube.com/watch?v=Qp7qmYMfdUA
 
+## Connect Online
+- Discord: https://discord.gg/5aN9qwkEc6
+- YouTube: https://www.youtube.com/@jblanked
+- Instagram: https://www.instagram.com/jblanked
+- Other: https://www.jblanked.com/social/
+
 ## Requirements
-- WiFi Developer Board, Raspberry Pi, or ESP32 device with the FlipperHTTP flash: https://github.com/jblanked/FlipperHTTP
+- WiFi Developer Board, Raspberry Pi, or ESP32 device flashed with FlipperHTTP v1.8.2 or higher: https://github.com/jblanked/FlipperHTTP
 - 2.4 GHz WiFi access point
 
 ## How It Works
@@ -20,7 +26,7 @@ FlipWorld and FlipSocial are connected. Your login information is the same in bo
 - **Press/Hold RIGHT**: Turn right if not already facing right, then walk right if the button is still pressed.
 - **Press/Hold UP**: Walk up.
 - **Press/Hold DOWN**: Walk down.
-- **Press OK**: Interact/Attack/Teleport (set to attack until all enemies are defeated and interact when colliding with NPCs)
+- **Press OK**: Interact, attack, or teleport. Attacks enemies when colliding with them until all enemies are defeated. Interacts with NPCs when colliding with them.
 - **HOLD OK**: In-Game Menu.
 - **Press BACK**: Leave the menu.
 - **HOLD BACK**: Exit the game.
@@ -57,7 +63,7 @@ NPCs are friendly characters that players can interact with. Currently, you can
 3. Restart your Flipper Zero, then open FlipWorld.
 4. Click "Settings -> WiFi", then input your WiFi SSID and password.
 5. Hit the "BACK" button, click "User". If your username is not present, click "Username" and add one. Do the same for the password field.
-6. Go back to the main menu and hit "Play". It will register an account if necessary and fetch data from our API that's used to render our graphics.
+6. Go back to the main menu and hit "Play", followed by "Tutorial". It will register an account if necessary and fetch data from our API that's used to render our graphics.
 
 ## Roadmap
 
@@ -84,7 +90,7 @@ NPCs are friendly characters that players can interact with. Currently, you can
 - New game features
 
 **v0.8**
-- Multiplayer support
+- Multiplayer support (PvP Beta)
 
 **v1.0**
 - Official release

BIN
flip_world/assets/icon_alien_gun_10x10px.png


BIN
flip_world/assets/icon_axe_10x10px.png


BIN
flip_world/assets/icon_axe_16x16px.png


BIN
flip_world/assets/icon_axe_alt_10x10px.png


BIN
flip_world/assets/icon_axe_alt_16x16px.png


BIN
flip_world/assets/icon_bow_10x10px.png


BIN
flip_world/assets/icon_title_screen_128x64px.png


+ 398 - 0
flip_world/callback/alloc.c

@@ -0,0 +1,398 @@
+#include "callback/alloc.h"
+#include "alloc/alloc.h"
+#include "callback/callback.h"
+#include <flip_storage/storage.h>
+
+bool alloc_message_view(void *context, MessageState state)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->view_message)
+    {
+        FURI_LOG_E(TAG, "Message view already allocated");
+        return false;
+    }
+    switch (state)
+    {
+    case MessageStateAbout:
+        easy_flipper_set_view(&app->view_message, FlipWorldViewMessage, callback_message_draw, NULL, callback_to_submenu, &app->view_dispatcher, app);
+        break;
+    case MessageStateLoading:
+        easy_flipper_set_view(&app->view_message, FlipWorldViewMessage, callback_message_draw, NULL, NULL, &app->view_dispatcher, app);
+        break;
+    case MessageStateWaitingLobby:
+        easy_flipper_set_view(&app->view_message, FlipWorldViewMessage, callback_message_draw, callback_message_input, NULL, &app->view_dispatcher, app);
+        break;
+    }
+    if (!app->view_message)
+    {
+        FURI_LOG_E(TAG, "Failed to allocate message view");
+        return false;
+    }
+    view_allocate_model(app->view_message, ViewModelTypeLockFree, sizeof(MessageModel));
+    MessageModel *model = view_get_model(app->view_message);
+    model->message_state = state;
+    return true;
+}
+
+bool alloc_text_input_view(void *context, char *title)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (!title)
+    {
+        FURI_LOG_E(TAG, "Title is NULL");
+        return false;
+    }
+    app->text_input_buffer_size = 64;
+    if (!app->text_input_buffer)
+    {
+        if (!easy_flipper_set_buffer(&app->text_input_buffer, app->text_input_buffer_size))
+        {
+            return false;
+        }
+    }
+    if (!app->text_input_temp_buffer)
+    {
+        if (!easy_flipper_set_buffer(&app->text_input_temp_buffer, app->text_input_buffer_size))
+        {
+            return false;
+        }
+    }
+    if (!app->text_input)
+    {
+        if (!easy_flipper_set_uart_text_input(
+                &app->text_input,
+                FlipWorldViewTextInput,
+                title,
+                app->text_input_temp_buffer,
+                app->text_input_buffer_size,
+                is_str(title, "SSID") ? callback_updated_wifi_ssid : is_str(title, "Password")     ? callback_updated_wifi_pass
+                                                                 : is_str(title, "Username-Login") ? callback_updated_username
+                                                                                                   : callback_updated_password,
+                callback_to_wifi_settings,
+                &app->view_dispatcher,
+                app))
+        {
+            return false;
+        }
+        if (!app->text_input)
+        {
+            return false;
+        }
+        char ssid[64];
+        char pass[64];
+        char username[64];
+        char password[64];
+        if (load_settings(ssid, sizeof(ssid), pass, sizeof(pass), username, sizeof(username), password, sizeof(password)))
+        {
+            if (is_str(title, "SSID"))
+            {
+                strncpy(app->text_input_temp_buffer, ssid, app->text_input_buffer_size);
+            }
+            else if (is_str(title, "Password"))
+            {
+                strncpy(app->text_input_temp_buffer, pass, app->text_input_buffer_size);
+            }
+            else if (is_str(title, "Username-Login"))
+            {
+                strncpy(app->text_input_temp_buffer, username, app->text_input_buffer_size);
+            }
+            else if (is_str(title, "Password-Login"))
+            {
+                strncpy(app->text_input_temp_buffer, password, app->text_input_buffer_size);
+            }
+        }
+    }
+    return true;
+}
+bool alloc_variable_item_list(void *context, uint32_t view_id)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app, "FlipWorldApp is NULL");
+    char ssid[64];
+    char pass[64];
+    char username[64];
+    char password[64];
+    if (!app->variable_item_list)
+    {
+        switch (view_id)
+        {
+        case FlipWorldSubmenuIndexWiFiSettings:
+            if (!easy_flipper_set_variable_item_list(&app->variable_item_list, FlipWorldViewVariableItemList, callback_wifi_settings_select, callback_to_settings, &app->view_dispatcher, app))
+            {
+                FURI_LOG_E(TAG, "Failed to allocate variable item list");
+                return false;
+            }
+
+            if (!app->variable_item_list)
+            {
+                FURI_LOG_E(TAG, "Variable item list is NULL");
+                return false;
+            }
+
+            if (!app->variable_item_wifi_ssid)
+            {
+                app->variable_item_wifi_ssid = variable_item_list_add(app->variable_item_list, "SSID", 0, NULL, NULL);
+                variable_item_set_current_value_text(app->variable_item_wifi_ssid, "");
+            }
+            if (!app->variable_item_wifi_pass)
+            {
+                app->variable_item_wifi_pass = variable_item_list_add(app->variable_item_list, "Password", 0, NULL, NULL);
+                variable_item_set_current_value_text(app->variable_item_wifi_pass, "");
+            }
+            if (load_settings(ssid, sizeof(ssid), pass, sizeof(pass), username, sizeof(username), password, sizeof(password)))
+            {
+                variable_item_set_current_value_text(app->variable_item_wifi_ssid, ssid);
+                // variable_item_set_current_value_text(app->variable_item_wifi_pass, pass);
+                save_char("WiFi-SSID", ssid);
+                save_char("WiFi-Password", pass);
+                save_char("Flip-Social-Username", username);
+                save_char("Flip-Social-Password", password);
+            }
+            break;
+        case FlipWorldSubmenuIndexGameSettings:
+            if (!easy_flipper_set_variable_item_list(&app->variable_item_list, FlipWorldViewVariableItemList, callback_game_settings_select, callback_to_settings, &app->view_dispatcher, app))
+            {
+                FURI_LOG_E(TAG, "Failed to allocate variable item list");
+                return false;
+            }
+
+            if (!app->variable_item_list)
+            {
+                FURI_LOG_E(TAG, "Variable item list is NULL");
+                return false;
+            }
+
+            if (!app->variable_item_game_download_world)
+            {
+                app->variable_item_game_download_world = variable_item_list_add(app->variable_item_list, "Install Official World Pack", 0, NULL, NULL);
+                variable_item_set_current_value_text(app->variable_item_game_download_world, "");
+            }
+            if (!app->variable_item_game_player_sprite)
+            {
+                app->variable_item_game_player_sprite = variable_item_list_add(app->variable_item_list, "Weapon", 4, callback_player_on_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_player_sprite, 1);
+                variable_item_set_current_value_text(app->variable_item_game_player_sprite, player_sprite_choices[1]);
+            }
+            if (!app->variable_item_game_fps)
+            {
+                app->variable_item_game_fps = variable_item_list_add(app->variable_item_list, "FPS", 4, callback_fps_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_fps, 0);
+                variable_item_set_current_value_text(app->variable_item_game_fps, fps_choices_str[0]);
+            }
+            if (!app->variable_item_game_vgm_x)
+            {
+                app->variable_item_game_vgm_x = variable_item_list_add(app->variable_item_list, "VGM Horizontal", 12, callback_vgm_x_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_vgm_x, 2);
+                variable_item_set_current_value_text(app->variable_item_game_vgm_x, vgm_levels[2]);
+            }
+            if (!app->variable_item_game_vgm_y)
+            {
+                app->variable_item_game_vgm_y = variable_item_list_add(app->variable_item_list, "VGM Vertical", 12, callback_vgm_y_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_vgm_y, 2);
+                variable_item_set_current_value_text(app->variable_item_game_vgm_y, vgm_levels[2]);
+            }
+            if (!app->variable_item_game_screen_always_on)
+            {
+                app->variable_item_game_screen_always_on = variable_item_list_add(app->variable_item_list, "Keep Screen On?", 2, callback_screen_on_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_screen_always_on, 1);
+                variable_item_set_current_value_text(app->variable_item_game_screen_always_on, yes_or_no_choices[1]);
+            }
+            if (!app->variable_item_game_sound_on)
+            {
+                app->variable_item_game_sound_on = variable_item_list_add(app->variable_item_list, "Sound On?", 2, callback_sound_on_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_sound_on, 0);
+                variable_item_set_current_value_text(app->variable_item_game_sound_on, yes_or_no_choices[0]);
+            }
+            if (!app->variable_item_game_vibration_on)
+            {
+                app->variable_item_game_vibration_on = variable_item_list_add(app->variable_item_list, "Vibration On?", 2, callback_vibration_on_change, NULL);
+                variable_item_set_current_value_index(app->variable_item_game_vibration_on, 0);
+                variable_item_set_current_value_text(app->variable_item_game_vibration_on, yes_or_no_choices[0]);
+            }
+            char _game_player_sprite[8];
+            if (load_char("Game-Player-Sprite", _game_player_sprite, sizeof(_game_player_sprite)))
+            {
+                int index = is_str(_game_player_sprite, "naked") ? 0 : is_str(_game_player_sprite, "sword") ? 1
+                                                                   : is_str(_game_player_sprite, "axe")     ? 2
+                                                                   : is_str(_game_player_sprite, "bow")     ? 3
+                                                                                                            : 0;
+                variable_item_set_current_value_index(app->variable_item_game_player_sprite, index);
+                variable_item_set_current_value_text(
+                    app->variable_item_game_player_sprite,
+                    is_str(player_sprite_choices[index], "naked") ? "None" : player_sprite_choices[index]);
+            }
+            char _game_fps[8];
+            if (load_char("Game-FPS", _game_fps, sizeof(_game_fps)))
+            {
+                int index = is_str(_game_fps, "30") ? 0 : is_str(_game_fps, "60") ? 1
+                                                      : is_str(_game_fps, "120")  ? 2
+                                                      : is_str(_game_fps, "240")  ? 3
+                                                                                  : 0;
+                variable_item_set_current_value_text(app->variable_item_game_fps, fps_choices_str[index]);
+                variable_item_set_current_value_index(app->variable_item_game_fps, index);
+            }
+            char _game_vgm_x[8];
+            if (load_char("Game-VGM-X", _game_vgm_x, sizeof(_game_vgm_x)))
+            {
+                int vgm_x = atoi(_game_vgm_x);
+                int index = vgm_x == -2 ? 0 : vgm_x == -1 ? 1
+                                          : vgm_x == 0    ? 2
+                                          : vgm_x == 1    ? 3
+                                          : vgm_x == 2    ? 4
+                                          : vgm_x == 3    ? 5
+                                          : vgm_x == 4    ? 6
+                                          : vgm_x == 5    ? 7
+                                          : vgm_x == 6    ? 8
+                                          : vgm_x == 7    ? 9
+                                          : vgm_x == 8    ? 10
+                                          : vgm_x == 9    ? 11
+                                          : vgm_x == 10   ? 12
+                                                          : 2;
+                variable_item_set_current_value_index(app->variable_item_game_vgm_x, index);
+                variable_item_set_current_value_text(app->variable_item_game_vgm_x, vgm_levels[index]);
+            }
+            char _game_vgm_y[8];
+            if (load_char("Game-VGM-Y", _game_vgm_y, sizeof(_game_vgm_y)))
+            {
+                int vgm_y = atoi(_game_vgm_y);
+                int index = vgm_y == -2 ? 0 : vgm_y == -1 ? 1
+                                          : vgm_y == 0    ? 2
+                                          : vgm_y == 1    ? 3
+                                          : vgm_y == 2    ? 4
+                                          : vgm_y == 3    ? 5
+                                          : vgm_y == 4    ? 6
+                                          : vgm_y == 5    ? 7
+                                          : vgm_y == 6    ? 8
+                                          : vgm_y == 7    ? 9
+                                          : vgm_y == 8    ? 10
+                                          : vgm_y == 9    ? 11
+                                          : vgm_y == 10   ? 12
+                                                          : 2;
+                variable_item_set_current_value_index(app->variable_item_game_vgm_y, index);
+                variable_item_set_current_value_text(app->variable_item_game_vgm_y, vgm_levels[index]);
+            }
+            char _game_screen_always_on[8];
+            if (load_char("Game-Screen-Always-On", _game_screen_always_on, sizeof(_game_screen_always_on)))
+            {
+                int index = is_str(_game_screen_always_on, "No") ? 0 : is_str(_game_screen_always_on, "Yes") ? 1
+                                                                                                             : 0;
+                variable_item_set_current_value_text(app->variable_item_game_screen_always_on, yes_or_no_choices[index]);
+                variable_item_set_current_value_index(app->variable_item_game_screen_always_on, index);
+            }
+            char _game_sound_on[8];
+            if (load_char("Game-Sound-On", _game_sound_on, sizeof(_game_sound_on)))
+            {
+                int index = is_str(_game_sound_on, "No") ? 0 : is_str(_game_sound_on, "Yes") ? 1
+                                                                                             : 0;
+                variable_item_set_current_value_text(app->variable_item_game_sound_on, yes_or_no_choices[index]);
+                variable_item_set_current_value_index(app->variable_item_game_sound_on, index);
+            }
+            char _game_vibration_on[8];
+            if (load_char("Game-Vibration-On", _game_vibration_on, sizeof(_game_vibration_on)))
+            {
+                int index = is_str(_game_vibration_on, "No") ? 0 : is_str(_game_vibration_on, "Yes") ? 1
+                                                                                                     : 0;
+                variable_item_set_current_value_text(app->variable_item_game_vibration_on, yes_or_no_choices[index]);
+                variable_item_set_current_value_index(app->variable_item_game_vibration_on, index);
+            }
+            break;
+        case FlipWorldSubmenuIndexUserSettings:
+            if (!easy_flipper_set_variable_item_list(&app->variable_item_list, FlipWorldViewVariableItemList, callback_user_settings_select, callback_to_settings, &app->view_dispatcher, app))
+            {
+                FURI_LOG_E(TAG, "Failed to allocate variable item list");
+                return false;
+            }
+
+            if (!app->variable_item_list)
+            {
+                FURI_LOG_E(TAG, "Variable item list is NULL");
+                return false;
+            }
+
+            // if logged in, show profile info, otherwise show login/register
+            if (is_logged_in() || is_logged_in_to_flip_social())
+            {
+                if (!app->variable_item_user_username)
+                {
+                    app->variable_item_user_username = variable_item_list_add(app->variable_item_list, "Username", 0, NULL, NULL);
+                    variable_item_set_current_value_text(app->variable_item_user_username, "");
+                }
+                if (!app->variable_item_user_password)
+                {
+                    app->variable_item_user_password = variable_item_list_add(app->variable_item_list, "Password", 0, NULL, NULL);
+                    variable_item_set_current_value_text(app->variable_item_user_password, "");
+                }
+                if (load_settings(ssid, sizeof(ssid), pass, sizeof(pass), username, sizeof(username), password, sizeof(password)))
+                {
+                    variable_item_set_current_value_text(app->variable_item_user_username, username);
+                    variable_item_set_current_value_text(app->variable_item_user_password, "*****");
+                }
+            }
+            else
+            {
+                if (!app->variable_item_user_username)
+                {
+                    app->variable_item_user_username = variable_item_list_add(app->variable_item_list, "Username", 0, NULL, NULL);
+                    variable_item_set_current_value_text(app->variable_item_user_username, "");
+                }
+                if (!app->variable_item_user_password)
+                {
+                    app->variable_item_user_password = variable_item_list_add(app->variable_item_list, "Password", 0, NULL, NULL);
+                    variable_item_set_current_value_text(app->variable_item_user_password, "");
+                }
+            }
+            break;
+        }
+    }
+    return true;
+}
+bool alloc_submenu_other(void *context, uint32_t view_id)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->submenu_other)
+    {
+        FURI_LOG_I(TAG, "Submenu already allocated");
+        return true;
+    }
+    switch (view_id)
+    {
+    case FlipWorldViewSettings:
+        if (!easy_flipper_set_submenu(&app->submenu_other, FlipWorldViewSubmenuOther, "Settings", callback_to_submenu, &app->view_dispatcher))
+        {
+            FURI_LOG_E(TAG, "Failed to allocate submenu settings");
+            return false;
+        }
+        submenu_add_item(app->submenu_other, "WiFi", FlipWorldSubmenuIndexWiFiSettings, callback_submenu_choices, app);
+        submenu_add_item(app->submenu_other, "Game", FlipWorldSubmenuIndexGameSettings, callback_submenu_choices, app);
+        submenu_add_item(app->submenu_other, "User", FlipWorldSubmenuIndexUserSettings, callback_submenu_choices, app);
+        return true;
+    case FlipWorldViewLobby:
+        return easy_flipper_set_submenu(&app->submenu_other, FlipWorldViewSubmenuOther, "Lobbies", callback_to_submenu, &app->view_dispatcher);
+    default:
+        return false;
+    }
+}
+
+bool alloc_game_submenu(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (!app->submenu_game)
+    {
+        if (!easy_flipper_set_submenu(&app->submenu_game, FlipWorldViewGameSubmenu, "Play", callback_to_submenu, &app->view_dispatcher))
+        {
+            return false;
+        }
+        if (!app->submenu_game)
+        {
+            return false;
+        }
+        submenu_add_item(app->submenu_game, "Tutorial", FlipWorldSubmenuIndexStory, callback_submenu_choices, app);
+        submenu_add_item(app->submenu_game, "PvP (Beta)", FlipWorldSubmenuIndexPvP, callback_submenu_choices, app);
+        submenu_add_item(app->submenu_game, "PvE", FlipWorldSubmenuIndexPvE, callback_submenu_choices, app);
+    }
+    return true;
+}

+ 9 - 0
flip_world/callback/alloc.h

@@ -0,0 +1,9 @@
+#pragma once
+#include <flip_world.h>
+#include <callback/utils.h>
+
+bool alloc_message_view(void *context, MessageState state);
+bool alloc_text_input_view(void *context, char *title);
+bool alloc_variable_item_list(void *context, uint32_t view_id);
+bool alloc_submenu_other(void *context, uint32_t view_id);
+bool alloc_game_submenu(void *context);

File diff suppressed because it is too large
+ 56 - 1256
flip_world/callback/callback.c


+ 17 - 53
flip_world/callback/callback.h

@@ -1,57 +1,21 @@
 #pragma once
 #include <flip_world.h>
-#include <flip_storage/storage.h>
 
-void free_all_views(void *context, bool should_free_variable_item_list, bool should_free_submenu_settings);
 void callback_submenu_choices(void *context, uint32_t index);
-uint32_t callback_to_submenu(void *context);
-
-// Add edits by Derek Jamison
-typedef enum DataState DataState;
-enum DataState
-{
-    DataStateInitial,
-    DataStateRequested,
-    DataStateReceived,
-    DataStateParsed,
-    DataStateParseError,
-    DataStateError,
-};
-
-typedef enum MessageState MessageState;
-enum MessageState
-{
-    MessageStateAbout,
-    MessageStateLoading,
-};
-typedef struct MessageModel MessageModel;
-struct MessageModel
-{
-    MessageState message_state;
-};
-
-typedef struct DataLoaderModel DataLoaderModel;
-typedef bool (*DataLoaderFetch)(DataLoaderModel *model);
-typedef char *(*DataLoaderParser)(DataLoaderModel *model);
-struct DataLoaderModel
-{
-    char *title;
-    char *data_text;
-    DataState data_state;
-    DataLoaderFetch fetcher;
-    DataLoaderParser parser;
-    void *parser_context;
-    size_t request_index;
-    size_t request_count;
-    ViewNavigationCallback back_callback;
-    FuriTimer *timer;
-    FlipperHTTP *fhttp;
-};
-void generic_switch_to_view(FlipWorldApp *app, char *title, DataLoaderFetch fetcher, DataLoaderParser parser, size_t request_count, ViewNavigationCallback back, uint32_t view_id);
-
-void loader_draw_callback(Canvas *canvas, void *model);
-
-void loader_init(View *view);
-
-void loader_free_model(View *view);
-bool custom_event_callback(void *context, uint32_t index);
+bool callback_message_input(InputEvent *event, void *context);
+void callback_message_draw(Canvas *canvas, void *model);
+void callback_wifi_settings_select(void *context, uint32_t index);
+void callback_updated_wifi_ssid(void *context);
+void callback_updated_wifi_pass(void *context);
+void callback_updated_username(void *context);
+void callback_updated_password(void *context);
+void callback_fps_change(VariableItem *item);
+void callback_game_settings_select(void *context, uint32_t index);
+void callback_user_settings_select(void *context, uint32_t index);
+void callback_screen_on_change(VariableItem *item);
+void callback_sound_on_change(VariableItem *item);
+void callback_vibration_on_change(VariableItem *item);
+void callback_player_on_change(VariableItem *item);
+void callback_vgm_x_change(VariableItem *item);
+void callback_vgm_y_change(VariableItem *item);
+void callback_submenu_lobby_choices(void *context, uint32_t index);

+ 192 - 0
flip_world/callback/free.c

@@ -0,0 +1,192 @@
+#include "callback/free.h"
+#include "callback/loader.h"
+#include "callback/game.h"
+
+void free_game_submenu(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->submenu_game)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewGameSubmenu);
+        submenu_free(app->submenu_game);
+        app->submenu_game = NULL;
+    }
+}
+
+void free_submenu_other(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->submenu_other)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+        submenu_free(app->submenu_other);
+        app->submenu_other = NULL;
+    }
+    for (int i = 0; i < 10; i++)
+    {
+        if (lobby_list[i])
+        {
+            free(lobby_list[i]);
+            lobby_list[i] = NULL;
+        }
+    }
+}
+
+void free_message_view(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->view_message)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewMessage);
+        view_free(app->view_message);
+        app->view_message = NULL;
+    }
+}
+
+void free_text_input_view(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->text_input)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewTextInput);
+        uart_text_input_free(app->text_input);
+        app->text_input = NULL;
+    }
+    if (app->text_input_buffer)
+    {
+        free(app->text_input_buffer);
+        app->text_input_buffer = NULL;
+    }
+    if (app->text_input_temp_buffer)
+    {
+        free(app->text_input_temp_buffer);
+        app->text_input_temp_buffer = NULL;
+    }
+}
+
+void free_variable_item_list(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (app->variable_item_list)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewVariableItemList);
+        variable_item_list_free(app->variable_item_list);
+        app->variable_item_list = NULL;
+    }
+    if (app->variable_item_wifi_ssid)
+    {
+        free(app->variable_item_wifi_ssid);
+        app->variable_item_wifi_ssid = NULL;
+    }
+    if (app->variable_item_wifi_pass)
+    {
+        free(app->variable_item_wifi_pass);
+        app->variable_item_wifi_pass = NULL;
+    }
+    if (app->variable_item_game_fps)
+    {
+        free(app->variable_item_game_fps);
+        app->variable_item_game_fps = NULL;
+    }
+    if (app->variable_item_game_screen_always_on)
+    {
+        free(app->variable_item_game_screen_always_on);
+        app->variable_item_game_screen_always_on = NULL;
+    }
+    if (app->variable_item_game_download_world)
+    {
+        free(app->variable_item_game_download_world);
+        app->variable_item_game_download_world = NULL;
+    }
+    if (app->variable_item_game_sound_on)
+    {
+        free(app->variable_item_game_sound_on);
+        app->variable_item_game_sound_on = NULL;
+    }
+    if (app->variable_item_game_vibration_on)
+    {
+        free(app->variable_item_game_vibration_on);
+        app->variable_item_game_vibration_on = NULL;
+    }
+    if (app->variable_item_game_player_sprite)
+    {
+        free(app->variable_item_game_player_sprite);
+        app->variable_item_game_player_sprite = NULL;
+    }
+    if (app->variable_item_game_vgm_x)
+    {
+        free(app->variable_item_game_vgm_x);
+        app->variable_item_game_vgm_x = NULL;
+    }
+    if (app->variable_item_game_vgm_y)
+    {
+        free(app->variable_item_game_vgm_y);
+        app->variable_item_game_vgm_y = NULL;
+    }
+    if (app->variable_item_user_username)
+    {
+        free(app->variable_item_user_username);
+        app->variable_item_user_username = NULL;
+    }
+    if (app->variable_item_user_password)
+    {
+        free(app->variable_item_user_password);
+        app->variable_item_user_password = NULL;
+    }
+}
+
+void free_all_views(void *context, bool free_variable_list, bool free_settings_other, bool free_submenu_game)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    if (free_variable_list)
+    {
+        free_variable_item_list(app);
+    }
+    free_message_view(app);
+    free_text_input_view(app);
+
+    // free game thread
+    if (game_thread_running)
+    {
+        game_thread_running = false;
+        if (game_thread)
+        {
+            furi_thread_flags_set(furi_thread_get_id(game_thread), WorkerEvtStop);
+            furi_thread_join(game_thread);
+            furi_thread_free(game_thread);
+            game_thread = NULL;
+        }
+    }
+
+    if (free_settings_other)
+    {
+        free_submenu_other(app);
+    }
+
+    // free Derek's loader
+    loader_view_free(app);
+
+    if (free_submenu_game)
+    {
+        free_game_submenu(app);
+    }
+
+    // free waiting thread
+    if (waiting_thread_running)
+    {
+        waiting_thread_running = false;
+        if (waiting_thread)
+        {
+            furi_thread_flags_set(furi_thread_get_id(waiting_thread), WorkerEvtStop);
+            furi_thread_join(waiting_thread);
+            furi_thread_free(waiting_thread);
+            waiting_thread = NULL;
+        }
+    }
+}

+ 9 - 0
flip_world/callback/free.h

@@ -0,0 +1,9 @@
+#pragma once
+#include <flip_world.h>
+
+void free_game_submenu(void *context);
+void free_submenu_other(void *context);
+void free_message_view(void *context);
+void free_text_input_view(void *context);
+void free_variable_item_list(void *context);
+void free_all_views(void *context, bool free_variable_list, bool free_settings_other, bool free_submenu_game);

+ 1091 - 0
flip_world/callback/game.c

@@ -0,0 +1,1091 @@
+#include <callback/game.h>
+//
+#include "engine/engine.h"
+#include "engine/game_engine.h"
+#include "engine/game_manager_i.h"
+#include "engine/level_i.h"
+#include "engine/entity_i.h"
+//
+#include "game/storage.h"
+//
+#include <callback/loader.h>
+#include <callback/free.h>
+#include <callback/alloc.h>
+#include <callback/callback.h>
+#include "alloc/alloc.h"
+#include <flip_storage/storage.h>
+
+bool user_hit_back = false;
+uint32_t lobby_index = -1;
+char *lobby_list[10];
+
+static uint8_t timer_iteration = 0; // timer iteration for the loading screen
+static uint8_t timer_refresh = 5;   // duration for timer to refresh
+
+FuriThread *game_thread = NULL;
+FuriThread *waiting_thread = NULL;
+bool game_thread_running = false;
+bool waiting_thread_running = false;
+
+static void game_frame_cb(GameEngine *engine, Canvas *canvas, InputState input, void *context)
+{
+    UNUSED(engine);
+    GameManager *game_manager = context;
+    game_manager_input_set(game_manager, input);
+    game_manager_update(game_manager);
+    game_manager_render(game_manager, canvas);
+}
+
+static int32_t game_app(void *p)
+{
+    UNUSED(p);
+    GameManager *game_manager = game_manager_alloc();
+    if (!game_manager)
+    {
+        FURI_LOG_E("Game", "Failed to allocate game manager");
+        return -1;
+    }
+
+    // Setup game engine settings...
+    GameEngineSettings settings = game_engine_settings_init();
+    settings.target_fps = atof_(fps_choices_str[fps_index]);
+    settings.show_fps = game.show_fps;
+    settings.always_backlight = strstr(yes_or_no_choices[screen_always_on_index], "Yes") != NULL;
+    settings.frame_callback = game_frame_cb;
+    settings.context = game_manager;
+    GameEngine *engine = game_engine_alloc(settings);
+    if (!engine)
+    {
+        FURI_LOG_E("Game", "Failed to allocate game engine");
+        game_manager_free(game_manager);
+        return -1;
+    }
+    game_manager_engine_set(game_manager, engine);
+
+    // Allocate custom game context if needed
+    void *game_context = NULL;
+    if (game.context_size > 0)
+    {
+        game_context = malloc(game.context_size);
+        game_manager_game_context_set(game_manager, game_context);
+    }
+
+    // Start the game
+    game.start(game_manager, game_context);
+
+    // 1) Run the engine
+    game_engine_run(engine);
+
+    // 2) Stop the game FIRST, so it can do any internal cleanup
+    game.stop(game_context);
+
+    // 3) Now free the engine
+    game_engine_free(engine);
+
+    // 4) Now free the manager
+    game_manager_free(game_manager);
+
+    // 5) Finally, free your custom context if it was allocated
+    if (game_context)
+    {
+        free(game_context);
+    }
+
+    // 6) Check for leftover entities
+    int32_t entities = entities_get_count();
+    if (entities != 0)
+    {
+        FURI_LOG_E("Game", "Memory leak detected: %ld entities still allocated", entities);
+        return -1;
+    }
+
+    return 0;
+}
+
+static int32_t game_waiting_app_callback(void *p)
+{
+    FlipWorldApp *app = (FlipWorldApp *)p;
+    furi_check(app);
+    FlipperHTTP *fhttp = flipper_http_alloc();
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "Failed to allocate FlipperHTTP");
+        easy_flipper_dialog("Error", "Failed to allocate FlipperHTTP");
+        return -1;
+    }
+    user_hit_back = false;
+    timer_iteration = 0;
+    while (timer_iteration < 60 && !user_hit_back)
+    {
+        FURI_LOG_I(TAG, "Waiting for more players...");
+        game_waiting_process(fhttp, app);
+        FURI_LOG_I(TAG, "Waiting for more players... %d", timer_iteration);
+        timer_iteration++;
+        furi_delay_ms(1000 * timer_refresh);
+    }
+    // if we reach here, it means we timed out or the user hit back
+    FURI_LOG_E(TAG, "No players joined within the timeout or user hit back");
+    remove_player_from_lobby(fhttp);
+    flipper_http_free(fhttp);
+    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+    return 0;
+}
+
+static bool game_start_waiting_thread(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app);
+    // free game thread
+    if (waiting_thread_running)
+    {
+        waiting_thread_running = false;
+        if (waiting_thread)
+        {
+            furi_thread_flags_set(furi_thread_get_id(waiting_thread), WorkerEvtStop);
+            furi_thread_join(waiting_thread);
+            furi_thread_free(waiting_thread);
+        }
+    }
+    // start waiting thread
+    FuriThread *thread = furi_thread_alloc_ex("waiting_thread", 2048, game_waiting_app_callback, app);
+    if (!thread)
+    {
+        FURI_LOG_E(TAG, "Failed to allocate waiting thread");
+        easy_flipper_dialog("Error", "Failed to allocate waiting thread. Restart your Flipper.");
+        return false;
+    }
+    furi_thread_start(thread);
+    waiting_thread = thread;
+    waiting_thread_running = true;
+    return true;
+}
+
+static bool game_fetch_world_list(FlipperHTTP *fhttp)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        easy_flipper_dialog("Error", "fhttp is NULL. Press BACK to return.");
+        return false;
+    }
+
+    // ensure flip_world directory exists
+    char directory_path[128];
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
+    Storage *storage = furi_record_open(RECORD_STORAGE);
+    storage_common_mkdir(storage, directory_path);
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds");
+    storage_common_mkdir(storage, directory_path);
+    furi_record_close(RECORD_STORAGE);
+
+    snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/world_list.json");
+
+    fhttp->save_received_data = true;
+    return flipper_http_request(fhttp, GET, "https://www.jblanked.com/flipper/api/world/v5/list/10/", "{\"Content-Type\":\"application/json\"}", NULL);
+}
+// we will load the palyer stats from the API and save them
+// in player_spawn game method, it will load the player stats that we saved
+static bool game_fetch_player_stats(FlipperHTTP *fhttp)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        easy_flipper_dialog("Error", "fhttp is NULL. Press BACK to return.");
+        return false;
+    }
+    char username[64];
+    if (!load_char("Flip-Social-Username", username, sizeof(username)))
+    {
+        FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+        easy_flipper_dialog("Error", "Failed to load saved username. Go to settings to update.");
+        return false;
+    }
+    char url[128];
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/user/game-stats/%s/", username);
+
+    // ensure the folders exist
+    char directory_path[128];
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
+    Storage *storage = furi_record_open(RECORD_STORAGE);
+    storage_common_mkdir(storage, directory_path);
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data");
+    storage_common_mkdir(storage, directory_path);
+    snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data/player");
+    storage_common_mkdir(storage, directory_path);
+    furi_record_close(RECORD_STORAGE);
+
+    snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data/player/player_stats.json");
+    fhttp->save_received_data = true;
+    return flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
+}
+
+static bool game_thread_start(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    if (!app)
+    {
+        FURI_LOG_E(TAG, "app is NULL");
+        easy_flipper_dialog("Error", "app is NULL. Press BACK to return.");
+        return false;
+    }
+
+    // free everything but message_view
+    free_variable_item_list(app);
+    free_text_input_view(app);
+    // free_submenu_other(app); // free lobby list or settings
+    loader_view_free(app);
+    free_game_submenu(app);
+
+    // free game thread
+    if (game_thread_running)
+    {
+        game_thread_running = false;
+        if (game_thread)
+        {
+            furi_thread_flags_set(furi_thread_get_id(game_thread), WorkerEvtStop);
+            furi_thread_join(game_thread);
+            furi_thread_free(game_thread);
+        }
+    }
+    // start game thread
+    FuriThread *thread = furi_thread_alloc_ex("game", 2048, game_app, app);
+    if (!thread)
+    {
+        FURI_LOG_E(TAG, "Failed to allocate game thread");
+        easy_flipper_dialog("Error", "Failed to allocate game thread. Restart your Flipper.");
+        return false;
+    }
+    furi_thread_start(thread);
+    game_thread = thread;
+    game_thread_running = true;
+    return true;
+}
+// combine register, login, and world list fetch into one function to switch to the loader view
+static bool game_fetch(DataLoaderModel *model)
+{
+    FlipWorldApp *app = (FlipWorldApp *)model->parser_context;
+    if (!app)
+    {
+        FURI_LOG_E(TAG, "app is NULL");
+        easy_flipper_dialog("Error", "app is NULL. Press BACK to return.");
+        return false;
+    }
+    if (model->request_index == 0)
+    {
+        // login
+        char username[64];
+        char password[64];
+        if (!load_char("Flip-Social-Username", username, sizeof(username)))
+        {
+            FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+            view_dispatcher_switch_to_view(app->view_dispatcher,
+                                           FlipWorldViewSubmenu); // just go back to the main menu for now
+            easy_flipper_dialog("Error", "Failed to load saved username\nGo to user settings to update.");
+            return false;
+        }
+        if (!load_char("Flip-Social-Password", password, sizeof(password)))
+        {
+            FURI_LOG_E(TAG, "Failed to load Flip-Social-Password");
+            view_dispatcher_switch_to_view(app->view_dispatcher,
+                                           FlipWorldViewSubmenu); // just go back to the main menu for now
+            easy_flipper_dialog("Error", "Failed to load saved password\nGo to settings to update.");
+            return false;
+        }
+        char payload[256];
+        snprintf(payload, sizeof(payload), "{\"username\":\"%s\",\"password\":\"%s\"}", username, password);
+        return flipper_http_request(model->fhttp, POST, "https://www.jblanked.com/flipper/api/user/login/", "{\"Content-Type\":\"application/json\"}", payload);
+    }
+    else if (model->request_index == 1)
+    {
+        // check if login was successful
+        char is_logged_in[8];
+        if (!load_char("is_logged_in", is_logged_in, sizeof(is_logged_in)))
+        {
+            FURI_LOG_E(TAG, "Failed to load is_logged_in");
+            easy_flipper_dialog("Error", "Failed to load is_logged_in\nGo to user settings to update.");
+            view_dispatcher_switch_to_view(app->view_dispatcher,
+                                           FlipWorldViewSubmenu); // just go back to the main menu for now
+            return false;
+        }
+        if (is_str(is_logged_in, "false") && is_str(model->title, "Registering..."))
+        {
+            // register
+            char username[64];
+            char password[64];
+            if (!load_char("Flip-Social-Username", username, sizeof(username)))
+            {
+                FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+                easy_flipper_dialog("Error", "Failed to load saved username. Go to settings to update.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return false;
+            }
+            if (!load_char("Flip-Social-Password", password, sizeof(password)))
+            {
+                FURI_LOG_E(TAG, "Failed to load Flip-Social-Password");
+                easy_flipper_dialog("Error", "Failed to load saved password. Go to settings to update.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return false;
+            }
+            char payload[172];
+            snprintf(payload, sizeof(payload), "{\"username\":\"%s\",\"password\":\"%s\"}", username, password);
+            model->title = "Registering...";
+            return flipper_http_request(model->fhttp, POST, "https://www.jblanked.com/flipper/api/user/register/", "{\"Content-Type\":\"application/json\"}", payload);
+        }
+        else
+        {
+            model->title = "Fetching World List..";
+            return game_fetch_world_list(model->fhttp);
+        }
+    }
+    else if (model->request_index == 2)
+    {
+        model->title = "Fetching World List..";
+        return game_fetch_world_list(model->fhttp);
+    }
+    else if (model->request_index == 3)
+    {
+        snprintf(model->fhttp->file_path, sizeof(model->fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/world_list.json");
+
+        FuriString *world_list = flipper_http_load_from_file(model->fhttp->file_path);
+        if (!world_list)
+        {
+            view_dispatcher_switch_to_view(app->view_dispatcher,
+                                           FlipWorldViewSubmenu); // just go back to the main menu for now
+            FURI_LOG_E(TAG, "Failed to load world list");
+            easy_flipper_dialog("Error", "Failed to load world list. Go to game settings to download packs.");
+            return false;
+        }
+        FuriString *first_world = get_json_array_value_furi("worlds", 0, world_list);
+        if (!first_world)
+        {
+            view_dispatcher_switch_to_view(app->view_dispatcher,
+                                           FlipWorldViewSubmenu); // just go back to the main menu for now
+            FURI_LOG_E(TAG, "Failed to get first world");
+            easy_flipper_dialog("Error", "Failed to get first world. Go to game settings to download packs.");
+            furi_string_free(world_list);
+            return false;
+        }
+        if (world_exists(furi_string_get_cstr(first_world)))
+        {
+            furi_string_free(world_list);
+            furi_string_free(first_world);
+
+            if (!game_thread_start(app))
+            {
+                FURI_LOG_E(TAG, "Failed to start game thread");
+                easy_flipper_dialog("Error", "Failed to start game thread. Press BACK to return.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return "Failed to start game thread";
+            }
+            return true;
+        }
+        snprintf(model->fhttp->file_path, sizeof(model->fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/%s.json", furi_string_get_cstr(first_world));
+
+        model->fhttp->save_received_data = true;
+        char url[128];
+        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/v5/get/world/%s/", furi_string_get_cstr(first_world));
+        furi_string_free(world_list);
+        furi_string_free(first_world);
+        return flipper_http_request(model->fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
+    }
+    FURI_LOG_E(TAG, "Unknown request index");
+    return false;
+}
+static char *game_parse(DataLoaderModel *model)
+{
+    FlipWorldApp *app = (FlipWorldApp *)model->parser_context;
+
+    if (model->request_index == 0)
+    {
+        if (!model->fhttp->last_response)
+        {
+            save_char("is_logged_in", "false");
+            // Go back to the main menu
+            easy_flipper_dialog("Error", "Response is empty. Press BACK to return.");
+            view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+            return "Response is empty...";
+        }
+
+        // Check for successful conditions
+        if (strstr(model->fhttp->last_response, "[SUCCESS]") != NULL || strstr(model->fhttp->last_response, "User found") != NULL)
+        {
+            save_char("is_logged_in", "true");
+            model->title = "Login successful!";
+            model->title = "Fetching World List..";
+            return "Login successful!";
+        }
+
+        // Check if user not found
+        if (strstr(model->fhttp->last_response, "User not found") != NULL)
+        {
+            save_char("is_logged_in", "false");
+            model->title = "Registering...";
+            return "Account not found...\nRegistering now.."; // if they see this an issue happened switching to register
+        }
+
+        // If not success, not found, check length conditions
+        size_t resp_len = strlen(model->fhttp->last_response);
+        if (resp_len == 0 || resp_len > 127)
+        {
+            // Empty or too long means failed login
+            save_char("is_logged_in", "false");
+            // Go back to the main menu
+            easy_flipper_dialog("Error", "Failed to login. Press BACK to return.");
+            view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+            return "Failed to login...";
+        }
+
+        // Handle any other unknown response as a failure
+        save_char("is_logged_in", "false");
+        // Go back to the main menu
+        easy_flipper_dialog("Error", "Failed to login. Press BACK to return.");
+        view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+        return "Failed to login...";
+    }
+    else if (model->request_index == 1)
+    {
+        if (is_str(model->title, "Registering..."))
+        {
+            // check registration response
+            if (model->fhttp->last_response != NULL && (strstr(model->fhttp->last_response, "[SUCCESS]") != NULL || strstr(model->fhttp->last_response, "User created") != NULL))
+            {
+                save_char("is_logged_in", "true");
+                char username[64];
+                char password[64];
+                // load the username and password, then save them
+                if (!load_char("Flip-Social-Username", username, sizeof(username)))
+                {
+                    FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+                    easy_flipper_dialog("Error", "Failed to load Flip-Social-Username");
+                    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+                    return "Failed to load Flip-Social-Username";
+                }
+                if (!load_char("Flip-Social-Password", password, sizeof(password)))
+                {
+                    FURI_LOG_E(TAG, "Failed to load Flip-Social-Password");
+                    easy_flipper_dialog("Error", "Failed to load Flip-Social-Password");
+                    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+                    return "Failed to load Flip-Social-Password";
+                }
+                // load wifi ssid,pass then save
+                char ssid[64];
+                char pass[64];
+                if (!load_char("WiFi-SSID", ssid, sizeof(ssid)))
+                {
+                    FURI_LOG_E(TAG, "Failed to load WiFi-SSID");
+                    easy_flipper_dialog("Error", "Failed to load WiFi-SSID");
+                    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+                    return "Failed to load WiFi-SSID";
+                }
+                if (!load_char("WiFi-Password", pass, sizeof(pass)))
+                {
+                    FURI_LOG_E(TAG, "Failed to load WiFi-Password");
+                    easy_flipper_dialog("Error", "Failed to load WiFi-Password");
+                    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu);
+                    return "Failed to load WiFi-Password";
+                }
+                save_settings(ssid, pass, username, password);
+                model->title = "Fetching World List..";
+                return "Account created!";
+            }
+            else if (strstr(model->fhttp->last_response, "Username or password not provided") != NULL)
+            {
+                easy_flipper_dialog("Error", "Please enter your credentials.\nPress BACK to return.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return "Please enter your credentials.";
+            }
+            else if (strstr(model->fhttp->last_response, "User already exists") != NULL || strstr(model->fhttp->last_response, "Multiple users found") != NULL)
+            {
+                easy_flipper_dialog("Error", "Registration failed...\nUsername already exists.\nPress BACK to return.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return "Username already exists.";
+            }
+            else
+            {
+                easy_flipper_dialog("Error", "Registration failed...\nUpdate your credentials.\nPress BACK to return.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return "Registration failed...";
+            }
+        }
+        else
+        {
+            if (!game_thread_start(app))
+            {
+                FURI_LOG_E(TAG, "Failed to start game thread");
+                easy_flipper_dialog("Error", "Failed to start game thread. Press BACK to return.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu); // just go back to the main menu for now
+                return "Failed to start game thread";
+            }
+            return "Thanks for playing FlipWorld!\n\n\n\nPress BACK to return if this\ndoesn't automatically close.";
+        }
+    }
+    else if (model->request_index == 2)
+    {
+        return "Welcome to FlipWorld!\n\n\n\nPress BACK to return if this\ndoesn't automatically close.";
+    }
+    else if (model->request_index == 3)
+    {
+        if (!game_thread_start(app))
+        {
+            FURI_LOG_E(TAG, "Failed to start game thread");
+            easy_flipper_dialog("Error", "Failed to start game thread. Press BACK to return.");
+            view_dispatcher_switch_to_view(app->view_dispatcher,
+                                           FlipWorldViewSubmenu); // just go back to the main menu for now
+            return "Failed to start game thread";
+        }
+        return "Thanks for playing FlipWorld!\n\n\n\nPress BACK to return if this\ndoesn't automatically close.";
+    }
+    easy_flipper_dialog("Error", "Unknown error. Press BACK to return.");
+    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenu); // just go back to the main menu for now
+    return "Unknown error";
+}
+static void game_switch_to_view(FlipWorldApp *app)
+{
+    if (!loader_view_alloc(app))
+    {
+        FURI_LOG_E(TAG, "Failed to allocate view loader");
+        return;
+    }
+    loader_switch_to_view(app, "Starting Game..", game_fetch, game_parse, 5, callback_to_submenu, FlipWorldViewLoader);
+}
+void game_run(FlipWorldApp *app)
+{
+    furi_check(app, "FlipWorldApp is NULL");
+    free_all_views(app, true, true, false);
+    // only need to check if they have 30k free (game needs about 12k currently)
+    if (!is_enough_heap(30000, false))
+    {
+        const size_t min_free = memmgr_get_free_heap();
+        char message[64];
+        snprintf(message, sizeof(message), "Not enough heap memory.\nThere are %zu bytes free.", min_free);
+        easy_flipper_dialog("Error", message);
+        return;
+    }
+    // check if logged in
+    if (is_logged_in() || is_logged_in_to_flip_social())
+    {
+        FlipperHTTP *fhttp = flipper_http_alloc();
+        if (!fhttp)
+        {
+            FURI_LOG_E(TAG, "Failed to allocate FlipperHTTP");
+            easy_flipper_dialog("Error", "Failed to allocate FlipperHTTP. Press BACK to return.");
+            return;
+        }
+        bool game_fetch_world_list_i()
+        {
+            return game_fetch_world_list(fhttp);
+        }
+        bool parse_world_list_i()
+        {
+            return fhttp->state != ISSUE;
+        }
+
+        bool game_fetch_player_stats_i()
+        {
+            return game_fetch_player_stats(fhttp);
+        }
+
+        if (!alloc_message_view(app, MessageStateLoading))
+        {
+            FURI_LOG_E(TAG, "Failed to allocate message view");
+            return;
+        }
+        view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewMessage);
+
+        // Make the request
+        if (game_mode_index != 1) // not GAME_MODE_PVP
+        {
+            if (!flipper_http_process_response_async(fhttp, game_fetch_world_list_i, parse_world_list_i) || !flipper_http_process_response_async(fhttp, game_fetch_player_stats_i, set_player_context))
+            {
+                FURI_LOG_E(HTTP_TAG, "Failed to make request");
+                flipper_http_free(fhttp);
+            }
+            else
+            {
+                flipper_http_free(fhttp);
+            }
+
+            if (!game_thread_start(app))
+            {
+                FURI_LOG_E(TAG, "Failed to start game thread");
+                easy_flipper_dialog("Error", "Failed to start game thread. Press BACK to return.");
+                return;
+            }
+        }
+        else
+        {
+            // load pvp info (this returns the lobbies available)
+            bool fetch_pvp_lobbies()
+            {
+                // ensure flip_world directory exists
+                char directory_path[128];
+                snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
+                Storage *storage = furi_record_open(RECORD_STORAGE);
+                storage_common_mkdir(storage, directory_path);
+                snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp");
+                storage_common_mkdir(storage, directory_path);
+                snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp/lobbies");
+                storage_common_mkdir(storage, directory_path);
+                snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp/pvp_lobbies.json");
+                storage_simply_remove_recursive(storage, fhttp->file_path); // ensure the file is empty
+                furi_record_close(RECORD_STORAGE);
+                fhttp->save_received_data = true;
+                // 2 players max, 10 lobbies
+                return flipper_http_request(fhttp, GET, "https://www.jblanked.com/flipper/api/world/pvp/lobbies/2/10/", "{\"Content-Type\":\"application/json\"}", NULL);
+            }
+
+            bool parse_pvp_lobbies()
+            {
+
+                free_submenu_other(app);
+                if (!alloc_submenu_other(app, FlipWorldViewLobby))
+                {
+                    FURI_LOG_E(TAG, "Failed to allocate lobby submenu");
+                    return false;
+                }
+
+                // add the lobbies to the submenu
+                FuriString *lobbies = flipper_http_load_from_file(fhttp->file_path);
+                if (!lobbies)
+                {
+                    FURI_LOG_E(TAG, "Failed to load lobbies");
+                    return false;
+                }
+
+                // parse the lobbies
+                for (uint32_t i = 0; i < 10; i++)
+                {
+                    FuriString *lobby = get_json_array_value_furi("lobbies", i, lobbies);
+                    if (!lobby)
+                    {
+                        FURI_LOG_I(TAG, "No more lobbies");
+                        break;
+                    }
+                    FuriString *lobby_id = get_json_value_furi("id", lobby);
+                    if (!lobby_id)
+                    {
+                        FURI_LOG_E(TAG, "Failed to get lobby id");
+                        furi_string_free(lobby);
+                        return false;
+                    }
+                    // add the lobby to the submenu
+                    submenu_add_item(app->submenu_other, furi_string_get_cstr(lobby_id), FlipWorldSubmenuIndexLobby + i, callback_submenu_lobby_choices, app);
+                    // add the lobby to the list
+                    if (!easy_flipper_set_buffer(&lobby_list[i], 64))
+                    {
+                        FURI_LOG_E(TAG, "Failed to allocate lobby list");
+                        furi_string_free(lobby);
+                        furi_string_free(lobby_id);
+                        return false;
+                    }
+                    snprintf(lobby_list[i], 64, "%s", furi_string_get_cstr(lobby_id));
+                    furi_string_free(lobby);
+                    furi_string_free(lobby_id);
+                }
+                furi_string_free(lobbies);
+                return true;
+            }
+
+            // load pvp lobbies and player stats
+            if (!flipper_http_process_response_async(fhttp, fetch_pvp_lobbies, parse_pvp_lobbies) || !flipper_http_process_response_async(fhttp, game_fetch_player_stats_i, set_player_context))
+            {
+                // unlike the pve/story, receiving data is necessary
+                // so send the user back to the main menu if it fails
+                FURI_LOG_E(HTTP_TAG, "Failed to make request");
+                easy_flipper_dialog("Error", "Failed to make request. Press BACK to return.");
+                view_dispatcher_switch_to_view(app->view_dispatcher,
+                                               FlipWorldViewSubmenu);
+                flipper_http_free(fhttp);
+            }
+            else
+            {
+                flipper_http_free(fhttp);
+            }
+
+            // switch to the lobby submenu
+            view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+        }
+    }
+    else
+    {
+        game_switch_to_view(app);
+    }
+}
+
+bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    if (!lobby_name || strlen(lobby_name) == 0)
+    {
+        FURI_LOG_E(TAG, "Lobby name is NULL or empty");
+        return false;
+    }
+    char username[64];
+    if (!load_char("Flip-Social-Username", username, sizeof(username)))
+    {
+        FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+        return false;
+    }
+    // send the request to fetch the lobby details, with player_username
+    char url[128];
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/pvp/lobby/get/%s/%s/", lobby_name, username);
+    snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/pvp/lobbies/%s.json", lobby_name);
+    fhttp->save_received_data = true;
+    if (!flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL))
+    {
+        FURI_LOG_E(TAG, "Failed to fetch lobby details");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        furi_delay_ms(100);
+    }
+    return true;
+}
+bool game_join_lobby(FlipperHTTP *fhttp, char *lobby_name)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    if (!lobby_name || strlen(lobby_name) == 0)
+    {
+        FURI_LOG_E(TAG, "Lobby name is NULL or empty");
+        return false;
+    }
+    char username[64];
+    if (!load_char("Flip-Social-Username", username, sizeof(username)))
+    {
+        FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+        return false;
+    }
+    char url[128];
+    char payload[128];
+    snprintf(payload, sizeof(payload), "{\"username\":\"%s\", \"game_id\":\"%s\"}", username, lobby_name);
+    save_char("pvp_lobby_name", lobby_name); // save the lobby name
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/pvp/lobby/join/");
+    if (!flipper_http_request(fhttp, POST, url, "{\"Content-Type\":\"application/json\"}", payload))
+    {
+        FURI_LOG_E(TAG, "Failed to join lobby");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        furi_delay_ms(100);
+    }
+    return true;
+}
+static bool game_create_pvp_enemy(FuriString *lobby_details)
+{
+    if (!lobby_details)
+    {
+        FURI_LOG_E(TAG, "Failed to load lobby details");
+        return false;
+    }
+
+    char current_user[64];
+    if (!load_char("Flip-Social-Username", current_user, sizeof(current_user)))
+    {
+        FURI_LOG_E(TAG, "Failed to load Flip-Social-Username");
+        save_char("create_pvp_error", "Failed to load Flip-Social-Username");
+        return false;
+    }
+
+    for (uint8_t i = 0; i < 2; i++)
+    {
+        // parse the lobby details
+        FuriString *player_stats = get_json_array_value_furi("player_stats", i, lobby_details);
+        if (!player_stats)
+        {
+            FURI_LOG_E(TAG, "Failed to get player stats");
+            save_char("create_pvp_error", "Failed to get player stats array");
+            return false;
+        }
+
+        // available keys from player_stats
+        FuriString *username = get_json_value_furi("username", player_stats);
+        if (!username)
+        {
+            FURI_LOG_E(TAG, "Failed to get username");
+            save_char("create_pvp_error", "Failed to get username");
+            furi_string_free(player_stats);
+            return false;
+        }
+
+        // check if the username is the same as the current user
+        if (is_str(furi_string_get_cstr(username), current_user))
+        {
+            furi_string_free(player_stats);
+            furi_string_free(username);
+            continue; // skip the current user
+        }
+
+        FuriString *strength = get_json_value_furi("strength", player_stats);
+        FuriString *health = get_json_value_furi("health", player_stats);
+        FuriString *attack_timer = get_json_value_furi("attack_timer", player_stats);
+
+        if (!strength || !health || !attack_timer)
+        {
+            FURI_LOG_E(TAG, "Failed to get player stats");
+            save_char("create_pvp_error", "Failed to get player stats");
+            furi_string_free(player_stats);
+            furi_string_free(username);
+            if (strength)
+                furi_string_free(strength);
+            if (health)
+                furi_string_free(health);
+            if (attack_timer)
+                furi_string_free(attack_timer);
+            return false;
+        }
+
+        // create enemy data
+        FuriString *enemy_data = furi_string_alloc();
+        furi_string_printf(
+            enemy_data,
+            "{\"enemy_data\":[{\"id\":\"sword\",\"is_user\":\"true\",\"username\":\"%s\","
+            "\"index\":0,\"start_position\":{\"x\":350,\"y\":210},\"end_position\":{\"x\":350,\"y\":210},"
+            "\"move_timer\":1,\"speed\":1,\"attack_timer\":%f,\"strength\":%f,\"health\":%f}]}",
+            furi_string_get_cstr(username),
+            (double)atof_furi(attack_timer),
+            (double)atof_furi(strength),
+            (double)atof_furi(health));
+
+        char directory_path[128];
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
+        Storage *storage = furi_record_open(RECORD_STORAGE);
+        storage_common_mkdir(storage, directory_path);
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds");
+        storage_common_mkdir(storage, directory_path);
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/pvp_world");
+        storage_common_mkdir(storage, directory_path);
+        furi_record_close(RECORD_STORAGE);
+
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/worlds/pvp_world/pvp_world_enemy_data.json");
+
+        // remove the enemy_data file if it exists
+        storage_simply_remove_recursive(storage, directory_path);
+
+        File *file = storage_file_alloc(storage);
+        if (!storage_file_open(file, directory_path, FSAM_WRITE, FSOM_CREATE_ALWAYS))
+        {
+            FURI_LOG_E("Game", "Failed to open file for writing: %s", directory_path);
+            save_char("create_pvp_error", "Failed to open file for writing");
+            storage_file_free(file);
+            furi_record_close(RECORD_STORAGE);
+            furi_string_free(enemy_data);
+            furi_string_free(player_stats);
+            furi_string_free(username);
+            furi_string_free(strength);
+            furi_string_free(health);
+            furi_string_free(attack_timer);
+            return false;
+        }
+
+        size_t data_size = furi_string_size(enemy_data);
+        if (storage_file_write(file, furi_string_get_cstr(enemy_data), data_size) != data_size)
+        {
+            FURI_LOG_E("Game", "Failed to write enemy_data");
+            save_char("create_pvp_error", "Failed to write enemy_data");
+        }
+        storage_file_close(file);
+
+        furi_string_free(enemy_data);
+        furi_string_free(player_stats);
+        furi_string_free(username);
+        furi_string_free(strength);
+        furi_string_free(health);
+        furi_string_free(attack_timer);
+
+        // player is found so break
+        break;
+    }
+
+    return true;
+}
+
+size_t game_lobby_count(FlipperHTTP *fhttp, FuriString *lobby)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return -1;
+    }
+    if (!lobby)
+    {
+        FURI_LOG_E(TAG, "Lobby details are NULL");
+        return -1;
+    }
+    // check if the player is in the lobby
+    FuriString *player_count = get_json_value_furi("player_count", lobby);
+    if (!player_count)
+    {
+        FURI_LOG_E(TAG, "Failed to get player count");
+        return -1;
+    }
+    const size_t count = atoi(furi_string_get_cstr(player_count));
+    furi_string_free(player_count);
+    return count;
+}
+bool game_in_lobby(FlipperHTTP *fhttp, FuriString *lobby)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    if (!lobby)
+    {
+        FURI_LOG_E(TAG, "Lobby details are NULL");
+        return false;
+    }
+    // check if the player is in the lobby
+    FuriString *is_in_game = get_json_value_furi("is_in_game", lobby);
+    if (!is_in_game)
+    {
+        FURI_LOG_E(TAG, "Failed to get is_in_game");
+        furi_string_free(is_in_game);
+        return false;
+    }
+    const bool in_game = is_str(furi_string_get_cstr(is_in_game), "true");
+    furi_string_free(is_in_game);
+    return in_game;
+}
+
+static bool game_start_ws(FlipperHTTP *fhttp, char *lobby_name)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    if (!lobby_name || strlen(lobby_name) == 0)
+    {
+        FURI_LOG_E(TAG, "Lobby name is NULL or empty");
+        return false;
+    }
+    fhttp->state = IDLE; // ensure it's set to IDLE for the next request
+    char websocket_url[128];
+    snprintf(websocket_url, sizeof(websocket_url), "ws://www.jblanked.com/ws/game/%s/", lobby_name);
+    if (!flipper_http_websocket_start(fhttp, websocket_url, 80, "{\"Content-Type\":\"application/json\"}"))
+    {
+        FURI_LOG_E(TAG, "Failed to start websocket");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        furi_delay_ms(100);
+    }
+    return true;
+}
+// this will free both the fhttp and lobby
+void game_start_pvp(FlipperHTTP *fhttp, FuriString *lobby, void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app, "FlipWorldApp is NULL");
+    // only thing left to do is create the enemy data and start the websocket session
+    if (!game_create_pvp_enemy(lobby))
+    {
+        FURI_LOG_E(TAG, "Failed to create pvp enemy context.");
+        easy_flipper_dialog("Error", "Failed to create pvp enemy context. Press BACK to return.");
+        flipper_http_free(fhttp);
+        furi_string_free(lobby);
+        return;
+    }
+
+    furi_string_free(lobby);
+
+    // start the websocket session
+    if (!game_start_ws(fhttp, lobby_list[lobby_index]))
+    {
+        FURI_LOG_E(TAG, "Failed to start websocket session");
+        easy_flipper_dialog("Error", "Failed to start websocket session. Press BACK to return.");
+        flipper_http_free(fhttp);
+        return;
+    }
+
+    flipper_http_free(fhttp);
+
+    // start the game thread
+    if (!game_thread_start(app))
+    {
+        FURI_LOG_E(TAG, "Failed to start game thread");
+        easy_flipper_dialog("Error", "Failed to start game thread. Press BACK to return.");
+        return;
+    }
+};
+void game_waiting_process(FlipperHTTP *fhttp, void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app, "FlipWorldApp is NULL");
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "Failed to allocate FlipperHTTP");
+        easy_flipper_dialog("Error", "Failed to allocate FlipperHTTP. Press BACK to return.");
+        view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+        return;
+    }
+    // fetch the lobby details
+    if (!game_fetch_lobby(fhttp, lobby_list[lobby_index]))
+    {
+        FURI_LOG_E(TAG, "Failed to fetch lobby details");
+        flipper_http_free(fhttp);
+        easy_flipper_dialog("Error", "Failed to fetch lobby details. Press BACK to return.");
+        view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+        return;
+    }
+    // load the lobby details
+    FuriString *lobby = flipper_http_load_from_file(fhttp->file_path);
+    if (!lobby)
+    {
+        FURI_LOG_E(TAG, "Failed to load lobby details");
+        flipper_http_free(fhttp);
+        easy_flipper_dialog("Error", "Failed to load lobby details. Press BACK to return.");
+        view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewSubmenuOther);
+        return;
+    }
+    // get the player count
+    const size_t count = game_lobby_count(fhttp, lobby);
+    if (count == 2)
+    {
+        // break out of this and start the game
+        game_start_pvp(fhttp, lobby, app); // this will free both the fhttp and lobby
+        return;
+    }
+    furi_string_free(lobby);
+}
+
+void game_waiting_lobby(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app, "waiting_lobby: FlipWorldApp is NULL");
+    if (!game_start_waiting_thread(app))
+    {
+        FURI_LOG_E(TAG, "Failed to start waiting thread");
+        easy_flipper_dialog("Error", "Failed to start waiting thread. Press BACK to return.");
+        return;
+    }
+    free_message_view(app);
+    if (!alloc_message_view(app, MessageStateWaitingLobby))
+    {
+        FURI_LOG_E(TAG, "Failed to allocate message view");
+        return;
+    }
+    // finally, switch to the waiting lobby view
+    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewMessage);
+};

+ 18 - 0
flip_world/callback/game.h

@@ -0,0 +1,18 @@
+#pragma once
+#include <flip_world.h>
+extern bool user_hit_back;
+extern uint32_t lobby_index;
+extern char *lobby_list[10];
+extern FuriThread *game_thread;
+extern FuriThread *waiting_thread;
+extern bool game_thread_running;
+extern bool waiting_thread_running;
+//
+void game_run(FlipWorldApp *app);
+bool game_fetch_lobby(FlipperHTTP *fhttp, char *lobby_name);
+bool game_join_lobby(FlipperHTTP *fhttp, char *lobby_name);
+size_t game_lobby_count(FlipperHTTP *fhttp, FuriString *lobby);
+bool game_in_lobby(FlipperHTTP *fhttp, FuriString *lobby);
+void game_start_pvp(FlipperHTTP *fhttp, FuriString *lobby, void *context);
+void game_waiting_lobby(void *context);
+void game_waiting_process(FlipperHTTP *fhttp, void *context);

+ 590 - 0
flip_world/callback/loader.c

@@ -0,0 +1,590 @@
+#include <callback/loader.h>
+#include <callback/utils.h>
+#include <alloc/alloc.h>
+
+bool loader_view_alloc(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app, "FlipWorldApp is NULL");
+    if (app->view_loader)
+    {
+        FURI_LOG_E(TAG, "View loader already allocated");
+        return false;
+    }
+    if (app->widget_result)
+    {
+        FURI_LOG_E(TAG, "Widget result already allocated");
+        return false;
+    }
+
+    view_dispatcher_set_custom_event_callback(app->view_dispatcher, loader_custom_event_callback);
+
+    if (!easy_flipper_set_view(&app->view_loader, FlipWorldViewLoader, loader_draw_callback, NULL, callback_to_submenu, &app->view_dispatcher, app))
+    {
+        return false;
+    }
+
+    loader_init(app->view_loader);
+
+    return easy_flipper_set_widget(&app->widget_result, FlipWorldViewWidgetResult, "", callback_to_submenu, &app->view_dispatcher);
+}
+
+void loader_view_free(void *context)
+{
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    furi_check(app, "FlipWorldApp is NULL");
+    // Free Widget(s)
+    if (app->widget_result)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewWidgetResult);
+        widget_free(app->widget_result);
+        app->widget_result = NULL;
+    }
+
+    // Free View(s)
+    if (app->view_loader)
+    {
+        view_dispatcher_remove_view(app->view_dispatcher, FlipWorldViewLoader);
+        loader_free_model(app->view_loader);
+        view_free(app->view_loader);
+        app->view_loader = NULL;
+    }
+}
+
+static void loader_error_draw(Canvas *canvas, DataLoaderModel *model)
+{
+    if (canvas == NULL)
+    {
+        FURI_LOG_E(TAG, "error_draw - canvas is NULL");
+        DEV_CRASH();
+        return;
+    }
+    if (model->fhttp->last_response != NULL)
+    {
+        if (strstr(model->fhttp->last_response, "[ERROR] Not connected to Wifi. Failed to reconnect.") != NULL)
+        {
+            canvas_clear(canvas);
+            canvas_draw_str(canvas, 0, 10, "[ERROR] Not connected to Wifi.");
+            canvas_draw_str(canvas, 0, 50, "Update your WiFi settings.");
+            canvas_draw_str(canvas, 0, 60, "Press BACK to return.");
+        }
+        else if (strstr(model->fhttp->last_response, "[ERROR] Failed to connect to Wifi.") != NULL)
+        {
+            canvas_clear(canvas);
+            canvas_draw_str(canvas, 0, 10, "[ERROR] Not connected to Wifi.");
+            canvas_draw_str(canvas, 0, 50, "Update your WiFi settings.");
+            canvas_draw_str(canvas, 0, 60, "Press BACK to return.");
+        }
+        else if (strstr(model->fhttp->last_response, "[ERROR] GET request failed or returned empty data.") != NULL)
+        {
+            canvas_clear(canvas);
+            canvas_draw_str(canvas, 0, 10, "[ERROR] WiFi error.");
+            canvas_draw_str(canvas, 0, 50, "Update your WiFi settings.");
+            canvas_draw_str(canvas, 0, 60, "Press BACK to return.");
+        }
+        else if (strstr(model->fhttp->last_response, "[PONG]") != NULL)
+        {
+            canvas_clear(canvas);
+            canvas_draw_str(canvas, 0, 10, "[STATUS]Connecting to AP...");
+        }
+        else
+        {
+            canvas_clear(canvas);
+            FURI_LOG_E(TAG, "Received an error: %s", model->fhttp->last_response);
+            canvas_draw_str(canvas, 0, 10, "[ERROR] Unusual error...");
+            canvas_draw_str(canvas, 0, 60, "Press BACK and retry.");
+        }
+    }
+    else
+    {
+        canvas_clear(canvas);
+        canvas_draw_str(canvas, 0, 10, "[ERROR] Unknown error.");
+        canvas_draw_str(canvas, 0, 50, "Update your WiFi settings.");
+        canvas_draw_str(canvas, 0, 60, "Press BACK to return.");
+    }
+}
+
+static void loader_process_callback(void *context)
+{
+    if (context == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_process_callback - context is NULL");
+        DEV_CRASH();
+        return;
+    }
+
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    View *view = app->view_loader;
+
+    DataState current_data_state;
+    DataLoaderModel *loader_model = NULL;
+    with_view_model(
+        view,
+        DataLoaderModel * model,
+        {
+            current_data_state = model->data_state;
+            loader_model = model;
+        },
+        false);
+    if (!loader_model || !loader_model->fhttp)
+    {
+        FURI_LOG_E(TAG, "Model or fhttp is NULL");
+        DEV_CRASH();
+        return;
+    }
+
+    if (current_data_state == DataStateInitial)
+    {
+        with_view_model(
+            view,
+            DataLoaderModel * model,
+            {
+                model->data_state = DataStateRequested;
+                DataLoaderFetch fetch = model->fetcher;
+                if (fetch == NULL)
+                {
+                    FURI_LOG_E(TAG, "Model doesn't have Fetch function assigned.");
+                    model->data_state = DataStateError;
+                    return;
+                }
+
+                // Clear any previous responses
+                strncpy(model->fhttp->last_response, "", 1);
+                bool request_status = fetch(model);
+                if (!request_status)
+                {
+                    model->data_state = DataStateError;
+                }
+            },
+            true);
+    }
+    else if (current_data_state == DataStateRequested || current_data_state == DataStateError)
+    {
+        if (loader_model->fhttp->state == IDLE && loader_model->fhttp->last_response != NULL)
+        {
+            if (strstr(loader_model->fhttp->last_response, "[PONG]") != NULL)
+            {
+                FURI_LOG_DEV(TAG, "PONG received.");
+            }
+            else if (strncmp(loader_model->fhttp->last_response, "[SUCCESS]", 9))
+            {
+                FURI_LOG_DEV(TAG, "SUCCESS received. %s", loader_model->fhttp->last_response ? loader_model->fhttp->last_response : "NULL");
+            }
+            else if (strncmp(loader_model->fhttp->last_response, "[ERROR]", 9))
+            {
+                FURI_LOG_DEV(TAG, "ERROR received. %s", loader_model->fhttp->last_response ? loader_model->fhttp->last_response : "NULL");
+            }
+            else if (strlen(loader_model->fhttp->last_response))
+            {
+                // Still waiting on response
+            }
+            else
+            {
+                with_view_model(view, DataLoaderModel * model, { model->data_state = DataStateReceived; }, true);
+            }
+        }
+        else if (loader_model->fhttp->state == SENDING || loader_model->fhttp->state == RECEIVING)
+        {
+            // continue waiting
+        }
+        else if (loader_model->fhttp->state == INACTIVE)
+        {
+            // inactive. try again
+        }
+        else if (loader_model->fhttp->state == ISSUE)
+        {
+            with_view_model(view, DataLoaderModel * model, { model->data_state = DataStateError; }, true);
+        }
+        else
+        {
+            FURI_LOG_DEV(TAG, "Unexpected state: %d lastresp: %s", loader_model->fhttp->state, loader_model->fhttp->last_response ? loader_model->fhttp->last_response : "NULL");
+            DEV_CRASH();
+        }
+    }
+    else if (current_data_state == DataStateReceived)
+    {
+        with_view_model(
+            view,
+            DataLoaderModel * model,
+            {
+                char *data_text;
+                if (model->parser == NULL)
+                {
+                    data_text = NULL;
+                    FURI_LOG_DEV(TAG, "Parser is NULL");
+                    DEV_CRASH();
+                }
+                else
+                {
+                    data_text = model->parser(model);
+                }
+                FURI_LOG_DEV(TAG, "Parsed data: %s\r\ntext: %s", model->fhttp->last_response ? model->fhttp->last_response : "NULL", data_text ? data_text : "NULL");
+                model->data_text = data_text;
+                if (data_text == NULL)
+                {
+                    model->data_state = DataStateParseError;
+                }
+                else
+                {
+                    model->data_state = DataStateParsed;
+                }
+            },
+            true);
+    }
+    else if (current_data_state == DataStateParsed)
+    {
+        with_view_model(
+            view,
+            DataLoaderModel * model,
+            {
+                if (++model->request_index < model->request_count)
+                {
+                    model->data_state = DataStateInitial;
+                }
+                else
+                {
+                    loader_widget_set_text(model->data_text != NULL ? model->data_text : "", &app->widget_result);
+                    if (model->data_text != NULL)
+                    {
+                        free(model->data_text);
+                        model->data_text = NULL;
+                    }
+                    view_set_previous_callback(widget_get_view(app->widget_result), model->back_callback);
+                    view_dispatcher_switch_to_view(app->view_dispatcher, FlipWorldViewWidgetResult);
+                }
+            },
+            true);
+    }
+}
+
+bool loader_custom_event_callback(void *context, uint32_t index)
+{
+    if (context == NULL)
+    {
+        FURI_LOG_E(TAG, "custom_event_callback - context is NULL");
+        DEV_CRASH();
+        return false;
+    }
+
+    switch (index)
+    {
+    case FlipWorldCustomEventProcess:
+        loader_process_callback(context);
+        return true;
+    default:
+        FURI_LOG_DEV(TAG, "custom_event_callback. Unknown index: %ld", index);
+        return false;
+    }
+}
+
+void loader_draw_callback(Canvas *canvas, void *model)
+{
+    if (!canvas || !model)
+    {
+        FURI_LOG_E(TAG, "loader_draw_callback - canvas or model is NULL");
+        return;
+    }
+
+    DataLoaderModel *data_loader_model = (DataLoaderModel *)model;
+    HTTPState http_state = data_loader_model->fhttp->state;
+    DataState data_state = data_loader_model->data_state;
+    char *title = data_loader_model->title;
+
+    canvas_set_font(canvas, FontSecondary);
+
+    if (http_state == INACTIVE)
+    {
+        canvas_draw_str(canvas, 0, 7, "Wifi Dev Board disconnected.");
+        canvas_draw_str(canvas, 0, 17, "Please connect to the board.");
+        canvas_draw_str(canvas, 0, 32, "If your board is connected,");
+        canvas_draw_str(canvas, 0, 42, "make sure you have flashed");
+        canvas_draw_str(canvas, 0, 52, "your WiFi Devboard with the");
+        canvas_draw_str(canvas, 0, 62, "latest FlipperHTTP flash.");
+        return;
+    }
+
+    if (data_state == DataStateError || data_state == DataStateParseError)
+    {
+        loader_error_draw(canvas, data_loader_model);
+        return;
+    }
+
+    canvas_draw_str(canvas, 0, 7, title);
+    canvas_draw_str(canvas, 0, 17, "Loading...");
+
+    if (data_state == DataStateInitial)
+    {
+        return;
+    }
+
+    if (http_state == SENDING)
+    {
+        canvas_draw_str(canvas, 0, 27, "Fetching...");
+        return;
+    }
+
+    if (http_state == RECEIVING || data_state == DataStateRequested)
+    {
+        canvas_draw_str(canvas, 0, 27, "Receiving...");
+        return;
+    }
+
+    if (http_state == IDLE && data_state == DataStateReceived)
+    {
+        canvas_draw_str(canvas, 0, 27, "Processing...");
+        return;
+    }
+
+    if (http_state == IDLE && data_state == DataStateParsed)
+    {
+        canvas_draw_str(canvas, 0, 27, "Processed...");
+        return;
+    }
+}
+
+static void loader_timer_callback(void *context)
+{
+    if (context == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_timer_callback - context is NULL");
+        DEV_CRASH();
+        return;
+    }
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    view_dispatcher_send_custom_event(app->view_dispatcher, FlipWorldCustomEventProcess);
+}
+
+static void loader_on_enter(void *context)
+{
+    if (context == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_on_enter - context is NULL");
+        DEV_CRASH();
+        return;
+    }
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    View *view = app->view_loader;
+    with_view_model(
+        view,
+        DataLoaderModel * model,
+        {
+            view_set_previous_callback(view, model->back_callback);
+            if (model->timer == NULL)
+            {
+                model->timer = furi_timer_alloc(loader_timer_callback, FuriTimerTypePeriodic, app);
+            }
+            furi_timer_start(model->timer, 250);
+        },
+        true);
+}
+
+static void loader_on_exit(void *context)
+{
+    if (context == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_on_exit - context is NULL");
+        DEV_CRASH();
+        return;
+    }
+    FlipWorldApp *app = (FlipWorldApp *)context;
+    View *view = app->view_loader;
+    with_view_model(
+        view,
+        DataLoaderModel * model,
+        {
+            if (model->timer)
+            {
+                furi_timer_stop(model->timer);
+            }
+        },
+        false);
+}
+
+void loader_init(View *view)
+{
+    if (view == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_init - view is NULL");
+        DEV_CRASH();
+        return;
+    }
+    view_allocate_model(view, ViewModelTypeLocking, sizeof(DataLoaderModel));
+    view_set_enter_callback(view, loader_on_enter);
+    view_set_exit_callback(view, loader_on_exit);
+}
+
+void loader_free_model(View *view)
+{
+    if (view == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_free_model - view is NULL");
+        DEV_CRASH();
+        return;
+    }
+    with_view_model(
+        view,
+        DataLoaderModel * model,
+        {
+            if (model->timer)
+            {
+                furi_timer_free(model->timer);
+                model->timer = NULL;
+            }
+            if (model->parser_context)
+            {
+                // do not free the context here, it is the app context
+                // free(model->parser_context);
+                // model->parser_context = NULL;
+            }
+            if (model->fhttp)
+            {
+                flipper_http_free(model->fhttp);
+                model->fhttp = NULL;
+            }
+        },
+        false);
+}
+
+void loader_switch_to_view(FlipWorldApp *app, char *title, DataLoaderFetch fetcher, DataLoaderParser parser, size_t request_count, ViewNavigationCallback back, uint32_t view_id)
+{
+    if (app == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_switch_to_view - app is NULL");
+        DEV_CRASH();
+        return;
+    }
+
+    View *view = app->view_loader;
+    if (view == NULL)
+    {
+        FURI_LOG_E(TAG, "loader_switch_to_view - view is NULL");
+        DEV_CRASH();
+        return;
+    }
+
+    with_view_model(
+        view,
+        DataLoaderModel * model,
+        {
+            model->title = title;
+            model->fetcher = fetcher;
+            model->parser = parser;
+            model->request_index = 0;
+            model->request_count = request_count;
+            model->back_callback = back;
+            model->data_state = DataStateInitial;
+            model->data_text = NULL;
+            //
+            model->parser_context = app;
+            if (!model->fhttp)
+            {
+                model->fhttp = flipper_http_alloc();
+            }
+        },
+        true);
+
+    view_dispatcher_switch_to_view(app->view_dispatcher, view_id);
+}
+
+void loader_widget_set_text(char *message, Widget **widget)
+{
+    if (widget == NULL)
+    {
+        FURI_LOG_E(TAG, "set_widget_text - widget is NULL");
+        DEV_CRASH();
+        return;
+    }
+    if (message == NULL)
+    {
+        FURI_LOG_E(TAG, "set_widget_text - message is NULL");
+        DEV_CRASH();
+        return;
+    }
+    widget_reset(*widget);
+
+    uint32_t message_length = strlen(message); // Length of the message
+    uint32_t i = 0;                            // Index tracker
+    uint32_t formatted_index = 0;              // Tracker for where we are in the formatted message
+    char *formatted_message;                   // Buffer to hold the final formatted message
+
+    // Allocate buffer with double the message length plus one for safety
+    if (!easy_flipper_set_buffer(&formatted_message, message_length * 2 + 1))
+    {
+        return;
+    }
+
+    while (i < message_length)
+    {
+        uint32_t max_line_length = 31;                  // Maximum characters per line
+        uint32_t remaining_length = message_length - i; // Remaining characters
+        uint32_t line_length = (remaining_length < max_line_length) ? remaining_length : max_line_length;
+
+        // Check for newline character within the current segment
+        uint32_t newline_pos = i;
+        bool found_newline = false;
+        for (; newline_pos < i + line_length && newline_pos < message_length; newline_pos++)
+        {
+            if (message[newline_pos] == '\n')
+            {
+                found_newline = true;
+                break;
+            }
+        }
+
+        if (found_newline)
+        {
+            // If newline found, set line_length up to the newline
+            line_length = newline_pos - i;
+        }
+
+        // Temporary buffer to hold the current line
+        char line[32];
+        strncpy(line, message + i, line_length);
+        line[line_length] = '\0';
+
+        // If newline was found, skip it for the next iteration
+        if (found_newline)
+        {
+            i += line_length + 1; // +1 to skip the '\n' character
+        }
+        else
+        {
+            // Check if the line ends in the middle of a word and adjust accordingly
+            if (line_length == max_line_length && message[i + line_length] != '\0' && message[i + line_length] != ' ')
+            {
+                // Find the last space within the current line to avoid breaking a word
+                char *last_space = strrchr(line, ' ');
+                if (last_space != NULL)
+                {
+                    // Adjust the line_length to avoid cutting the word
+                    line_length = last_space - line;
+                    line[line_length] = '\0'; // Null-terminate at the space
+                }
+            }
+
+            // Move the index forward by the determined line_length
+            i += line_length;
+
+            // Skip any spaces at the beginning of the next line
+            while (i < message_length && message[i] == ' ')
+            {
+                i++;
+            }
+        }
+
+        // Manually copy the fixed line into the formatted_message buffer
+        for (uint32_t j = 0; j < line_length; j++)
+        {
+            formatted_message[formatted_index++] = line[j];
+        }
+
+        // Add a newline character for line spacing
+        formatted_message[formatted_index++] = '\n';
+    }
+
+    // Null-terminate the formatted_message
+    formatted_message[formatted_index] = '\0';
+
+    // Add the formatted message to the widget
+    widget_add_text_scroll_element(*widget, 0, 0, 128, 64, formatted_message);
+}

+ 39 - 0
flip_world/callback/loader.h

@@ -0,0 +1,39 @@
+#pragma once
+#include <flip_world.h>
+
+typedef enum DataState DataState;
+enum DataState
+{
+    DataStateInitial,
+    DataStateRequested,
+    DataStateReceived,
+    DataStateParsed,
+    DataStateParseError,
+    DataStateError,
+};
+
+typedef struct DataLoaderModel DataLoaderModel;
+typedef bool (*DataLoaderFetch)(DataLoaderModel *model);
+typedef char *(*DataLoaderParser)(DataLoaderModel *model);
+struct DataLoaderModel
+{
+    char *title;
+    char *data_text;
+    DataState data_state;
+    DataLoaderFetch fetcher;
+    DataLoaderParser parser;
+    void *parser_context;
+    size_t request_index;
+    size_t request_count;
+    ViewNavigationCallback back_callback;
+    FuriTimer *timer;
+    FlipperHTTP *fhttp;
+};
+bool loader_view_alloc(void *context);
+void loader_view_free(void *context);
+void loader_switch_to_view(FlipWorldApp *app, char *title, DataLoaderFetch fetcher, DataLoaderParser parser, size_t request_count, ViewNavigationCallback back, uint32_t view_id);
+void loader_draw_callback(Canvas *canvas, void *model);
+void loader_init(View *view);
+void loader_widget_set_text(char *message, Widget **widget);
+void loader_free_model(View *view);
+bool loader_custom_event_callback(void *context, uint32_t index);

+ 25 - 0
flip_world/callback/utils.h

@@ -0,0 +1,25 @@
+#pragma once
+#include <flip_world.h>
+
+// Below added by Derek Jamison
+// FURI_LOG_DEV will log only during app development. Be sure that Settings/System/Log Device is "LPUART"; so we dont use serial port.
+#ifdef DEVELOPMENT
+#define FURI_LOG_DEV(tag, format, ...) furi_log_print_format(FuriLogLevelInfo, tag, format, ##__VA_ARGS__)
+#define DEV_CRASH() furi_crash()
+#else
+#define FURI_LOG_DEV(tag, format, ...)
+#define DEV_CRASH()
+#endif
+
+typedef enum MessageState MessageState;
+enum MessageState
+{
+    MessageStateAbout,        // The about screen
+    MessageStateLoading,      // The loading screen (for game)
+    MessageStateWaitingLobby, // The waiting lobby screen
+};
+typedef struct MessageModel MessageModel;
+struct MessageModel
+{
+    MessageState message_state;
+};

+ 453 - 9
flip_world/flip_world.c

@@ -1,17 +1,461 @@
 #include <flip_world.h>
+#include <flip_storage/storage.h>
 char *fps_choices_str[] = {"30", "60", "120", "240"};
-int fps_index = 0;
+uint8_t fps_index = 0;
 char *yes_or_no_choices[] = {"No", "Yes"};
-int screen_always_on_index = 1;
-int sound_on_index = 0;
-int vibration_on_index = 0;
+uint8_t screen_always_on_index = 1;
+uint8_t sound_on_index = 1;
+uint8_t vibration_on_index = 1;
 char *player_sprite_choices[] = {"naked", "sword", "axe", "bow"};
-int player_sprite_index = 1;
+uint8_t player_sprite_index = 1;
 char *vgm_levels[] = {"-2", "-1", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"};
-int vgm_x_index = 2;
-int vgm_y_index = 2;
-int game_mode_index = 0;
+uint8_t vgm_x_index = 2;
+uint8_t vgm_y_index = 2;
+uint8_t game_mode_index = 0;
 float atof_(const char *nptr) { return (float)strtod(nptr, NULL); }
 float atof_furi(const FuriString *nptr) { return atof_(furi_string_get_cstr(nptr)); }
 bool is_str(const char *src, const char *dst) { return strcmp(src, dst) == 0; }
-bool is_enough_heap(size_t heap_size) { return memmgr_get_free_heap() > (heap_size + 1024); } // 1KB buffer
+bool is_enough_heap(size_t heap_size, bool check_blocks)
+{
+    const size_t min_heap = heap_size + 1024; // 1KB buffer
+    const size_t min_free = memmgr_get_free_heap();
+    if (min_free < min_heap)
+    {
+        FURI_LOG_E(TAG, "Not enough heap memory: There are %zu bytes free.", min_free);
+        return false;
+    }
+    if (check_blocks)
+    {
+        const size_t max_free_block = memmgr_heap_get_max_free_block();
+        if (max_free_block < min_heap)
+        {
+            FURI_LOG_E(TAG, "Not enough free blocks: %zu bytes", max_free_block);
+            return false;
+        }
+    }
+    return true;
+}
+
+static bool flip_world_json_to_datetime(DateTime *rtc_time, FuriString *str)
+{
+    if (!rtc_time || !str)
+    {
+        FURI_LOG_E(TAG, "rtc_time or str is NULL");
+        return false;
+    }
+    FuriString *hour = get_json_value_furi("hour", str);
+    if (hour)
+    {
+        rtc_time->hour = atoi(furi_string_get_cstr(hour));
+        furi_string_free(hour);
+    }
+    FuriString *minute = get_json_value_furi("minute", str);
+    if (minute)
+    {
+        rtc_time->minute = atoi(furi_string_get_cstr(minute));
+        furi_string_free(minute);
+    }
+    FuriString *second = get_json_value_furi("second", str);
+    if (second)
+    {
+        rtc_time->second = atoi(furi_string_get_cstr(second));
+        furi_string_free(second);
+    }
+    FuriString *day = get_json_value_furi("day", str);
+    if (day)
+    {
+        rtc_time->day = atoi(furi_string_get_cstr(day));
+        furi_string_free(day);
+    }
+    FuriString *month = get_json_value_furi("month", str);
+    if (month)
+    {
+        rtc_time->month = atoi(furi_string_get_cstr(month));
+        furi_string_free(month);
+    }
+    FuriString *year = get_json_value_furi("year", str);
+    if (year)
+    {
+        rtc_time->year = atoi(furi_string_get_cstr(year));
+        furi_string_free(year);
+    }
+    FuriString *weekday = get_json_value_furi("weekday", str);
+    if (weekday)
+    {
+        rtc_time->weekday = atoi(furi_string_get_cstr(weekday));
+        furi_string_free(weekday);
+    }
+    return datetime_validate_datetime(rtc_time);
+}
+
+static FuriString *flip_world_datetime_to_json(DateTime *rtc_time)
+{
+    if (!rtc_time)
+    {
+        FURI_LOG_E(TAG, "rtc_time is NULL");
+        return NULL;
+    }
+    char json[256];
+    snprintf(
+        json,
+        sizeof(json),
+        "{\"hour\":%d,\"minute\":%d,\"second\":%d,\"day\":%d,\"month\":%d,\"year\":%d,\"weekday\":%d}",
+        rtc_time->hour,
+        rtc_time->minute,
+        rtc_time->second,
+        rtc_time->day,
+        rtc_time->month,
+        rtc_time->year,
+        rtc_time->weekday);
+    return furi_string_alloc_set_str(json);
+}
+
+static bool flip_world_save_rtc_time(DateTime *rtc_time)
+{
+    if (!rtc_time)
+    {
+        FURI_LOG_E(TAG, "rtc_time is NULL");
+        return false;
+    }
+    FuriString *json = flip_world_datetime_to_json(rtc_time);
+    if (!json)
+    {
+        FURI_LOG_E(TAG, "Failed to convert DateTime to JSON");
+        return false;
+    }
+    save_char("last_checked", furi_string_get_cstr(json));
+    furi_string_free(json);
+    return true;
+}
+
+//
+// Returns true if time_current is one hour (or more) later than the stored last_updated time
+//
+static bool flip_world_is_update_time(DateTime *time_current)
+{
+    if (!time_current)
+    {
+        FURI_LOG_E(TAG, "time_current is NULL");
+        return false;
+    }
+    char last_updated_old[128];
+    if (!load_char("last_updated", last_updated_old, sizeof(last_updated_old)))
+    {
+        FURI_LOG_E(TAG, "Failed to load last_updated");
+        FuriString *json = flip_world_datetime_to_json(time_current);
+        if (json)
+        {
+            save_char("last_updated", furi_string_get_cstr(json));
+            furi_string_free(json);
+        }
+        return false;
+    }
+
+    DateTime last_updated_time;
+
+    FuriString *last_updated_furi = char_to_furi_string(last_updated_old);
+    if (!last_updated_furi)
+    {
+        FURI_LOG_E(TAG, "Failed to convert char to FuriString");
+        return false;
+    }
+    if (!flip_world_json_to_datetime(&last_updated_time, last_updated_furi))
+    {
+        FURI_LOG_E(TAG, "Failed to convert JSON to DateTime");
+        furi_string_free(last_updated_furi);
+        return false;
+    }
+    furi_string_free(last_updated_furi); // Free after usage.
+
+    bool time_diff = false;
+    // If the date is different assume more than one hour has passed.
+    if (time_current->year != last_updated_time.year ||
+        time_current->month != last_updated_time.month ||
+        time_current->day != last_updated_time.day)
+    {
+        time_diff = true;
+    }
+    else
+    {
+        // For the same day, compute seconds from midnight.
+        int seconds_current = time_current->hour * 3600 + time_current->minute * 60 + time_current->second;
+        int seconds_last = last_updated_time.hour * 3600 + last_updated_time.minute * 60 + last_updated_time.second;
+        if ((seconds_current - seconds_last) >= 3600)
+        {
+            time_diff = true;
+        }
+    }
+
+    return time_diff;
+}
+
+// Sends a request to fetch the last updated date of the app.
+static bool flip_world_last_app_update(FlipperHTTP *fhttp, bool flipper_server)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    char url[256];
+    if (flipper_server)
+    {
+        // make sure folder is created
+        char directory_path[256];
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world");
+
+        // Create the directory
+        Storage *storage = furi_record_open(RECORD_STORAGE);
+        storage_common_mkdir(storage, directory_path);
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data");
+        storage_common_mkdir(storage, directory_path);
+        snprintf(directory_path, sizeof(directory_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data/last_update_request.txt");
+        storage_simply_remove_recursive(storage, directory_path); // ensure the file is empty
+        furi_record_close(RECORD_STORAGE);
+
+        fhttp->save_received_data = true;
+        fhttp->is_bytes_request = false;
+
+        snprintf(fhttp->file_path, sizeof(fhttp->file_path), STORAGE_EXT_PATH_PREFIX "/apps_data/flip_world/data/last_update_request.txt");
+        snprintf(url, sizeof(url), "https://catalog.flipperzero.one/api/v0/0/application/%s?is_latest_release_version=true", BUILD_ID);
+        return flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
+    }
+    else
+    {
+        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/app/last-updated/flip_world/");
+        return flipper_http_request(fhttp, GET, url, "{\"Content-Type\":\"application/json\"}", NULL);
+    }
+}
+
+// Parses the server response and returns true if an update is available.
+static bool flip_world_parse_last_app_update(FlipperHTTP *fhttp, DateTime *time_current, bool flipper_server)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    if (fhttp->state == ISSUE)
+    {
+        FURI_LOG_E(TAG, "Failed to fetch last app update");
+        return false;
+    }
+    char version_str[32];
+    if (!flipper_server)
+    {
+        if (fhttp->last_response == NULL || strlen(fhttp->last_response) == 0)
+        {
+            FURI_LOG_E(TAG, "fhttp->last_response is NULL or empty");
+            return false;
+        }
+
+        char *app_version = get_json_value("version", fhttp->last_response);
+        if (app_version)
+        {
+            // Save the server app version: it should save something like: 0.8
+            save_char("server_app_version", app_version);
+            snprintf(version_str, sizeof(version_str), "%s", app_version);
+            free(app_version);
+        }
+        else
+        {
+            FURI_LOG_E(TAG, "Failed to get app version");
+            return false;
+        }
+    }
+    else
+    {
+        FuriString *app_data = flipper_http_load_from_file_with_limit(fhttp->file_path, memmgr_heap_get_max_free_block());
+        if (!app_data)
+        {
+            FURI_LOG_E(TAG, "Failed to load app data");
+            return false;
+        }
+        FuriString *current_version = get_json_value_furi("current_version", app_data);
+        if (!current_version)
+        {
+            FURI_LOG_E(TAG, "Failed to get current version");
+            furi_string_free(app_data);
+            return false;
+        }
+        furi_string_free(app_data);
+        FuriString *version = get_json_value_furi("version", current_version);
+        if (!version)
+        {
+            FURI_LOG_E(TAG, "Failed to get version");
+            furi_string_free(current_version);
+            furi_string_free(app_data);
+            return false;
+        }
+        // Save the server app version: it should save something like: 0.8
+        save_char("server_app_version", furi_string_get_cstr(version));
+        snprintf(version_str, sizeof(version_str), "%s", furi_string_get_cstr(version));
+        furi_string_free(current_version);
+        furi_string_free(version);
+        // furi_string_free(app_data);
+    }
+    // Only check for an update if an hour or more has passed.
+    if (flip_world_is_update_time(time_current))
+    {
+        char app_version[32];
+        if (!load_char("app_version", app_version, sizeof(app_version)))
+        {
+            FURI_LOG_E(TAG, "Failed to load app version");
+            return false;
+        }
+        FURI_LOG_I(TAG, "App version: %s", app_version);
+        FURI_LOG_I(TAG, "Server version: %s", version_str);
+        // Check if the app version is different from the server version.
+        if (!is_str(app_version, version_str))
+        {
+            easy_flipper_dialog("Update available", "New update available!\nPress BACK to download.");
+            return true; // Update available.
+        }
+        FURI_LOG_I(TAG, "No update available");
+        return false; // No update available.
+    }
+    FURI_LOG_I(TAG, "Not enough time has passed since the last update check");
+    return false; // Not yet time to update.
+}
+
+static bool flip_world_get_fap_file(FlipperHTTP *fhttp, bool flipper_server)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL.");
+        return false;
+    }
+    char url[256];
+    fhttp->save_received_data = false;
+    fhttp->is_bytes_request = true;
+#ifndef FW_ORIGIN_Momentum
+    snprintf(
+        fhttp->file_path,
+        sizeof(fhttp->file_path),
+        STORAGE_EXT_PATH_PREFIX "/apps/GPIO/flip_world.fap");
+#else
+    snprintf(
+        fhttp->file_path,
+        sizeof(fhttp->file_path),
+        STORAGE_EXT_PATH_PREFIX "/apps/GPIO/FlipperHTTP/flip_world.fap");
+#endif
+    if (flipper_server)
+    {
+        char build_id[32];
+        snprintf(build_id, sizeof(build_id), "%s", BUILD_ID);
+        uint8_t target;
+        target = furi_hal_version_get_hw_target();
+        uint16_t api_major, api_minor;
+        furi_hal_info_get_api_version(&api_major, &api_minor);
+        snprintf(
+            url,
+            sizeof(url),
+            "https://catalog.flipperzero.one/api/v0/application/version/%s/build/compatible?target=f%d&api=%d.%d",
+            build_id,
+            target,
+            api_major,
+            api_minor);
+    }
+    else
+    {
+        snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/app/download/flip_world/");
+    }
+    return flipper_http_request(fhttp, BYTES, url, "{\"Content-Type\": \"application/octet-stream\"}", NULL);
+}
+
+// Updates the app. Uses the supplied current time for validating if update check should proceed.
+static bool flip_world_update_app(FlipperHTTP *fhttp, DateTime *time_current, bool use_flipper_api)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    if (!flip_world_last_app_update(fhttp, use_flipper_api))
+    {
+        FURI_LOG_E(TAG, "Failed to fetch last app update");
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    furi_timer_start(fhttp->get_timeout_timer, TIMEOUT_DURATION_TICKS);
+    while (fhttp->state == RECEIVING && furi_timer_is_running(fhttp->get_timeout_timer) > 0)
+    {
+        furi_delay_ms(100);
+    }
+    furi_timer_stop(fhttp->get_timeout_timer);
+    if (flip_world_parse_last_app_update(fhttp, time_current, use_flipper_api))
+    {
+        if (!flip_world_get_fap_file(fhttp, false))
+        {
+            FURI_LOG_E(TAG, "Failed to fetch fap file 1");
+            return false;
+        }
+        fhttp->state = RECEIVING;
+
+        while (fhttp->state == RECEIVING)
+        {
+            furi_delay_ms(100);
+        }
+
+        if (fhttp->state == ISSUE)
+        {
+            FURI_LOG_E(TAG, "Failed to fetch fap file 2");
+            easy_flipper_dialog("Update Error", "Failed to download the\nupdate file.\nPlease try again.");
+            return false;
+        }
+        return true;
+    }
+
+    FURI_LOG_I(TAG, "No update available");
+    return false; // No update available.
+}
+
+// Handles the app update routine. This function obtains the current RTC time,
+// checks the "last_checked" value, and if it is more than one hour old, calls for an update.
+bool flip_world_handle_app_update(FlipperHTTP *fhttp, bool use_flipper_api)
+{
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "fhttp is NULL");
+        return false;
+    }
+    DateTime rtc_time;
+    furi_hal_rtc_get_datetime(&rtc_time);
+    char last_checked[32];
+    if (!load_char("last_checked", last_checked, sizeof(last_checked)))
+    {
+        // First time – save the current time and check for an update.
+        if (!flip_world_save_rtc_time(&rtc_time))
+        {
+            FURI_LOG_E(TAG, "Failed to save RTC time");
+            return false;
+        }
+        return flip_world_update_app(fhttp, &rtc_time, use_flipper_api);
+    }
+    else
+    {
+        // Check if the current RTC time is at least one hour past the stored time.
+        if (flip_world_is_update_time(&rtc_time))
+        {
+            if (!flip_world_update_app(fhttp, &rtc_time, use_flipper_api))
+            {
+                FURI_LOG_E(TAG, "Failed to update app");
+                // save the current time for the next check.
+                if (!flip_world_save_rtc_time(&rtc_time))
+                {
+                    FURI_LOG_E(TAG, "Failed to save RTC time");
+                    return false;
+                }
+                return false;
+            }
+            // Save the current time for the next check.
+            if (!flip_world_save_rtc_time(&rtc_time))
+            {
+                FURI_LOG_E(TAG, "Failed to save RTC time");
+                return false;
+            }
+            return true;
+        }
+        return false; // No update necessary.
+    }
+}

+ 21 - 11
flip_world/flip_world.h

@@ -15,8 +15,12 @@
 //
 
 #define TAG "FlipWorld"
-#define VERSION 0.7
+#define VERSION "0.8.1"
 #define VERSION_TAG TAG " " FAP_VERSION
+//
+
+#define APP_ID "67f22e9a25a4a6f1fb4a2c4a"
+#define BUILD_ID "676900d983aa88302bc114c6"
 
 // Define the submenu items for our FlipWorld application
 typedef enum
@@ -30,6 +34,7 @@ typedef enum
     FlipWorldSubmenuIndexWiFiSettings,
     FlipWorldSubmenuIndexGameSettings,
     FlipWorldSubmenuIndexUserSettings,
+    FlipWorldSubmenuIndexLobby,
 } FlipWorldSubmenuIndex;
 
 // Define a single view for our FlipWorld application
@@ -37,8 +42,11 @@ typedef enum
 {
     FlipWorldViewSubmenu,          // The submenu
     FlipWorldViewGameSubmenu,      // The game submenu
+    FlipWorldViewSubmenuOther,     // The submenu used by settings and lobby
     FlipWorldViewMessage,          // The about, loading screen
     FlipWorldViewSettings,         // The settings screen
+    FlipWorldViewLobby,            // The lobby screen
+    FlipWorldViewWaitingLobby,     // The waiting lobby screen
     FlipWorldViewVariableItemList, // The variable item list screen
     FlipWorldViewTextInput,        // The text input screen
     //
@@ -63,7 +71,7 @@ typedef struct
     View *view_message;                    // The about, loading screen
     Submenu *submenu;                      // The submenu
     Submenu *submenu_game;                 // The game submenu
-    Submenu *submenu_settings;             // The settings submenu
+    Submenu *submenu_other;                // submenu used by settings and lobby
     VariableItemList *variable_item_list;  // The variable item list (settngs)
     VariableItem *variable_item_wifi_ssid; // The variable item for WiFi SSID
     VariableItem *variable_item_wifi_pass; // The variable item for WiFi password
@@ -84,21 +92,23 @@ typedef struct
     char *text_input_buffer;         // Buffer for the text input
     char *text_input_temp_buffer;    // Temporary buffer for the text input
     uint32_t text_input_buffer_size; // Size of the text input buffer
+    //
 } FlipWorldApp;
 
 extern char *fps_choices_str[];
-extern int fps_index;
+extern uint8_t fps_index;
 extern char *yes_or_no_choices[];
-extern int screen_always_on_index;
-extern int sound_on_index;
-extern int vibration_on_index;
+extern uint8_t screen_always_on_index;
+extern uint8_t sound_on_index;
+extern uint8_t vibration_on_index;
 extern char *player_sprite_choices[];
-extern int player_sprite_index;
+extern uint8_t player_sprite_index;
 extern char *vgm_levels[];
-extern int vgm_x_index;
-extern int vgm_y_index;
-extern int game_mode_index;
+extern uint8_t vgm_x_index;
+extern uint8_t vgm_y_index;
+extern uint8_t game_mode_index;
 float atof_(const char *nptr);
 float atof_furi(const FuriString *nptr);
 bool is_str(const char *src, const char *dst);
-bool is_enough_heap(size_t heap_size);
+bool is_enough_heap(size_t heap_size, bool check_blocks);
+bool flip_world_handle_app_update(FlipperHTTP *fhttp, bool use_flipper_api);

+ 80 - 80
flip_world/flipper_http/flipper_http.c

@@ -153,22 +153,26 @@ static void get_timeout_timer_callback(void *context)
 }
 
 static void flipper_http_rx_callback(const char *line, void *context); // forward declaration
+// Instead of two globals, we use a single static pointer to the active instance.
+static FlipperHTTP *active_fhttp = NULL;
 
-// UART initialization function
-/**
- * @brief      Initialize UART.
- * @return     FlipperHTTP context if the UART was initialized successfully, NULL otherwise.
- * @note       The received data will be handled asynchronously via the callback.
- */
 FlipperHTTP *flipper_http_alloc()
 {
-    FlipperHTTP *fhttp = (FlipperHTTP *)malloc(sizeof(FlipperHTTP));
+    // If an active instance already exists, free it first.
+    if (active_fhttp != NULL)
+    {
+        FURI_LOG_E(HTTP_TAG, "Existing UART instance detected, freeing previous instance.");
+        flipper_http_free(active_fhttp);
+        active_fhttp = NULL;
+    }
+
+    FlipperHTTP *fhttp = malloc(sizeof(FlipperHTTP));
     if (!fhttp)
     {
         FURI_LOG_E(HTTP_TAG, "Failed to allocate FlipperHTTP.");
         return NULL;
     }
-    memset(fhttp, 0, sizeof(FlipperHTTP)); // Initialize allocated memory to zero
+    memset(fhttp, 0, sizeof(FlipperHTTP));
 
     fhttp->flipper_http_stream = furi_stream_buffer_alloc(RX_BUF_SIZE, 1);
     if (!fhttp->flipper_http_stream)
@@ -189,7 +193,7 @@ FlipperHTTP *flipper_http_alloc()
 
     furi_thread_set_name(fhttp->rx_thread, "FlipperHTTP_RxThread");
     furi_thread_set_stack_size(fhttp->rx_thread, 1024);
-    furi_thread_set_context(fhttp->rx_thread, fhttp); // Corrected context
+    furi_thread_set_context(fhttp->rx_thread, fhttp);
     furi_thread_set_callback(fhttp->rx_thread, flipper_http_worker);
 
     fhttp->handle_rx_line_cb = flipper_http_rx_callback;
@@ -198,24 +202,11 @@ FlipperHTTP *flipper_http_alloc()
     furi_thread_start(fhttp->rx_thread);
     fhttp->rx_thread_id = furi_thread_get_id(fhttp->rx_thread);
 
-    // Handle when the UART control is busy to avoid furi_check failed
-    if (furi_hal_serial_control_is_busy(UART_CH))
-    {
-        FURI_LOG_E(HTTP_TAG, "UART control is busy.");
-        // Cleanup resources
-        furi_thread_flags_set(fhttp->rx_thread_id, WorkerEvtStop);
-        furi_thread_join(fhttp->rx_thread);
-        furi_thread_free(fhttp->rx_thread);
-        furi_stream_buffer_free(fhttp->flipper_http_stream);
-        free(fhttp);
-        return NULL;
-    }
-
+    // Acquire UART control
     fhttp->serial_handle = furi_hal_serial_control_acquire(UART_CH);
     if (!fhttp->serial_handle)
     {
         FURI_LOG_E(HTTP_TAG, "Failed to acquire UART control - handle is NULL");
-        // Cleanup resources
         furi_thread_flags_set(fhttp->rx_thread_id, WorkerEvtStop);
         furi_thread_join(fhttp->rx_thread);
         furi_thread_free(fhttp->rx_thread);
@@ -224,29 +215,17 @@ FlipperHTTP *flipper_http_alloc()
         return NULL;
     }
 
-    // Initialize UART with acquired handle
+    // Initialize and enable UART
     furi_hal_serial_init(fhttp->serial_handle, BAUDRATE);
-
-    // Enable RX direction
     furi_hal_serial_enable_direction(fhttp->serial_handle, FuriHalSerialDirectionRx);
-
-    // Start asynchronous RX with the corrected callback and context
-    furi_hal_serial_async_rx_start(fhttp->serial_handle, _flipper_http_rx_callback, fhttp, false); // Corrected context
-
-    // Wait for the TX to complete to ensure UART is ready
+    furi_hal_serial_async_rx_start(fhttp->serial_handle, _flipper_http_rx_callback, fhttp, false);
     furi_hal_serial_tx_wait_complete(fhttp->serial_handle);
 
-    // Allocate the timer for handling timeouts
-    fhttp->get_timeout_timer = furi_timer_alloc(
-        get_timeout_timer_callback, // Callback function
-        FuriTimerTypeOnce,          // One-shot timer
-        fhttp                       // Corrected context
-    );
-
+    // Allocate the timeout timer
+    fhttp->get_timeout_timer = furi_timer_alloc(get_timeout_timer_callback, FuriTimerTypeOnce, fhttp);
     if (!fhttp->get_timeout_timer)
     {
         FURI_LOG_E(HTTP_TAG, "Failed to allocate HTTP request timeout timer.");
-        // Cleanup resources
         furi_hal_serial_async_rx_stop(fhttp->serial_handle);
         furi_hal_serial_disable_direction(fhttp->serial_handle, FuriHalSerialDirectionRx);
         furi_hal_serial_control_release(fhttp->serial_handle);
@@ -258,15 +237,12 @@ FlipperHTTP *flipper_http_alloc()
         free(fhttp);
         return NULL;
     }
-
-    // Set the timer thread priority if needed
     furi_timer_set_thread_priority(FuriTimerThreadPriorityElevated);
 
-    fhttp->last_response = (char *)malloc(RX_BUF_SIZE);
+    fhttp->last_response = malloc(RX_BUF_SIZE);
     if (!fhttp->last_response)
     {
         FURI_LOG_E(HTTP_TAG, "Failed to allocate memory for last_response.");
-        // Cleanup resources
         furi_timer_free(fhttp->get_timeout_timer);
         furi_hal_serial_async_rx_stop(fhttp->serial_handle);
         furi_hal_serial_disable_direction(fhttp->serial_handle, FuriHalSerialDirectionRx);
@@ -279,21 +255,15 @@ FlipperHTTP *flipper_http_alloc()
         free(fhttp);
         return NULL;
     }
-    memset(fhttp->last_response, 0, RX_BUF_SIZE); // Initialize last_response
-
+    memset(fhttp->last_response, 0, RX_BUF_SIZE);
     fhttp->state = IDLE;
 
-    // FURI_LOG_I(HTTP_TAG, "UART initialized successfully.");
+    // Track the active instance globally.
+    active_fhttp = fhttp;
+
     return fhttp;
 }
 
-// Deinitialize UART
-/**
- * @brief      Deinitialize UART.
- * @return     void
- * @param fhttp The FlipperHTTP context
- * @note       This function will stop the asynchronous RX, release the serial handle, and free the resources.
- */
 void flipper_http_free(FlipperHTTP *fhttp)
 {
     if (!fhttp)
@@ -306,43 +276,42 @@ void flipper_http_free(FlipperHTTP *fhttp)
         FURI_LOG_E(HTTP_TAG, "UART handle is NULL. Already deinitialized?");
         return;
     }
-    // Stop asynchronous RX
+    // Stop asynchronous RX and clean up UART
     furi_hal_serial_async_rx_stop(fhttp->serial_handle);
-
-    // Release and deinitialize the serial handle
     furi_hal_serial_disable_direction(fhttp->serial_handle, FuriHalSerialDirectionRx);
-    furi_hal_serial_control_release(fhttp->serial_handle);
     furi_hal_serial_deinit(fhttp->serial_handle);
+    furi_hal_serial_control_release(fhttp->serial_handle);
 
-    // Signal the worker thread to stop
+    // Signal and free the worker thread
     furi_thread_flags_set(fhttp->rx_thread_id, WorkerEvtStop);
-    // Wait for the thread to finish
     furi_thread_join(fhttp->rx_thread);
-    // Free the thread resources
     furi_thread_free(fhttp->rx_thread);
 
     // Free the stream buffer
     furi_stream_buffer_free(fhttp->flipper_http_stream);
 
-    // Free the timer
+    // Free the timer, if allocated
     if (fhttp->get_timeout_timer)
     {
         furi_timer_free(fhttp->get_timeout_timer);
         fhttp->get_timeout_timer = NULL;
     }
 
-    // Free the last response
+    // Free the last_response buffer
     if (fhttp->last_response)
     {
         free(fhttp->last_response);
         fhttp->last_response = NULL;
     }
 
-    // Free the FlipperHTTP context
-    free(fhttp);
-    fhttp = NULL;
+    // If this instance is the active instance, clear the static pointer.
+    if (active_fhttp == fhttp)
+    {
+        free(active_fhttp);
+        active_fhttp = NULL;
+    }
 
-    // FURI_LOG_I("FlipperHTTP", "UART deinitialized successfully.");
+    free(fhttp);
 }
 
 /**
@@ -446,32 +415,43 @@ FuriString *flipper_http_load_from_file(char *file_path)
         return NULL;
     }
 
-    // Allocate a FuriString to hold the received data
-    FuriString *str_result = furi_string_alloc();
-    if (!str_result)
+    size_t file_size = storage_file_size(file);
+
+    // final memory check
+    if (memmgr_heap_get_max_free_block() < file_size)
     {
-        FURI_LOG_E(HTTP_TAG, "Failed to allocate FuriString");
+        FURI_LOG_E(HTTP_TAG, "Not enough heap to read file.");
         storage_file_close(file);
         storage_file_free(file);
         furi_record_close(RECORD_STORAGE);
         return NULL;
     }
 
-    // Reset the FuriString to ensure it's empty before reading
-    furi_string_reset(str_result);
-
-    // Define a buffer to hold the read data
-    uint8_t *buffer = (uint8_t *)malloc(MAX_FILE_SHOW);
+    // Allocate a buffer to hold the read data
+    uint8_t *buffer = (uint8_t *)malloc(file_size);
     if (!buffer)
     {
         FURI_LOG_E(HTTP_TAG, "Failed to allocate buffer");
-        furi_string_free(str_result);
         storage_file_close(file);
         storage_file_free(file);
         furi_record_close(RECORD_STORAGE);
         return NULL;
     }
 
+    // Allocate a FuriString to hold the received data
+    FuriString *str_result = furi_string_alloc();
+    if (!str_result)
+    {
+        FURI_LOG_E(HTTP_TAG, "Failed to allocate FuriString");
+        storage_file_close(file);
+        storage_file_free(file);
+        furi_record_close(RECORD_STORAGE);
+        return NULL;
+    }
+
+    // Reset the FuriString to ensure it's empty before reading
+    furi_string_reset(str_result);
+
     // Read data into the buffer
     size_t read_count = storage_file_read(file, buffer, MAX_FILE_SHOW);
     if (storage_file_get_error(file) != FSE_OK)
@@ -506,6 +486,12 @@ FuriString *flipper_http_load_from_file(char *file_path)
  */
 FuriString *flipper_http_load_from_file_with_limit(char *file_path, size_t limit)
 {
+    if (memmgr_heap_get_max_free_block() < limit)
+    {
+        FURI_LOG_E(HTTP_TAG, "Not enough heap to read file.");
+        return NULL;
+    }
+
     // Open the storage record
     Storage *storage = furi_record_open(RECORD_STORAGE);
     if (!storage)
@@ -532,7 +518,19 @@ FuriString *flipper_http_load_from_file_with_limit(char *file_path, size_t limit
         return NULL;
     }
 
-    if (memmgr_get_free_heap() < limit)
+    size_t file_size = storage_file_size(file);
+
+    if (file_size > limit)
+    {
+        FURI_LOG_E(HTTP_TAG, "File size exceeds limit: %d > %d", file_size, limit);
+        storage_file_close(file);
+        storage_file_free(file);
+        furi_record_close(RECORD_STORAGE);
+        return NULL;
+    }
+
+    // final memory check
+    if (memmgr_heap_get_max_free_block() < file_size)
     {
         FURI_LOG_E(HTTP_TAG, "Not enough heap to read file.");
         storage_file_close(file);
@@ -542,7 +540,7 @@ FuriString *flipper_http_load_from_file_with_limit(char *file_path, size_t limit
     }
 
     // Allocate a buffer to hold the read data
-    uint8_t *buffer = (uint8_t *)malloc(limit);
+    uint8_t *buffer = (uint8_t *)malloc(file_size);
     if (!buffer)
     {
         FURI_LOG_E(HTTP_TAG, "Failed to allocate buffer");
@@ -563,10 +561,10 @@ FuriString *flipper_http_load_from_file_with_limit(char *file_path, size_t limit
         furi_record_close(RECORD_STORAGE);
         return NULL;
     }
-    furi_string_reserve(str_result, limit);
+    furi_string_reserve(str_result, file_size);
 
     // Read data into the buffer
-    size_t read_count = storage_file_read(file, buffer, limit);
+    size_t read_count = storage_file_read(file, buffer, file_size);
     if (storage_file_get_error(file) != FSE_OK)
     {
         FURI_LOG_E(HTTP_TAG, "Error reading from file.");
@@ -945,6 +943,8 @@ bool flipper_http_send_command(FlipperHTTP *fhttp, HTTPCommand command)
     case HTTP_CMD_PING:
         fhttp->state = INACTIVE; // set state as INACTIVE to be made IDLE if PONG is received
         return flipper_http_send_data(fhttp, "[PING]");
+    case HTTP_CMD_REBOOT:
+        return flipper_http_send_data(fhttp, "[REBOOT]");
     default:
         FURI_LOG_E(HTTP_TAG, "Invalid command.");
         return false;

+ 3 - 3
flip_world/flipper_http/flipper_http.h

@@ -21,7 +21,6 @@
 
 #undef FURI_LOG_I
 #define FURI_LOG_I(tag, msg, ...)
-//
 
 #define HTTP_TAG "FlipWorld"              // change this to your app name
 #define http_tag "flip_world"             // change this to your app id
@@ -29,7 +28,7 @@
 #define TIMEOUT_DURATION_TICKS (5 * 1000) // 5 seconds
 #define BAUDRATE (115200)                 // UART baudrate
 #define RX_BUF_SIZE 2048                  // UART RX buffer size
-#define RX_LINE_BUFFER_SIZE 3000          // UART RX line buffer size (increase for large responses)
+#define RX_LINE_BUFFER_SIZE 1024          // UART RX line buffer size (increase for large responses)
 #define MAX_FILE_SHOW 3000                // Maximum data from file to show
 #define FILE_BUFFER_SIZE 512              // File buffer size
 
@@ -74,7 +73,8 @@ typedef enum
     HTTP_CMD_LIST_COMMANDS,
     HTTP_CMD_LED_ON,
     HTTP_CMD_LED_OFF,
-    HTTP_CMD_PING
+    HTTP_CMD_PING,
+    HTTP_CMD_REBOOT
 } HTTPCommand; // list of non-input commands
 
 // FlipperHTTP Structure

+ 14 - 50
flip_world/game/draw.c

@@ -1,14 +1,14 @@
 #include <game/draw.h>
 
 // Global variables to store camera position
-int camera_x = 0;
-int camera_y = 0;
+int draw_camera_x = 0;
+int draw_camera_y = 0;
 
 // Draw the user stats (health, xp, and level)
 void draw_user_stats(Canvas *canvas, Vector pos, GameManager *manager)
 {
     GameContext *game_context = game_manager_game_context_get(manager);
-    PlayerContext *player = game_context->player_context;
+    PlayerContext *player = entity_context_get(game_context->player);
 
     // first draw a black rectangle to make the text more readable
     canvas_invert_color(canvas);
@@ -38,55 +38,18 @@ void draw_username(Canvas *canvas, Vector pos, char *username)
     // first draw a black rectangle to make the text more readable
     // draw box around the username
     canvas_invert_color(canvas);
-    canvas_draw_box(canvas, pos.x - camera_x - (strlen(username) * 2) - 1, pos.y - camera_y - 14, strlen(username) * 4 + 1, 8);
+    canvas_draw_box(canvas, pos.x - draw_camera_x - (strlen(username) * 2) - 1, pos.y - draw_camera_y - 14, strlen(username) * 4 + 1, 8);
     canvas_invert_color(canvas);
 
     // draw username over player's head
     canvas_set_font_custom(canvas, FONT_SIZE_SMALL);
-    canvas_draw_str(canvas, pos.x - camera_x - (strlen(username) * 2), pos.y - camera_y - 7, username);
-}
-
-char g_name[32];
-// Draw an icon at a specific position (with collision detection)
-void spawn_icon(GameManager *manager, Level *level, const char *icon_id, float x, float y)
-{
-    snprintf(g_name, sizeof(g_name), "%s", icon_id);
-    Entity *e = level_add_entity(level, &icon_desc);
-    entity_pos_set(e, (Vector){x, y});
-    GameContext *game_context = game_manager_game_context_get(manager);
-    game_context->icon_count++;
-}
-// Draw a line of icons at a specific position (with collision detection)
-void spawn_icon_line(GameManager *manager, Level *level, const char *icon_id, float x, float y, uint8_t amount, bool horizontal, uint8_t spacing)
-{
-    for (int i = 0; i < amount; i++)
-    {
-        if (horizontal)
-        {
-            // check if element is outside the world
-            if (x + (i * spacing) > WORLD_WIDTH)
-            {
-                break;
-            }
-
-            spawn_icon(manager, level, icon_id, x + (i * spacing), y);
-        }
-        else
-        {
-            // check if element is outside the world
-            if (y + (i * spacing) > WORLD_HEIGHT)
-            {
-                break;
-            }
-
-            spawn_icon(manager, level, icon_id, x, y + (i * spacing));
-        }
-    }
+    canvas_draw_str(canvas, pos.x - draw_camera_x - (strlen(username) * 2), pos.y - draw_camera_y - 7, username);
 }
 
 static void draw_menu(GameManager *manager, Canvas *canvas)
 {
     GameContext *game_context = game_manager_game_context_get(manager);
+    PlayerContext *player_context = entity_context_get(game_context->player);
 
     // draw background rectangle
     canvas_draw_icon(
@@ -115,12 +78,12 @@ static void draw_menu(GameManager *manager, Canvas *canvas)
             char level[32];
             char strength[32];
 
-            snprintf(level, sizeof(level), "Level   : %ld", game_context->player_context->level);
-            snprintf(health, sizeof(health), "Health  : %ld", game_context->player_context->health);
-            snprintf(xp, sizeof(xp), "XP      : %ld", game_context->player_context->xp);
-            snprintf(strength, sizeof(strength), "Strength: %ld", game_context->player_context->strength);
+            snprintf(level, sizeof(level), "Level   : %ld", player_context->level);
+            snprintf(health, sizeof(health), "Health  : %ld", player_context->health);
+            snprintf(xp, sizeof(xp), "XP      : %ld", player_context->xp);
+            snprintf(strength, sizeof(strength), "Strength: %ld", player_context->strength);
             canvas_set_font(canvas, FontPrimary);
-            canvas_draw_str(canvas, 7, 16, game_context->player_context->username);
+            canvas_draw_str(canvas, 7, 16, player_context->username);
             canvas_set_font_custom(canvas, FONT_SIZE_SMALL);
             canvas_draw_str(canvas, 7, 30, level);
             canvas_draw_str(canvas, 7, 37, health);
@@ -171,19 +134,20 @@ static void draw_menu(GameManager *manager, Canvas *canvas)
     }
 }
 
-void background_render(Canvas *canvas, GameManager *manager)
+void draw_background_render(Canvas *canvas, GameManager *manager)
 {
     if (!canvas || !manager)
         return;
 
     GameContext *game_context = game_manager_game_context_get(manager);
+    PlayerContext *player_context = entity_context_get(game_context->player);
     if (!game_context->is_menu_open)
     {
         // get player position
         Vector posi = entity_pos_get(game_context->player);
 
         // draw username over player's head
-        draw_username(canvas, posi, game_context->player_context->username);
+        draw_username(canvas, posi, player_context->username);
 
         if (game_context->is_switching_level)
             // draw switch world icon

+ 3 - 8
flip_world/game/draw.h

@@ -2,13 +2,8 @@
 #include "game.h"
 #include "game/icon.h"
 #include <game/player.h>
-
-// Global variables to store camera position
-extern int camera_x;
-extern int camera_y;
+extern int draw_camera_x;
+extern int draw_camera_y;
 void draw_user_stats(Canvas *canvas, Vector pos, GameManager *manager);
 void draw_username(Canvas *canvas, Vector pos, char *username);
-void spawn_icon(GameManager *manager, Level *level, const char *icon_id, float x, float y);
-void spawn_icon_line(GameManager *manager, Level *level, const char *icon_id, float x, float y, uint8_t amount, bool horizontal, uint8_t spacing);
-extern char g_name[32];
-void background_render(Canvas *canvas, GameManager *manager);
+void draw_background_render(Canvas *canvas, GameManager *manager);

+ 325 - 149
flip_world/game/enemy.c

@@ -1,12 +1,13 @@
 // enemy.c
 #include <game/enemy.h>
 #include <notification/notification_messages.h>
+#include <flip_storage/storage.h>
+#include <game/storage.h>
 
 static EntityContext *enemy_context_generic;
-
 // Allocation function
 static EntityContext *enemy_generic_alloc(
-    const char *id,
+    SpriteID id,
     int index,
     Vector size,
     Vector start_position,
@@ -15,7 +16,9 @@ static EntityContext *enemy_generic_alloc(
     float speed,
     float attack_timer,
     float strength,
-    float health)
+    float health,
+    bool is_user,
+    FuriString *username)
 {
     if (!enemy_context_generic)
     {
@@ -26,7 +29,7 @@ static EntityContext *enemy_generic_alloc(
         FURI_LOG_E("Game", "Failed to allocate EntityContext");
         return NULL;
     }
-    snprintf(enemy_context_generic->id, sizeof(enemy_context_generic->id), "%s", id);
+    enemy_context_generic->id = id;
     enemy_context_generic->index = index;
     enemy_context_generic->size = size;
     enemy_context_generic->start_position = start_position;
@@ -44,6 +47,17 @@ static EntityContext *enemy_generic_alloc(
     enemy_context_generic->state = ENTITY_MOVING_TO_END; // Start in IDLE state
     // Set radius based on size, for example, average of size.x and size.y divided by 2
     enemy_context_generic->radius = (size.x + size.y) / 4.0f;
+    //
+    enemy_context_generic->is_user = is_user;
+    //
+    if (username != NULL)
+    {
+        snprintf(enemy_context_generic->username, sizeof(enemy_context_generic->username), "%s", furi_string_get_cstr(username));
+    }
+    else
+    {
+        snprintf(enemy_context_generic->username, sizeof(enemy_context_generic->username), "SYSTEM_ENEMY");
+    }
     return enemy_context_generic;
 }
 
@@ -64,7 +78,7 @@ static void enemy_start(Entity *self, GameManager *manager, void *context)
 
     EntityContext *enemy_context = (EntityContext *)context;
     // Copy fields from generic context
-    snprintf(enemy_context->id, sizeof(enemy_context->id), "%s", enemy_context_generic->id);
+    enemy_context->id = enemy_context_generic->id;
     enemy_context->index = enemy_context_generic->index;
     enemy_context->size = enemy_context_generic->size;
     enemy_context->start_position = enemy_context_generic->start_position;
@@ -80,6 +94,8 @@ static void enemy_start(Entity *self, GameManager *manager, void *context)
     enemy_context->direction = enemy_context_generic->direction;
     enemy_context->state = enemy_context_generic->state;
     enemy_context->radius = enemy_context_generic->radius;
+    enemy_context->is_user = enemy_context_generic->is_user;
+    snprintf(enemy_context->username, sizeof(enemy_context->username), "%s", enemy_context_generic->username);
 
     // Set enemy's initial position based on start_position
     entity_pos_set(self, enemy_context->start_position);
@@ -101,8 +117,8 @@ static void enemy_render(Entity *self, GameManager *manager, Canvas *canvas, voi
     Vector pos = entity_pos_get(self);
 
     // Get the camera position
-    int x_pos = pos.x - camera_x - enemy_context->size.x / 2;
-    int y_pos = pos.y - camera_y - enemy_context->size.y / 2;
+    int x_pos = pos.x - draw_camera_x - enemy_context->size.x / 2;
+    int y_pos = pos.y - draw_camera_y - enemy_context->size.y / 2;
 
     // check if position is within the screen
     if (x_pos + enemy_context->size.x < 0 || x_pos > SCREEN_WIDTH || y_pos + enemy_context->size.y < 0 || y_pos > SCREEN_HEIGHT)
@@ -127,8 +143,8 @@ static void enemy_render(Entity *self, GameManager *manager, Canvas *canvas, voi
         canvas_draw_sprite(
             canvas,
             current_sprite,
-            pos.x - camera_x - (enemy_context->size.x / 2),
-            pos.y - camera_y - (enemy_context->size.y / 2));
+            pos.x - draw_camera_x - (enemy_context->size.x / 2),
+            pos.y - draw_camera_y - (enemy_context->size.y / 2));
 
         // draw health of enemy
         char health_str[32];
@@ -137,7 +153,7 @@ static void enemy_render(Entity *self, GameManager *manager, Canvas *canvas, voi
     }
 }
 
-static void atk_notify(GameContext *game_context, EntityContext *enemy_context, bool player_attacked)
+static void enemy_atk_notify(GameContext *game_context, EntityContext *enemy_context, bool player_attacked)
 {
     if (!game_context || !enemy_context)
     {
@@ -169,7 +185,7 @@ static void atk_notify(GameContext *game_context, EntityContext *enemy_context,
         {
             notification_message(notifications, &sequence_blink_blue_100);
         }
-        FURI_LOG_I("Game", "Player attacked enemy '%s'!", enemy_context->id);
+        FURI_LOG_I("Game", "Player attacked enemy '%d'!", enemy_context->id);
     }
     else
     {
@@ -191,7 +207,7 @@ static void atk_notify(GameContext *game_context, EntityContext *enemy_context,
             notification_message(notifications, &sequence_blink_red_100);
         }
 
-        FURI_LOG_I("Game", "Enemy '%s' attacked the player!", enemy_context->id);
+        FURI_LOG_I("Game", "Enemy '%d' attacked the player!", enemy_context->id);
     }
 
     // close the notifications
@@ -210,6 +226,7 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
     furi_check(enemy_context, "Enemy collision: EntityContext is NULL");
     GameContext *game_context = game_manager_game_context_get(manager);
     furi_check(game_context, "Enemy collision: GameContext is NULL");
+    PlayerContext *player_context = entity_context_get(game_context->player);
     if (game_context->game_mode == GAME_MODE_STORY && game_context->tutorial_step != 4)
     {
         // FURI_LOG_I("Game", "Enemy collision: No enemies in story mode");
@@ -237,10 +254,10 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
         }
 
         // Determine if the player is facing the enemy
-        if ((game_context->player_context->direction == ENTITY_LEFT && enemy_pos.x < player_pos.x) ||
-            (game_context->player_context->direction == ENTITY_RIGHT && enemy_pos.x > player_pos.x) ||
-            (game_context->player_context->direction == ENTITY_UP && enemy_pos.y < player_pos.y) ||
-            (game_context->player_context->direction == ENTITY_DOWN && enemy_pos.y > player_pos.y))
+        if ((player_context->direction == ENTITY_LEFT && enemy_pos.x < player_pos.x) ||
+            (player_context->direction == ENTITY_RIGHT && enemy_pos.x > player_pos.x) ||
+            (player_context->direction == ENTITY_UP && enemy_pos.y < player_pos.y) ||
+            (player_context->direction == ENTITY_DOWN && enemy_pos.y > player_pos.y))
         {
             player_is_facing_enemy = true;
         }
@@ -250,37 +267,47 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
         {
             if (game_context->game_mode == GAME_MODE_STORY && game_context->tutorial_step == 4)
             {
-                // FURI_LOG_I("Game", "Player attacked enemy '%s'!", enemy_context->id);
+                // FURI_LOG_I("Game", "Player attacked enemy '%d'!", enemy_context->id);
                 game_context->tutorial_step++;
             }
             // Reset last button
             game_context->last_button = -1;
 
-            if (game_context->player_context->elapsed_attack_timer >= game_context->player_context->attack_timer)
+            if (player_context->elapsed_attack_timer >= player_context->attack_timer)
             {
-                atk_notify(game_context, enemy_context, true);
+                enemy_atk_notify(game_context, enemy_context, true);
 
                 // Reset player's elapsed attack timer
-                game_context->player_context->elapsed_attack_timer = 0.0f;
+                player_context->elapsed_attack_timer = 0.0f;
                 enemy_context->elapsed_attack_timer = 0.0f; // Reset enemy's attack timer to block enemy attack
 
                 // Increase XP by the enemy's strength
-                game_context->player_context->xp += enemy_context->strength;
+                player_context->xp += enemy_context->strength;
 
                 // Increase healthy by 10% of the enemy's strength
-                game_context->player_context->health += enemy_context->strength * 0.1f;
-                if (game_context->player_context->health > game_context->player_context->max_health)
+                player_context->health += enemy_context->strength * 0.1f;
+                if (player_context->health > player_context->max_health)
                 {
-                    game_context->player_context->health = game_context->player_context->max_health;
+                    player_context->health = player_context->max_health;
                 }
 
                 // Decrease enemy health by player strength
-                enemy_context->health -= game_context->player_context->strength;
+                enemy_context->health -= player_context->strength;
 
                 if (enemy_context->health <= 0)
                 {
                     enemy_context->state = ENTITY_DEAD;
 
+                    // if pvp, end the game
+                    if (game_context->game_mode == GAME_MODE_PVP)
+                    {
+                        player_context->health = player_context->max_health;
+                        save_player_context(player_context);
+                        furi_delay_ms(100);
+                        game_manager_game_stop(manager);
+                        return;
+                    }
+
                     // Reset enemy position and health
                     enemy_context->health = 100; // this needs to be set to the enemy's max health
 
@@ -296,18 +323,18 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
                     enemy_context->state = ENTITY_ATTACKED;
                     // Vector old_pos = entity_pos_get(self);
                     //  Bounce the enemy back by X units opposite their last movement direction
-                    enemy_pos.x -= game_context->player_context->dx * enemy_context->radius + game_context->icon_offset;
-                    // enemy_pos.y -= game_context->player_context->dy * enemy_context->radius + game_context->icon_offset;
+                    enemy_pos.x -= player_context->dx * enemy_context->radius + game_context->icon_offset;
+                    // enemy_pos.y -= player_context->dy * enemy_context->radius + game_context->icon_offset;
                     entity_pos_set(self, enemy_pos);
 
                     // Reset enemy's movement direction to prevent immediate re-collision
-                    game_context->player_context->dx = 0;
-                    game_context->player_context->dy = 0;
+                    player_context->dx = 0;
+                    player_context->dy = 0;
                 }
             }
             else
             {
-                FURI_LOG_I("Game", "Player attack on enemy '%s' is on cooldown: %f seconds remaining", enemy_context->id, (double)(game_context->player_context->attack_timer - game_context->player_context->elapsed_attack_timer));
+                FURI_LOG_I("Game", "Player attack on enemy '%d' is on cooldown: %f seconds remaining", enemy_context->id, (double)(player_context->attack_timer - player_context->elapsed_attack_timer));
             }
         }
         // Handle Enemy Attacking Player (enemy facing player)
@@ -315,60 +342,69 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
         {
             if (enemy_context->elapsed_attack_timer >= enemy_context->attack_timer)
             {
-                atk_notify(game_context, enemy_context, false);
+                enemy_atk_notify(game_context, enemy_context, false);
 
                 // Reset enemy's elapsed attack timer
                 enemy_context->elapsed_attack_timer = 0.0f;
 
                 // Decrease player health by enemy strength
-                game_context->player_context->health -= enemy_context->strength;
+                player_context->health -= enemy_context->strength;
 
-                if (game_context->player_context->health <= 0)
+                if (player_context->health <= 0)
                 {
                     FURI_LOG_I("Game", "Player is dead.. resetting player position and health");
-                    game_context->player_context->state = ENTITY_DEAD;
+                    player_context->state = ENTITY_DEAD;
+
+                    // if pvp, end the game
+                    if (game_context->game_mode == GAME_MODE_PVP)
+                    {
+                        save_player_context(player_context);
+                        furi_delay_ms(100);
+                        game_manager_game_stop(manager);
+                        return;
+                    }
 
                     // Reset player position and health
-                    entity_pos_set(other, game_context->player_context->start_position);
-                    game_context->player_context->health = game_context->player_context->max_health;
+                    entity_pos_set(other, player_context->start_position);
+                    player_context->health = player_context->max_health;
 
                     // subtract player's XP by the enemy's strength
-                    game_context->player_context->xp -= enemy_context->strength;
-                    if ((int)game_context->player_context->xp < 0)
+                    player_context->xp -= enemy_context->strength;
+                    if ((int)player_context->xp < 0)
                     {
-                        game_context->player_context->xp = 0;
+                        player_context->xp = 0;
                     }
                 }
                 else
                 {
-                    FURI_LOG_I("Game", "Player took %f damage from enemy '%s'", (double)enemy_context->strength, enemy_context->id);
-                    game_context->player_context->state = ENTITY_ATTACKED;
+                    FURI_LOG_I("Game", "Player took %f damage from enemy '%d'", (double)enemy_context->strength, enemy_context->id);
+                    player_context->state = ENTITY_ATTACKED;
 
                     // Bounce the player back by X units opposite their last movement direction
-                    player_pos.x -= game_context->player_context->dx * enemy_context->radius + game_context->icon_offset;
-                    // player_pos.y -= game_context->player_context->dy * enemy_context->radius + game_context->icon_offset;
+                    player_pos.x -= player_context->dx * enemy_context->radius + game_context->icon_offset;
+                    // player_pos.y -= player_context->dy * enemy_context->radius + game_context->icon_offset;
                     entity_pos_set(other, player_pos);
 
                     // Reset player's movement direction to prevent immediate re-collision
-                    game_context->player_context->dx = 0;
-                    game_context->player_context->dy = 0;
+                    player_context->dx = 0;
+                    player_context->dy = 0;
                 }
             }
         }
         else // handle other collisions
         {
             // Set the player's old position to prevent collision
-            entity_pos_set(other, game_context->player_context->old_position);
+            entity_pos_set(other, player_context->old_position);
             // Reset player's movement direction to prevent immediate re-collision
-            game_context->player_context->dx = 0;
-            game_context->player_context->dy = 0;
+            player_context->dx = 0;
+            player_context->dy = 0;
         }
 
-        if (game_context->player_context->state == ENTITY_DEAD)
+        if (player_context->state == ENTITY_DEAD)
         {
             // Reset player's position and health
-            entity_pos_set(other, game_context->player_context->start_position);
-            game_context->player_context->health = game_context->player_context->max_health;
+            entity_pos_set(other, player_context->start_position);
+            player_context->health = player_context->max_health;
         }
     }
     // if not player than must be an icon or npc; so push back
@@ -397,6 +433,113 @@ static void enemy_collision(Entity *self, Entity *other, GameManager *manager, v
     }
 }
 
+static void enemy_pvp_position(GameContext *game_context, EntityContext *enemy, Entity *self)
+{
+    if (!game_context || !enemy || !self)
+    {
+        FURI_LOG_E("Game", "PVP position: Invalid parameters");
+        return;
+    }
+
+    if (game_context->fhttp->last_response != NULL && strlen(game_context->fhttp->last_response) > 0)
+    {
+        // for debugging
+        // save_char("received_pvp_position", game_context->fhttp->last_response);
+        // parse the response and set the enemy position
+        /* expected response:
+        {
+            "u": "JBlanked",
+            "xp": 37743,
+            "h": 207,
+            "ehr": 0.7,
+            "eat": 127.5,
+            "d": 2,
+            "s": 1,
+            "sp": {
+                "x": 381.0,
+                "y": 192.0
+            }
+        }
+        */
+
+        // FuriStrings are probably safer but we already last_response as a char*
+
+        // match username
+        char *u = get_json_value("u", game_context->fhttp->last_response);
+        if (!u || !is_str(u, enemy->username))
+        {
+            if (u)
+                free(u);
+            return;
+        }
+
+        // we need the health, elapsed attack timer, direction, and position
+        char *h = get_json_value("h", game_context->fhttp->last_response);
+        char *eat = get_json_value("eat", game_context->fhttp->last_response);
+        char *d = get_json_value("d", game_context->fhttp->last_response);
+        char *sp = get_json_value("sp", game_context->fhttp->last_response);
+        char *x = get_json_value("x", sp);
+        char *y = get_json_value("y", sp);
+
+        if (!h || !eat || !d || !sp || !x || !y)
+        {
+            if (h)
+                free(h);
+            if (eat)
+                free(eat);
+            if (d)
+                free(d);
+            if (sp)
+                free(sp);
+            if (x)
+                free(x);
+            if (y)
+                free(y);
+            free(u);
+            return;
+        }
+
+        // set enemy info
+        enemy->health = (float)atoi(h);
+        enemy->elapsed_attack_timer = (float)atof_(eat);
+        switch (atoi(d))
+        {
+        case 0:
+            enemy->direction = ENTITY_LEFT;
+            break;
+        case 1:
+            enemy->direction = ENTITY_RIGHT;
+            break;
+        case 2:
+            enemy->direction = ENTITY_UP;
+            break;
+        case 3:
+            enemy->direction = ENTITY_DOWN;
+            break;
+        default:
+            enemy->direction = ENTITY_RIGHT;
+            break;
+        }
+
+        Vector new_pos = (Vector){
+            .x = (float)atof_(x),
+            .y = (float)atof_(y),
+        };
+
+        // set enemy position
+        entity_pos_set(self, new_pos);
+
+        // free the strings
+        free(h);
+        free(eat);
+        free(d);
+        free(sp);
+        free(x);
+        free(y);
+        free(u);
+    }
+}
+
 // Enemy update function
 static void enemy_update(Entity *self, GameManager *manager, void *context)
 {
@@ -416,117 +559,127 @@ static void enemy_update(Entity *self, GameManager *manager, void *context)
         return;
     }
 
-    float delta_time = 1.0f / game_context->fps;
-
-    // Increment the elapsed_attack_timer for the enemy
-    enemy_context->elapsed_attack_timer += delta_time;
+    const float delta_time = 1.0f / game_context->fps;
 
-    switch (enemy_context->state)
+    if (game_context->game_mode == GAME_MODE_PVP)
+    {
+        // update enemy position
+        enemy_pvp_position(game_context, enemy_context, self);
+    }
+    else
     {
-    case ENTITY_IDLE:
-        // Increment the elapsed_move_timer
-        enemy_context->elapsed_move_timer += delta_time;
+        // Increment the elapsed_attack_timer for the enemy
+        enemy_context->elapsed_attack_timer += delta_time;
 
-        // Check if it's time to move again
-        if (enemy_context->elapsed_move_timer >= enemy_context->move_timer)
+        switch (enemy_context->state)
         {
-            // Determine the next state based on the current position
-            Vector current_pos = entity_pos_get(self);
-            if (fabs(current_pos.x - enemy_context->start_position.x) < (double)1.0 &&
-                fabs(current_pos.y - enemy_context->start_position.y) < (double)1.0)
-            {
-                enemy_context->state = ENTITY_MOVING_TO_END;
-            }
-            else
+        case ENTITY_IDLE:
+            // Increment the elapsed_move_timer
+            enemy_context->elapsed_move_timer += delta_time;
+
+            // Check if it's time to move again
+            if (enemy_context->elapsed_move_timer >= enemy_context->move_timer)
             {
-                enemy_context->state = ENTITY_MOVING_TO_START;
+                // Determine the next state based on the current position
+                Vector current_pos = entity_pos_get(self);
+                if (fabs(current_pos.x - enemy_context->start_position.x) < (double)1.0 &&
+                    fabs(current_pos.y - enemy_context->start_position.y) < (double)1.0)
+                {
+                    enemy_context->state = ENTITY_MOVING_TO_END;
+                }
+                else
+                {
+                    enemy_context->state = ENTITY_MOVING_TO_START;
+                }
+                enemy_context->elapsed_move_timer = 0.0f;
             }
-            enemy_context->elapsed_move_timer = 0.0f;
-        }
-        break;
 
-    case ENTITY_MOVING_TO_END:
-    case ENTITY_MOVING_TO_START:
-    case ENTITY_ATTACKED:
-    {
-        // Get current position
-        Vector current_pos = entity_pos_get(self);
-        if (enemy_context->state == ENTITY_ATTACKED)
+            break;
+
+        case ENTITY_MOVING_TO_END:
+        case ENTITY_MOVING_TO_START:
+        case ENTITY_ATTACKED:
         {
-            // set direction again
-            enemy_context->state = enemy_context->direction == ENTITY_LEFT ? ENTITY_MOVING_TO_START : ENTITY_MOVING_TO_END;
-        }
+            // Get current position
+            Vector current_pos = entity_pos_get(self);
+            if (enemy_context->state == ENTITY_ATTACKED)
+            {
+                // set direction again
+                enemy_context->state = enemy_context->direction == ENTITY_LEFT ? ENTITY_MOVING_TO_START : ENTITY_MOVING_TO_END;
+            }
 
-        // Determine the target position based on the current state
-        Vector target_position = (enemy_context->state == ENTITY_MOVING_TO_END) ? enemy_context->end_position : enemy_context->start_position;
-        Vector direction_vector = {0, 0};
+            // Determine the target position based on the current state
+            Vector target_position = (enemy_context->state == ENTITY_MOVING_TO_END) ? enemy_context->end_position : enemy_context->start_position;
+            Vector direction_vector = {0, 0};
 
-        // Calculate direction towards the target
-        if (current_pos.x < target_position.x)
-        {
-            direction_vector.x = 1.0f;
-            enemy_context->direction = ENTITY_RIGHT;
-        }
-        else if (current_pos.x > target_position.x)
-        {
-            direction_vector.x = -1.0f;
-            enemy_context->direction = ENTITY_LEFT;
-        }
+            // Calculate direction towards the target
+            if (current_pos.x < target_position.x)
+            {
+                direction_vector.x = 1.0f;
+                enemy_context->direction = ENTITY_RIGHT;
+            }
+            else if (current_pos.x > target_position.x)
+            {
+                direction_vector.x = -1.0f;
+                enemy_context->direction = ENTITY_LEFT;
+            }
 
-        if (current_pos.y < target_position.y)
-        {
-            direction_vector.y = 1.0f;
-            enemy_context->direction = ENTITY_DOWN;
-        }
-        else if (current_pos.y > target_position.y)
-        {
-            direction_vector.y = -1.0f;
-            enemy_context->direction = ENTITY_UP;
-        }
+            if (current_pos.y < target_position.y)
+            {
+                direction_vector.y = 1.0f;
+                enemy_context->direction = ENTITY_DOWN;
+            }
+            else if (current_pos.y > target_position.y)
+            {
+                direction_vector.y = -1.0f;
+                enemy_context->direction = ENTITY_UP;
+            }
 
-        // Normalize direction vector
-        float length = sqrt(direction_vector.x * direction_vector.x + direction_vector.y * direction_vector.y);
-        if (length != 0)
-        {
-            direction_vector.x /= length;
-            direction_vector.y /= length;
-        }
+            // Normalize direction vector
+            float length = sqrt(direction_vector.x * direction_vector.x + direction_vector.y * direction_vector.y);
+            if (length != 0)
+            {
+                direction_vector.x /= length;
+                direction_vector.y /= length;
+            }
 
-        // Update position based on direction and speed
-        Vector new_pos = current_pos;
-        new_pos.x += direction_vector.x * enemy_context->speed * delta_time;
-        new_pos.y += direction_vector.y * enemy_context->speed * delta_time;
+            // Update position based on direction and speed
+            Vector new_pos = current_pos;
+            new_pos.x += direction_vector.x * enemy_context->speed * delta_time;
+            new_pos.y += direction_vector.y * enemy_context->speed * delta_time;
 
-        // Clamp the position to the target to prevent overshooting
-        if ((direction_vector.x > 0.0f && new_pos.x > target_position.x) ||
-            (direction_vector.x < 0.0f && new_pos.x < target_position.x))
-        {
-            new_pos.x = target_position.x;
-        }
+            // Clamp the position to the target to prevent overshooting
+            if ((direction_vector.x > 0.0f && new_pos.x > target_position.x) ||
+                (direction_vector.x < 0.0f && new_pos.x < target_position.x))
+            {
+                new_pos.x = target_position.x;
+            }
 
-        if ((direction_vector.y > 0.0f && new_pos.y > target_position.y) ||
-            (direction_vector.y < 0.0f && new_pos.y < target_position.y))
-        {
-            new_pos.y = target_position.y;
-        }
+            if ((direction_vector.y > 0.0f && new_pos.y > target_position.y) ||
+                (direction_vector.y < 0.0f && new_pos.y < target_position.y))
+            {
+                new_pos.y = target_position.y;
+            }
 
-        entity_pos_set(self, new_pos);
+            // Set the new position
+            entity_pos_set(self, new_pos);
 
-        // Check if the enemy has reached or surpassed the target_position
-        bool reached_x = fabs(new_pos.x - target_position.x) < (double)1.0;
-        bool reached_y = fabs(new_pos.y - target_position.y) < (double)1.0;
+            // Check if the enemy has reached or surpassed the target_position
+            bool reached_x = fabs(new_pos.x - target_position.x) < (double)1.0;
+            bool reached_y = fabs(new_pos.y - target_position.y) < (double)1.0;
 
-        // If reached the target position on both axes, transition to IDLE
-        if (reached_x && reached_y)
-        {
-            enemy_context->state = ENTITY_IDLE;
-            enemy_context->elapsed_move_timer = 0.0f;
+            // If reached the target position on both axes, transition to IDLE
+            if (reached_x && reached_y)
+            {
+                enemy_context->state = ENTITY_IDLE;
+                enemy_context->elapsed_move_timer = 0.0f;
+            }
         }
-    }
-    break;
-
-    default:
         break;
+
+        default:
+            break;
+        }
     }
 }
 
@@ -555,7 +708,7 @@ static const EntityDescription _generic_enemy = {
 };
 
 // Enemy function to return the entity description
-const EntityDescription *enemy(
+static const EntityDescription *enemy(
     GameManager *manager,
     const char *id,
     int index,
@@ -565,9 +718,12 @@ const EntityDescription *enemy(
     float speed,
     float attack_timer,
     float strength,
-    float health)
+    float health,
+    bool is_user,
+    FuriString *username)
+
 {
-    SpriteContext *sprite_context = get_sprite_context(id);
+    SpriteContext *sprite_context = sprite_context_get(id);
     if (!sprite_context)
     {
         FURI_LOG_E("Game", "Failed to get SpriteContext");
@@ -576,7 +732,7 @@ const EntityDescription *enemy(
 
     // Allocate a new EntityContext with provided parameters
     enemy_context_generic = enemy_generic_alloc(
-        id,
+        sprite_context->id,
         index,
         (Vector){sprite_context->width, sprite_context->height},
         start_position,
@@ -585,7 +741,8 @@ const EntityDescription *enemy(
         speed,
         attack_timer,
         strength,
-        health);
+        health,
+        is_user, username);
     if (!enemy_context_generic)
     {
         FURI_LOG_E("Game", "Failed to allocate EntityContext");
@@ -619,7 +776,7 @@ const EntityDescription *enemy(
     return &_generic_enemy;
 }
 
-void spawn_enemy(Level *level, GameManager *manager, FuriString *json)
+void enemy_spawn(Level *level, GameManager *manager, FuriString *json)
 {
     if (!level || !manager || !json)
     {
@@ -651,6 +808,16 @@ void spawn_enemy(Level *level, GameManager *manager, FuriString *json)
         return;
     }
 
+    FuriString *is_user = get_json_value_furi("is_user", json);
+    bool is_user_value = false;
+    if (is_user)
+    {
+        is_user_value = strstr(furi_string_get_cstr(is_user), "true") != NULL;
+    }
+
+    FuriString *username = get_json_value_furi("username", json);
+    // no need to check for username, it is optional
+
     GameContext *game_context = game_manager_game_context_get(manager);
     if (game_context && game_context->enemy_count < MAX_ENEMIES && !game_context->enemies[game_context->enemy_count])
     {
@@ -664,7 +831,8 @@ void spawn_enemy(Level *level, GameManager *manager, FuriString *json)
                                                                                        atof_furi(speed),
                                                                                        atof_furi(attack_timer),
                                                                                        atof_furi(strength),
-                                                                                       atof_furi(health)));
+                                                                                       atof_furi(health),
+                                                                                       is_user_value, username));
         game_context->enemy_count++;
     }
 
@@ -681,4 +849,12 @@ void spawn_enemy(Level *level, GameManager *manager, FuriString *json)
     furi_string_free(attack_timer);
     furi_string_free(strength);
     furi_string_free(health);
+    if (is_user)
+    {
+        furi_string_free(is_user);
+    }
+    if (username)
+    {
+        furi_string_free(username);
+    }
 }

+ 1 - 1
flip_world/game/enemy.h

@@ -2,4 +2,4 @@
 #include <game/game.h>
 #include "flip_world.h"
 
-void spawn_enemy(Level *level, GameManager *manager, FuriString *json);
+void enemy_spawn(Level *level, GameManager *manager, FuriString *json);

+ 60 - 8
flip_world/game/game.c

@@ -11,10 +11,19 @@
 static void game_start(GameManager *game_manager, void *ctx)
 {
     // Do some initialization here, for example you can load score from storage.
+    // check if enough memory
+    if (!is_enough_heap(sizeof(GameContext), true))
+    {
+        FURI_LOG_E("Game", "Not enough heap memory.. ending game early.");
+        GameContext *game_context = ctx;
+        game_context->ended_early = true;
+        game_manager_game_stop(game_manager); // end game early
+        return;
+    }
     // For simplicity, we will just set it to 0.
     GameContext *game_context = ctx;
     game_context->fps = atof_(fps_choices_str[fps_index]);
-    game_context->player_context = NULL;
+    game_context->player = NULL;
     game_context->ended_early = false;
     game_context->current_level = 0;
     game_context->level_count = 0;
@@ -44,7 +53,7 @@ static void game_start(GameManager *game_manager, void *ctx)
             {
                 if (i == 0)
                 {
-                    game_context->levels[0] = game_manager_add_level(game_manager, training_world());
+                    game_context->levels[0] = game_manager_add_level(game_manager, world_training());
                     game_context->level_count = 1;
                 }
                 break;
@@ -56,18 +65,40 @@ static void game_start(GameManager *game_manager, void *ctx)
     else if (game_context->game_mode == GAME_MODE_STORY)
     {
         // show tutorial only for now
-        game_context->levels[0] = game_manager_add_level(game_manager, training_world());
+        game_context->levels[0] = game_manager_add_level(game_manager, world_training());
         game_context->level_count = 1;
     }
     else if (game_context->game_mode == GAME_MODE_PVP)
     {
-        // show pvp menu
-        easy_flipper_dialog("Unavailable", "\nPvP mode is not ready yet.\nPress BACK to return.");
+        // show pvp
+        game_context->levels[0] = game_manager_add_level(game_manager, world_pvp());
+        game_context->level_count = 1;
     }
 
     // imu
     game_context->imu = imu_alloc();
     game_context->imu_present = imu_present(game_context->imu);
+
+    // FlipperHTTP
+    if (game_context->game_mode == GAME_MODE_PVP)
+    {
+        // check if enough memory
+        if (!is_enough_heap(sizeof(FlipperHTTP), true))
+        {
+            FURI_LOG_E("Game", "Not enough heap memory.. ending game early.");
+            game_context->ended_early = true;
+            game_manager_game_stop(game_manager); // end game early
+            return;
+        }
+        game_context->fhttp = flipper_http_alloc();
+        if (!game_context->fhttp)
+        {
+            FURI_LOG_E("Game", "Failed to allocate FlipperHTTP");
+            game_context->ended_early = true;
+            game_manager_game_stop(game_manager); // end game early
+            return;
+        }
+    }
 }
 
 static void thanks(Canvas *canvas, void *context)
@@ -89,6 +120,7 @@ static void game_stop(void *ctx)
 {
     furi_check(ctx);
     GameContext *game_context = ctx;
+    const size_t heap_size = memmgr_heap_get_max_free_block();
     imu_free(game_context->imu);
     game_context->imu = NULL;
 
@@ -98,6 +130,16 @@ static void game_stop(void *ctx)
         level_clear(game_context->levels[game_context->current_level]);
     }
 
+    if (game_context->game_mode == GAME_MODE_PVP)
+    {
+        if (game_context->fhttp)
+        {
+            flipper_http_websocket_stop(game_context->fhttp); // close websocket
+            remove_player_from_lobby(game_context->fhttp);    // remove player from lobby
+            flipper_http_free(game_context->fhttp);
+        }
+    }
+
     PlayerContext *player_context = malloc(sizeof(PlayerContext));
     if (!player_context)
     {
@@ -110,9 +152,13 @@ static void game_stop(void *ctx)
             "Game Over",
             "Thanks for playing FlipWorld!\nHit BACK then wait for\nthe game to save.");
     else
-        easy_flipper_dialog(
-            "Game Over", "Ran out of memory so the\ngame ended early.\nHit BACK to exit.");
+    {
+        char message[128];
+        snprintf(message, sizeof(message), "Ran out of memory so the\ngame ended early. There were\n%zu bytes free.\n\nHit BACK to exit.", heap_size);
+        easy_flipper_dialog("Game Over", message);
+    }
 
+    // save the player context
     if (load_player_context(player_context))
     {
         ViewPort *view_port = view_port_alloc();
@@ -122,7 +168,13 @@ static void game_stop(void *ctx)
         uint32_t tick_count = furi_get_tick();
         furi_delay_ms(800);
 
-        save_player_context_api(player_context);
+        // save the player context to the API
+        game_context->fhttp = flipper_http_alloc();
+        if (game_context->fhttp)
+        {
+            save_player_context_api(player_context, game_context->fhttp);
+            flipper_http_free(game_context->fhttp);
+        }
 
         const uint32_t delay = 3500;
         tick_count = (tick_count + delay) - furi_get_tick();

+ 46 - 185
flip_world/game/icon.c

@@ -1,209 +1,70 @@
 #include "game/icon.h"
+#include <stdlib.h>
+#include <string.h>
+#include <math.h>
 
-static void icon_collision(Entity *self, Entity *other, GameManager *manager, void *context)
-{
-    UNUSED(manager);
-    UNUSED(self);
-    IconContext *ictx = (IconContext *)context;
-    if (ictx && entity_description_get(other) == &player_desc)
-    {
-        PlayerContext *player = (PlayerContext *)entity_context_get(other);
-        if (player)
-        {
-            // Set the player's old position to prevent collision
-            entity_pos_set(other, player->old_position);
+IconGroupContext *g_current_icon_group = NULL;
 
-            // Reset movement to prevent re-collision
-            player->dx = 0;
-            player->dy = 0;
-        }
-    }
-}
-
-static void icon_render(Entity *self, GameManager *manager, Canvas *canvas, void *context)
-{
-    UNUSED(manager);
-    IconContext *ictx = (IconContext *)context;
-    furi_check(ictx, "Icon context is NULL");
-    Vector pos = entity_pos_get(self);
-    int x_pos = pos.x - camera_x - ictx->size.x / 2;
-    int y_pos = pos.y - camera_y - ictx->size.y / 2;
-    // check if position is within the screen
-    if (x_pos + ictx->size.x < 0 || x_pos > SCREEN_WIDTH || y_pos + ictx->size.y < 0 || y_pos > SCREEN_HEIGHT)
-        return;
-    canvas_draw_icon(canvas, x_pos, y_pos, ictx->icon);
-}
+// ---------------------------------------------------------------------
+// Icon Group Entity Implementation
+// ---------------------------------------------------------------------
 
-static void icon_start(Entity *self, GameManager *manager, void *context)
+// Render callback: iterate over the group and draw each icon.
+static void icon_group_render(Entity *self, GameManager *manager, Canvas *canvas, void *context)
 {
+    UNUSED(self);
     UNUSED(manager);
-
-    IconContext *ictx_self = (IconContext *)context;
-    if (!ictx_self)
+    IconGroupContext *igctx = (IconGroupContext *)context;
+    if (!igctx)
     {
-        FURI_LOG_E("Game", "Icon context self is NULL");
+        FURI_LOG_E("Game", "Icon group context is NULL in render");
         return;
     }
-    IconContext *ictx = entity_context_get(self);
-    if (!ictx)
+    for (int i = 0; i < igctx->count; i++)
     {
-        FURI_LOG_E("Game", "Icon context is NULL");
-        return;
-    }
-
-    IconContext *loaded_data = get_icon_context(g_name);
-    if (!loaded_data)
-    {
-        FURI_LOG_E("Game", "Failed to find icon data for %s", g_name);
-        return;
+        IconSpec *spec = &igctx->icons[i];
+        int x_pos = spec->pos.x - draw_camera_x - (spec->size.x / 2);
+        int y_pos = spec->pos.y - draw_camera_y - (spec->size.y / 2);
+        if (x_pos + spec->size.x < 0 || x_pos > SCREEN_WIDTH ||
+            y_pos + spec->size.y < 0 || y_pos > SCREEN_HEIGHT)
+        {
+            continue;
+        }
+        canvas_draw_icon(canvas, x_pos, y_pos, spec->icon);
     }
-
-    ictx_self->icon = loaded_data->icon;
-    ictx_self->size = (Vector){loaded_data->size.x, loaded_data->size.y};
-    ictx->icon = loaded_data->icon;
-    ictx->size = (Vector){loaded_data->size.x, loaded_data->size.y};
-
-    Vector pos = entity_pos_get(self);
-    pos.x += ictx_self->size.x / 2;
-    pos.y += ictx_self->size.y / 2;
-    entity_pos_set(self, pos);
-
-    entity_collider_add_circle(
-        self,
-        (ictx_self->size.x + ictx_self->size.y) / 4);
-
-    free(loaded_data);
 }
 
-// -------------- Stop callback --------------
-static void icon_free(Entity *self, GameManager *manager, void *context)
+// Start callback: nothing special is needed here.
+static void icon_group_start(Entity *self, GameManager *manager, void *context)
 {
     UNUSED(self);
     UNUSED(manager);
     UNUSED(context);
+    // The context is assumed to be pre-filled by the world code.
 }
 
-// -------------- Entity description --------------
-const EntityDescription icon_desc = {
-    .start = icon_start,
-    .stop = icon_free,
-    .update = NULL,
-    .render = icon_render,
-    .collision = icon_collision,
-    .event = NULL,
-    .context_size = sizeof(IconContext),
-};
-
-static IconContext *icon_generic_alloc(const char *id, const Icon *icon, uint8_t width, uint8_t height)
+// Free callback: free the icon specs array.
+static void icon_group_free(Entity *self, GameManager *manager, void *context)
 {
-    IconContext *ctx = malloc(sizeof(IconContext));
-    if (!ctx)
+    UNUSED(self);
+    UNUSED(manager);
+    IconGroupContext *igctx = (IconGroupContext *)context;
+    if (igctx && igctx->icons)
     {
-        FURI_LOG_E("Game", "Failed to allocate IconContext");
-        return NULL;
+        free(igctx->icons);
+        igctx->icons = NULL;
     }
-    snprintf(ctx->id, sizeof(ctx->id), "%s", id);
-    ctx->icon = icon;
-    ctx->size = (Vector){width, height};
-    return ctx;
+    g_current_icon_group = NULL;
 }
 
-IconContext *get_icon_context(const char *name)
-{
-    if (is_str(name, "house"))
-        return icon_generic_alloc("house", &I_icon_house_48x32px, 48, 32);
-    else if (is_str(name, "man"))
-        return icon_generic_alloc("man", &I_icon_man_7x16, 7, 16);
-    else if (is_str(name, "plant"))
-        return icon_generic_alloc("plant", &I_icon_plant_16x16, 16, 16);
-    else if (is_str(name, "tree"))
-        return icon_generic_alloc("tree", &I_icon_tree_16x16, 16, 16);
-    else if (is_str(name, "woman"))
-        return icon_generic_alloc("woman", &I_icon_woman_9x16, 9, 16);
-    else if (is_str(name, "fence"))
-        return icon_generic_alloc("fence", &I_icon_fence_16x8px, 16, 8);
-    else if (is_str(name, "fence_end"))
-        return icon_generic_alloc("fence_end", &I_icon_fence_end_16x8px, 16, 8);
-    // else if (is_str(name, "fence_vertical_end") )
-    //     return icon_generic_alloc("fence_vertical_end", &I_icon_fence_vertical_end_6x8px, 6, 8);
-    // else if (is_str(name, "fence_vertical_start") )
-    //     return icon_generic_alloc("fence_vertical_start", &I_icon_fence_vertical_start_6x15px, 6, 15);
-    else if (is_str(name, "flower"))
-        return icon_generic_alloc("flower", &I_icon_flower_16x16, 16, 16);
-    else if (is_str(name, "lake_bottom"))
-        return icon_generic_alloc("lake_bottom", &I_icon_lake_bottom_31x12px, 31, 12);
-    else if (is_str(name, "lake_bottom_left"))
-        return icon_generic_alloc("lake_bottom_left", &I_icon_lake_bottom_left_24x22px, 24, 22);
-    else if (is_str(name, "lake_bottom_right"))
-        return icon_generic_alloc("lake_bottom_right", &I_icon_lake_bottom_right_24x22px, 24, 22);
-    else if (is_str(name, "lake_left"))
-        return icon_generic_alloc("lake_left", &I_icon_lake_left_11x31px, 11, 31);
-    else if (is_str(name, "lake_right"))
-        return icon_generic_alloc("lake_right", &I_icon_lake_right_11x31, 11, 31);
-    else if (is_str(name, "lake_top"))
-        return icon_generic_alloc("lake_top", &I_icon_lake_top_31x12px, 31, 12);
-    else if (is_str(name, "lake_top_left"))
-        return icon_generic_alloc("lake_top_left", &I_icon_lake_top_left_24x22px, 24, 22);
-    else if (is_str(name, "lake_top_right"))
-        return icon_generic_alloc("lake_top_right", &I_icon_lake_top_right_24x22px, 24, 22);
-    else if (is_str(name, "rock_large"))
-        return icon_generic_alloc("rock_large", &I_icon_rock_large_18x19px, 18, 19);
-    else if (is_str(name, "rock_medium"))
-        return icon_generic_alloc("rock_medium", &I_icon_rock_medium_16x14px, 16, 14);
-    else if (is_str(name, "rock_small"))
-        return icon_generic_alloc("rock_small", &I_icon_rock_small_10x8px, 10, 8);
-
-    // If no match is found
-    FURI_LOG_E("Game", "Icon not found: %s", name);
-    return NULL;
-}
-
-const char *icon_get_id(const Icon *icon)
-{
-    if (icon == &I_icon_house_48x32px)
-        return "house";
-    else if (icon == &I_icon_man_7x16)
-        return "man";
-    else if (icon == &I_icon_plant_16x16)
-        return "plant";
-    else if (icon == &I_icon_tree_16x16)
-        return "tree";
-    else if (icon == &I_icon_woman_9x16)
-        return "woman";
-    else if (icon == &I_icon_fence_16x8px)
-        return "fence";
-    else if (icon == &I_icon_fence_end_16x8px)
-        return "fence_end";
-    // else if (icon == &I_icon_fence_vertical_end_6x8px)
-    //     return "fence_vertical_end";
-    // else if (icon == &I_icon_fence_vertical_start_6x15px)
-    //     return "fence_vertical_start";
-    else if (icon == &I_icon_flower_16x16)
-        return "flower";
-    else if (icon == &I_icon_lake_bottom_31x12px)
-        return "lake_bottom";
-    else if (icon == &I_icon_lake_bottom_left_24x22px)
-        return "lake_bottom_left";
-    else if (icon == &I_icon_lake_bottom_right_24x22px)
-        return "lake_bottom_right";
-    else if (icon == &I_icon_lake_left_11x31px)
-        return "lake_left";
-    else if (icon == &I_icon_lake_right_11x31)
-        return "lake_right";
-    else if (icon == &I_icon_lake_top_31x12px)
-        return "lake_top";
-    else if (icon == &I_icon_lake_top_left_24x22px)
-        return "lake_top_left";
-    else if (icon == &I_icon_lake_top_right_24x22px)
-        return "lake_top_right";
-    else if (icon == &I_icon_rock_large_18x19px)
-        return "rock_large";
-    else if (icon == &I_icon_rock_medium_16x14px)
-        return "rock_medium";
-    else if (icon == &I_icon_rock_small_10x8px)
-        return "rock_small";
-
-    // If no match is found
-    FURI_LOG_E("Game", "Icon ID not found for given icon pointer.");
-    return NULL;
-}
+// The entity description for the icon group.
+// Note: we set context_size to sizeof(IconGroupContext).
+const EntityDescription icon_desc = {
+    .start = icon_group_start,
+    .stop = icon_group_free,
+    .update = NULL,
+    .render = icon_group_render,
+    .collision = NULL,
+    .event = NULL,
+    .context_size = sizeof(IconGroupContext),
+};

+ 38 - 6
flip_world/game/icon.h

@@ -2,13 +2,45 @@
 #include "flip_world_icons.h"
 #include "game.h"
 
+typedef enum
+{
+    ICON_ID_HOUSE,
+    ICON_ID_MAN,
+    ICON_ID_PLANT,
+    ICON_ID_TREE,
+    ICON_ID_WOMAN,
+    ICON_ID_FENCE,
+    ICON_ID_FENCE_END,
+    ICON_ID_FENCE_VERTICAL_END,
+    ICON_ID_FENCE_VERTICAL_START,
+    ICON_ID_FLOWER,
+    ICON_ID_LAKE_BOTTOM,
+    ICON_ID_LAKE_BOTTOM_LEFT,
+    ICON_ID_LAKE_BOTTOM_RIGHT,
+    ICON_ID_LAKE_LEFT,
+    ICON_ID_LAKE_RIGHT,
+    ICON_ID_LAKE_TOP,
+    ICON_ID_LAKE_TOP_LEFT,
+    ICON_ID_LAKE_TOP_RIGHT,
+    ICON_ID_ROCK_LARGE,
+    ICON_ID_ROCK_MEDIUM,
+    ICON_ID_ROCK_SMALL,
+} IconID;
+
 typedef struct
 {
-    char id[32];
+    IconID id;
     const Icon *icon;
-    Vector size;
-} IconContext;
+    Vector pos;  // position at which to draw the icon
+    Vector size; // dimensions for centering
+} IconSpec;
+
+typedef struct
+{
+    int count;       // number of icons in this group
+    IconSpec *icons; // pointer to an array of icon specs
+} IconGroupContext;
+
+extern IconGroupContext *g_current_icon_group;
 
-extern const EntityDescription icon_desc;
-IconContext *get_icon_context(const char *name);
-const char *icon_get_id(const Icon *icon);
+extern const EntityDescription icon_desc;

+ 15 - 13
flip_world/game/level.c

@@ -12,7 +12,7 @@ bool allocate_level(GameManager *manager, int index)
     if (!world_list)
     {
         FURI_LOG_E("Game", "Failed to load world list");
-        game_context->levels[0] = game_manager_add_level(manager, training_world());
+        game_context->levels[0] = game_manager_add_level(manager, world_training());
         game_context->level_count = 1;
         return false;
     }
@@ -29,7 +29,7 @@ bool allocate_level(GameManager *manager, int index)
     furi_string_free(world_list);
     return true;
 }
-void set_world(Level *level, GameManager *manager, char *id)
+void level_set_world(Level *level, GameManager *manager, char *id)
 {
     char file_path[256];
     snprintf(file_path, sizeof(file_path),
@@ -40,11 +40,12 @@ void set_world(Level *level, GameManager *manager, char *id)
     if (!json_data_str || furi_string_empty(json_data_str))
     {
         FURI_LOG_E("Game", "Failed to load json data from file");
-        // draw_town_world(manager, level);
+        if (json_data_str)
+            furi_string_free(json_data_str);
         return;
     }
 
-    if (!is_enough_heap(28400))
+    if (!is_enough_heap(20000, true))
     {
         FURI_LOG_E("Game", "Not enough heap memory.. ending game early.");
         GameContext *game_context = game_manager_game_context_get(manager);
@@ -55,10 +56,9 @@ void set_world(Level *level, GameManager *manager, char *id)
     }
 
     FURI_LOG_I("Game", "Drawing world");
-    if (!draw_json_world_furi(manager, level, json_data_str))
+    if (!world_json_draw(manager, level, json_data_str))
     {
         FURI_LOG_E("Game", "Failed to draw world");
-        // draw_town_world(manager, level);
         furi_string_free(json_data_str);
     }
     else
@@ -73,7 +73,8 @@ void set_world(Level *level, GameManager *manager, char *id)
         if (!enemy_data_str || furi_string_empty(enemy_data_str))
         {
             FURI_LOG_E("Game", "Failed to get enemy data");
-            // draw_town_world(manager, level);
+            if (enemy_data_str)
+                furi_string_free(enemy_data_str);
             return;
         }
 
@@ -89,7 +90,7 @@ void set_world(Level *level, GameManager *manager, char *id)
                 break;
             }
 
-            spawn_enemy(level, manager, single_enemy_data);
+            enemy_spawn(level, manager, single_enemy_data);
             furi_string_free(single_enemy_data);
         }
         furi_string_free(enemy_data_str);
@@ -104,7 +105,8 @@ void set_world(Level *level, GameManager *manager, char *id)
         if (!npc_data_str || furi_string_empty(npc_data_str))
         {
             FURI_LOG_E("Game", "Failed to get npc data");
-            // draw_town_world(manager, level);
+            if (npc_data_str)
+                furi_string_free(npc_data_str);
             return;
         }
 
@@ -120,7 +122,7 @@ void set_world(Level *level, GameManager *manager, char *id)
                 break;
             }
 
-            spawn_npc(level, manager, single_npc_data);
+            npc_spawn(level, manager, single_npc_data);
             furi_string_free(single_npc_data);
         }
         furi_string_free(npc_data_str);
@@ -157,7 +159,7 @@ static void level_start(Level *level, GameManager *manager, void *context)
     if (!world_exists(level_context->id))
     {
         FURI_LOG_E("Game", "World does not exist.. downloading now");
-        FuriString *world_data = fetch_world(level_context->id);
+        FuriString *world_data = world_fetch(level_context->id);
         if (!world_data)
         {
             FURI_LOG_E("Game", "Failed to fetch world data");
@@ -169,7 +171,7 @@ static void level_start(Level *level, GameManager *manager, void *context)
         }
         furi_string_free(world_data);
 
-        set_world(level, manager, level_context->id);
+        level_set_world(level, manager, level_context->id);
         FURI_LOG_I("Game", "World set.");
         // furi_delay_ms(1000);
         game_context->is_switching_level = false;
@@ -177,7 +179,7 @@ static void level_start(Level *level, GameManager *manager, void *context)
     else
     {
         FURI_LOG_I("Game", "World exists.. loading now");
-        set_world(level, manager, level_context->id);
+        level_set_world(level, manager, level_context->id);
         FURI_LOG_I("Game", "World set.");
         // furi_delay_ms(1000);
         game_context->is_switching_level = false;

+ 2 - 2
flip_world/game/level.h

@@ -3,10 +3,10 @@
 #include "flip_world.h"
 typedef struct
 {
-    char id[64];
+    char id[32];
     int index;
 } LevelContext;
 
 const LevelBehaviour *generic_level(const char *id, int index);
 bool allocate_level(GameManager *manager, int index);
-void set_world(Level *level, GameManager *manager, char *id);
+void level_set_world(Level *level, GameManager *manager, char *id);

+ 21 - 18
flip_world/game/npc.c

@@ -3,7 +3,7 @@ static EntityContext *npc_context_generic;
 
 // Allocation function
 static EntityContext *npc_generic_alloc(
-    const char *id,
+    SpriteID id,
     int index,
     Vector size,
     Vector start_position,
@@ -21,7 +21,7 @@ static EntityContext *npc_generic_alloc(
         FURI_LOG_E("Game", "Failed to allocate EntityContext");
         return NULL;
     }
-    snprintf(npc_context_generic->id, sizeof(npc_context_generic->id), "%s", id);
+    npc_context_generic->id = id;
     npc_context_generic->index = index;
     npc_context_generic->size = size;
     npc_context_generic->start_position = start_position;
@@ -57,7 +57,7 @@ static void npc_start(Entity *self, GameManager *manager, void *context)
 
     EntityContext *npc_context = (EntityContext *)context;
     // Copy fields from generic context
-    snprintf(npc_context->id, sizeof(npc_context->id), "%s", npc_context_generic->id);
+    npc_context->id = npc_context_generic->id;
     snprintf(npc_context->message, sizeof(npc_context->message), "%s", npc_context_generic->message);
     npc_context->index = npc_context_generic->index;
     npc_context->size = npc_context_generic->size;
@@ -91,8 +91,8 @@ static void npc_render(Entity *self, GameManager *manager, Canvas *canvas, void
     // Get the position of the NPC
     Vector pos = entity_pos_get(self);
 
-    int x_pos = pos.x - camera_x - npc_context->size.x / 2;
-    int y_pos = pos.y - camera_y - npc_context->size.y / 2;
+    int x_pos = pos.x - draw_camera_x - npc_context->size.x / 2;
+    int y_pos = pos.y - draw_camera_y - npc_context->size.y / 2;
 
     // check if position is within the screen
     if (x_pos + npc_context->size.x < 0 || x_pos > SCREEN_WIDTH || y_pos + npc_context->size.y < 0 || y_pos > SCREEN_HEIGHT)
@@ -115,8 +115,8 @@ static void npc_render(Entity *self, GameManager *manager, Canvas *canvas, void
         canvas_draw_sprite(
             canvas,
             current_sprite,
-            pos.x - camera_x - (npc_context->size.x / 2),
-            pos.y - camera_y - (npc_context->size.y / 2));
+            pos.x - draw_camera_x - (npc_context->size.x / 2),
+            pos.y - draw_camera_y - (npc_context->size.y / 2));
     }
 }
 
@@ -135,8 +135,10 @@ static void npc_collision(Entity *self, Entity *other, GameManager *manager, voi
         // Retrieve NPC context
         EntityContext *npc_context = (EntityContext *)context;
         GameContext *game_context = game_manager_game_context_get(manager);
+        PlayerContext *player_context = entity_context_get(game_context->player);
         furi_check(npc_context);
         furi_check(game_context);
+        furi_check(player_context);
 
         // Get positions of the NPC and the player
         Vector npc_pos = entity_pos_get(self);
@@ -146,20 +148,20 @@ static void npc_collision(Entity *self, Entity *other, GameManager *manager, voi
         bool player_is_facing_npc = false;
 
         // Determine if the player is facing the NPC
-        if ((game_context->player_context->direction == ENTITY_LEFT && npc_pos.x < player_pos.x) ||
-            (game_context->player_context->direction == ENTITY_RIGHT && npc_pos.x > player_pos.x) ||
-            (game_context->player_context->direction == ENTITY_UP && npc_pos.y < player_pos.y) ||
-            (game_context->player_context->direction == ENTITY_DOWN && npc_pos.y > player_pos.y))
+        if ((player_context->direction == ENTITY_LEFT && npc_pos.x < player_pos.x) ||
+            (player_context->direction == ENTITY_RIGHT && npc_pos.x > player_pos.x) ||
+            (player_context->direction == ENTITY_UP && npc_pos.y < player_pos.y) ||
+            (player_context->direction == ENTITY_DOWN && npc_pos.y > player_pos.y))
         {
             player_is_facing_npc = true;
         }
 
         // bounce the player back to where it came from
         // Set the player's old position to prevent collision
-        entity_pos_set(other, game_context->player_context->old_position);
+        entity_pos_set(other, player_context->old_position);
         // Reset player's movement direction to prevent immediate re-collision
-        game_context->player_context->dx = 0;
-        game_context->player_context->dy = 0;
+        player_context->dx = 0;
+        player_context->dy = 0;
 
         // Press OK and facing NPC
         if (player_is_facing_npc && game_context->last_button == GameKeyOk)
@@ -322,7 +324,7 @@ static const EntityDescription _generic_npc = {
 };
 
 // Spawn function to return the entity description
-const EntityDescription *npc(
+static const EntityDescription *npc(
     GameManager *manager,
     const char *id,
     int index,
@@ -332,7 +334,7 @@ const EntityDescription *npc(
     float speed,
     const char *message)
 {
-    SpriteContext *sprite_context = get_sprite_context(id);
+    SpriteContext *sprite_context = sprite_context_get(id);
     if (!sprite_context)
     {
         FURI_LOG_E("Game", "Failed to get SpriteContext");
@@ -341,7 +343,7 @@ const EntityDescription *npc(
 
     // Allocate a new EntityContext with provided parameters
     npc_context_generic = npc_generic_alloc(
-        id,
+        sprite_context->id,
         index,
         (Vector){sprite_context->width, sprite_context->height},
         start_position,
@@ -382,7 +384,7 @@ const EntityDescription *npc(
     return &_generic_npc;
 }
 
-void spawn_npc(Level *level, GameManager *manager, FuriString *json)
+void npc_spawn(Level *level, GameManager *manager, FuriString *json)
 {
     if (!level || !manager || !json)
     {
@@ -437,4 +439,5 @@ void spawn_npc(Level *level, GameManager *manager, FuriString *json)
     furi_string_free(end_position_y);
     furi_string_free(move_timer);
     furi_string_free(speed);
+    furi_string_free(message);
 }

+ 1 - 1
flip_world/game/npc.h

@@ -2,4 +2,4 @@
 #include <game/game.h>
 #include "flip_world.h"
 
-void spawn_npc(Level *level, GameManager *manager, FuriString *json);
+void npc_spawn(Level *level, GameManager *manager, FuriString *json);

+ 112 - 50
flip_world/game/player.c

@@ -1,11 +1,12 @@
 #include <game/player.h>
+#include <game/icon.h>
 #include <game/storage.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <math.h>
 #include <engine/entity_i.h>
 /****** Entities: Player ******/
-static Level *next_level(GameManager *manager)
+static Level *player_next_level(GameManager *manager)
 {
     GameContext *game_context = game_manager_game_context_get(manager);
     if (!game_context)
@@ -55,7 +56,7 @@ static Level *next_level(GameManager *manager)
 }
 
 // Update player stats based on XP using iterative method
-static int get_player_level_iterative(uint32_t xp)
+static int player_level_iterative_get(uint32_t xp)
 {
     int level = 1;
     uint32_t xp_required = 100; // Base XP for level 2
@@ -102,7 +103,7 @@ void player_spawn(Level *level, GameManager *manager)
         return;
     }
 
-    SpriteContext *sprite_context = get_sprite_context(player_sprite_choices[player_sprite_index]);
+    SpriteContext *sprite_context = sprite_context_get(player_sprite_choices[player_sprite_index]);
     if (!sprite_context)
     {
         FURI_LOG_E(TAG, "Failed to get sprite context");
@@ -143,9 +144,6 @@ void player_spawn(Level *level, GameManager *manager)
                 snprintf(pctx->username, sizeof(pctx->username), "Player");
             }
         }
-
-        game_context->player_context = pctx;
-
         // Save the initialized context
         if (!save_player_context(pctx))
         {
@@ -162,7 +160,7 @@ void player_spawn(Level *level, GameManager *manager)
     pctx->start_position = entity_pos_get(game_context->player);
 
     // Determine the player's level based on XP
-    pctx->level = get_player_level_iterative(pctx->xp);
+    pctx->level = player_level_iterative_get(pctx->xp);
 
     // Update strength and max health based on the new level
     pctx->strength = 10 + (pctx->level * 1);           // 1 strength per level
@@ -171,18 +169,16 @@ void player_spawn(Level *level, GameManager *manager)
     // set the player's left sprite direction
     pctx->left = pctx->direction == ENTITY_LEFT ? true : false;
 
-    // Assign loaded player context to game context
-    game_context->player_context = pctx;
     free(sprite_context);
 }
 
-static int vgm_increase(float value, float increase)
+static int player_vgm_increase(float value, float increase)
 {
     const int val = abs((int)(round(value + increase) / 2));
     return val < 1 ? 1 : val;
 }
 
-static void vgm_direction(Imu *imu, PlayerContext *player, Vector *pos)
+static void player_vgm_direction(Imu *imu, PlayerContext *player, Vector *pos)
 {
     const float pitch = -imu_pitch_get(imu);
     const float roll = -imu_roll_get(imu);
@@ -190,30 +186,63 @@ static void vgm_direction(Imu *imu, PlayerContext *player, Vector *pos)
     const float min_y = atof_(vgm_levels[vgm_y_index]) + 5.0; // minimum of 3
     if (pitch > min_x)
     {
-        pos->x += vgm_increase(pitch, min_x);
+        pos->x += player_vgm_increase(pitch, min_x);
         player->dx = 1;
         player->direction = ENTITY_RIGHT;
     }
     else if (pitch < -min_x)
     {
-        pos->x += -vgm_increase(pitch, min_x);
+        pos->x += -player_vgm_increase(pitch, min_x);
         player->dx = -1;
         player->direction = ENTITY_LEFT;
     }
     if (roll > min_y)
     {
-        pos->y += vgm_increase(roll, min_y);
+        pos->y += player_vgm_increase(roll, min_y);
         player->dy = 1;
         player->direction = ENTITY_DOWN;
     }
     else if (roll < -min_y)
     {
-        pos->y += -vgm_increase(roll, min_y);
+        pos->y += -player_vgm_increase(roll, min_y);
         player->dy = -1;
         player->direction = ENTITY_UP;
     }
 }
 
+// This static function handles collisions with icons.
+// It receives the player entity pointer, the player's current position, and a pointer to PlayerContext.
+static void player_handle_collision(Entity *playerEntity, Vector playerPos, PlayerContext *player)
+{
+    // If there is no active icon group, do nothing.
+    if (!g_current_icon_group)
+        return;
+
+    // Loop over all icon specifications in the current icon group.
+    for (int i = 0; i < g_current_icon_group->count; i++)
+    {
+        IconSpec *spec = &g_current_icon_group->icons[i];
+
+        // Calculate the difference between player's position and the icon's center.
+        float dx = playerPos.x - spec->pos.x;
+        float dy = playerPos.y - spec->pos.y;
+
+        // Use an approximate collision radius:
+        float radius = (spec->size.x + spec->size.y) / 4.0f;
+
+        // Collision: if player's distance to the icon center is less than the collision radius.
+        if ((dx * dx + dy * dy) < (radius * radius))
+        {
+            // Revert the player's position and reset movement.
+            entity_pos_set(playerEntity, player->old_position);
+            player->dx = 0;
+            player->dy = 0;
+            break;
+        }
+    }
+}
+
+uint16_t elapsed_ws_timer = 0;
 static void player_update(Entity *self, GameManager *manager, void *context)
 {
     if (!self || !manager || !context)
@@ -222,11 +251,41 @@ static void player_update(Entity *self, GameManager *manager, void *context)
     PlayerContext *player = (PlayerContext *)context;
     InputState input = game_manager_input_get(manager);
     Vector pos = entity_pos_get(self);
-    player->old_position = pos;
     GameContext *game_context = game_manager_game_context_get(manager);
 
+    // update websocket player context
+    if (game_context->game_mode == GAME_MODE_PVP)
+    {
+        // if pvp, end the game if the player is dead
+        if (player->health <= 0)
+        {
+            player->health = player->max_health;
+            save_player_context(player);
+            furi_delay_ms(100);
+            game_manager_game_stop(manager);
+            return;
+        }
+
+        if (player->old_position.x != pos.x || player->old_position.y != pos.y)
+        {
+            elapsed_ws_timer++;
+            // only send the websocket update every 200ms
+            if (elapsed_ws_timer >= (game_context->fps / 5))
+            {
+                if (game_context->fhttp)
+                {
+                    player->start_position = player->old_position;
+                    websocket_player_context(player, game_context->fhttp);
+                }
+                elapsed_ws_timer = 0;
+            }
+        }
+    }
+
+    player->old_position = pos;
+
     // Determine the player's level based on XP
-    player->level = get_player_level_iterative(player->xp);
+    player->level = player_level_iterative_get(player->xp);
     player->strength = 10 + (player->level * 1);           // 1 strength per level
     player->max_health = 100 + ((player->level - 1) * 10); // 10 health per level
 
@@ -241,7 +300,7 @@ static void player_update(Entity *self, GameManager *manager, void *context)
     if (game_context->imu_present)
     {
         // update position using the IMU
-        vgm_direction(game_context->imu, player, &pos);
+        player_vgm_direction(game_context->imu, player, &pos);
     }
 
     // Apply health regeneration
@@ -357,12 +416,12 @@ static void player_update(Entity *self, GameManager *manager, void *context)
 
         // if all enemies are dead, allow the "OK" button to switch levels
         // otherwise the "OK" button will be used to attack
-        if (game_context->enemy_count == 0 && !game_context->is_switching_level)
+        if (game_context->game_mode != GAME_MODE_PVP && game_context->enemy_count == 0 && !game_context->is_switching_level)
         {
             game_context->is_switching_level = true;
             save_player_context(player);
             furi_delay_ms(100);
-            game_manager_next_level_set(manager, next_level(manager));
+            game_manager_next_level_set(manager, player_next_level(manager));
             return;
         }
 
@@ -454,9 +513,12 @@ static void player_update(Entity *self, GameManager *manager, void *context)
     }
     else
         player->state = ENTITY_MOVING;
+
+    // handle icon collision
+    player_handle_collision(self, pos, player);
 }
 
-static void draw_tutorial(Canvas *canvas, GameManager *manager)
+static void player_draw_tutorial(Canvas *canvas, GameManager *manager)
 {
     GameContext *game_context = game_manager_game_context_get(manager);
     canvas_set_font(canvas, FontPrimary);
@@ -515,12 +577,12 @@ static void player_render(Entity *self, GameManager *manager, Canvas *canvas, vo
     Vector pos = entity_pos_get(self);
 
     // Calculate camera offset to center the player
-    camera_x = pos.x - (SCREEN_WIDTH / 2);
-    camera_y = pos.y - (SCREEN_HEIGHT / 2);
+    draw_camera_x = pos.x - (SCREEN_WIDTH / 2);
+    draw_camera_y = pos.y - (SCREEN_HEIGHT / 2);
 
     // Clamp camera position to prevent showing areas outside the world
-    camera_x = CLAMP(camera_x, WORLD_WIDTH - SCREEN_WIDTH, 0);
-    camera_y = CLAMP(camera_y, WORLD_HEIGHT - SCREEN_HEIGHT, 0);
+    draw_camera_x = CLAMP(draw_camera_x, WORLD_WIDTH - SCREEN_WIDTH, 0);
+    draw_camera_y = CLAMP(draw_camera_y, WORLD_HEIGHT - SCREEN_HEIGHT, 0);
 
     // if player is moving right or left, draw the corresponding sprite
     if (player->direction == ENTITY_RIGHT || player->direction == ENTITY_LEFT)
@@ -528,8 +590,8 @@ static void player_render(Entity *self, GameManager *manager, Canvas *canvas, vo
         canvas_draw_sprite(
             canvas,
             player->direction == ENTITY_RIGHT ? player->sprite_right : player->sprite_left,
-            pos.x - camera_x - 5, // Center the sprite horizontally
-            pos.y - camera_y - 5  // Center the sprite vertically
+            pos.x - draw_camera_x - 5, // Center the sprite horizontally
+            pos.y - draw_camera_y - 5  // Center the sprite vertically
         );
         player->left = false;
     }
@@ -539,28 +601,28 @@ static void player_render(Entity *self, GameManager *manager, Canvas *canvas, vo
         canvas_draw_sprite(
             canvas,
             player->left ? player->sprite_left : player->sprite_right,
-            pos.x - camera_x - 5, // Center the sprite horizontally
-            pos.y - camera_y - 5  // Center the sprite vertically
+            pos.x - draw_camera_x - 5, // Center the sprite horizontally
+            pos.y - draw_camera_y - 5  // Center the sprite vertically
         );
     }
 
     // Draw the outer bounds adjusted by camera offset
-    canvas_draw_frame(canvas, -camera_x, -camera_y, WORLD_WIDTH, WORLD_HEIGHT);
+    canvas_draw_frame(canvas, -draw_camera_x, -draw_camera_y, WORLD_WIDTH, WORLD_HEIGHT);
 
     // render tutorial
     if (game_context->game_mode == GAME_MODE_STORY)
     {
-        draw_tutorial(canvas, manager);
+        player_draw_tutorial(canvas, manager);
 
         if (game_context->is_menu_open)
         {
-            background_render(canvas, manager);
+            draw_background_render(canvas, manager);
         }
     }
     else
     {
         // render background
-        background_render(canvas, manager);
+        draw_background_render(canvas, manager);
     }
 }
 
@@ -574,7 +636,7 @@ const EntityDescription player_desc = {
     .context_size = sizeof(PlayerContext), // size of entity context, will be automatically allocated and freed
 };
 
-static SpriteContext *sprite_generic_alloc(const char *id, const char *type, uint8_t width, uint8_t height)
+static SpriteContext *sprite_generic_alloc(SpriteID id, const char *char_id, const char *type, uint8_t width, uint8_t height)
 {
     SpriteContext *ctx = malloc(sizeof(SpriteContext));
     if (!ctx)
@@ -582,47 +644,47 @@ static SpriteContext *sprite_generic_alloc(const char *id, const char *type, uin
         FURI_LOG_E("Game", "Failed to allocate SpriteContext");
         return NULL;
     }
-    snprintf(ctx->id, sizeof(ctx->id), "%s", id);
+    ctx->id = id;
     ctx->width = width;
     ctx->height = height;
     if (is_str(type, "player"))
     {
-        snprintf(ctx->right_file_name, sizeof(ctx->right_file_name), "player_right_%s_%dx%dpx.fxbm", id, width, height);
-        snprintf(ctx->left_file_name, sizeof(ctx->left_file_name), "player_left_%s_%dx%dpx.fxbm", id, width, height);
+        snprintf(ctx->right_file_name, sizeof(ctx->right_file_name), "player_right_%s_%dx%dpx.fxbm", char_id, width, height);
+        snprintf(ctx->left_file_name, sizeof(ctx->left_file_name), "player_left_%s_%dx%dpx.fxbm", char_id, width, height);
     }
     else if (is_str(type, "enemy"))
     {
-        snprintf(ctx->right_file_name, sizeof(ctx->right_file_name), "enemy_right_%s_%dx%dpx.fxbm", id, width, height);
-        snprintf(ctx->left_file_name, sizeof(ctx->left_file_name), "enemy_left_%s_%dx%dpx.fxbm", id, width, height);
+        snprintf(ctx->right_file_name, sizeof(ctx->right_file_name), "enemy_right_%s_%dx%dpx.fxbm", char_id, width, height);
+        snprintf(ctx->left_file_name, sizeof(ctx->left_file_name), "enemy_left_%s_%dx%dpx.fxbm", char_id, width, height);
     }
     else if (is_str(type, "npc"))
     {
-        snprintf(ctx->right_file_name, sizeof(ctx->right_file_name), "npc_right_%s_%dx%dpx.fxbm", id, width, height);
-        snprintf(ctx->left_file_name, sizeof(ctx->left_file_name), "npc_left_%s_%dx%dpx.fxbm", id, width, height);
+        snprintf(ctx->right_file_name, sizeof(ctx->right_file_name), "npc_right_%s_%dx%dpx.fxbm", char_id, width, height);
+        snprintf(ctx->left_file_name, sizeof(ctx->left_file_name), "npc_left_%s_%dx%dpx.fxbm", char_id, width, height);
     }
     return ctx;
 }
 
-SpriteContext *get_sprite_context(const char *name)
+SpriteContext *sprite_context_get(const char *name)
 {
     if (is_str(name, "axe"))
-        return sprite_generic_alloc("axe", "player", 15, 11);
+        return sprite_generic_alloc(SPRITE_ID_AXE, "axe", "player", 15, 11);
     else if (is_str(name, "bow"))
-        return sprite_generic_alloc("bow", "player", 13, 11);
+        return sprite_generic_alloc(SPRITE_ID_BOW, "bow", "player", 13, 11);
     else if (is_str(name, "naked"))
-        return sprite_generic_alloc("naked", "player", 10, 10);
+        return sprite_generic_alloc(SPRITE_ID_NAKED, "naked", "player", 10, 10);
     else if (is_str(name, "sword"))
-        return sprite_generic_alloc("sword", "player", 15, 11);
+        return sprite_generic_alloc(SPRITE_ID_SWORD, "sword", "player", 15, 11);
     //
     else if (is_str(name, "cyclops"))
-        return sprite_generic_alloc("cyclops", "enemy", 10, 11);
+        return sprite_generic_alloc(SPRITE_ID_CYCLOPS, "cyclops", "enemy", 10, 11);
     else if (is_str(name, "ghost"))
-        return sprite_generic_alloc("ghost", "enemy", 15, 15);
+        return sprite_generic_alloc(SPRITE_ID_GHOST, "ghost", "enemy", 15, 15);
     else if (is_str(name, "ogre"))
-        return sprite_generic_alloc("ogre", "enemy", 10, 13);
+        return sprite_generic_alloc(SPRITE_ID_OGRE, "ogre", "enemy", 10, 13);
     //
     else if (is_str(name, "funny"))
-        return sprite_generic_alloc("funny", "npc", 15, 21);
+        return sprite_generic_alloc(SPRITE_ID_FUNNY, "funny", "npc", 15, 21);
 
     // If no match is found
     FURI_LOG_E("Game", "Sprite not found: %s", name);

+ 35 - 20
flip_world/game/player.h

@@ -4,15 +4,27 @@
 #include <game/game.h>
 #include "engine/sensors/imu.h"
 
-#define MAX_ENEMIES 10
-#define MAX_LEVELS 10
-#define MAX_NPCS 10
+#define MAX_ENEMIES 5
+#define MAX_LEVELS 5
+#define MAX_NPCS 1
+
+typedef enum
+{
+    SPRITE_ID_AXE,
+    SPRITE_ID_BOW,
+    SPRITE_ID_NAKED,
+    SPRITE_ID_SWORD,
+    SPRITE_ID_CYCLOPS,
+    SPRITE_ID_GHOST,
+    SPRITE_ID_OGRE,
+    SPRITE_ID_FUNNY
+} SpriteID;
 
 // EntityContext definition
 typedef struct
 {
-    char id[64];                // Unique ID for the entity type
-    int index;                  // Index for the specific entity instance
+    SpriteID id;                // Unique ID for the entity type
+    uint8_t index;              // Index for the specific entity instance
     Vector size;                // Size of the entity
     Sprite *sprite_right;       // Entity sprite when looking right
     Sprite *sprite_left;        // Entity sprite when looking left
@@ -26,9 +38,11 @@ typedef struct
     float speed;                // Speed of the entity
     float attack_timer;         // Cooldown duration between attacks
     float elapsed_attack_timer; // Time elapsed since the last attack
-    float strength;             // Damage the entity deals
+    uint32_t strength;          // Damage the entity deals
     float health;               // Health of the entity
     char message[64];           // Message to display when interacting with the entity
+    bool is_user;               // Flag to indicate if the entity is a live player or not
+    char username[32];          // entity username
 } EntityContext;
 
 typedef struct
@@ -46,7 +60,7 @@ typedef struct
     uint32_t strength;          // player strength
     uint32_t health;            // player health
     uint32_t max_health;        // player maximum health
-    uint32_t health_regen;      // player health regeneration rate per second/frame
+    uint8_t health_regen;       // player health regeneration rate per second/frame
     float elapsed_health_regen; // time elapsed since last health regeneration
     float attack_timer;         // Cooldown duration between attacks
     float elapsed_attack_timer; // Time elapsed since the last attack
@@ -72,17 +86,16 @@ typedef enum
 
 typedef struct
 {
-    PlayerContext *player_context;
     Level *levels[MAX_LEVELS];
     Entity *enemies[MAX_ENEMIES];
     Entity *npcs[MAX_NPCS];
     Entity *player;
     //
     float fps;
-    int level_count;
-    int enemy_count;
-    int npc_count;
-    int current_level;
+    int8_t level_count;
+    int8_t enemy_count;
+    int8_t npc_count;
+    int8_t current_level;
     bool ended_early;
     Imu *imu;
     bool imu_present;
@@ -90,31 +103,33 @@ typedef struct
     bool is_switching_level;
     bool is_menu_open;
     //
-    uint32_t elapsed_button_timer;
-    uint32_t last_button;
+    uint16_t elapsed_button_timer;
+    uint8_t last_button;
     //
     GameMenuScreen menu_screen;
     uint8_t menu_selection;
     //
     GameMode game_mode;
     //
-    int icon_count;
-    int icon_offset;
+    uint32_t icon_count;
+    uint16_t icon_offset;
     //
     char message[64];
     //
     uint8_t tutorial_step;
+    //
+    FlipperHTTP *fhttp;
 } GameContext;
 
 typedef struct
 {
-    char id[16];
-    char left_file_name[64];
-    char right_file_name[64];
+    SpriteID id;
+    char left_file_name[33];
+    char right_file_name[33];
     uint8_t width;
     uint8_t height;
 } SpriteContext;
 
 extern const EntityDescription player_desc;
 void player_spawn(Level *level, GameManager *manager);
-SpriteContext *get_sprite_context(const char *name);
+SpriteContext *sprite_context_get(const char *name);

+ 292 - 144
flip_world/game/storage.c

@@ -203,183 +203,329 @@ bool save_player_context(PlayerContext *player_context)
     return true;
 }
 
-bool save_player_context_api(PlayerContext *player_context)
+static FuriString *player_context_json(PlayerContext *player_context, bool websocket)
 {
-    if (!player_context)
-    {
-        FURI_LOG_E(TAG, "Invalid player context");
-        return false;
-    }
-
-    FlipperHTTP *fhttp = flipper_http_alloc();
-    if (!fhttp)
-    {
-        FURI_LOG_E(TAG, "Failed to allocate FlipperHTTP");
-        return false;
-    }
-
-    // create JSON for all the player context data
     FuriString *json = furi_string_alloc();
     if (!json)
     {
         FURI_LOG_E(TAG, "Failed to allocate JSON string");
-        return false;
+        return NULL;
     }
 
-    // opening brace
     furi_string_cat_str(json, "{");
 
-    // 1. Username (String)
-    furi_string_cat_str(json, "\"username\":\"");
-    furi_string_cat_str(json, player_context->username);
-    furi_string_cat_str(json, "\",");
+    if (websocket)
+    {
+        // Minimal JSON for WebSocket (abbreviated, <128 characters)
+        // "u": username
+        furi_string_cat_str(json, "\"u\":\"");
+        furi_string_cat_str(json, player_context->username);
+        furi_string_cat_str(json, "\",");
+
+        // "xp": experience
+        furi_string_cat_str(json, "\"xp\":");
+        char buffer[32];
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->xp);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // "h": health
+        furi_string_cat_str(json, "\"h\":");
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->health);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // "ehr": elapsed health regen (1 decimal)
+        furi_string_cat_str(json, "\"ehr\":");
+        snprintf(buffer, sizeof(buffer), "%.1f", (double)player_context->elapsed_health_regen);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // "eat": elapsed attack timer (1 decimal)
+        furi_string_cat_str(json, "\"eat\":");
+        snprintf(buffer, sizeof(buffer), "%.1f", (double)player_context->elapsed_attack_timer);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // "d": direction (numeric code)
+        furi_string_cat_str(json, "\"d\":");
+        snprintf(buffer, sizeof(buffer), "%d", player_context->direction);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // "s": state (numeric code)
+        furi_string_cat_str(json, "\"s\":");
+        snprintf(buffer, sizeof(buffer), "%d", player_context->state);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // "sp": start position object with x and y (1 decimal)
+        furi_string_cat_str(json, "\"sp\":{");
+        furi_string_cat_str(json, "\"x\":");
+        snprintf(buffer, sizeof(buffer), "%.1f", (double)player_context->start_position.x);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",\"y\":");
+        snprintf(buffer, sizeof(buffer), "%.1f", (double)player_context->start_position.y);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, "}");
+    }
+    else
+    {
+        // Full JSON output (unchanged)
+        // 1. Username
+        furi_string_cat_str(json, "\"username\":\"");
+        furi_string_cat_str(json, player_context->username);
+        furi_string_cat_str(json, "\",");
+
+        // 2. Level
+        furi_string_cat_str(json, "\"level\":");
+        char buffer[32];
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->level);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 3. XP
+        furi_string_cat_str(json, "\"xp\":");
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->xp);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 4. Health
+        furi_string_cat_str(json, "\"health\":");
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->health);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 5. Strength
+        furi_string_cat_str(json, "\"strength\":");
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->strength);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 6. Max Health
+        furi_string_cat_str(json, "\"max_health\":");
+        snprintf(buffer, sizeof(buffer), "%lu", player_context->max_health);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 7. Health Regen
+        furi_string_cat_str(json, "\"health_regen\":");
+        snprintf(buffer, sizeof(buffer), "%u", player_context->health_regen);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 8. Elapsed Health Regen
+        furi_string_cat_str(json, "\"elapsed_health_regen\":");
+        snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->elapsed_health_regen);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 9. Attack Timer
+        furi_string_cat_str(json, "\"attack_timer\":");
+        snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->attack_timer);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 10. Elapsed Attack Timer
+        furi_string_cat_str(json, "\"elapsed_attack_timer\":");
+        snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->elapsed_attack_timer);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
+
+        // 11. Direction (string representation)
+        furi_string_cat_str(json, "\"direction\":");
+        switch (player_context->direction)
+        {
+        case ENTITY_UP:
+            furi_string_cat_str(json, "\"up\",");
+            break;
+        case ENTITY_DOWN:
+            furi_string_cat_str(json, "\"down\",");
+            break;
+        case ENTITY_LEFT:
+            furi_string_cat_str(json, "\"left\",");
+            break;
+        case ENTITY_RIGHT:
+        default:
+            furi_string_cat_str(json, "\"right\",");
+            break;
+        }
 
-    // 2. Level (uint32_t)
-    furi_string_cat_str(json, "\"level\":");
-    char buffer[32];
-    snprintf(buffer, sizeof(buffer), "%lu", player_context->level);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+        // 12. State (string representation)
+        furi_string_cat_str(json, "\"state\":");
+        switch (player_context->state)
+        {
+        case ENTITY_IDLE:
+            furi_string_cat_str(json, "\"idle\",");
+            break;
+        case ENTITY_MOVING:
+            furi_string_cat_str(json, "\"moving\",");
+            break;
+        case ENTITY_ATTACKING:
+            furi_string_cat_str(json, "\"attacking\",");
+            break;
+        case ENTITY_ATTACKED:
+            furi_string_cat_str(json, "\"attacked\",");
+            break;
+        case ENTITY_DEAD:
+            furi_string_cat_str(json, "\"dead\",");
+            break;
+        default:
+            furi_string_cat_str(json, "\"unknown\",");
+            break;
+        }
 
-    // 3. XP (uint32_t)
-    furi_string_cat_str(json, "\"xp\":");
-    snprintf(buffer, sizeof(buffer), "%lu", player_context->xp);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+        // 13. Start Position X
+        furi_string_cat_str(json, "\"start_position_x\":");
+        snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->start_position.x);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
 
-    // 4. Health (uint32_t)
-    furi_string_cat_str(json, "\"health\":");
-    snprintf(buffer, sizeof(buffer), "%lu", player_context->health);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+        // 14. Start Position Y
+        furi_string_cat_str(json, "\"start_position_y\":");
+        snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->start_position.y);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
 
-    // 5. Strength (uint32_t)
-    furi_string_cat_str(json, "\"strength\":");
-    snprintf(buffer, sizeof(buffer), "%lu", player_context->strength);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+        // 15. dx
+        furi_string_cat_str(json, "\"dx\":");
+        snprintf(buffer, sizeof(buffer), "%d", player_context->dx);
+        furi_string_cat_str(json, buffer);
+        furi_string_cat_str(json, ",");
 
-    // 6. Max Health (uint32_t)
-    furi_string_cat_str(json, "\"max_health\":");
-    snprintf(buffer, sizeof(buffer), "%lu", player_context->max_health);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+        // 16. dy
+        furi_string_cat_str(json, "\"dy\":");
+        snprintf(buffer, sizeof(buffer), "%d", player_context->dy);
+        furi_string_cat_str(json, buffer);
+    }
 
-    // 7. Health Regen (uint32_t)
-    furi_string_cat_str(json, "\"health_regen\":");
-    snprintf(buffer, sizeof(buffer), "%lu", player_context->health_regen);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+    furi_string_cat_str(json, "}");
 
-    // 8. Elapsed Health Regen (float)
-    furi_string_cat_str(json, "\"elapsed_health_regen\":");
-    snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->elapsed_health_regen);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+    // For websocket, output only the minimal JSON (without extra wrapping)
+    FuriString *json_data = furi_string_alloc();
+    if (!json_data)
+    {
+        FURI_LOG_E(TAG, "Failed to allocate JSON string");
+        furi_string_free(json);
+        return NULL;
+    }
 
-    // 9. Attack Timer (float)
-    furi_string_cat_str(json, "\"attack_timer\":");
-    snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->attack_timer);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+    if (websocket)
+    {
+        furi_string_cat(json_data, json);
+    }
+    else
+    {
+        furi_string_cat_str(json_data, "{\"username\":\"");
+        furi_string_cat_str(json_data, player_context->username);
+        furi_string_cat_str(json_data, "\",\"game_stats\":");
+        furi_string_cat(json_data, json);
+        furi_string_cat_str(json_data, "}");
+    }
 
-    // 10. Elapsed Attack Timer (float)
-    furi_string_cat_str(json, "\"elapsed_attack_timer\":");
-    snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->elapsed_attack_timer);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+    furi_string_free(json);
+    return json_data;
+}
 
-    // 11. Direction (enum PlayerDirection)
-    furi_string_cat_str(json, "\"direction\":");
-    switch (player_context->direction)
-    {
-    case ENTITY_UP:
-        furi_string_cat_str(json, "\"up\",");
-        break;
-    case ENTITY_DOWN:
-        furi_string_cat_str(json, "\"down\",");
-        break;
-    case ENTITY_LEFT:
-        furi_string_cat_str(json, "\"left\",");
-        break;
-    case ENTITY_RIGHT:
-    default:
-        furi_string_cat_str(json, "\"right\",");
-        break;
+bool save_player_context_api(PlayerContext *player_context, FlipperHTTP *fhttp)
+{
+    if (!player_context)
+    {
+        FURI_LOG_E(TAG, "Invalid player context");
+        return false;
     }
 
-    // 12. State (enum PlayerState)
-    furi_string_cat_str(json, "\"state\":");
-    switch (player_context->state)
-    {
-    case ENTITY_IDLE:
-        furi_string_cat_str(json, "\"idle\",");
-        break;
-    case ENTITY_MOVING:
-        furi_string_cat_str(json, "\"moving\",");
-        break;
-    case ENTITY_ATTACKING:
-        furi_string_cat_str(json, "\"attacking\",");
-        break;
-    case ENTITY_ATTACKED:
-        furi_string_cat_str(json, "\"attacked\",");
-        break;
-    case ENTITY_DEAD:
-        furi_string_cat_str(json, "\"dead\",");
-        break;
-    default:
-        furi_string_cat_str(json, "\"unknown\",");
-        break;
+    if (!fhttp)
+    {
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
     }
 
-    // 13. Start Position X (float)
-    furi_string_cat_str(json, "\"start_position_x\":");
-    snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->start_position.x);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
-
-    // 14. Start Position Y (float)
-    furi_string_cat_str(json, "\"start_position_y\":");
-    snprintf(buffer, sizeof(buffer), "%.6f", (double)player_context->start_position.y);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+    FuriString *json_data = player_context_json(player_context, false);
+    if (!json_data)
+    {
+        FURI_LOG_E(TAG, "Failed to create JSON data");
+        return false;
+    }
 
-    // 15. dx (int8_t)
-    furi_string_cat_str(json, "\"dx\":");
-    snprintf(buffer, sizeof(buffer), "%d", player_context->dx);
-    furi_string_cat_str(json, buffer);
-    furi_string_cat_str(json, ",");
+    // save the json_data to the API
+    if (!flipper_http_request(fhttp, POST, "https://www.jblanked.com/flipper/api/user/update-game-stats/", "{\"Content-Type\": \"application/json\"}", furi_string_get_cstr(json_data)))
+    {
+        FURI_LOG_E(TAG, "Failed to save player context to API");
+        furi_string_free(json_data);
+        return false;
+    }
+    fhttp->state = RECEIVING;
+    while (fhttp->state != IDLE)
+    {
+        furi_delay_ms(100);
+    }
+    furi_string_free(json_data);
 
-    // 16. dy (int8_t)
-    furi_string_cat_str(json, "\"dy\":");
-    snprintf(buffer, sizeof(buffer), "%d", player_context->dy);
-    furi_string_cat_str(json, buffer);
+    return true;
+}
 
-    // closing brace
-    furi_string_cat_str(json, "}");
+bool websocket_player_context(PlayerContext *player_context, FlipperHTTP *fhttp)
+{
+    if (!player_context)
+    {
+        FURI_LOG_E(TAG, "Invalid player context");
+        return false;
+    }
 
-    // create new JSON with username key (of just username), and game_stats key (of the all of the data)
-    FuriString *json_data = furi_string_alloc();
-    if (!json_data)
+    if (!fhttp)
     {
-        FURI_LOG_E(TAG, "Failed to allocate JSON string");
-        furi_string_free(json);
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
         return false;
     }
 
-    furi_string_cat_str(json_data, "{\"username\":\"");
-    furi_string_cat_str(json_data, player_context->username);
-    furi_string_cat_str(json_data, "\",\"game_stats\":");
-    furi_string_cat(json_data, json);
-    furi_string_cat_str(json_data, "}");
+    // create JSON for all the player context data
+    FuriString *json = player_context_json(player_context, true);
+    if (!json)
+    {
+        FURI_LOG_E(TAG, "Failed to create JSON data");
+        return false;
+    }
 
+    // websocket session is already started, so just send json to esp32
+    if (!flipper_http_send_data(fhttp, furi_string_get_cstr(json)))
+    {
+        FURI_LOG_E(TAG, "Failed to send player context to websocket");
+        furi_string_free(json);
+        return false;
+    }
     furi_string_free(json);
 
-    // save the json_data to the API
-    if (!flipper_http_request(fhttp, POST, "https://www.jblanked.com/flipper/api/user/update-game-stats/", "{\"Content-Type\": \"application/json\"}", furi_string_get_cstr(json_data)))
+    return true;
+}
+
+bool remove_player_from_lobby(FlipperHTTP *fhttp)
+{
+    if (!fhttp)
     {
-        FURI_LOG_E(TAG, "Failed to save player context to API");
-        furi_string_free(json_data);
+        FURI_LOG_E(TAG, "FlipperHTTP is NULL");
+        return false;
+    }
+    char username[32];
+    if (!load_char("Flip-Social-Username", username, sizeof(username)))
+    {
+        FURI_LOG_E(TAG, "Failed to load data/Flip-Social-Username");
+        return false;
+    }
+    char lobby_name[32];
+    if (!load_char("pvp_lobby_name", lobby_name, sizeof(lobby_name)))
+    {
+        FURI_LOG_E(TAG, "Failed to load data/pvp_lobby_name");
+        return false;
+    }
+    char url[128];
+    char payload[128];
+    snprintf(payload, sizeof(payload), "{\"username\":\"%s\", \"game_id\":\"%s\"}", username, lobby_name);
+    snprintf(url, sizeof(url), "https://www.jblanked.com/flipper/api/world/pvp/lobby/remove/");
+    fhttp->state = IDLE;
+    if (!flipper_http_request(fhttp, POST, url, "{\"Content-Type\":\"application/json\"}", payload))
+    {
+        FURI_LOG_E(TAG, "Failed to remove player from lobby");
         return false;
     }
     fhttp->state = RECEIVING;
@@ -387,8 +533,6 @@ bool save_player_context_api(PlayerContext *player_context)
     {
         furi_delay_ms(100);
     }
-    furi_string_free(json_data);
-    flipper_http_free(fhttp);
     return true;
 }
 
@@ -961,6 +1105,10 @@ bool separate_world_data(char *id, FuriString *world_data)
     if (!file_json_data || furi_string_size(file_json_data) == 0)
     {
         FURI_LOG_E("Game", "Failed to get json data in separate_world_data");
+        if (file_json_data)
+        {
+            furi_string_free(file_json_data);
+        }
         return false;
     }
 

+ 3 - 1
flip_world/game/storage.h

@@ -5,7 +5,9 @@
 #include <flip_storage/storage.h>
 
 bool save_player_context(PlayerContext *player_context);
-bool save_player_context_api(PlayerContext *player_context);
+bool save_player_context_api(PlayerContext *player_context, FlipperHTTP *fhttp);
+bool websocket_player_context(PlayerContext *player_context, FlipperHTTP *fhttp);
+bool remove_player_from_lobby(FlipperHTTP *fhttp);
 bool load_player_context(PlayerContext *player_context);
 bool set_player_context();
 

+ 200 - 65
flip_world/game/world.c

@@ -1,91 +1,186 @@
 #include <game/world.h>
 #include <game/storage.h>
 #include <flip_storage/storage.h>
+#include "game/icon.h"
 
-bool draw_json_world_furi(GameManager *manager, Level *level, const FuriString *json_data)
+static IconSpec world_get_icon_spec(const char *name)
+{
+    if (is_str(name, "house"))
+        return (IconSpec){.id = ICON_ID_HOUSE, .icon = &I_icon_house_48x32px, .size = (Vector){48, 32}};
+    else if (is_str(name, "man"))
+        return (IconSpec){.id = ICON_ID_MAN, .icon = &I_icon_man_7x16, .size = (Vector){7, 16}};
+    else if (is_str(name, "plant"))
+        return (IconSpec){.id = ICON_ID_PLANT, .icon = &I_icon_plant_16x16, .size = (Vector){16, 16}};
+    else if (is_str(name, "tree"))
+        return (IconSpec){.id = ICON_ID_TREE, .icon = &I_icon_tree_16x16, .size = (Vector){16, 16}};
+    else if (is_str(name, "woman"))
+        return (IconSpec){.id = ICON_ID_WOMAN, .icon = &I_icon_woman_9x16, .size = (Vector){9, 16}};
+    else if (is_str(name, "fence"))
+        return (IconSpec){.id = ICON_ID_FENCE, .icon = &I_icon_fence_16x8px, .size = (Vector){16, 8}};
+    else if (is_str(name, "fence_end"))
+        return (IconSpec){.id = ICON_ID_FENCE_END, .icon = &I_icon_fence_end_16x8px, .size = (Vector){16, 8}};
+    else if (is_str(name, "fence_vertical_end"))
+        return (IconSpec){.id = ICON_ID_FENCE_VERTICAL_END, .icon = &I_icon_fence_vertical_end_6x8px, .size = (Vector){6, 8}};
+    else if (is_str(name, "fence_vertical_start"))
+        return (IconSpec){.id = ICON_ID_FENCE_VERTICAL_START, .icon = &I_icon_fence_vertical_start_6x15px, .size = (Vector){6, 15}};
+    else if (is_str(name, "flower"))
+        return (IconSpec){.id = ICON_ID_FLOWER, .icon = &I_icon_flower_16x16, .size = (Vector){16, 16}};
+    else if (is_str(name, "lake_bottom"))
+        return (IconSpec){.id = ICON_ID_LAKE_BOTTOM, .icon = &I_icon_lake_bottom_31x12px, .size = (Vector){31, 12}};
+    else if (is_str(name, "lake_bottom_left"))
+        return (IconSpec){.id = ICON_ID_LAKE_BOTTOM_LEFT, .icon = &I_icon_lake_bottom_left_24x22px, .size = (Vector){24, 22}};
+    else if (is_str(name, "lake_bottom_right"))
+        return (IconSpec){.id = ICON_ID_LAKE_BOTTOM_RIGHT, .icon = &I_icon_lake_bottom_right_24x22px, .size = (Vector){24, 22}};
+    else if (is_str(name, "lake_left"))
+        return (IconSpec){.id = ICON_ID_LAKE_LEFT, .icon = &I_icon_lake_left_11x31px, .size = (Vector){11, 31}};
+    else if (is_str(name, "lake_right"))
+        return (IconSpec){.id = ICON_ID_LAKE_RIGHT, .icon = &I_icon_lake_right_11x31, .size = (Vector){11, 31}};
+    else if (is_str(name, "lake_top"))
+        return (IconSpec){.id = ICON_ID_LAKE_TOP, .icon = &I_icon_lake_top_31x12px, .size = (Vector){31, 12}};
+    else if (is_str(name, "lake_top_left"))
+        return (IconSpec){.id = ICON_ID_LAKE_TOP_LEFT, .icon = &I_icon_lake_top_left_24x22px, .size = (Vector){24, 22}};
+    else if (is_str(name, "lake_top_right"))
+        return (IconSpec){.id = ICON_ID_LAKE_TOP_RIGHT, .icon = &I_icon_lake_top_right_24x22px, .size = (Vector){24, 22}};
+    else if (is_str(name, "rock_large"))
+        return (IconSpec){.id = ICON_ID_ROCK_LARGE, .icon = &I_icon_rock_large_18x19px, .size = (Vector){18, 19}};
+    else if (is_str(name, "rock_medium"))
+        return (IconSpec){.id = ICON_ID_ROCK_MEDIUM, .icon = &I_icon_rock_medium_16x14px, .size = (Vector){16, 14}};
+    else if (is_str(name, "rock_small"))
+        return (IconSpec){.id = ICON_ID_ROCK_SMALL, .icon = &I_icon_rock_small_10x8px, .size = (Vector){10, 8}};
+
+    return (IconSpec){.id = -1, .icon = NULL, .size = (Vector){0, 0}};
+}
+
+bool world_json_draw(GameManager *manager, Level *level, const FuriString *json_data)
 {
     if (!json_data)
     {
         FURI_LOG_E("Game", "JSON data is NULL");
         return false;
     }
-    int levels_added = 0;
-    FURI_LOG_I("Game", "Looping through world data");
+
+    // Pass 1: Count the total number of icons.
+    int total_icons = 0;
     for (int i = 0; i < MAX_WORLD_OBJECTS; i++)
     {
-        FURI_LOG_I("Game", "Looping through world data: %d", i);
         FuriString *data = get_json_array_value_furi("json_data", i, json_data);
         if (!data)
-        {
             break;
-        }
-
-        FuriString *icon = get_json_value_furi("icon", data);
-        FuriString *x = get_json_value_furi("x", data);
-        FuriString *y = get_json_value_furi("y", data);
         FuriString *amount = get_json_value_furi("amount", data);
-        FuriString *horizontal = get_json_value_furi("horizontal", data);
-
-        if (!icon || !x || !y || !amount || !horizontal)
+        if (amount)
         {
-            FURI_LOG_E("Game", "Failed Data: %s", furi_string_get_cstr(data));
-
-            if (data)
-                furi_string_free(data);
-            if (icon)
-                furi_string_free(icon);
-            if (x)
-                furi_string_free(x);
-            if (y)
-                furi_string_free(y);
-            if (amount)
-                furi_string_free(amount);
-            if (horizontal)
-                furi_string_free(horizontal);
-
-            level_clear(level);
-            return false;
+            int count = atoi(furi_string_get_cstr(amount));
+            if (count < 1)
+                count = 1;
+            total_icons += count;
+            furi_string_free(amount);
         }
+        furi_string_free(data);
+    }
+    FURI_LOG_I("Game", "Total icons to spawn: %d", total_icons);
+
+    // Allocate the icon group context (local instance)
+    IconGroupContext igctx;
+    igctx.count = total_icons;
+    igctx.icons = malloc(total_icons * sizeof(IconSpec));
+    if (!igctx.icons)
+    {
+        FURI_LOG_E("Game", "Failed to allocate icon group array for %d icons", total_icons);
+        return false;
+    }
+    GameContext *game_context = game_manager_game_context_get(manager);
+    game_context->icon_count = total_icons;
 
-        int count = atoi(furi_string_get_cstr(amount));
-        if (count < 2)
+    // Pass 2: Parse the JSON to fill the icon specs.
+    int spec_index = 0;
+    for (int i = 0; i < MAX_WORLD_OBJECTS; i++)
+    {
+        FuriString *data = get_json_array_value_furi("json_data", i, json_data);
+        if (!data)
+            break;
+
+        FuriString *icon_str = get_json_value_furi("icon", data);
+        FuriString *x_str = get_json_value_furi("x", data);
+        FuriString *y_str = get_json_value_furi("y", data);
+        FuriString *amount_str = get_json_value_furi("amount", data);
+        FuriString *horizontal_str = get_json_value_furi("horizontal", data);
+
+        if (!icon_str || !x_str || !y_str || !amount_str || !horizontal_str)
         {
-            // Just one icon
-            spawn_icon(
-                manager,
-                level,
-                furi_string_get_cstr(icon),
-                atoi(furi_string_get_cstr(x)),
-                atoi(furi_string_get_cstr(y)));
+            FURI_LOG_E("Game", "Incomplete icon data: %s", furi_string_get_cstr(data));
+            if (icon_str)
+                furi_string_free(icon_str);
+            if (x_str)
+                furi_string_free(x_str);
+            if (y_str)
+                furi_string_free(y_str);
+            if (amount_str)
+                furi_string_free(amount_str);
+            if (horizontal_str)
+                furi_string_free(horizontal_str);
+            furi_string_free(data);
+            continue;
         }
-        else
+
+        int count = atoi(furi_string_get_cstr(amount_str));
+        if (count < 1)
+            count = 1;
+        float base_x = atof_furi(x_str);
+        float base_y = atof_furi(y_str);
+        bool is_horizontal = (furi_string_cmp(horizontal_str, "true") == 0);
+        int spacing = 17;
+
+        for (int j = 0; j < count; j++)
         {
-            bool is_horizontal = (furi_string_cmp(horizontal, "true") == 0);
-            spawn_icon_line(
-                manager,
-                level,
-                furi_string_get_cstr(icon),
-                atoi(furi_string_get_cstr(x)),
-                atoi(furi_string_get_cstr(y)),
-                count,
-                is_horizontal,
-                17 // set as 17 for now
-            );
+            IconSpec spec = world_get_icon_spec(furi_string_get_cstr(icon_str));
+            if (!spec.icon)
+            {
+                FURI_LOG_E("Game", "Icon name not recognized: %s", furi_string_get_cstr(icon_str));
+                continue;
+            }
+            if (is_horizontal)
+            {
+                spec.pos.x = base_x + (j * spacing);
+                spec.pos.y = base_y;
+            }
+            else
+            {
+                spec.pos.x = base_x;
+                spec.pos.y = base_y + (j * spacing);
+            }
+            igctx.icons[spec_index++] = spec;
         }
 
+        furi_string_free(icon_str);
+        furi_string_free(x_str);
+        furi_string_free(y_str);
+        furi_string_free(amount_str);
+        furi_string_free(horizontal_str);
         furi_string_free(data);
-        furi_string_free(icon);
-        furi_string_free(x);
-        furi_string_free(y);
-        furi_string_free(amount);
-        furi_string_free(horizontal);
-        levels_added++;
     }
+
+    // Spawn one icon group entity.
+    Entity *groupEntity = level_add_entity(level, &icon_desc);
+    IconGroupContext *entityContext = (IconGroupContext *)entity_context_get(groupEntity);
+    if (entityContext)
+    {
+        memcpy(entityContext, &igctx, sizeof(IconGroupContext));
+    }
+    else
+    {
+        FURI_LOG_E("Game", "Failed to get entity context for icon group");
+        free(igctx.icons);
+        return false;
+    }
+
+    // Set the global pointer so that player collision logic can use it.
+    g_current_icon_group = entityContext;
+
     FURI_LOG_I("Game", "Finished loading world data");
-    return levels_added > 0;
+    return true;
 }
 
-static void draw_town_world(Level *level, GameManager *manager, void *context)
+static void world_draw_town(Level *level, GameManager *manager, void *context)
 {
     UNUSED(context);
     if (!manager || !level)
@@ -102,7 +197,47 @@ static void draw_town_world(Level *level, GameManager *manager, void *context)
         FURI_LOG_E("Game", "Failed to separate world data");
     }
     furi_string_free(json_data_str);
-    set_world(level, manager, "shadow_woods_v5");
+    level_set_world(level, manager, "shadow_woods_v5");
+    game_context->icon_offset = 0;
+    if (!game_context->imu_present)
+    {
+        game_context->icon_offset += ((game_context->icon_count / 10) / 15);
+    }
+    player_spawn(level, manager);
+}
+
+static const LevelBehaviour _world_training = {
+    .alloc = NULL,
+    .free = NULL,
+    .start = world_draw_town,
+    .stop = NULL,
+    .context_size = 0,
+};
+
+const LevelBehaviour *world_training()
+{
+    return &_world_training;
+}
+
+static void world_draw_pvp(Level *level, GameManager *manager, void *context)
+{
+    UNUSED(context);
+    if (!manager || !level)
+    {
+        FURI_LOG_E("Game", "Manager or level is NULL");
+        return;
+    }
+    GameContext *game_context = game_manager_game_context_get(manager);
+    level_clear(level);
+    FuriString *json_data_str = furi_string_alloc();
+    furi_string_cat_str(json_data_str, "{\"name\":\"pvp_world\",\"author\":\"ChatGPT\",\"json_data\":[{\"icon\":\"rock_medium\",\"x\":100,\"y\":100,\"amount\":10,\"horizontal\":true},{\"icon\":\"rock_medium\",\"x\":400,\"y\":300,\"amount\":6,\"horizontal\":true},{\"icon\":\"rock_small\",\"x\":600,\"y\":200,\"amount\":8,\"horizontal\":true},{\"icon\":\"fence\",\"x\":50,\"y\":50,\"amount\":10,\"horizontal\":true},{\"icon\":\"fence\",\"x\":250,\"y\":150,\"amount\":12,\"horizontal\":true},{\"icon\":\"fence\",\"x\":550,\"y\":350,\"amount\":12,\"horizontal\":true},{\"icon\":\"rock_large\",\"x\":400,\"y\":70,\"amount\":12,\"horizontal\":true},{\"icon\":\"rock_large\",\"x\":200,\"y\":200,\"amount\":6,\"horizontal\":false},{\"icon\":\"tree\",\"x\":5,\"y\":5,\"amount\":45,\"horizontal\":true},{\"icon\":\"tree\",\"x\":5,\"y\":5,\"amount\":20,\"horizontal\":false},{\"icon\":\"tree\",\"x\":22,\"y\":22,\"amount\":44,\"horizontal\":true},{\"icon\":\"tree\",\"x\":22,\"y\":22,\"amount\":20,\"horizontal\":false},{\"icon\":\"tree\",\"x\":5,\"y\":347,\"amount\":45,\"horizontal\":true},{\"icon\":\"tree\",\"x\":5,\"y\":364,\"amount\":45,\"horizontal\":true},{\"icon\":\"tree\",\"x\":735,\"y\":37,\"amount\":18,\"horizontal\":false},{\"icon\":\"tree\",\"x\":752,\"y\":37,\"amount\":18,\"horizontal\":false}]}");
+    if (!separate_world_data("pvp_world", json_data_str))
+    {
+        FURI_LOG_E("Game", "Failed to separate world data");
+    }
+    furi_string_free(json_data_str);
+    level_set_world(level, manager, "pvp_world");
+    game_context->is_switching_level = false;
     game_context->icon_offset = 0;
     if (!game_context->imu_present)
     {
@@ -111,20 +246,20 @@ static void draw_town_world(Level *level, GameManager *manager, void *context)
     player_spawn(level, manager);
 }
 
-static const LevelBehaviour _training_world = {
+static const LevelBehaviour _world_pvp = {
     .alloc = NULL,
     .free = NULL,
-    .start = draw_town_world,
+    .start = world_draw_pvp,
     .stop = NULL,
     .context_size = 0,
 };
 
-const LevelBehaviour *training_world()
+const LevelBehaviour *world_pvp()
 {
-    return &_training_world;
+    return &_world_pvp;
 }
 
-FuriString *fetch_world(const char *name)
+FuriString *world_fetch(const char *name)
 {
     if (!name)
     {

+ 6 - 4
flip_world/game/world.h

@@ -9,7 +9,9 @@
 #define WORLD_HEIGHT 384
 
 // Maximum number of world objects
-#define MAX_WORLD_OBJECTS 25 // any more than that and we may run out of heap when switching worlds
-const LevelBehaviour *training_world();
-bool draw_json_world_furi(GameManager *manager, Level *level, const FuriString *json_data);
-FuriString *fetch_world(const char *name);
+#define MAX_WORLD_OBJECTS 25
+
+const LevelBehaviour *world_training();
+const LevelBehaviour *world_pvp();
+bool world_json_draw(GameManager *manager, Level *level, const FuriString *json_data);
+FuriString *world_fetch(const char *name);

+ 1 - 1
flip_world/jsmn/jsmn_h.h

@@ -10,7 +10,7 @@
 
 #undef FURI_LOG_I
 #define FURI_LOG_I(tag, msg, ...)
-//
+// //
 
 typedef enum
 {

Some files were not shown because too many files changed in this diff