Google CTF Final 2025
Also known as Hackceler8 by Dudcom
Finals this year was held in Mexico City. As always, it was based on game hacking + speedrunning to get flags and beat a boss. I was playing for the team SLICES [:]. For quals it was mostly us (Squid Proxy Lovers) and Infobahn, plus a handful from our other sub-teams. Sadly, for finals we didn’t really have any remote help outside of the start of day two due to the time zone and us not planning it out well. Onsite, the team was Clovis Mint, ContronThePanda, and BraydenPikachu. Because flights got in the way, Brayden didn’t arrive until after competition day 1, and I was also there.
Game Summary
The game is a 4-person 2D Zelda-like dungeon crawler; the objective is to clear mini-bosses in four temples. Each temple is on a corner of the map. The maps are generally the same, but some rounds do change them, while items and enemies are moved around per round/patch. The way to beat the game is to defeat the final boss. To actually fight the final boss, we need to get some number of “flags” that are given to you arbitrarily from the mini-bosses. In the demo version there is 1 boss with 1 flag, and one boss has 2 flags in every temple, but this is not the case in the actual rounds - it’s entirely up to the devs.
Generally speaking, the more game-breaking bugs you found, the more flags you were awarded. There was only one mini-boss per temple. The required flags for the final boss were 4 on day one, 5 on day two, and 5 on day three. Rankings worked like this: clearing the boss equaled winning the game; time broke ties. If no one beat the boss, ranking was based on the number of flags, with time breaking ties again. An interesting aspect of the game was the tradeoff: if the final boss was too hard, you often gained more by completing all the temples as fast as possible. This happened fairly often since the final boss was generally memory exploitation, and doing that on a controller was hard.
Hardware/architecture-wise the game was built for the Sega Genesis and was running on custom hardware to allow for 4-person co-op. Most standard emulators will not be able to support testing with four people, given that 4-person play wasn’t really made for those systems. The only way to get it working that we found was to use Kega Fusion, an emulator which allows you to enable 4-person play in Sega Genesis games, and that is what the team used for testing.
Rounds Summary
Every round, Google would provide a brand-new version of the game with source code and a compiled version of the game. The base game was relatively safe. There were some bugs and issues that existed, but they weren’t the easiest to exploit. The patches generally introduced logic bugs or created situations that allowed for exploiting kinks in the game engine, like clipping. They generally had a few common patch structures that we saw over and over:
- Creating Items to solve specific situations
- Speed-buff issues that allowed for wall clipping
- NPCs were added with exploitable input fields
- Enemies were located in exploitable/finicky sections of the game
Tooling
Ok, so if we were really trying, this would be something that we made at least a few days or weeks before, but honestly everyone on our team was fairly busy, so most of the tooling was pretty much set up on site. A lot of it was built by our best friend AI, but regardless, let’s get into it:
Map Visualization
One of the most useful things you could do while playing the game was to figure out the map layout and enemy/boss locations. This helps you prioritize and figure out what you need to target in order to optimize your speedrun. On top of that the map layout also directly gave you hints on which parts of the patch would correlate with which temple. Every temple would generally speaking have a unique exploit and bug that you had to use in order to beat it.
Here is what our tooling would spit out:
As you can see here, the tooling would create a simple PNG that showed the overall layout and some basic info on what each thing in a map was, such as whether they were enemies, mini-bosses, the flag value of a mini-boss, objects, and more. I want to make it clear, though, that the game was actually built on a standard map format and you could use applications like Tiled to analyze the map as well. I just found it somewhat tedious to use for a quick overview, though it still gave lots of useful information, so we did end up using both.
While this does work, I didn’t like that it didn’t show all objects and that I had to click through things to figure out the properties.
AI generated lots of stupid, semi-useless things in terms of how you can use it, but honestly I only really did:
python3 map_visualizer.py ../round_3/resources/maps/water-temple/water-temple.world -o water3.pngOctopus: 14Siren: 3HeartItem: 3Boots: 2Sword: 2Spawn: 1DuckNpc: 1DoorH: 1Saved world visualization to: water3.pngI added the prints here to summarize the map objects pretty fast. It’s dynamically defined via the map using the game’s internal object, so I never need to update names and such.
Hack Client
Well, it’s a game hacking competition, so how could you not make a hack client? The hack client I made had several goals in mind:
- show speed, let you modify speed
- modify health
- modify flag values
- add random objects
- TP/Movement hacks
This isn’t perfect, and honestly I had several things I wish I could have added, but they weren’t really the biggest priorities unless we had a proper remote team backing us up. So maybe for another year. I mostly wanted to add:
- Macro system for controlled input & and easy testing of memory exploitation
- Hitbox analysis
Beyond that, I don’t think there was too much else that would help. Most of the bugs and hacks were fairly obvious; the main reason I think a hack client was nice is that it allowed faster testing of specific things like the boss by just giving yourself flags and whatnot from the start.
Installing is fairly simple:
You just drop debug_client.rs into the source folder and then update:
- add
mod debug_client;in lib.rs - Follow this diff for game.rs:
19a20+ use crate::debug_client::DebugClient;179a181+ pub debug_client: DebugClient,254a257+ debug_client: DebugClient::new(),267a271,273+ // Update debug client first to check for toggle+ ctx.debug_client.update(&ctx.controller, &mut ctx.players, &mut ctx.world, &mut ctx.captured_flags, &mut ctx.defeated_minibosses, ctx.frame);+420a427,429++ // Render debug client on top of everything+ self.debug_client.render(&mut self.vdp, &self.players, &self.ui, self.captured_flags, self.defeated_minibosses);- You’re just including the debug client.
- Making it public to use.
- Initializing it in the game state.
- Calling it with the necessary args for it to run.
- Rendering it on top of the current game state.
Using it is fairly simple as well:
Click "X" to enter debug modeleft and right while in NAV mode to switch usersUp and Down to pick object to editClick "A" to enter edit modeUp and Down to inc values for everything but itemsItems work by you going up and down to pick the item and then right to addClick "X" to Exit debug mode
Round-1 (4 Flags For Boss)
Fire-Temple
python3 2025/hackceler8/map_visualizer.py 2025/round_1/resources/maps/fire-temple/fire-temple.worldBlob: 10Flameboi: 8CatNpc: 1HeartItem: 7Goggles: 2Boots: 2Sword: 1DoorH: 5Spawn: 1Switch: 2Key Diff:
fn unique_item() -> Dialogue { Dialogue::new_multiple_choice( "Would you like to have my key?", &["Yes", "No"], Some(unique_item_2), )}
fn unique_item_2(ctx: &mut Ctx, response: &str) { if eq(response, "No") { return; }
if ctx.world.inventory.contains(ItemType::Key) { ctx.start_dialogue(Dialogue::new_no_response( "I already gave you my key.", None, )); return; }
ctx.world.inventory.add(ItemType::Key); ctx.start_dialogue(Dialogue::new_no_response("Here you go!", None));}
fn eq(a: &str, b: &str) -> bool { a.len() == b.len() && a.chars().zip(b.chars()).all(|(a, b)| a == b)}
fn get_item(ctx: &mut Ctx, _: &str) { ctx.world.inventory.add(ItemType::Key);This part of the game was fairly trivial; you just had a wall.
As you can see here, there are 4 doors that you need to pass. You just talk to the NPC nearby several times, and after doing so you can beat the mini-boss. This entire section was pretty trivial.
Water-Temple
python3 2025/hackceler8/map_visualizer.py 2025/round_1/resources/maps/water-temple/water-temple.worldOctopus: 16Siren: 5DoorV: 1HeartItem: 3Boots: 2Key: 2Sword: 1Goggles: 1Spawn: 1DuckNpc: 1DoorH: 1Key Diff:
diff '--color=auto' dist/2025/hackceler8/game/src/player.rs round1/round_1/game/src/player.rs153c153- if !self.is_alive() {---+ if self.health == 0 {This was the part of the map that you actually needed to exploit and find a way to bypass: it was a wall of enemies, and there was no way to pass them normally. The actual hack here is the fact that when you die, you go into a dying animation but aren’t actually fully set to dead. What this allows you to do is move from room to room and then enter an invulnerable state and kill the enemies. This hack is actually doable throughout the game and makes you semi-unkillable. You can still die from touching any auto-kill objects, and if you regain any health, you leave the state.

Sky-Temple

python3 2025/hackceler8/map_visualizer.py 2025/round_1/resources/maps/sky-temple/sky-temple.worldAngel: 9Goggles: 1HeartItem: 7Archer: 5SnakeNpc: 1Sword: 1DoorV: 5ChestNpc: 5MimicNpc: 7Spawn: 1Key Diff:
(Sprite::Anim::IdleDown, false) }; npc.sprite.maybe_set_anim(anim as usize); npc.sprite.flip_h = flip; break; }114a130 ctx.dialogue_npc_id = Some(npc_id);150a167,172 NpcType::ChestNpc | NpcType::MimicNpc => Hitbox { x: 1, y: 11, w: 21, h: 13, },181a204,276 }
fn mimic_challenge() -> Dialogue { Dialogue::new_multiple_choice( "I swear I'm not a mimic!\nOpen me?", &["Yes", "No"], Some(mimic_challenge_open), ) }
fn mimic_challenge_opened() -> Dialogue { Dialogue::new_no_response("This chest is already open.", None) }
fn mimic_challenge_open(ctx: &mut Ctx, response: &str) { if eq(response, "No") { return; }
if let Some(npc_id) = ctx.dialogue_npc_id { let npc = &mut ctx.world.npcs[npc_id]; if npc.npc_type == NpcType::ChestNpc { npc.sprite.set_anim(MimicSprite::Anim::Open as usize); // Switch to the "already opened" dialogue. npc.dialogue_id = 3; ctx.start_dialogue(Dialogue::new_no_response( "Obtained *Key* !", Some(get_item), )); } else { npc.sprite.set_anim(MimicSprite::Anim::OpenMimic as usize); ctx.start_dialogue(Dialogue::new_no_response("I LIED, BWA HA HA!", Some(lose))); } } } pub items_collected: Bitmask,308a311,343 pub fn shuffle_chests(&mut self, rng_state: &mut u32) { let mut filled_position = [false; 12]; let mut found_chest = false; for npc in &mut self.npcs { if npc.npc_type != NpcType::ChestNpc && npc.npc_type != NpcType::MimicNpc { continue; } found_chest = true;
let mut pos = (Self::get_random_int(rng_state) % 10) as usize; if filled_position.iter().all(|x| *x) { panic!("All chest slots filled"); } while filled_position[pos] { pos = (pos + 1) % 12; } filled_position[pos] = true;
npc.set_position(128 + 1 + pos as i16 * 24, npc.y); }
if found_chest { for _ in 0..5 { self.inventory.remove(ItemType::Key); } } }
fn get_random_int(rng_state: &mut u32) -> u32 { *rng_state = rng_state.wrapping_mul(1664525).wrapping_add(1013904223); *rng_state }The addition here was the creation of a new NPC based on a seeded random value. It would generate five keys. When you pick up a key, the other mimics lose their keys. You have to keep resetting the state to reseed the mimics. You will die when you get it wrong, but you can use the earlier bug to reset the player trying the mimic, giving you effectively infinite attempts.
White dots are the mimics; the brown tiles are the doors. You need five keys to get past.
Forest-Temple

python3 2025/hackceler8/map_visualizer.py 2025/round_1/resources/maps/forest-temple/forest-temple.worldGoblin: 10Boots: 2RacoonNpc: 1Goggles: 1Orc: 10HeartItem: 6Spawn: 1Key: 1Sword: 1DoorV: 3Switch: 2Key Diff:
- Nothing! The bug here is a core issue in the game, or at least that’s how we solved it. If you time it correctly, you can pick up the key with 3–4 people in the room before the three doors. This is more or less an item duplication glitch by timing everything correctly. The best way we found to do this was to let one person hold the controller and pick it up for everyone at once.
Round-2 (4 Flags For Boss)
Fire-Temple
python3 2025/hackceler8/map_visualizer.py 2025/round_2/resources/maps/fire-temple/fire-temple.worldBlob: 12Flameboi: 9HeartItem: 7Knife: 1Boots: 2DoorH: 3Key: 1Sword: 1Spawn: 1Goggles: 1Switch: 2CatNpc: 1Key Diff:
fn engrave() -> Dialogue { Dialogue::new_free_text("Can you guess my secret?", Some(engrave_guess))}
fn engrave_guess(ctx: &mut Ctx, response: &str) { if eq(response, SECRET) { ctx.start_dialogue(Dialogue::new_no_response( "That's correct! Here's your reward.\n\nObtained *Key* !", Some(get_item), )); } else { ctx.start_dialogue(Dialogue::new_free_text( "That's not it.\n:C\n\ As consolation I can engrave your name onto the map.\nWhat's your name?", Some(engrave_name), )); }}
fn engrave_name(ctx: &mut Ctx, response: &str) { let mut tiles: Vec<TileFlags, 64> = Vec::new(); text_to_tiles(ctx, &mut tiles, response); text_to_tiles(ctx, &mut tiles, SECRET); ctx.vdp .set_plane_tiles(Plane::A, 45 * 64 + 10, &tiles[0..response.len()]);
let img = &ctx.ui.inventory_text_img; ctx.vdp .set_tiles(img.start_tile, tileset::TILESETS[img.tiles_idx]); let cur = ctx.world.plane_window.current_scroll(); ctx.vdp.set_h_scroll(0, &[-(cur.0 as i16), 0]); ctx.vdp .set_v_scroll(0, &[cur.1 as i16, image::SCREEN_V_SCROLL]);
ctx.start_dialogue(Dialogue::new_no_response( "What a lovely name!\nI engraved it for your viewing pleasure.", None, ));}
fn text_to_tiles(ctx: &Ctx, tiles: &mut Vec<TileFlags, 64>, text: &str) { let img = &ctx.ui.inventory_text_img; for chr in text.as_bytes().iter() { let tile = TileFlags::for_tile( img.start_tile + ui::CHAR_TILES_INDEXES[*chr as usize] as u16, img.palette, ); info!("Converting {} -> {}", chr, tile.tile_index()); tiles .push(tile) .unwrap_or_else(|_| panic!("tile vec too small")); }}
fn eq(a: &str, b: &str) -> bool { a.len() == b.len() && a.chars().zip(b.chars()).all(|(a, b)| a == b)}
fn get_item(ctx: &mut Ctx, _: &str) { ctx.world.inventory.add(ItemType::Key);}
The challenge here is straightforward: we have a door without a key, and we need to get one. If you go through the diff above, you realize that the Engraver NPC in the Fire Temple can give you a key if you guess his “Secret” correctly. Obviously we don’t know it. So how do we pull it off? You can corrupt the game state and leak the key by inputting an empty field when he asks for a name to engrave. Here is the result:
After this it’s literally just using the key and beating the mini-bosses that are located after the door.
Water-Temple
python3 2025/hackceler8/map_visualizer.py 2025/round_2/resources/maps/water-temple/water-temple.worldOctopus: 9Siren: 4HeartItem: 3Boots: 2Key: 2DoorH: 3Knife: 1Goggles: 1Spawn: 1DuckNpc: 1Key Diff:
self.portal .save_to_persistent_storage(&[0x1234, 0x5678, 0x9ABC, 0xDEF0]) if matches!(self.world.world_type, WorldType::BossTemple) || self.players.iter().all(|p| !p.is_active()) { return; }
let storage = &mut [0u16; 11]; // Save current world and map. storage[0] = world_type_to_u16(self.world.world_type); storage[1] = self.world.current_position.0 as u16; storage[2] = self.world.current_position.1 as u16; // Save player positions on the map. for i in 0..4 { if self.players[i].is_active() { storage[3 + i * 2] = (self.players[i].x + 128) as u16; storage[4 + i * 2] = (self.players[i].y + 128) as u16; } else { // Magic value to indicate inactive players. storage[3 + i * 2] = 0xffff; storage[4 + i * 2] = 0xffff; } } self.portal.save_to_persistent_storage(storage) }
fn load_persistent_state(portal: &TargetPortal) { let mut save_buf = [0; 4]; /// Returns (starting_world, (map_x, map_y), [Option<player.start_x, player.start_y>]). fn load_persistent_state( portal: &TargetPortal, ) -> (WorldType, (i16, i16), [Option<(i16, i16)>; 4]) { let mut save_buf = [0; 11]; portal.load_from_persistent_storage(&mut save_buf); info!("Loaded data from storage: {:?}", save_buf);
let starting_world = u16_to_world_type(save_buf[0]).unwrap_or(WorldType::Overworld); if matches!(starting_world, WorldType::Overworld) { // Return default values as the state might not be initialized yet. return (starting_world, (1, 1), [Some((280, 177)), None, None, None]); }
let map_x = save_buf[1] as i16; let map_y = save_buf[2] as i16;
let mut player_pos = [None; 4]; for i in 0..4 { if save_buf[3 + i * 2] != 0xffff { player_pos[i] = Some(( (save_buf[3 + i * 2] - 128) as i16, (save_buf[4 + i * 2] - 128) as i16, )); } }
(starting_world, (map_x, map_y), player_pos) }
/// Check if the server is paused and block + display a loading text until it gets unpaused.@@ -553,6 +604,29 @@ fn wait_for_server_init(vdp: &mut TargetVdp, portal: &mut TargetPortal) { State::clear_screen(vdp, &[Plane::A, Plane::B]); }
fn world_type_to_u16(world_type: WorldType) -> u16 { match world_type { WorldType::Overworld => 0, WorldType::FireTemple => 1, WorldType::WaterTemple => 2, WorldType::ForestTemple => 3, WorldType::SkyTemple => 4, WorldType::BossTemple => 5, }}
fn u16_to_world_type(val: u16) -> Option<WorldType> { match val { 0 => Some(WorldType::Overworld), 1 => Some(WorldType::FireTemple), 2 => Some(WorldType::WaterTemple), 3 => Some(WorldType::ForestTemple), 4 => Some(WorldType::SkyTemple), 5 => Some(WorldType::BossTemple), _ => None, }}
This code adds save states. You can reset the world using the hard reset button on the device; instead of going back to the very start, you revert the entire current “world”/temple. With this you can essentially double the number of keys you have access to in order to beat the Water Temple: go to the very top room.
Here, you open the door. Have one person stand in front of the door and one behind. Wait 10 seconds for the current game state to save, then restart the system. One player will be able to access the key at the top of the room while the bottom key regenerates, allowing you to open the mini-boss room, which requires two keys.
Sky-Temple

python3 2025/hackceler8/map_visualizer.py 2025/round_2/resources/maps/sky-temple/sky-temple.worldAngel: 7Knife: 1Key: 1HeartItem: 7Archer: 7SnakeNpc: 1Sword: 1DoorV: 1Spawn: 1DoorH: 1Key Diff:
ItemType::Knife => { player.strength *= 2; } ItemType::Boots => { player.speed += crate::player::SPEED_SCALE_FACTOR; }@@ -147,9 +156,12 @@ impl InventoryItem { /// Remove the item's effects from a player it was previously applied to. pub fn remove_effect(&self, player: &mut Player) { match self.item_type { ItemType::Knife => { player.strength /= 2; }
--- a/game/src/projectile.rs+++ b/game/src/projectile.rs@@ -97,6 +97,16 @@ impl Projectile { break; } } for enemy in &mut ctx.world.enemies { if !enemy.is_alive() || enemy.id == projectile.shooter_id { continue; } if projectile.hitbox().collides(&enemy.hitbox()) { enemy.on_hit(push_dir, projectile.damage); projectile.status = Status::Dead; break; } }The exploit for this temple set a precedent going forward regarding multiplicative vs. additive bugs. The whole idea here is that your knife will 2x your damage and then 1/2 your damage, i.e. acting as a multiplicative increase in your weapon’s damage. In comparison, the sword is a +1/-1 toggle. What you can do here is equip the knife, then equip the sword so you are at (1*2)+1, and then remove the knife so you go from 3 to 1.5. Then you remove the sword and you go to 0 because the game rounds down. This then lets you abuse the second part of the patch shown above, which is that projectiles are now able to hit enemies. This allows you to move the archer in the same room to face the boss. Since you do no damage, you can keep hitting the enemy until he is facing or angled toward the boss.
So here the enemy would be facing the boss.
Forest-Temple
Ignore the invisible keys, that’s for later.
python3 2025/hackceler8/map_visualizer.py 2025/round_2/resources/maps/forest-temple/forest-temple.world Goblin: 7 Boots: 2 RacoonNpc: 1 Knife: 1 Orc: 7 HeartItem: 6 Spawn: 1 Key: 2 Sword: 1 DoorV: 1 Switch: 1Key Diff:
/// NPCs are friendly entities on the map that the player can talk to. pub struct Npc { pub x: i16,@@ -88,6 +99,7 @@ impl Npc { { continue; }+ ctx.dialogue_player = p; dialogue_id = Some(npc.dialogue_id); // Face the player. let center = hitbox.center();@@ -181,6 +193,137 @@ fn bark_bark() -> Dialogue { Dialogue::new_no_response("BARK BARK!", None) }
+fn fashion_advice() -> Dialogue {+ Dialogue::new_multiple_choice(+ "Would you like to hear my expert opinion on which item you should wear?",+ &["Yes", "No"],+ Some(fashion_advice_2),+ )+}++fn fashion_advice_2(ctx: &mut Ctx, response: &str) {+ if eq(response, "No") {+ return;+ }++ let fashion_id = get_random_wearable_id(ctx);+ if fashion_id.is_none() {+ ctx.start_dialogue(Dialogue::new_no_response(+ "It doesn't look like you have anything to wear.",+ None,+ ));+ return;+ }+ let fashion_id = fashion_id.unwrap();++ // Unequip previous items and equip the fashionable one. Looking sharp!+ for i in &mut ctx.world.inventory.items {+ if i.worn_by == Some(ctx.dialogue_player) {+ i.remove_effect(&mut ctx.players[ctx.dialogue_player]);+ i.worn_by = None;+ }+ }+ let fashion_item = &mut ctx.world.inventory.items[fashion_id];+ fashion_item.apply_effect(&mut ctx.players[ctx.dialogue_player]);+ fashion_item.worn_by = Some(ctx.dialogue_player);++ let mut text: String<64> = String::new();+ let _ = write!(+ text,+ "<{}> definitely looks best on you!",+ fashion_item.display_name()+ );+ ctx.start_dialogue(Dialogue::new_no_response(&text, None))+}++fn get_random_wearable_id(ctx: &mut Ctx) -> Option<usize> {+ let wearable_count = ctx+ .world+ .inventory+ .items+ .iter()+ .filter(|i| InventoryItem::is_wearable(i.item_type))+ .count();+ if wearable_count == 0 {+ return None;+ }++ let pos = ctx.portal.get_random_int() as usize % wearable_count;+ let mut i = 0;+ for item in &mut ctx.world.inventory.items {+ if InventoryItem::is_wearable(item.item_type) {+ if i == pos {+ return Some(i);+ }+ i += 1;+ }+ }++ None+}So the idea here is that we have an NPC that is able to equip and de-equip items from our character, and there is a physical wall stopping our character from moving to the next section where the boss is. In other words, we are going to need to perform some kind of wall clip in order to get to the mini-boss, and given the fact that we have an NPC who can add or remove items, we know that this is going to be a speed-related exploit. To put it simply, the way this actually works is pretty simple: we can use two characters to talk to the NPC and continually swap who actually has the “boots,” but when the NPC removes the boots from character A and moves them to character B, we don’t actually lose the speed buff since we don’t perform a proper de-equip, allowing you to infinitely stack the speed buff. This allows you to then directly clip through walls if you have enough speed, or glitch into the side of a wall in a transitional state and move through the walls until you reach the boss.
Round-3 (4 Flags For Boss)
Fire-Temple
python3 2025/hackceler8/map_visualizer.py 2025/round_3/resources/maps/fire-temple/fire-temple.worldBlob: 9Flameboi: 9CatNpc: 1HeartItem: 7Goggles: 2Boots: 2DoorH: 2Key: 1Sword: 1Spawn: 1Switch: 2Key Diff:
The boss he had 31337 in health the only thing you had to to beat the boss is keep hitting him till you got him to a transition room and he will die. Boss apparently ends up dying on transition states.
Sky-Temple
python3 2025/hackceler8/map_visualizer.py 2025/round_3/resources/maps/sky-temple/sky-temple.worldAngel: 12Boots: 1HeartItem: 7Archer: 6SnakeNpc: 1Sword: 1Cloak: 1DoorV: 1Switch: 8Spawn: 1Key Diff:
diff -r hackceler8/game/src/enemy/mod.rs round_3/game/src/enemy/mod.rs550c563- if !player.is_active() {---+ if !player.is_active() || player.is_cloaked {diff -r hackceler8/game/src/inventory.rs round_3/game/src/inventory.rs24c24,29- const WEARABLE_ITEMS: &[ItemType] = &[ItemType::Boots, ItemType::Goggles, ItemType::Sword];---+ const WEARABLE_ITEMS: &[ItemType] = &[+ ItemType::Boots,+ ItemType::Goggles,+ ItemType::Sword,+ ItemType::Cloak,+ ];125a131+ ItemType::Cloak => "Cloak",142a149,151+ ItemType::Cloak => {+ player.is_cloaked = true;+ }165a175,177+ ItemType::Cloak => {+ player.is_cloaked = false;+ }229c241- if players[player_id].is_alive() && input.just_pressed(Button::B) {---+ if input.just_pressed(Button::B) {How this works is fairly simple: you are given the cloak object here, which enables one of your characters to basically become invisible and remove enemy aggro. As such, you are going to want to get both the boots and cloak in the map and give them to one person. The first problem you are going to see is that to even get to the boss room you are going to need to spawn 4 players, which is then followed by a room with 3 very fast, high-damage, invulnerable enemies. The trick is to use the character with the boots and cloak to rush middle, and then decloak to retake enemy aggro. You can use pause buffering in order to make this easier. From there the basic idea is to keep one character at the top of the cross and keep cloaking and decloaking while sprinting with the bottom characters to get to the next room. After you reach the boss room it’s more or less the same idea; you can kill the minions, but you need to use the pressure plates/switches to actually kill the boss. Do the same thing where you mess around with boss aggro using the cloak.
Forest-Temple
python3 hackceler8/map_visualizer.py round_3/resources/maps/water-temple/water-temple.world Octopus: 14 Siren: 3 HeartItem: 3 Boots: 2 Sword: 2 Spawn: 1 DuckNpc: 1 DoorH: 1Key Diff:
229c241
- if players[player_id].is_alive() && input.just_pressed(Button::B) {
---
+ if input.just_pressed(Button::B) {The change is fairly simple and the exploit is also preaty easy to understand. When a player is now dying they can still change the menu and equip items. What this allows you as the player to do is die and and then equip the googles just in time to wall clip into the boss room. From there you just kill the boss, timing and character placement are required to pull this off.
Round-4 (4 Flags For Boss)
Fire-Temple
Key Diff:
diff -r hackceler8/game/src/inventory.rs round_4/game/src/inventory.rs24c24,30- const WEARABLE_ITEMS: &[ItemType] = &[ItemType::Boots, ItemType::Goggles, ItemType::Sword];---+ const WEARABLE_ITEMS: &[ItemType] = &[+ ItemType::Boots,+ ItemType::Goggles,+ ItemType::Sword,+ ItemType::Staff,+ ItemType::Pencil,+ ];85a92,99+ // Checks if there's at least one of the specified item type+ // equipped by the specified player.+ pub fn contains_equipped(&self, item_type: ItemType, player_id: usize) -> bool {+ self.items+ .iter()+ .any(|i| i.item_type == item_type && i.worn_by == Some(player_id))+ }+93a108,117++ /// Remove the staff from the inventory and unequip it from players.+ pub fn remove_staff(&mut self, players: &mut [Player]) {+ for i in &mut self.items {+ if i.item_type == ItemType::Staff {+ i.unequip(players);+ }+ }+ self.items.retain(|i| i.item_type != ItemType::Staff);+ }The exploit/bug here is how the actual staff ends up working. It’s a single-use item that is supposed to disappear when it gets used. The real issue that exists in the code here, though, is the fact that it doesn’t actually leave your inventory until the staff animation ends. What this allows you to do is chain it to be reused by multiple players. This can then be used to help break through the ranged enemy wall of flame blobs. I will note that you don’t necessarily need to use this bug since, if you have all 4 characters and time it well, you can simply brute-force your way to the solve here by just having players behave as tanks.
Water-Temple
Key Diff:
diff -r hackceler8/game/src/inventory.rs round_4/game/src/inventory.rs24c24,30- const WEARABLE_ITEMS: &[ItemType] = &[ItemType::Boots, ItemType::Goggles, ItemType::Sword];---+ const WEARABLE_ITEMS: &[ItemType] = &[+ ItemType::Boots,+ ItemType::Goggles,+ ItemType::Sword,+ ItemType::Staff,+ ItemType::Pencil,+ ];85a92,99+ // Checks if there's at least one of the specified item type+ // equipped by the specified player.+ pub fn contains_equipped(&self, item_type: ItemType, player_id: usize) -> bool {+ self.items+ .iter()+ .any(|i| i.item_type == item_type && i.worn_by == Some(player_id))+ }+93a108,117++ /// Remove the staff from the inventory and unequip it from players.+ pub fn remove_staff(&mut self, players: &mut [Player]) {+ for i in &mut self.items {+ if i.item_type == ItemType::Staff {+ i.unequip(players);+ }+ }+ self.items.retain(|i| i.item_type != ItemType::Staff);+ }The idea here is you use the staff to kill the siren that is on the middle, since the staff can be used twice since it doesn’t leave your inventory till its animation ends you can take 2. This allows you to kill the siren fairly easily by having player 1 shot the staff then give it to player 2 and they then shot the staff all fairly trivial.
Sky-Temple
python3 hackceler8/map_visualizer.py round_4/resources/maps/sky-temple/sky-temple.worldAngel: 8Goggles: 1Key: 1HeartItem: 7Archer: 6SnakeNpc: 1Sword: 1DoorV: 1Spawn: 1Key Diff:
diff -r hackceler8/game/src/projectile.rs round_4/game/src/projectile.rs22d21- use crate::map;27a27,28+ /// Special shooter ID that indicates this was fired by a player.+ pub const PLAYER_SHOOTER_ID: u16 = 1337;90,91c91,116- for player in &mut ctx.players {- if !player.is_active() {---+ if projectile.shooter_id == PLAYER_SHOOTER_ID {+ // Shot by player, damages enemies+ for enemy in &mut ctx.world.enemies {+ if !enemy.is_alive() {+ continue;+ }+ if projectile.hitbox().collides(&enemy.hitbox()) {+ enemy.on_hit(push_dir, projectile.damage);+ projectile.status = Status::Dead;+ break;+ }+ }+ } else {+ for player in &mut ctx.players {+ if !player.is_active() {+ continue;+ }+ if projectile.hitbox().collides(&player.hitbox()) {+ player.on_hit(push_dir, projectile.damage);+ projectile.status = Status::Dead;+ break;+ }+ }+ }+ for enemy in &mut ctx.world.enemies {+ if !enemy.is_alive() || enemy.id == projectile.shooter_id {94,95c119,120- if projectile.hitbox().collides(&player.hitbox()) {- player.on_hit(push_dir, projectile.damage);---+ if projectile.hitbox().collides(&enemy.hitbox()) {+ enemy.on_hit(push_dir, projectile.damage);104c129,132- || map::off_screen(projectile.x, projectile.y)---+ || projectile.x - 100+ || projectile.x + 460+ || projectile.y - 100+ || projectile.y + 370This map was pretty simple - basically, you can clip the arrow from the enemy on the left here. If you time it correctly, the arrow will move from the first map to the second one. I don’t fully remember what the timing requirements were, but there was a pretty straightforward trick that you can figure out after trying it for a few minutes.

Forest-Temple
python3 hackceler8/map_visualizer.py round_4/resources/maps/forest-temple/forest-temple.worldGoblin: 9Boots: 1RacoonNpc: 1Goggles: 1Orc: 9HeartItem: 6Spawn: 1Key: 2Sword: 1DoorH: 1DoorV: 1Switch: 2Staff: 1Key Diff:
diff -r hackceler8/game/src/enemy/mod.rs round_4/game/src/enemy/mod.rs50a51,55+ // Slowdown happens every 1s for 1s.+ pub const SLOWDOWN_FREQUENCY: u16 = 50;+ // Players are slow down to 1/4 of normal speed+ pub const SLOWDOWN_RATE: i16 = 4;+73a79,80+ pub slows_down: bool,+ pub shoots_down: bool,110a118,119+ slows_down: bool,+ shoots_down: bool,124a134+ SlowingDown,172a183,184+ slows_down: properties.slows_down,+ shoots_down: properties.shoots_down,270c282,284- if enemy.stats.shoots && ctx.frame % shooter::SHOOT_FREQUENCY == 0 {---+ if enemy.stats.shoots+ && (enemy.shoots_down || ctx.frame % shooter::SHOOT_FREQUENCY == 0)+ {281a296,306++ if enemy.slows_down && ctx.frame % SLOWDOWN_FREQUENCY == 0 {+ for p in 0..ctx.players.len() {+ let player = &mut ctx.players[p];+ if !player.is_active() {+ continue;+ }+ player.speed /= SLOWDOWN_RATE;+ }+ enemy.status = Status::SlowingDown;+ }290a316,320+ let (dx, dy) = if enemy.shoots_down {+ (0, 100)+ } else {+ (player.x - enemy.x, player.y - enemy.y)+ };295,296c325,326- player.x - enemy.x,- player.y - enemy.y,---+ dx,+ dy,315a346,357+ }+ Status::SlowingDown =+ {+ if ctx.frame % SLOWDOWN_FREQUENCY == 0 {+ for p in 0..ctx.players.len() {+ let player = &mut ctx.players[p];+ if !player.is_active() {+ continue;+ }+ player.speed *= SLOWDOWN_RATE;+ }+ enemy.status = Status::Idle;+ }The way this bug works is rather simple: we have certain enemies that actually slow down the character by 4x, but after a second it will result in speed of 4x. What you can actually do is fairly simple - instead of just doing nothing, you equip the boots during the slowdown. This allows you to increase your speed by +1, so when the 4x hits, the equation changes from (x * 0.25) * 4 to ((x * 0.25) + 1) * 4. If you keep using this exploit, you can get your speed high enough that you are able to clip through the wall and get to the boss room. Note that it’s important to make sure you save an account for spawning into the boss room just in case you clip into a weird section of the map.
Round-5 (4 Flags For Boss)
Fire-Temple
python3 hackceler8/map_visualizer.py round_5/resources/maps/fire-temple/fire-temple.worldBlob: 10Flameboi: 8CatNpc: 1HeartItem: 6Goggles: 2DoorH: 2Key: 1Sword: 1Spawn: 1Switch: 6Key Diff:
diff -r hackceler8/game/src/switch.rs round_5/game/src/switch.rs17a18+ use crate::enemy::EnemyProperties;19a21+ use crate::res::enemies::EnemyType;21a24,25+ use crate::walk::*;+ use crate::Enemy;26a31+ spawn_enemies, // 2159a165,212+ }+ }++ const SPAWNED_ENEMY_WALK_DATA: &[WalkData] = &[+ WalkData {+ cmd: Cmd::Right,+ dur: 200,+ },+ WalkData {+ cmd: Cmd::Left,+ dur: 200,+ },+ ];+ const SPAWNED_ENEMY_PROPS: &EnemyProperties = &EnemyProperties {+ walk_data: SPAWNED_ENEMY_WALK_DATA,+ speed: None,+ health: None,+ strength: Some(0),+ invulnerable: false,+ flags: None,+ };+ fn spawn_enemies(ctx: &mut Ctx) {+ if !ctx.players[0].is_active() || ctx.world.enemies.len() >= 28 {+ return;+ }++ for (dx, dy) in [(16, -8), (16, 8)] {+ let player_center = ctx.players[0].hitbox().center();+ let enemy = Enemy::new(+ EnemyType::Flameboi,+ player_center.0 + dx - 128,+ player_center.1 + dy - 128,+ /*id=*/ 1337,+ &SPAWNED_ENEMY_PROPS,+ &mut ctx.res_state,+ &mut ctx.vdp,+ );+ ctx.world+ .enemies+ .push(enemy)+ .unwrap_or_else(|_| panic!("too many enemies"));+ }++ for switch_id in 0..ctx.world.switches.len() {+ let switch = &mut ctx.world.switches[switch_id];+ switch.move_relative(0, if switch.y % 16 == 0 { -17 } else { 17 });+ ctx.world.switches_completed.clear(switch.id);If you have all 4 characters stand on the switches it will create enemies that do like 0 damage, you can then us them to go into the boss room. The large number of enemies will greatly slow down the enemy slimes that are moving in a pattern till you finally reach the boss. It is possible to solve this without any help/slowing down the enemies as well but they definitely help. Note that the enemies spawn on your first player so make sure they are right next to the boss room to make it easier. The enemies will struggle to properly go to the next area you want to wait till they are half way to the next section so you wait till you can move to the next zone.
Water-Temple
There is a maze, you build the maze, solve the maze, follow the path !
Sky-Temple
python3 hackceler8/map_visualizer.py round_5/resources/maps/sky-temple/sky-temple.worldGoggles: 1Key: 1HeartItem: 6Archer: 5Angel: 4SnakeNpc: 1Sword: 1DoorV: 1Spawn: 1DoorH: 1Key Diff:
diff -r hackceler8/game/src/game.rs round_5/game/src/game.rs500,502c508,530- fn save_persistent_state(&mut self) {- self.portal- .save_to_persistent_storage(&[0x1234, 0x5678, 0x9ABC, 0xDEF0])---+ pub fn save_persistent_state(&mut self) {+ const SAVE_BUF_LEN: usize = crate::inventory::MAX_ITEMS + 1;+ let mut save_buf = [0; SAVE_BUF_LEN];++ let inventory_len = self.world.inventory.items.len();+ if !matches!(self.world.world_type, WorldType::Overworld)+ && !matches!(self.world.world_type, WorldType::ForestTemple)+ {+ let items_to_save = self+ .world+ .inventory+ .items+ .iter()+ .skip(inventory_len.saturating_sub(crate::inventory::MAX_ITEMS));
+ for (i, item) in items_to_save.enumerate() {+ let item_type_bits = item_type_to_u16(item.item_type);+ let amount_bits = item.amount & 0x1FFF;+ save_buf[i] = (item_type_bits << 13) | amount_bits;+ }+ }+ save_buf[crate::inventory::MAX_ITEMS] = world_type_to_u16(self.world.world_type);+ self.portal.save_to_persistent_storage(&save_buf);505,506c533,537- fn load_persistent_state(portal: &TargetPortal) {- let mut save_buf = [0; 4];---+ fn load_persistent_state(+ portal: &TargetPortal,+ ) -> (WorldType, [u16; crate::inventory::MAX_ITEMS]) {+ const SAVE_BUF_LEN: usize = crate::inventory::MAX_ITEMS + 1;+ let mut save_buf = [0; SAVE_BUF_LEN];508a540,547
+ u16_to_world_type(save_buf[crate::inventory::MAX_ITEMS])+ .map(|world_type| {+ let inventory_data: [u16; crate::inventory::MAX_ITEMS] =+ save_buf[..crate::inventory::MAX_ITEMS].try_into().unwrap();+ (world_type, inventory_data)+ })+ .unwrap_or((WorldType::Overworld, [0; crate::inventory::MAX_ITEMS]))510a550,564+ fn load_inventory_data(&mut self, inventory_data: &[u16; crate::inventory::MAX_ITEMS]) {+ for &item_data in inventory_data {+ if item_data == 0 {+ continue;+ }+ let item_type_bits = item_data >> 13;+ let amount = item_data & 0x1FFF;+ if let Some(item_type) = u16_to_item_type(item_type_bits) {+ for _ in 0..amount {+ self.world.inventory.add(item_type);+ }+ }+ }+ }+553a608,653+ }++ fn item_type_to_u16(item_type: ItemType) -> u16 {+ match item_type {+ ItemType::Boots => 0,+ ItemType::Goggles => 1,+ ItemType::HeartItem => 2,+ ItemType::Key => 3,+ ItemType::Sword => 4,+ ItemType::InvisibleKey => 5,+ }+ }++ fn u16_to_item_type(val: u16) -> Option<ItemType> {+ match val {+ 0 => Some(ItemType::Boots),+ 1 => Some(ItemType::Goggles),+ 2 => Some(ItemType::HeartItem),+ 3 => Some(ItemType::Key),+ 4 => Some(ItemType::Sword),+ 5 => Some(ItemType::InvisibleKey),+ _ => None,+ }+ }++ fn world_type_to_u16(world_type: WorldType) -> u16 {+ match world_type {+ WorldType::Overworld => 0,+ WorldType::FireTemple => 1,+ WorldType::WaterTemple => 2,+ WorldType::ForestTemple => 3,+ WorldType::SkyTemple => 4,+ WorldType::BossTemple => 5,+ }+ }++ fn u16_to_world_type(val: u16) -> Option<WorldType> {+ match val {+ 0 => Some(WorldType::Overworld),+ 1 => Some(WorldType::FireTemple),+ 2 => Some(WorldType::WaterTemple),+ 3 => Some(WorldType::ForestTemple),+ 4 => Some(WorldType::SkyTemple),+ 5 => Some(WorldType::BossTemple),+ _ => None,+ }The patch here ends up adding a custom state saving system which will keep your world state whenever you hard rest the game using the physical restart button. It will keep the items in your inventory while also restarting the world which will result in the ability to open the first door, restart then open the second door.
Forest-Temple
python3 hackceler8/map_visualizer.py round_5/resources/maps/forest-temple/forest-temple.worldGoblin: 9Boots: 3RacoonNpc: 1Goggles: 2Orc: 9HeartItem: 6Spawn: 1Sword: 1DoorV: 2Switch: 2Key Diff:
diff -r hackceler8/game/src/inventory.rs round_5/game/src/inventory.rs22c22- const MAX_ITEMS: usize = 16;---+ pub const MAX_ITEMS: usize = 16;126a127+ ItemType::InvisibleKey => "Key",
diff -r hackceler8/game/src/player.rs round_5/game/src/player.rs315a316,327+ break;+ }+ }+ }+ if input.just_pressed(Button::B) && inventory.contains(ItemType::InvisibleKey) {+ // Open a nearby door.+ let interaction_hitbox = player.hitbox().expand(5);+ for door in doors.iter_mut() {+ if !door.open && interaction_hitbox.collides(&door.hitbox()) {+ door.open();+ doors_opened.set(door.id);+ inventory.remove(ItemType::InvisibleKey);The patch here ends up adding invisible keys, they are dynamically loaded by the actual rust code base so they aren’t going to be in the actual Tile Map, as such I needed to hard code the location values not the my python generation script. The location isn’t really hidden or obfuscated in any manner, they are both right next to other objects. The weird part about this round though is you need to be holding nothing when you try and pick up the keys. For some reason when you pick up any other item it would make the keys impossible to pick up, but besides that it was fairly easy.
Blog / Non-Technical
That was a lot of technical nonsense. Overall, I really liked the game and had quite a bit of fun playing it and doing all of this. I would absolutely do it again, and hopefully can next year. But beyond that, let me explain how the actual onsite went and all the random things we did.
Day 1
The first day was supposed to be the day before the actual competition started, when everyone on our team would get there and hopefully we’d do some level of prep. In reality, it turned out to be just 3 of us because one of our players, cough cough Braydenfreakchu, ended up getting totally screwed by flights, and we didn’t actually see him until the end of the next day, after all the technical stuff for the first round happened.
Anyway, setting that aside, I had lots of really good food from the get-go. The basic structure of the entire day was just me landing -> eating lunch -> doing some work + getting my room + waiting for teammates -> going to dinner -> more work. Honestly, it was a pretty chill day, but you still get to see the food we ate.
Day 2
I slept pretty early and woke up around 3—4 AM, then started practicing and getting decent at the game. I had looked at it a bit before, but most of my progress came during the competition itself. I tried to find some bugs and issues in the codebase --- and found some funny ones that I didn’t think would ever trigger. One of them was a bug where you could crash the game if you were too close to the projectiles, causing one of the internal values to become 0 and trigger a Rust panic. Surprisingly, we actually managed to trigger it once by accident. Anyway, the hotel provided breakfast --- it was a really nice buffet, to be honest.
After eating, we walked down to the venue, which was only about five minutes away from the hotel --- pretty convenient. The actual building had this cool orange Hispanic tone; I believe it was a wedding or event space, so it looked really nice. Inside, the entire venue felt like a gaming competition --- smoke machines, LED lights, electronic music, gaming chairs, and of course, energy drinks!
The first day had a total of three rounds, running from about 9:30 AM to 4 PM. Each round lasted an hour and a half, with breaks in between. During that time, we were trying to find someone to fill in for our missing fifth teammate. Even if they didn’t help with the technical parts of the competition, it would’ve been nice to just have a physical section player in order to be able to do all the challenges properly. Sadly, that didn’t happen.
The first round went alright, but we ended up being unable to get one of the wall-clip challenges and finished near the bottom of the leaderboard. Speed-wise we were doing alright, but it was still a total struggle. I think from here onward every other team just started to get the game more and more, and speed really started to make a difference.
Anyway, after a short coffee break, we did round two, which went pretty badly, honestly. We missed a lot of the bugs to start off with. That being said, our teammate ended up falling in his chair and had some sort of divine intervention, which apparently resulted in him figuring out the answers (Clovis moment).
From there we had the final round, which went alright. There was a section that required 4 people, which we somehow managed to do by using a lot of frame buffering/pausing. The round was alright, but we still didn’t do great. We spent the rest of the day just working after ending up meeting back up with Brayden. I spent a good chunk of the night making the hack client, as well as playing through the rounds for that day with Brayden as practice. Clovis and Contron were doing their own thing, and we also managed to order like 50 tacos for around 20 bucks, easily one of the best purchases of the trip.
Here are some cool photos from the venue:
Breakfast
The actual floor
The outside
Our team logo

Single best part of the competition
The actual workstation
Day 3
This weekend we had two rounds. From there, we basically woke up and did the same thing as before, same breakfast and all. The round lineup was SLICES (us), the THC merger, FMC, and Kalmarunion. During the first round, we ended up missing the spot for the next round by just about 30 seconds, which was pretty heartbreaking, but it is what it is. After that, three of us stayed to do the next round, which we managed to fully beat, but it was too little, too late. Overall, though, I had a lot of fun at the competition, and I think we can do well next year.
Now it was time for travel photos. After the rounds ended, it was around midday. Luckily for us, Google booked the hotels in an excellent spot; there were tons of monuments, art museums, and just an overall really nice area. Brayden and I decided to start side-questing around Mexico City, so here are some travel photos:




ITS CORGO!!! (For those of you who don’t know Corgo, he is our pwn/web goat and has the dog avatar.)
Day 4
Brayden and I went on a side quest again and went on a hot air balloon tour early in the morning, and then went to the finals venue:
Photo right after Kalmarunion won!
Final Remarks
Had a great time. My teammates are all amazing, and it was great to spend time with them. Hopefully we can win next year.