ART & REAL-TIME VFX / GODOT

Tiny Criminals

A playful island heist shaped through stylized 3D art, readable feedback, and character-driven effects.

  • Godot
  • Cute
  • RPG
  • Cozy
  • Funny
Tiny Criminals characters on their island hideout

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.

Tiny Criminals logo and character expressions

See the game, then download the release.

Project overview: team, design and technical art

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.

Blender to Godot collision plugin pipeline
Tiny Criminals game loop breakdown
Hand-painted poster and paper prop texture atlas
Hand-painted prop atlas from the same pipeline — WANTED posters, newspapers and paintings for the interiors.
Hand-painted carpet texture atlas
Carpet atlas — the interior textiles are painted by hand into shared texture sheets.
OUTLINER · scene.blend
N-PANEL · Godot Export ToolsCollision Creation:
Suffix Tools:
Export:

Select an object, then run the operators — same behaviour as the real panel in Blender.

Interactive recreation of the actual N-panel. Pick an object and run the real operators: suffix renames (-col / -colonly), auto-scaled collision Empty children, one-click glTF export — Godot then builds the physics bodies on import.
Addon_Godot.py — the Blender-side bridge, recovered from the .blend (227 lines)
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()
Tiny Criminals in-engine police chase with magnet power-up

GODOT / REAL-TIME

In-Engine Gameplay

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.

  • Godot
  • 185 character files
  • 638 environment files
  • 233 VFX files
  • 14 shaders · 18 VFX scenes
Tiny Criminals interior scene with shop NPC
Tiny Criminals overworld coin run with dust VFX
Tiny Criminals results screen
Feedback sprites and power-up icons
Notice/alert feedback sprites and the four power-up icons (coffee, magnet, skate, water gun).

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

Tiny Criminals island level overview

LEVEL ART / STYLIZED 3D

Island Level Art

The released island brings the hideout loop together through soft coastal colors, readable paths, compact landmarks, and a playful town silhouette.

  • Released build
  • Godot
  • Team environment

MY ROLE / SCOPEPresented as team work; this view supplies the game context for my Art & VFX modules below.

Island level map and terrain layout
Tiny Criminals power-up VFX and emoji feedback studies

REAL-TIME VFX / GAMEPLAY READABILITY

Loot VFX & Feedback

Power-up bursts, emoji states, decals, and graphic cues make steals, reactions, blocked actions, and character status readable at the game camera distance.

  • Godot
  • VFX
  • Character feedback

MY ROLE / SCOPEMy contribution: gameplay feedback effects, emoji pop-ups, and supporting art assets.

Tiny Criminals dialogue icons, emotes and notice sprites
The feedback sprite set from the Godot project: 35 dialogue icons, 10 emotes, and the cat/police notice marks.
Steal loop — approach, steal progress bar, carry-overhead state and the police response.
Police feedback — water-gun spray, question-mark bubble and splash-hit reaction.
Magnet power-up — pickup radius ring under the character, coin counter climbing.
Coffee power-up — speed boost with dust trail and the coffee meter.
Skateboard power-up — fast traversal with dust trail.
WATER-GUN HIT · INTERACTIVE

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

Tiny Criminals fading shader around the player character

SHADER / VISIBILITY SYSTEM

ShapeCast Fading Shader

A collision-aware fading treatment clears foreground geometry between the camera and player so the character remains visible without abandoning the stylized world.

  • Godot shader
  • ShapeCast3D
  • Camera readability

MY ROLE / SCOPEThe breakdown documents the final effect together with the ShapeCast-driven visibility logic.

  1. 01

    Detect obstructing geometry between camera and player.

  2. 02

    Pass the relevant collision position into the fading material.

  3. 03

    Interpolate alpha locally so the player stays readable in motion.

In-game: the cutout opens as the cat crosses under the bridge, tracks the character, then eases shut — dithered edge from the shader.
ObjectCuller.gd · cull_masking.gdshader — the run-time cutout logic and shader
// ── 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;
}

Credits

Animation & ArtMichelle
Programming & ComposingEelis
ProgrammingApurv
Art & VFXYuchen
UI, UX, Game Design & Sound DesignAaron

More Game Art

// MAKING THINGS WEIRD AND WONDERFUL

YUCHEN HU.

© 2026 — ALL RIGHTS RESERVED

Create a free website with Framer, the website builder loved by startups, designers and agencies.