
GAME DESIGN / TOOLING
Design & Pipeline
The design and tooling side of the island: the day-night city-house game loop, and a small Blender-to-Godot pipeline that auto-generates collision suffixes so art exports drop straight into the engine.
ART & REAL-TIME VFX / GODOT
A playful island heist shaped through stylized 3D art, readable feedback, and character-driven effects.

PROJECT VIEW
Tiny Criminals is a team project about planning heists, grabbing loot, and returning to an island hideout. My contribution focused on Art & VFX: character feedback, emoji pop-ups, power-up effects, the ShapeCast visibility treatment, and supporting 3D assets.

DOWNLOADABLE TEAM BUILD

GAME DESIGN / TOOLING
The design and tooling side of the island: the day-night city-house game loop, and a small Blender-to-Godot pipeline that auto-generates collision suffixes so art exports drop straight into the engine.




Select an object, then run the operators — same behaviour as the real panel in Blender.
import bpy
from bpy.types import Operator, Panel
from bpy.props import EnumProperty, BoolProperty
from mathutils import Vector
def clean_parent_name(parent):
"""Remove existing Godot suffixes from parent name"""
suffixes = ('-col', '-colonly', '-convcol', '-rigid', '-navmesh', '-vehicle', '-noimp')
base_name = parent.name
for suffix in suffixes:
if base_name.endswith(suffix):
return base_name[:-len(suffix)]
return base_name
# ---- Collision Creation Operator ----
class GODOT_OT_CreateCollisionChild(Operator):
"""Create collision empty child with accurate scaling"""
bl_idname = "object.create_collision_child"
bl_label = "Create Collision Child"
bl_options = {'REGISTER', 'UNDO'}
shape_type: EnumProperty(
name="Collision Type",
items=[
('CUBE', "Box", "BoxShape3D"),
('SPHERE', "Sphere", "SphereShape3D"),
('SINGLE_ARROW', "Ray", "SeparationRayShape3D"),
('IMAGE', "Plane", "WorldBoundaryShape3D"),
],
default='CUBE'
)
def execute(self, context):
if not context.selected_objects:
self.report({'ERROR'}, "Select a parent object first")
return {'CANCELLED'}
parent = context.active_object
# Remove existing collision children
for child in parent.children:
if child.name.startswith("COL_"):
bpy.data.objects.remove(child, do_unlink=True)
# Create new empty
bpy.ops.object.empty_add(type='PLAIN_AXES')
empty = context.active_object
empty.name = f"COL_{self.shape_type}"
empty.parent = parent
empty.empty_display_type = self.shape_type
empty.show_in_front = True
empty.show_name = True
# Set Godot suffix
suffix = '-colonly' if self.shape_type in {'SINGLE_ARROW', 'IMAGE'} else '-col'
empty.name += suffix
# Calculate proper scale
if parent.type == 'MESH':
# Get parent dimensions in world space
matrix = parent.matrix_world
local_bbox_center = 0.125 * sum((Vector(b) for b in parent.bound_box), Vector())
local_bbox_size = parent.dimensions
global_bbox_center = matrix @ local_bbox_center
global_bbox_size = matrix.to_scale() * local_bbox_size
# Apply actual scale instead of just display size
empty.empty_display_size = 1.0 # Reset to default
empty.scale = global_bbox_size * 0.5 # Half size for radius to diameter conversion
else:
empty.scale = parent.scale
# Set naming based on parent
base_name = clean_parent_name(parent)
suffix = '-colonly' if self.shape_type in {'SINGLE_ARROW', 'IMAGE'} else '-col'
empty.name = f"{base_name}{suffix}"
# Visual styling
empty.color = (0.8, 0.2, 0.2, 0.7)
return {'FINISHED'}
# ---- Suffix Management Operators ----
class GODOT_OT_ApplySuffix(Operator):
"""Apply Godot-specific suffix to objects"""
bl_idname = "object.apply_godot_suffix"
bl_label = "Apply Godot Suffix"
bl_options = {'REGISTER', 'UNDO'}
suffix_type: EnumProperty(
items=[
('-col', "Collision", "Concave collision with mesh"),
('-colonly', "Collision Only", "Replace with collision shape"),
('-rigid', "Rigid Body", "RigidBody physics"),
('-navmesh', "Navmesh", "Navigation mesh"),
('-noimp', "No Import", "Skip during import"),
],
default='-col'
)
def execute(self, context):
targets = context.selected_objects if context.window_manager.apply_to_selected else bpy.data.objects
renamed = 0
for obj in targets:
# Clean name and apply suffix
base_name = obj.name.split('-')[0].rstrip('_. ')
obj.name = f"{base_name}{self.suffix_type}"
renamed += 1
if self.suffix_type == '-colonly' and obj.type == 'MESH':
obj.display_type = 'WIRE'
if self.suffix_type == '-col' and obj.type == 'MESH':
obj.display_type = 'SOLID'
self.report({'INFO'}, f"Applied {self.suffix_type} to {renamed} objects")
return {'FINISHED'}
class GODOT_OT_RemoveSuffix(Operator):
"""Remove all Godot-specific suffixes from objects"""
bl_idname = "object.remove_godot_suffix"
bl_label = "Remove Godot Suffixes"
bl_options = {'REGISTER', 'UNDO'}
def execute(self, context):
targets = context.selected_objects if context.window_manager.apply_to_selected else bpy.data.objects
removed = 0
suffixes = ('-col', '-colonly', '-convcol', '-rigid', '-navmesh', '-noimp')
for obj in targets:
original_name = obj.name
# Remove all suffixes recursively
while any(obj.name.endswith(s) for s in suffixes):
for s in suffixes:
if obj.name.endswith(s):
obj.name = obj.name[:-len(s)]
removed += 1
break
# Reset display properties if name changed
if obj.name != original_name and obj.type == 'MESH':
obj.display_type = 'TEXTURED'
self.report({'INFO'}, f"Removed suffixes from {removed} objects")
return {'FINISHED'}
# ---- Export Operator ----
class GODOT_OT_ExportGLTF(Operator):
bl_idname = "object.export_gltf"
bl_label = "Export glTF 2.0"
bl_options = {'REGISTER'}
def execute(self, context):
bpy.ops.export_scene.gltf('INVOKE_DEFAULT')
return {'FINISHED'}
# ---- Panel Layout ----
class GODOT_PT_ToolsPanel(Panel):
bl_label = "Godot Export Tools"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = 'Godot'
def draw(self, context):
layout = self.layout
wm = context.window_manager
# Global setting
layout.prop(wm, "apply_to_selected", toggle=True)
# Collision Creation Section
layout.separator()
layout.label(text="Collision Creation:", icon='PHYSICS')
col = layout.column(align=True)
col.operator(GODOT_OT_CreateCollisionChild.bl_idname, text="Box Collision").shape_type = 'CUBE'
col.operator(GODOT_OT_CreateCollisionChild.bl_idname, text="Sphere Collision").shape_type = 'SPHERE'
col.operator(GODOT_OT_CreateCollisionChild.bl_idname, text="Ray Collision").shape_type = 'SINGLE_ARROW'
col.operator(GODOT_OT_CreateCollisionChild.bl_idname, text="Plane Collision").shape_type = 'IMAGE'
# Suffix Management Section
layout.separator()
layout.label(text="Suffix Tools:", icon='MODIFIER')
col = layout.column(align=True)
col.operator(GODOT_OT_ApplySuffix.bl_idname, text="Apply Collision Suffix").suffix_type = '-col'
col.operator(GODOT_OT_ApplySuffix.bl_idname, text="Collision Only Suffix").suffix_type = '-colonly'
col.operator(GODOT_OT_RemoveSuffix.bl_idname, text="Remove All Suffixes", icon='X')
# Other Suffixes
layout.separator()
layout.label(text="Other Markers:", icon='BOOKMARKS')
col = layout.column(align=True)
col.operator(GODOT_OT_ApplySuffix.bl_idname, text="Make Rigid Body").suffix_type = '-rigid'
col.operator(GODOT_OT_ApplySuffix.bl_idname, text="Create Navmesh").suffix_type = '-navmesh'
col.operator(GODOT_OT_ApplySuffix.bl_idname, text="Mark No Import").suffix_type = '-noimp'
# Export Section
layout.separator()
layout.label(text="Export:", icon='EXPORT')
layout.operator(
GODOT_OT_ExportGLTF.bl_idname,
text="Export glTF 2.0",
icon='EXPORT'
)
def register():
bpy.utils.register_class(GODOT_OT_CreateCollisionChild)
bpy.utils.register_class(GODOT_OT_ApplySuffix)
bpy.utils.register_class(GODOT_OT_RemoveSuffix)
bpy.utils.register_class(GODOT_OT_ExportGLTF)
bpy.utils.register_class(GODOT_PT_ToolsPanel)
bpy.types.WindowManager.apply_to_selected = BoolProperty(
name="Apply to Selected Only",
default=True
)
def unregister():
bpy.utils.unregister_class(GODOT_PT_ToolsPanel)
bpy.utils.unregister_class(GODOT_OT_ExportGLTF)
bpy.utils.unregister_class(GODOT_OT_RemoveSuffix)
bpy.utils.unregister_class(GODOT_OT_ApplySuffix)
bpy.utils.unregister_class(GODOT_OT_CreateCollisionChild)
del bpy.types.WindowManager.apply_to_selected
if __name__ == "__main__":
register()
GODOT / REAL-TIME
Captured straight from the Godot build: grab loot around the island, dodge the police, and spend power-ups. The art set behind it spans 185 character files, 638 environment files and 233 VFX files, driven by 14 custom shaders and 18 VFX scenes.




ARCHIVE NOTECaptured from the downloadable Windows build, with game audio.

LEVEL ART / STYLIZED 3D
The released island brings the hideout loop together through soft coastal colors, readable paths, compact landmarks, and a playful town silhouette.
MY ROLE / SCOPEPresented as team work; this view supplies the game context for my Art & VFX modules below.


REAL-TIME VFX / GAMEPLAY READABILITY
Power-up bursts, emoji states, decals, and graphic cues make steals, reactions, blocked actions, and character status readable at the game camera distance.
MY ROLE / SCOPEMy contribution: gameplay feedback effects, emoji pop-ups, and supporting art assets.

The water-gun hit VFX, captured from the Godot project at 12 orbit angles. Drag to rotate the burst, scrub the timeline frame by frame. GPU particles reseed per capture, so the burst shape varies slightly between angles.
Drag to rotate · scrub the timeline

SHADER / VISIBILITY SYSTEM
A collision-aware fading treatment clears foreground geometry between the camera and player so the character remains visible without abandoning the stylized world.
MY ROLE / SCOPEThe breakdown documents the final effect together with the ShapeCast-driven visibility logic.
Detect obstructing geometry between camera and player.
Pass the relevant collision position into the fading material.
Interpolate alpha locally so the player stays readable in motion.
// ── ObjectCuller.gd (CutoutComponent) ─ raycast from camera to player, tween the cutout ──
extends Node3D
class_name CutoutComponent
@export var cull_subviewport: SubViewportContainer
@export var sub_view_camera: Camera3D
@export_category("Cutout")
@export_range(0.0, 0.2, 0.01) var cutout_size: float = 0.03
@export_range(0.0, 0.2, 0.01) var cutout_feathering: float = 0.04
@export_range(0.0, 0.5, 0.01) var cutout_fade_time: float = 0.15
@export_category("Dithering")
@export_range(0, 8) var dither_size: int = 3: set = _update_dither_size
@export_range(0, 12) var dither_colors: int = 2: set = _update_dither_color
@export_category("Components")
@export var player: Player
@export var main_camera: Camera3D
var show_culling: bool = true
func _ready() -> void:
dither_size = dither_size
dither_colors = dither_colors
func _process(delta: float) -> void:
move_camera()
func _physics_process(delta: float) -> void:
move_cut_out()
func _update_dither_size(value: int):
if cull_subviewport:
cull_subviewport.material.set_shader_parameter("dither_size", value)
func _update_dither_color(value: int):
if cull_subviewport:
cull_subviewport.material.set_shader_parameter("colors", value)
func move_cut_out():
if not main_camera.is_position_behind(player.global_transform.origin):
var screen_pos = main_camera.unproject_position(player.global_transform.origin)
var from = main_camera.project_ray_origin(screen_pos)
var to = from + main_camera.project_ray_normal(screen_pos) * 1000
var space_state = get_world_3d().direct_space_state
var query = PhysicsRayQueryParameters3D.create(from, to)
query.set_collision_mask(0)
query.set_collision_mask(1)
var collision = get_world_3d().direct_space_state.intersect_ray(query)
if collision != null and collision.collider != null:
tween_culling(collision.collider == player)
var targetX: float = Utils.normalize(screen_pos.x, 0, get_viewport().size.x, 0, 1)
var targetY: float = Utils.normalize(screen_pos.y, 0, get_viewport().size.y, 0, 1)
cull_subviewport.material.set_shader_parameter("target", Vector2(targetX, targetY))
func tween_culling(hit_player: bool):
if hit_player and show_culling:
show_culling = false
var t: Tween = get_tree().create_tween()
t.tween_method(circle_size_tween, cutout_size, 0, cutout_fade_time)
t.tween_method(circle_feather_tween, cutout_feathering, 0, cutout_fade_time)
elif not hit_player and not show_culling:
show_culling = true
var t: Tween = get_tree().create_tween()
t.tween_method(circle_size_tween, 0.0, cutout_size, cutout_fade_time)
t.tween_method(circle_feather_tween, 0.0, cutout_feathering, cutout_fade_time)
func circle_size_tween(size: float):
cull_subviewport.material.set_shader_parameter("circle_size",size)
func circle_feather_tween(feather: float):
cull_subviewport.material.set_shader_parameter("feather", feather)
func move_camera():
sub_view_camera.fov = main_camera.fov
sub_view_camera.global_transform = main_camera.global_transform
// ── cull_masking.gdshader ─ screen-space dithered circle cutout ──
shader_type canvas_item;
#define MAXCOLORS 30
uniform float circle_size : hint_range(0.0, 1.0, 0.01) = 0.3;
uniform vec2 target = vec2(0.5);
uniform float feather : hint_range(0.0, 0.1, 0.01) = 0.1;
group_uniforms dithering;
uniform int dither_size: hint_range(1, 8) = 1;
uniform int colors : hint_range(1, MAXCOLORS) = 12;
float dithering_pattern(ivec2 fragcoord) {
const float pattern[] = {
0.00, 0.50, 0.10, 0.65,
0.75, 0.25, 0.90, 0.35,
0.20, 0.70, 0.05, 0.50,
0.95, 0.40, 0.80, 0.30
};
int x = fragcoord.x % 4;
int y = fragcoord.y % 4;
return pattern[y * 4 + x];
}
float reduce_color(float raw, float dither, int depth) {
float div = 1.0 / float(depth);
float val = 0.0;
int i = 0;
while (i <= MAXCOLORS)
{
if (raw > div * (float(i + 1))) {
i = i + 1;
continue;
}
if (raw * float(depth) - float(i) <= dither * 0.999)
{
val = div * float(i);
}
else
{
val = div * float(i + 1);
}
return val;
i = i+1;
}
return val;
}
void vertex() {
// Called for every vertex the material is visible on.
}
void fragment() {
vec2 resolution = 1.0 / SCREEN_PIXEL_SIZE;
vec2 current_pixel = SCREEN_UV * resolution;
vec2 target_pixel = vec2(target.x, 1.0 - target.y) * resolution;
float dist2 = distance(current_pixel, target_pixel);
ivec2 uv2 = ivec2(FRAGCOORD.xy / float(dither_size));
float dithering_value = dithering_pattern(uv2);
vec2 uv_centered = UV - target;
float aspect_ratio = TEXTURE_PIXEL_SIZE.y / TEXTURE_PIXEL_SIZE.x;
uv_centered.y *= (1.0 / aspect_ratio);
float dist = length(uv_centered);
float raw = 1.0 - smoothstep(circle_size, circle_size + feather, dist);
COLOR.a = reduce_color(raw, (dithering_value - 0.5) * dithering_value + 0.5, colors - 1);
//COLOR.a = 0.5;
}TEAM
KEEP EXPLORING